[
  {
    "path": ".all-contributorsrc",
    "content": "{\n  \"projectName\": \"homepage\",\n  \"projectOwner\": \"benphelps\",\n  \"files\": [\n    \"README.md\"\n  ],\n  \"imageSize\": 100,\n  \"contributors\": []\n}\n"
  },
  {
    "path": ".codecov.yml",
    "content": "codecov:\n  require_ci_to_pass: true\n\ncoverage:\n  precision: 2\n  round: down\n  range: \"0...100\"\n  status:\n    project:\n      default:\n        target: 100%\n        threshold: 15%\n    patch:\n      default:\n        target: 100%\n        threshold: 10%\n\ncomment:\n  layout: \"reach,diff,flags,files\"\n  behavior: default\n  require_changes: false\n"
  },
  {
    "path": ".devcontainer/Dockerfile",
    "content": "ARG VARIANT=\"16-buster\"\nFROM mcr.microsoft.com/vscode/devcontainers/javascript-node:${VARIANT}\n\nRUN npm install -g pnpm\n\nRUN apt-get update \\\n   && apt-get -y install --no-install-recommends \\\n        python3-pip \\\n        && apt-get clean -y \\\n        && rm -rf /var/lib/apt/lists/*\n\nENV PATH=\"${PATH}:./node_modules/.bin\"\n"
  },
  {
    "path": ".devcontainer/devcontainer.json",
    "content": "{\n  \"name\": \"homepage\",\n  \"build\": {\n    \"dockerfile\": \"Dockerfile\",\n    \"args\": {\n      \"VARIANT\": \"18-bullseye\",\n    },\n  },\n  \"customizations\": {\n    \"vscode\": {\n      \"extensions\": [\n        \"dbaeumer.vscode-eslint\",\n        \"mhutchie.git-graph\",\n        \"streetsidesoftware.code-spell-checker\",\n        \"esbenp.prettier-vscode\",\n      ],\n      \"settings\": {\n        \"eslint.format.enable\": true,\n        \"eslint.lintTask.enable\": true,\n        \"eslint.packageManager\": \"pnpm\",\n      },\n    },\n  },\n  \"postCreateCommand\": \".devcontainer/setup.sh\",\n  \"forwardPorts\": [3000],\n}\n"
  },
  {
    "path": ".devcontainer/setup.sh",
    "content": "#!/usr/bin/env bash\n\n# Install Node packages\npnpm install\n\npython3 -m pip install -r requirements.txt\n\n# Copy in skeleton configuration if there is no existing configuration\nif [ ! -d \"config/\" ]; then\n  echo \"Adding skeleton config\"\n  mkdir config/\n  cp -r src/skeleton/* config\nfi\n"
  },
  {
    "path": ".dockerignore",
    "content": "**/.classpath\n**/.dockerignore\n**/.env\n**/.git\n**/.gitignore\n**/.project\n**/.settings\n**/.toolstarget\n**/.vs\n**/.vscode\n**/*.*proj.user\n**/*.dbmdl\n**/*.jfm\n**/charts\n**/docker-compose*\n**/compose*\n**/Dockerfile*\n**/node_modules\n!.next/standalone/node_modules\n**/npm-debug.log\n**/obj\n**/secrets.dev.yaml\n**/values.dev.yaml\nREADME.md\nconfig/\nk3d/\n"
  },
  {
    "path": ".editorconfig",
    "content": "# EditorConfig is awesome: https://EditorConfig.org\n\n# top-most EditorConfig file\nroot = true\n\n[*]\nindent_style = space\nindent_size = 2\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\nmax_line_length = 120\n\n[*.md]\ntrim_trailing_whitespace = false\n"
  },
  {
    "path": ".github/DISCUSSION_TEMPLATE/feature-requests.yml",
    "content": "title: \"[Feature Request] \"\nlabels: [\"enhancement\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        #### ⚠️ Don't forget to search [existing issues](https://github.com/gethomepage/homepage/search?q=&type=issues) and [discussions](https://github.com/gethomepage/homepage/search?q=&type=discussions) (including closed ones!).\n  - type: textarea\n    id: description\n    attributes:\n      label: Description\n      description: A clear and concise description of what you would like to see.\n    validations:\n      required: true\n  - type: textarea\n    id: other\n    attributes:\n      label: Other\n      description: Add any other context or information about the feature request here.\n"
  },
  {
    "path": ".github/DISCUSSION_TEMPLATE/support.yml",
    "content": "body:\n  - type: markdown\n    attributes:\n      value: |\n        ### ⚠️ Before opening a discussion:\n\n        - [Check the troubleshooting guide](https://gethomepage.dev/troubleshooting/) and include the output of all steps below.\n        - [Search existing issues](https://github.com/gethomepage/homepage/search?q=&type=issues) [and discussions](https://github.com/gethomepage/homepage/search?q=&type=discussions) (including closed ones!).\n  - type: textarea\n    id: description\n    attributes:\n      label: Description\n      description: A clear and concise description of the issue or question. If applicable, add screenshots to help explain your problem.\n    validations:\n      required: true\n  - type: input\n    id: version\n    attributes:\n      label: homepage version\n      placeholder: e.g. v0.4.18 (4ea2798)\n    validations:\n      required: true\n  - type: dropdown\n    id: install-method\n    attributes:\n      label: Installation method\n      options:\n        - Docker\n        - Unraid\n        - Source\n        - Other (please describe above)\n    validations:\n      required: true\n  - type: textarea\n    id: config\n    attributes:\n      label: Configuration\n      description: Please provide any relevant service, widget or otherwise related configuration here\n      render: yaml\n  - type: textarea\n    id: container-logs\n    attributes:\n      label: Container Logs\n      description: Please review and provide any logs from the container, if relevant\n  - type: textarea\n    id: browser-logs\n    attributes:\n      label: Browser Logs\n      description: Please review and provide any logs from the browser, if relevant\n  - type: textarea\n    id: troubleshooting\n    attributes:\n      label: Troubleshooting\n      description: Please include output from your [troubleshooting steps](https://gethomepage.dev/troubleshooting/#service-widget-errors), if relevant.\n    validations:\n      required: true\n  - type: markdown\n    attributes:\n      value: |\n        ## ⚠️ STOP ⚠️\n\n        Before you submit this support request, please ensure you have entered your configuration files and actually followed the steps from the troubleshooting guide linked above *and posted the output*, if relevant. The troubleshooting steps often help to solve the problem or at least can help figure it out.\n\n        *Please remember that this project is maintained by regular people **just like you**, so if you don't take the time to fill out the requested information, don't expect a reply back.*\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: [gethomepage]\nopen_collective: homepage\npatreon: gethomepage\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: 🐛 Bug report\ndescription: Please only raise an issue if you've been advised to do so in a GitHub discussion. Thanks! 🙏\nlabels: [\"bug\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        ## ⚠️ Please note\n        The starting point for a bug report should always be a [GitHub discussion](https://github.com/gethomepage/homepage/discussions/new?category=support)\n        Thank you for contributing to homepage! ✊\n  - type: checkboxes\n    id: pre-flight\n    attributes:\n      label: Before submitting, please confirm the following\n      options:\n        - label: I confirm this was discussed, and the maintainers asked that I open an issue.\n          required: true\n        - label: I am aware that if I create this issue without a discussion, it will be removed without a response.\n          required: true\n  - type: input\n    id: discussion\n    attributes:\n      label: Discussion Link\n      description: |\n        Please link to the GitHub discussion that led to this issue.\n    validations:\n      required: true\n  - type: textarea\n    id: additional\n    attributes:\n      label: Additional context\n      description: Optional\n      render: Text\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: 🤔 Questions and Help\n    url: https://github.com/gethomepage/homepage/discussions\n    about: For support, possible bug reports or general questions.\n  - name: 💬 Chat\n    url: https://discord.gg/k4ruYNrudu\n    about: Want to discuss homepage with others? Check out our chat.\n  - name: 🚀 Feature Request\n    url: https://github.com/gethomepage/homepage/discussions/new?category=feature-requests\n    about: Remember to search for existing feature requests and \"up-vote\" any you like\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "<!--\n==== STOP ====================\n======== STOP ================\n============ STOP ============\n================ STOP ========\n==================== STOP ====\n\n⚠️ Before opening this pull request please review the guidelines in the checklist below.\n\nIf this PR does not meet those guidelines it will not be accepted, and everyone will be sad.\n-->\n\n## Proposed change\n\n<!--\nPlease include a summary of the change. Screenshots and/or videos can also be helpful if appropriate.\n\nNew service widgets should include example(s) of relevant API output as well as updates to the docs for the new widget.\n-->\n\nCloses # (issue)\n\n## Type of change\n\n<!--\nWhat type of change does your PR introduce to Homepage?\n-->\n\n- [ ] New service widget\n- [ ] Bug fix (non-breaking change which fixes an issue)\n- [ ] New feature or enhancement (non-breaking change which adds functionality)\n- [ ] Documentation only\n- [ ] Other (please explain)\n\n## Checklist:\n\n- [ ] If applicable, I have added corresponding documentation changes.\n- [ ] If applicable, I have added or updated tests for new features and bug fixes (see [testing](https://gethomepage.dev/widgets/authoring/getting-started/#testing)).\n- [ ] If applicable, I have reviewed the [feature / enhancement](https://gethomepage.dev/widgets/authoring/getting-started/#new-feature-guidelines) and / or [service widget guidelines](https://gethomepage.dev/widgets/authoring/getting-started/#service-widget-guidelines).\n- [ ] I have checked that all code style checks pass using [pre-commit hooks](https://gethomepage.dev/widgets/authoring/getting-started/#code-formatting-with-pre-commit-hooks) and [linting checks](https://gethomepage.dev/widgets/authoring/getting-started/#code-linting).\n- [ ] If applicable, I have tested my code for new features & regressions on both mobile & desktop devices, using the latest version of major browsers.\n- [ ] In the description above I have disclosed the use of AI tools in the coding of this PR.\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where the package manifests are located.\n# Please see the documentation for all configuration options:\n# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates\n\nversion: 2\nupdates:\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n    cooldown:\n      default-days: 7\n  - package-ecosystem: \"npm\"\n    directory: \"/\"\n    schedule:\n      interval: \"monthly\"\n    cooldown:\n      default-days: 7\n"
  },
  {
    "path": ".github/release-drafter.yml",
    "content": "name-template: 'v$RESOLVED_VERSION'\ntag-template: 'v$RESOLVED_VERSION'\nchange-template: '- $TITLE (#$NUMBER) @$AUTHOR'\nchange-title-escapes: '\\\\<*_&'\n\nversion-resolver:\n  major:\n    labels:\n      - 'major'\n      - 'breaking-change'\n  minor:\n    labels:\n      - 'enhancement'\n      - 'feature'\n  patch:\n    labels:\n      - 'bug'\n      - 'fix'\n      - 'dependencies'\n      - 'translation'\n      - 'documentation'\n  default: patch\n\ncategories:\n  - title: '⚠️ Breaking Changes'\n    labels:\n      - 'major'\n      - 'breaking-change'\n  - title: '🚀 Features'\n    labels:\n      - 'enhancement'\n      - 'feature'\n  - title: '🐛 Fixes'\n    labels:\n      - 'bug'\n      - 'fix'\n  - title: '🧰 Maintenance'\n    labels:\n      - 'dependencies'\n      - 'ci'\n      - 'chore'\n  - title: '🌐 Translations'\n    labels:\n      - 'translation'\n  - title: '📚 Documentation'\n    labels:\n      - 'documentation'\n\nautolabeler:\n  - label: 'documentation'\n    files:\n      - 'docs/**'\n      - '*.md'\n      - '.github/**/*.md'\n\n  - label: 'ci'\n    files:\n      - '.github/workflows/**'\n\n  - label: 'dependencies'\n    files:\n      - 'package.json'\n      - 'pnpm-lock.yaml'\n      - 'pyproject.toml'\n      - 'uv.lock'\n\n  - label: 'feature'\n    files:\n      - 'src/components/**'\n      - 'src/widgets/**'\n      - 'src/pages/**'\n      - 'src/utils/**'\n\n  - label: 'chore'\n    files:\n      - 'Dockerfile*'\n      - 'docker-entrypoint.sh'\n      - 'k3d/**'\n\n  - label: 'translation'\n    files:\n      - 'public/locales/**'\n\ntemplate: |\n  ## What's Changed\n\n  $CHANGES\n"
  },
  {
    "path": ".github/workflows/crowdin.yml",
    "content": "name: Crowdin Action\n\non:\n  workflow_dispatch:\n  schedule:\n    - cron: '2 */12 * * *'\n  push:\n    paths: [\n      '/public/locales/en/**',\n    ]\n    branches: [ dev ]\n\njobs:\n  synchronize-with-crowdin:\n    name: Crowdin Sync\n    runs-on: ubuntu-latest\n\n    steps:\n    - name: Checkout\n      uses: actions/checkout@v6\n    - name: crowdin action\n      uses: crowdin/github-action@v2\n      with:\n        upload_translations: false\n        download_translations: true\n        crowdin_branch_name: dev\n        localization_branch_name: l10n_dev\n        pull_request_labels: translation\n      env:\n        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}\n        CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/docker-publish.yml",
    "content": "name: Docker CI\n\non:\n  schedule:\n    - cron: '20 0 * * *'\n  push:\n    branches:\n      - main\n      - feature/**\n      - dev\n    tags: [ 'v*.*.*' ]\n  pull_request:\n    branches: [ \"dev\" ]\n  merge_group:\n\nenv:\n  IMAGE_NAME: ${{ github.repository }}\n\njobs:\n  pre-commit:\n    name: Linting Checks\n    runs-on: ubuntu-22.04\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n\n      - name: Install python\n        uses: actions/setup-python@v6\n        with:\n          python-version: 3.x\n\n      - name: Check files\n        uses: pre-commit/action@v3.0.1\n\n      - name: Install pnpm\n        uses: pnpm/action-setup@v5\n        with:\n          version: 10\n          run_install: false\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: 24\n          cache: 'pnpm'\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: Lint frontend\n        run: pnpm run lint\n\n  build:\n    name: Docker Build & Push\n    if: github.repository == 'gethomepage/homepage'\n    runs-on: self-hosted\n    needs: [ pre-commit ]\n    permissions:\n      contents: read\n      packages: write\n      id-token: write\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n\n      - name: Extract Docker metadata\n        id: meta\n        uses: docker/metadata-action@v6\n        with:\n          images: |\n            ${{ env.IMAGE_NAME }}\n            ghcr.io/${{ env.IMAGE_NAME }}\n          tags: |\n            # Default tags\n            type=schedule,pattern=nightly\n            type=ref,event=branch\n            type=ref,event=tag\n            # Versioning tags\n            type=semver,pattern=v{{version}}\n            type=semver,pattern=v{{major}}.{{minor}}\n            type=semver,pattern=v{{major}}\n          flavor: |\n            latest=auto\n\n      - name: Next.js build cache\n        uses: actions/cache@v5\n        with:\n          path: .next/cache\n          key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx') }}\n          restore-keys: |\n            nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}\n\n      - name: Install pnpm\n        uses: pnpm/action-setup@v5\n        with:\n          version: 10\n          run_install: false\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: 24\n          cache: 'pnpm'\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: Build app\n        run: |\n          NEXT_PUBLIC_BUILDTIME=\"${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}\" \\\n          NEXT_PUBLIC_VERSION=\"${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}\" \\\n          NEXT_PUBLIC_REVISION=\"${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}\" \\\n          pnpm run build\n\n      - name: Log into registry ${{ env.REGISTRY }}\n        if: github.event_name != 'pull_request'\n        uses: docker/login-action@v4\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Login to Docker Hub\n        if: github.event_name != 'pull_request'\n        uses: docker/login-action@v4\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: Setup QEMU\n        uses: docker/setup-qemu-action@v4.0.0\n\n      - name: Setup Docker buildx\n        uses: docker/setup-buildx-action@v4\n\n      - name: Build and push Docker image\n        id: build-and-push\n        uses: docker/build-push-action@v7\n        with:\n          context: .\n          push: ${{ github.event_name != 'pull_request' }}\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          build-args: |\n            CI=true\n            BUILDTIME=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}\n            VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}\n            REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}\n          platforms: linux/amd64,linux/arm64\n          provenance: false\n          cache-from: type=local,src=/tmp/.buildx-cache\n          cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max\n\n      # https://github.com/docker/build-push-action/issues/252 / https://github.com/moby/buildkit/issues/1896\n      - name: Move cache\n        run: |\n          rm -rf /tmp/.buildx-cache\n          mv /tmp/.buildx-cache-new /tmp/.buildx-cache\n"
  },
  {
    "path": ".github/workflows/docs-publish.yml",
    "content": "name: Docs\n\non:\n  push:\n    tags: [\"v*.*.*\"]\n    branches: [\"main\"]\n  pull_request:\n  merge_group:\n  workflow_dispatch:\n\npermissions:\n  contents: read\n  pages: write\n  id-token: write\n\njobs:\n  pre-commit:\n    name: Linting Checks\n    runs-on: ubuntu-22.04\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n      - name: Install python\n        uses: actions/setup-python@v6\n        with:\n          python-version: 3.x\n      - name: Check files\n        uses: pre-commit/action@v3.0.1\n\n  test:\n    name: Test Build Docs\n    if: github.repository == 'gethomepage/homepage' && github.event_name == 'pull_request'\n    runs-on: ubuntu-latest\n    needs:\n      - pre-commit\n    steps:\n      - uses: actions/checkout@v6\n      - uses: actions/setup-python@v6\n        with:\n          python-version-file: \".python-version\"\n      - name: Install uv\n        uses: astral-sh/setup-uv@v7\n      - run: sudo apt-get install pngquant\n      - name: Test Docs Build\n        run: uv run --frozen zensical build --clean\n  deploy:\n    name: Build & Deploy Docs\n    if: github.repository == 'gethomepage/homepage' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main'\n    runs-on: ubuntu-latest\n    environment:\n      name: github-pages\n      url: ${{ steps.deployment.outputs.page_url }}\n    needs:\n      - pre-commit\n    steps:\n      - uses: actions/configure-pages@v5\n      - uses: actions/checkout@v6\n      - uses: actions/setup-python@v6\n        with:\n          python-version-file: \".python-version\"\n      - name: Install uv\n        uses: astral-sh/setup-uv@v7\n      - run: sudo apt-get install pngquant\n      - name: Build Docs\n        run: uv run --frozen zensical build --clean\n      - uses: actions/upload-pages-artifact@v4\n        with:\n          path: site\n      - uses: actions/deploy-pages@v4\n        id: deployment\n"
  },
  {
    "path": ".github/workflows/pr-quality.yml",
    "content": "name: PR Quality\n\npermissions:\n  contents: read\n  issues: read\n  pull-requests: write\n\non:\n  pull_request_target:\n    types: [opened, reopened]\n\njobs:\n  anti-slop:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: peakoss/anti-slop@v0\n        with:\n          max-failures: 4\n"
  },
  {
    "path": ".github/workflows/reaction-comments.yml",
    "content": "name: 'Reaction Comments'\n\non:\n  issue_comment:\n    types: [created, edited]\n  pull_request_review_comment:\n    types: [created, edited]\n\npermissions:\n  actions: write\n  issues: write\n  pull-requests: write\n\njobs:\n  action:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: dessant/reaction-comments@v4\n"
  },
  {
    "path": ".github/workflows/release-drafter.yml",
    "content": "name: Release Drafter\n\non:\n  push:\n    branches:\n      - dev\n  pull_request_target:\n    types: [opened, reopened, synchronize]\n  workflow_dispatch:\n    inputs:\n      version:\n        description: \"Optional explicit version override (for example: 2.0.0)\"\n        required: false\n        type: string\n\npermissions:\n  contents: read\n\njobs:\n  update_release_draft:\n    name: Update Release Draft\n    if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'\n    permissions:\n      contents: write\n      pull-requests: read\n    runs-on: ubuntu-latest\n    steps:\n      - if: github.event_name == 'workflow_dispatch' && github.event.inputs.version != ''\n        uses: release-drafter/release-drafter@v7\n        with:\n          config-name: release-drafter.yml\n          version: ${{ github.event.inputs.version }}\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      - if: github.event_name != 'workflow_dispatch' || github.event.inputs.version == ''\n        uses: release-drafter/release-drafter@v7\n        with:\n          config-name: release-drafter.yml\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n  auto_label:\n    name: Auto Label PR\n    if: github.event_name == 'pull_request_target'\n    permissions:\n      contents: read\n      pull-requests: write\n    runs-on: ubuntu-latest\n    steps:\n      - uses: release-drafter/release-drafter/autolabeler@ebb69bb56f1b0ebd19897745035726b19bef973e\n        with:\n          config-name: release-drafter.yml\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/repo-maintenance.yml",
    "content": "name: 'Repository Maintenance'\n\non:\n  schedule:\n    - cron: '0 3 * * *'\n  workflow_dispatch:\n\npermissions:\n  issues: write\n  pull-requests: write\n  discussions: write\n\nconcurrency:\n  group: lock\n\njobs:\n  stale:\n    name: 'Stale'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/stale@v10\n        with:\n          days-before-stale: 7\n          days-before-close: 14\n          stale-issue-label: stale\n          stale-pr-label: stale\n          stale-issue-message: >\n            This issue has been automatically marked as stale because it has not had\n            recent activity. It will be closed if no further activity occurs. Thank you\n            for your contributions. See our [contributing guidelines](https://github.com/gethomepage/homepage/blob/main/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details.\n  lock-threads:\n    name: 'Lock Old Threads'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: dessant/lock-threads@v6\n        with:\n          issue-inactive-days: '30'\n          pr-inactive-days: '30'\n          discussion-inactive-days: '30'\n          log-output: true\n          issue-comment: >\n            This issue has been automatically locked since there\n            has not been any recent activity after it was closed.\n            Please open a new discussion for related concerns.\n            See our [contributing guidelines](https://github.com/gethomepage/homepage/blob/main/CONTRIBUTING.md#automatic-repository-maintenance) for more details.\n          pr-comment: >\n            This pull request has been automatically locked since there\n            has not been any recent activity after it was closed.\n            Please open a new discussion for related concerns.\n            See our [contributing guidelines](https://github.com/gethomepage/homepage/blob/main/CONTRIBUTING.md#automatic-repository-maintenance) for more details.\n          discussion-comment: >\n            This discussion has been automatically locked since there\n            has not been any recent activity after it was closed.\n            Please open a new discussion for related concerns.\n            See our [contributing guidelines](https://github.com/gethomepage/homepage/blob/main/CONTRIBUTING.md#automatic-repository-maintenance) for more details.\n  close-answered-discussions:\n    name: 'Close Answered Discussions'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/github-script@v8\n        with:\n          script: |\n            function sleep(ms) {\n              return new Promise(resolve => setTimeout(resolve, ms));\n            }\n\n            const query = `query($owner:String!, $name:String!) {\n              repository(owner:$owner, name:$name){\n                discussions(first:100, answered:true, states:[OPEN]) {\n                  nodes {\n                    id,\n                    number\n                  }\n                }\n              }\n            }`;\n            const variables = {\n              owner: context.repo.owner,\n              name: context.repo.repo,\n            }\n            const result = await github.graphql(query, variables)\n\n            console.log(`Found ${result.repository.discussions.nodes.length} open answered discussions`)\n\n            for (const discussion of result.repository.discussions.nodes) {\n              console.log(`Closing discussion #${discussion.number} (${discussion.id})`)\n\n              const addCommentMutation = `mutation($discussion:ID!, $body:String!) {\n                addDiscussionComment(input:{discussionId:$discussion, body:$body}) {\n                  clientMutationId\n                }\n              }`;\n              const commentVariables = {\n                discussion: discussion.id,\n                body: 'This discussion has been automatically closed because it was marked as answered. See our [contributing guidelines](https://github.com/gethomepage/homepage/blob/main/CONTRIBUTING.md#automatic-repository-maintenance) for more details.',\n              }\n              await github.graphql(addCommentMutation, commentVariables)\n\n              const closeDiscussionMutation = `mutation($discussion:ID!, $reason:DiscussionCloseReason!) {\n                closeDiscussion(input:{discussionId:$discussion, reason:$reason}) {\n                  clientMutationId\n                }\n              }`;\n              const closeVariables = {\n                discussion: discussion.id,\n                reason: \"RESOLVED\",\n              }\n              await github.graphql(closeDiscussionMutation, closeVariables)\n\n              await sleep(1000)\n            }\n  close-outdated-discussions:\n    name: 'Close Outdated Discussions'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/github-script@v8\n        with:\n          script: |\n            function sleep(ms) {\n              return new Promise(resolve => setTimeout(resolve, ms));\n            }\n\n            const CUTOFF_DAYS = 180;\n            const cutoff = new Date();\n            cutoff.setDate(cutoff.getDate() - CUTOFF_DAYS);\n\n            const query = `query(\n                $owner:String!,\n                $name:String!,\n                $supportCategory:ID!,\n                $generalCategory:ID!,\n              ) {\n              supportDiscussions: repository(owner:$owner, name:$name){\n                discussions(\n                  categoryId:$supportCategory,\n                  last:50,\n                  answered:false,\n                  states:[OPEN],\n                ) {\n                  nodes {\n                    id,\n                    number,\n                    updatedAt\n                  }\n                },\n              },\n              generalDiscussions: repository(owner:$owner, name:$name){\n                discussions(\n                  categoryId:$generalCategory,\n                  last:50,\n                  states:[OPEN],\n                ) {\n                  nodes {\n                    id,\n                    number,\n                    updatedAt\n                  }\n                }\n              }\n            }`;\n            const variables = {\n              owner: context.repo.owner,\n              name: context.repo.repo,\n              supportCategory: \"DIC_kwDOH31rQM4CRErR\",\n              generalCategory: \"DIC_kwDOH31rQM4CRErQ\"\n            }\n            const result = await github.graphql(query, variables);\n            const combinedDiscussions = [\n              ...result.supportDiscussions.discussions.nodes,\n              ...result.generalDiscussions.discussions.nodes,\n            ]\n\n            console.log(`Checking ${combinedDiscussions.length} open discussions`);\n\n            for (const discussion of combinedDiscussions) {\n              if (new Date(discussion.updatedAt) < cutoff) {\n                console.log(`Closing outdated discussion #${discussion.number} (${discussion.id}), last updated at ${discussion.updatedAt}`);\n                const addCommentMutation = `mutation($discussion:ID!, $body:String!) {\n                  addDiscussionComment(input:{discussionId:$discussion, body:$body}) {\n                    clientMutationId\n                  }\n                }`;\n                const commentVariables = {\n                  discussion: discussion.id,\n                  body: 'This discussion has been automatically closed due to inactivity. See our [contributing guidelines](https://github.com/gethomepage/homepage/blob/main/CONTRIBUTING.md#automatic-repository-maintenance) for more details.',\n                }\n                await github.graphql(addCommentMutation, commentVariables);\n\n                const closeDiscussionMutation = `mutation($discussion:ID!, $reason:DiscussionCloseReason!) {\n                  closeDiscussion(input:{discussionId:$discussion, reason:$reason}) {\n                    clientMutationId\n                  }\n                }`;\n                const closeVariables = {\n                  discussion: discussion.id,\n                  reason: \"OUTDATED\",\n                }\n                await github.graphql(closeDiscussionMutation, closeVariables);\n\n                await sleep(1000);\n              }\n            }\n  close-unsupported-feature-requests:\n    name: 'Close Unsupported Feature Requests'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/github-script@v8\n        with:\n          script: |\n            function sleep(ms) {\n              return new Promise(resolve => setTimeout(resolve, ms));\n            }\n\n            const CUTOFF_1_DAYS = 180;\n            const CUTOFF_1_COUNT = 20;\n            const CUTOFF_2_DAYS = 365;\n            const CUTOFF_2_COUNT = 40;\n\n            const cutoff1Date = new Date();\n            cutoff1Date.setDate(cutoff1Date.getDate() - CUTOFF_1_DAYS);\n            const cutoff2Date = new Date();\n            cutoff2Date.setDate(cutoff2Date.getDate() - CUTOFF_2_DAYS);\n\n            const query = `query(\n                $owner:String!,\n                $name:String!,\n                $featureRequestsCategory:ID!,\n              ) {\n              repository(owner:$owner, name:$name){\n                discussions(\n                  categoryId:$featureRequestsCategory,\n                  last:100,\n                  states:[OPEN],\n                ) {\n                  nodes {\n                    id,\n                    number,\n                    updatedAt,\n                    upvoteCount,\n                  }\n                },\n              }\n            }`;\n            const variables = {\n              owner: context.repo.owner,\n              name: context.repo.repo,\n              featureRequestsCategory: \"DIC_kwDOH31rQM4CRErS\"\n            }\n            const result = await github.graphql(query, variables);\n\n            for (const discussion of result.repository.discussions.nodes) {\n              const discussionDate = new Date(discussion.updatedAt);\n              if ((discussionDate < cutoff1Date && discussion.upvoteCount < CUTOFF_1_COUNT) ||\n                  (discussionDate < cutoff2Date && discussion.upvoteCount < CUTOFF_2_COUNT)) {\n                console.log(`Closing discussion #${discussion.number} (${discussion.id}), last updated at ${discussion.updatedAt} with votes ${discussion.upvoteCount}`);\n                const addCommentMutation = `mutation($discussion:ID!, $body:String!) {\n                  addDiscussionComment(input:{discussionId:$discussion, body:$body}) {\n                    clientMutationId\n                  }\n                }`;\n                const commentVariables = {\n                  discussion: discussion.id,\n                  body: 'This discussion has been automatically closed due to lack of community support. See our [contributing guidelines](https://github.com/gethomepage/homepage/blob/main/CONTRIBUTING.md#automatic-repository-maintenance) for more details.',\n                }\n                await github.graphql(addCommentMutation, commentVariables);\n\n                const closeDiscussionMutation = `mutation($discussion:ID!, $reason:DiscussionCloseReason!) {\n                  closeDiscussion(input:{discussionId:$discussion, reason:$reason}) {\n                    clientMutationId\n                  }\n                }`;\n                const closeVariables = {\n                  discussion: discussion.id,\n                  reason: \"OUTDATED\",\n                }\n                await github.graphql(closeDiscussionMutation, closeVariables);\n\n                await sleep(1000);\n              }\n            }\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Tests\n\non:\n  pull_request:\n  push:\n  workflow_dispatch:\n\njobs:\n  vitest:\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        shard: [1, 2, 3, 4]\n    steps:\n      - uses: actions/checkout@v6\n\n      - uses: pnpm/action-setup@v5\n        with:\n          version: 9\n\n      - uses: actions/setup-node@v6\n        with:\n          node-version: 20\n          cache: pnpm\n\n      - run: pnpm install --frozen-lockfile\n      # Run Vitest directly so `--shard` is parsed as an option\n      - run: pnpm -s exec vitest run --coverage --shard ${{ matrix.shard }}/4 --pool forks\n      - name: Upload coverage reports to Codecov\n        uses: codecov/codecov-action@v5\n        with:\n          token: ${{ secrets.CODECOV_TOKEN }}\n          files: ./coverage/lcov.info\n          flags: vitest,shard-${{ matrix.shard }}\n          name: vitest-shard-${{ matrix.shard }}\n          fail_ci_if_error: true\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n.pnpm-store\n\n# testing\n/coverage\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# log files\nerror.log\nhomepage.log\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n.pnpm-debug.log*\n\n# local env files\n.env*.local\n\n# vercel\n.vercel\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\n\n# homepage\n/config\n\n# IDEs\n/.idea/\n\n# Zensical documentation\nsite*/\n.cache/\n\n# venv\n.venv/\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "# See https://pre-commit.com for more information\n# See https://pre-commit.com/hooks.html for more hooks\nrepos:\n-   repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v3.2.0\n    hooks:\n    -   id: trailing-whitespace\n    -   id: end-of-file-fixer\n    -   id: check-yaml\n        exclude: \"(^mkdocs\\\\.yml$)\"\n    -   id: check-added-large-files\n-   repo: https://github.com/rbubley/mirrors-prettier\n    rev: 'v3.3.3'\n    hooks:\n    -   id: prettier\n        types_or:\n          - javascript\n          - markdown\n          - jsx\n        additional_dependencies:\n          - prettier@3.3.3\n          - 'prettier-plugin-organize-imports@4.1.0'\n"
  },
  {
    "path": ".prettierrc.js",
    "content": "const config = {\n  plugins: [require(\"prettier-plugin-organize-imports\")],\n};\n\nmodule.exports = config;\n"
  },
  {
    "path": ".python-version",
    "content": "3.13\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n  \"configurations\": [\n    {\n      \"name\": \"Debug homepage\",\n      \"type\": \"node\",\n      \"preLaunchTask\": \"pnpm install\",\n      \"request\": \"launch\",\n      \"runtimeExecutable\": \"pnpm\",\n      \"runtimeArgs\": [\"run\", \"dev\"],\n      \"env\": {\n        \"LOG_LEVEL\": \"debug\"\n      },\n      \"skipFiles\": [\"<node_internals>/**\"],\n      \"console\": \"integratedTerminal\",\n      \"serverReadyAction\":{\n        \"pattern\": \".*http://localhost:3000.*\",\n        \"action\": \"startDebugging\",\n        \"name\": \"Launch Chromium\",\n        \"killOnServerStop\": true,\n      }\n    },\n    {\n      \"name\": \"Launch Chromium\",\n      \"type\": \"chrome\",\n      \"request\": \"launch\",\n      \"url\": \"http://localhost:3000\",\n      \"urlFilter\": \"http://localhost:3000\",\n      \"webRoot\": \"${workspaceFolder}\",\n      \"trace\": true\n    }\n  ]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n    \"files.exclude\": {\n        \"**/.next\": true,\n        \"**/node_modules\": true\n    },\n    \"yaml.schemas\": {\n        \"https://squidfunk.github.io/mkdocs-material/schema.json\": \"mkdocs.yml\"\n    },\n    \"yaml.customTags\": [\n        \"!ENV scalar\",\n        \"!ENV sequence\",\n        \"tag:yaml.org,2002:python/name:material.extensions.emoji.to_svg\",\n        \"tag:yaml.org,2002:python/name:material.extensions.emoji.twemoji\",\n        \"tag:yaml.org,2002:python/name:pymdownx.superfences.fence_code_format\"\n    ],\n    \"[python]\": {\n        \"editor.defaultFormatter\": \"ms-python.autopep8\"\n    },\n}\n"
  },
  {
    "path": ".vscode/tasks.json",
    "content": "{\n\t\"version\": \"2.0.0\",\n\t\"tasks\": [\n\t\t{\n\t\t\t\"type\": \"shell\",\n\t\t\t\"label\": \"pnpm install\",\n            \"command\": \"pnpm install\",\n            \"group\": {\n                \"kind\": \"build\",\n                \"isDefault\": true\n            },\n            \"presentation\": {\n                \"clear\": true,\n                \"panel\": \"shared\",\n                \"showReuseMessage\": false\n            },\n            \"problemMatcher\": []\n\n\t\t}\n\t]\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\nben@phelps.io.\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 Homepage\n\nWe love your input! We want to make contributing to this project as easy and transparent as possible, whether it's:\n\n- Reporting a bug\n- Discussing the current state of the project\n- Submitting a fix\n- Proposing new features\n- Becoming a maintainer\n\n## We Develop with Github\n\nWe use github to host code, to track issues and feature requests, as well as accept pull requests.\n\n## Any contributions you make will be under the GNU General Public License v3.0\n\nIn short, when you submit code changes, your submissions are understood to be under the same [GNU General Public License v3.0](https://choosealicense.com/licenses/gpl-3.0/) that covers the project. Feel free to contact the maintainers if that's a concern.\n\n## Report bugs using Github [discussions](https://github.com/gethomepage/homepage/discussions)\n\nWe use GitHub discussions to triage bugs. Report a bug by [opening a new discussion](https://github.com/gethomepage/homepage/discussions/new?category=support); it's that easy! Please do not open an issue unless instructed to do so by a project maintainer.\n\n## Write bug reports with detail, background, and sample configurations\n\nHomepage includes a lot of configuration options and is often deploying in larger systems. Please include as much information (configurations, deployment method, Docker & API versions, etc) as you can when reporting an issue.\n\n**Great Bug Reports** tend to have:\n\n- A quick summary and/or background\n- Steps to reproduce\n  - Be specific!\n  - Give example configurations if you can.\n- What you expected would happen\n- What actually happens\n- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work)\n\nPeople _love_ thorough bug reports. I'm not even kidding.\n\n## Development Guidelines\n\nPlease see the [documentation regarding development](https://gethomepage.dev/widgets/authoring/getting-started/#development) and specifically the [guidelines for new service widgets](https://gethomepage.dev/widgets/authoring/getting-started/#service-widget-guidelines) if you are considering making one.\n\n## Use a Consistent Coding Style\n\nPlease see information in the docs regarding [code formatting with pre-commit hooks](https://gethomepage.dev/widgets/authoring/getting-started/#code-formatting-with-pre-commit-hooks).\n\n## License\n\nBy contributing, you agree that your contributions will be licensed under its GNU General Public License.\n\n## Use of AI for pull requests\n\nIn general, homepage does not accept \"AI-generated\" PRs. If you choose to use something like that to aid the development process to generate a significant proportion of the pull request, please make sure this is explicitly stated in the PR itself.\n\n## References\n\nThis document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/main/CONTRIBUTING.md)\n\n## Automatic Respository Maintenance\n\nThe homepage team appreciates all effort and interest from the community in filing bug reports, creating feature requests, sharing ideas and helping other community members. That said, in an effort to keep the repository organized and managebale the project uses automatic handling of certain areas:\n\n- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity.\n- Discussions with a marked answer will be automatically closed.\n- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.\n- Feature requests that do not meet the following thresholds will be closed: 20 \"up-votes\" after 180 days of inactivity or 40 \"up-votes\" after 365 days.\n\nIn all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns.\nFinally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features.\n\nThank you all for your contributions.\n"
  },
  {
    "path": "Dockerfile",
    "content": "# =========================\n# Builder Stage\n# =========================\nFROM node:22-slim AS builder\nWORKDIR /app\n\n# Setup\nRUN mkdir config\nCOPY . .\n\nARG CI\nARG BUILDTIME\nARG VERSION\nARG REVISION\nENV CI=$CI\n\n# Install and build only outside CI\nRUN if [ \"$CI\" != \"true\" ]; then \\\n      corepack enable && corepack prepare pnpm@latest --activate && \\\n      pnpm install --frozen-lockfile --prefer-offline && \\\n      NEXT_TELEMETRY_DISABLED=1 \\\n      NEXT_PUBLIC_BUILDTIME=$BUILDTIME \\\n      NEXT_PUBLIC_VERSION=$VERSION \\\n      NEXT_PUBLIC_REVISION=$REVISION \\\n      pnpm run build; \\\n    else \\\n      echo \"✅ Using prebuilt app from CI context\"; \\\n    fi\n\n# =========================\n# Runtime Stage\n# =========================\nFROM node:22-alpine AS runner\nLABEL org.opencontainers.image.title=\"Homepage\"\nLABEL org.opencontainers.image.description=\"A self-hosted services landing page, with docker and service integrations.\"\nLABEL org.opencontainers.image.url=\"https://github.com/gethomepage/homepage\"\nLABEL org.opencontainers.image.documentation='https://github.com/gethomepage/homepage/wiki'\nLABEL org.opencontainers.image.source='https://github.com/gethomepage/homepage'\nLABEL org.opencontainers.image.licenses='Apache-2.0'\n\n# Setup\nWORKDIR /app\n\n# Copy some files from context\nCOPY --link --chown=1000:1000 /public ./public/\nCOPY --link --chmod=755 docker-entrypoint.sh /usr/local/bin/\n\n# Copy only necessary files from the build stage\nCOPY --link --from=builder --chown=1000:1000 /app/.next/standalone/ ./\nCOPY --link --from=builder --chown=1000:1000 /app/.next/static/ ./.next/static\n\nRUN apk add --no-cache su-exec iputils-ping shadow\n\nUSER root\n\nENV NODE_ENV=production\nENV HOSTNAME=::\nENV PORT=3000\nEXPOSE $PORT\n\nHEALTHCHECK --interval=10s --timeout=3s --start-period=20s \\\n  CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:$PORT/api/healthcheck || exit 1\n\nENTRYPOINT [\"docker-entrypoint.sh\"]\nCMD [\"node\", \"server.js\"]\n"
  },
  {
    "path": "Dockerfile-tilt",
    "content": "# syntax = docker/dockerfile:latest\nFROM docker.io/node:18-alpine\n\nWORKDIR /app\n\nCOPY --link package.json pnpm-lock.yaml* ./\n\nRUN <<EOF\n    set -xe\n    apk add libc6-compat\n    apk add --virtual .gyp python3 make g++\n    npm install -g pnpm\n    pnpm install -g next\nEOF\n\nRUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store pnpm fetch | grep -v \"cross-device link not permitted\\|Falling back to copying packages from store\"\n\nRUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store pnpm install -r --offline\n\nCOPY . .\n\nCMD [\"npx\", \"next\", \"dev\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>.\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n  <picture>\n    <source media=\"(prefers-color-scheme: dark)\" srcset=\"images/banner_light@2x.png\">\n    <img src=\"images/banner_dark@2x.png\" width=\"65%\">\n  </picture>\n</p>\n\n<p align=\"center\">\n  A modern, <em>fully static, fast</em>, secure <em>fully proxied</em>, highly customizable application dashboard with integrations for over 100 services and translations into multiple languages. Easily configured via YAML files or through docker label discovery.\n</p>\n\n<p align=\"center\">\n  <img src=\"images/1.png?v=2\" />\n</p>\n\n<p align=\"center\">\n  <a href=\"https://github.com/gethomepage/homepage/actions/workflows/docker-publish.yml\"><img alt=\"GitHub Workflow Status (with event)\" src=\"https://img.shields.io/github/actions/workflow/status/gethomepage/homepage/docker-publish.yml\"></a>\n  &nbsp;\n  <a href=\"https://codecov.io/gh/gethomepage/homepage\"><img src=\"https://codecov.io/gh/gethomepage/homepage/graph/badge.svg?token=7SKFL4D9K7\"/></a>\n  &nbsp;\n  <a href=\"https://crowdin.com/project/gethomepage\" target=\"_blank\"><img src=\"https://badges.crowdin.net/gethomepage/localized.svg\"></a>\n  &nbsp;\n  <a href=\"https://discord.gg/k4ruYNrudu\"><img alt=\"Discord\" src=\"https://img.shields.io/discord/1019316731635834932\"></a>\n  &nbsp;\n  <a href=\"https://gethomepage.dev/\" title=\"Docs\"><img title=\"Docs\" src=\"https://github.com/gethomepage/homepage/actions/workflows/docs-publish.yml/badge.svg\"/></a>\n  &nbsp;\n  <a href=\"https://paypal.me/phelpsben\" title=\"Donate\"><img alt=\"GitHub Sponsors\" src=\"https://img.shields.io/github/sponsors/benphelps\"></a>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://www.digitalocean.com/?refcode=df14bcb7c016&utm_campaign=Referral_Invite&utm_medium=Referral_Program&utm_source=badge\"><img src=\"https://web-platforms.sfo2.cdn.digitaloceanspaces.com/WWW/Badge%201.svg\" alt=\"DigitalOcean Referral Badge\" /></a>\n</p>\n<p align=\"center\">\n<em>Homepage builds are kindly powered by DigitalOcean.</em>\n</p>\n\n# Features\n\nWith features like quick search, bookmarks, weather support, a wide range of integrations and widgets, an elegant and modern design, and a focus on performance, Homepage is your ideal start to the day and a handy companion throughout it.\n\n- **Fast** - The site is statically generated at build time for instant load times.\n- **Secure** - All API requests to backend services are proxied, keeping your API keys hidden. Constantly reviewed for security by the community.\n- **For Everyone** - Images built for AMD64, ARM64.\n- **Full i18n** - Support for over 40 languages.\n- **Service & Web Bookmarks** - Add custom links to the homepage.\n- **Docker Integration** - Container status and stats. Automatic service discovery via labels.\n- **Service Integration** - Over 100 service integrations, including popular starr and self-hosted apps.\n- **Information & Utility Widgets** - Weather, time, date, search, and more.\n- **And much more...**\n\n## Docker Integration\n\nHomepage has built-in support for Docker, and can automatically discover and add services to the homepage based on labels. See the [Docker Service Discovery](https://gethomepage.dev/configs/docker/#automatic-service-discovery) page for more information.\n\n## Service Widgets\n\nHomepage also has support for hundreds of 3rd-party services, including all popular \\*arr apps, and most popular self-hosted apps. Some examples include: Radarr, Sonarr, Lidarr, Bazarr, Ombi, Tautulli, Plex, Jellyfin, Emby, Transmission, qBittorrent, Deluge, Jackett, NZBGet, SABnzbd, etc. As well as service integrations, Homepage also has a number of information providers, sourcing information from a variety of external 3rd-party APIs. See the [Service](https://gethomepage.dev/widgets/) page for more information.\n\n## Information Widgets\n\nHomepage has built-in support for a number of information providers, including weather, time, date, search, glances and more. System and status information presented at the top of the page. See the [Information Providers](https://gethomepage.dev/widgets/) page for more information.\n\n## Customization\n\nHomepage is highly customizable, with support for custom themes, custom CSS & JS, custom layouts, formatting, localization and more. See the [Settings](https://gethomepage.dev/configs/settings/) page for more information.\n\n# Getting Started\n\nFor configuration options, examples and more, [please check out the homepage documentation](http://gethomepage.dev).\n\n## Security Notice 🔒\n\nPlease note that when using features such as widgets, Homepage can access personal information (for example from your home automation system) and Homepage currently does not (and is not planned to) include any authentication layer itself. If Homepage is reachable from any untrusted network, it **must** sit behind a reverse proxy (and/or VPN) that enforces authentication, TLS, and strictly validates Host headers. The built-in host check in Homepage is a best-effort guard and should not be treated as security when exposed publicly.\n\n## With Docker\n\nUsing docker compose:\n\n```yaml\nservices:\n  homepage:\n    image: ghcr.io/gethomepage/homepage:latest\n    container_name: homepage\n    environment:\n      HOMEPAGE_ALLOWED_HOSTS: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts\n      PUID: 1000 # optional, your user id\n      PGID: 1000 # optional, your group id\n    ports:\n      - 3000:3000\n    volumes:\n      - /path/to/config:/app/config # Make sure your local config directory exists\n      - /var/run/docker.sock:/var/run/docker.sock:ro # optional, for docker integrations\n    restart: unless-stopped\n```\n\nor docker run:\n\n```bash\ndocker run --name homepage \\\n  -e HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev \\\n  -e PUID=1000 \\\n  -e PGID=1000 \\\n  -p 3000:3000 \\\n  -v /path/to/config:/app/config \\\n  -v /var/run/docker.sock:/var/run/docker.sock:ro \\\n  --restart unless-stopped \\\n  ghcr.io/gethomepage/homepage:latest\n```\n\n## From Source\n\nFirst, clone the repository:\n\n```bash\ngit clone https://github.com/gethomepage/homepage.git\n```\n\nThen install dependencies and build the production bundle:\n\n```bash\npnpm install\npnpm build\n```\n\nIf this is your first time starting, copy the `src/skeleton` directory to `config/` to populate initial example config files.\n\nFinally, run the server in production mode:\n\n```bash\npnpm start\n```\n\n# Configuration\n\nPlease refer to the [homepage documentation website](https://gethomepage.dev/) for more information. Everything you need to know about configuring Homepage is there. Please read everything carefully before asking for help, as most questions are answered there or are simple YAML configuration issues.\n\n# Development\n\nInstall NPM packages, this project uses [pnpm](https://pnpm.io/) (and so should you!):\n\n```bash\npnpm install\n```\n\nStart the development server:\n\n```bash\npnpm dev\n```\n\nOpen [http://localhost:3000](http://localhost:3000) to start.\n\nThis is a [Next.js](https://nextjs.org/) application, see their documentation for more information.\n\n# Documentation\n\nThe homepage documentation is available at [https://gethomepage.dev/](https://gethomepage.dev/).\n\nHomepage uses Zensical for documentation. To run the documentation locally, first install the dependencies:\n\n```bash\nuv sync\n```\n\nThen run the development server:\n\n```bash\nuv run zensical serve # or build, to build the static site\n```\n\n# Support & Suggestions\n\nIf you have any questions, suggestions, or general issues, please start a discussion on the [Discussions](https://github.com/gethomepage/homepage/discussions) page.\n\n## Troubleshooting\n\nIn addition to the docs, the [troubleshooting guide](https://gethomepage.dev/troubleshooting/) can help reveal many basic config or network issues. If you're having a problem, it's a good place to start.\n\n## Contributing & Contributors\n\nContributions are welcome! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) file for more information.\n\nThanks to the over 200 contributors who have helped make this project what it is today!\n\nEspecially huge thanks to [@shamoon](https://github.com/shamoon), who has been the backbone of this community from the very start.\n"
  },
  {
    "path": "crowdin.yml",
    "content": "project_id_env: CROWDIN_PROJECT_ID\napi_token_env: CROWDIN_PERSONAL_TOKEN\npreserve_hierarchy: true\nfiles:\n  - source: /public/locales/en/*.json\n    translation: /public/locales/%osx_locale%/%original_file_name%\n"
  },
  {
    "path": "docker-entrypoint.sh",
    "content": "#!/bin/sh\n\nset -e\n\n# Default to root, so old installations won't break\nexport PUID=${PUID:-0}\nexport PGID=${PGID:-0}\n\n# This is in attempt to preserve the original behavior of the Dockerfile,\n# while also supporting the lscr.io /config directory\n[ ! -d \"/app/config\" ] && ln -s /config /app/config\n\nexport HOMEPAGE_BUILDTIME=$(date +%s)\n\n# Try IPv6 first (dual stack when available), but fall back to IPv4 if the bind fails\nexport HOSTNAME=${HOSTNAME:-::}\nif [ \"$HOSTNAME\" = \"::\" ]; then\n  if ! node -e \"const server = require('http').createServer(() => {}); const host = '::'; const port = process.env.PORT || 3000; server.once('error', (err) => { console.error('IPv6 bind failed:', err.message); process.exit(1); }); server.listen(port, host, () => server.close(() => process.exit(0)));\"; then\n    echo \"Falling back to IPv4 bind at 0.0.0.0\"\n    export HOSTNAME=0.0.0.0\n  fi\nfi\n\n# Check ownership before chown\nif [ \"$PUID\" = \"0\" ]; then\n  echo \"Skipping ownership changes for /app/config\"\nelif [ -e /app/config ]; then\n  CURRENT_UID=$(stat -c %u /app/config)\n  CURRENT_GID=$(stat -c %g /app/config)\n\n  if [ \"$CURRENT_UID\" -ne \"$PUID\" ] || [ \"$CURRENT_GID\" -ne \"$PGID\" ]; then\n    echo \"Fixing ownership of /app/config\"\n    if ! chown -R \"$PUID:$PGID\" /app/config 2>/dev/null; then\n      echo \"Warning: Could not chown /app/config; continuing anyway\"\n    fi\n  else\n    echo \"/app/config already owned by correct UID/GID, skipping chown\"\n  fi\nelse\n  echo \"/app/config does not exist; skipping ownership check\"\nfi\n\n# Ensure /app/config/logs exists and is owned\nif [ \"$PUID\" = \"0\" ]; then\n  echo \"Skipping ownership changes for /app/config/logs\"\nelif [ -n \"$PUID\" ] && [ -n \"$PGID\" ]; then\n  mkdir -p /app/config/logs 2>/dev/null || true\n  if [ -d /app/config/logs ]; then\n    LOG_UID=$(stat -c %u /app/config/logs)\n    LOG_GID=$(stat -c %g /app/config/logs)\n    if [ \"$LOG_UID\" -ne \"$PUID\" ] || [ \"$LOG_GID\" -ne \"$PGID\" ]; then\n      echo \"Fixing ownership of /app/config/logs\"\n      chown -R \"$PUID:$PGID\" /app/config/logs 2>/dev/null || echo \"Warning: Could not chown /app/config/logs\"\n    fi\n  fi\nfi\n\nif [ -d /app/.next ]; then\n  CURRENT_UID=$(stat -c %u /app/.next)\n  CURRENT_GID=$(stat -c %g /app/.next)\n\n  if [ \"$PUID\" -ne 0 ] && ([ \"$CURRENT_UID\" -ne \"$PUID\" ] || [ \"$CURRENT_GID\" -ne \"$PGID\" ]); then\n    echo \"Fixing ownership of /app/.next\"\n    if ! chown -R \"$PUID:$PGID\" /app/.next 2>/dev/null; then\n      echo \"Warning: Could not chown /app/.next; continuing anyway\"\n    fi\n  else\n    echo \"/app/.next already owned by correct UID/GID or running as root, skipping chown\"\n  fi\nfi\n\n# Drop privileges (when asked to) if root, otherwise run as current user\nif [ \"$(id -u)\" = \"0\" ] && [ \"${PUID}\" != \"0\" ]; then\n  exec su-exec ${PUID}:${PGID} \"$@\"\nelse\n  exec \"$@\"\nfi\n"
  },
  {
    "path": "docs/CNAME",
    "content": "gethomepage.dev\n"
  },
  {
    "path": "docs/configs/bookmarks.md",
    "content": "---\ntitle: Bookmarks\ndescription: Bookmark Configuration\n---\n\nBookmarks are configured in the `bookmarks.yaml` file. They function much the same as [Services](services.md), in how groups and lists work. They're just much simpler, smaller, and contain no extra features other than being a link out.\n\nThe design of homepage expects `abbr` to be 2 letters, but is not otherwise forced.\n\nYou can also use an icon for bookmarks similar to the [options for service icons](services.md#icons). If both icon and abbreviation are supplied, the icon takes precedence.\n\nBy default, the description will use the hostname of the link, but you can override it with a custom description.\n\n```yaml\n---\n- Developer:\n    - Github:\n        - abbr: GH\n          href: https://github.com/\n\n- Social:\n    - Reddit:\n        - icon: reddit.png\n          href: https://reddit.com/\n          description: The front page of the internet\n\n- Entertainment:\n    - YouTube:\n        - abbr: YT\n          href: https://youtube.com/\n```\n\nwhich renders to (depending on your theme, etc.):\n\n<img width=\"1000\" alt=\"Bookmarks\" src=\"https://user-images.githubusercontent.com/19408/269307009-d7e45885-230f-4e07-b421-9822017ae878.png\">\n\nThe default [bookmarks.yaml](https://github.com/gethomepage/homepage/blob/main/src/skeleton/bookmarks.yaml) is a working example.\n"
  },
  {
    "path": "docs/configs/custom-css-js.md",
    "content": "---\ntitle: Custom CSS & JS\ndescription: Adding Custom CSS or JS\n---\n\nAs of version v0.6.30 homepage supports adding your own custom css & javascript. Please do so **at your own risk**.\n\nTo add custom css simply edit the `custom.css` file under your config directory, similarly for javascript you would edit `custom.js`. You can then target elements in homepage with various classes / ids to customize things to your liking.\n\nYou can also set a specific `id` for a service or bookmark to target with your custom css or javascript, e.g.\n\n```yaml\nService:\n    id: myserviceid\n    icon: icon.png\n    ...\n```\n"
  },
  {
    "path": "docs/configs/docker.md",
    "content": "---\ntitle: Docker\ndescription: Docker Configuration\n---\n\nDocker instances are configured inside the `docker.yaml` file. Both IP:PORT and Socket connections are supported.\n\nFor IP:PORT, simply make sure your Docker instance [has been configured](https://gist.github.com/styblope/dc55e0ad2a9848f2cc3307d4819d819f) to accept API traffic over the HTTP API.\n\n```yaml\nmy-remote-docker:\n  host: 192.168.0.101\n  port: 2375\n```\n\n## Using Docker TLS\n\nSince Docker supports connecting with TLS and client certificate authentication, you can include TLS details when connecting to the HTTP API. Further details of setting up Docker to accept TLS connections, and generation of the keys and certs can be found [in the Docker documentation](https://docs.docker.com/engine/security/protect-access/#use-tls-https-to-protect-the-docker-daemon-socket). The file entries are relative to the `config` directory (location of `docker.yaml` file).\n\n```yaml\nmy-remote-docker:\n  host: 192.168.0.101\n  port: 2375\n  tls:\n    keyFile: tls/key.pem\n    caFile: tls/ca.pem\n    certFile: tls/cert.pem\n```\n\n## Using Docker Socket Proxy\n\nDue to security concerns with exposing the docker socket directly, you can use a [docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy) container to expose the docker socket on a more restricted and secure API.\n\nHere is an example docker-compose file that will expose the docker socket, and then connect to it from the homepage container:\n\n```yaml\ndockerproxy:\n  image: ghcr.io/tecnativa/docker-socket-proxy:latest\n  container_name: dockerproxy\n  environment:\n    - CONTAINERS=1 # Allow access to viewing containers\n    - SERVICES=1 # Allow access to viewing services (necessary when using Docker Swarm)\n    - TASKS=1 # Allow access to viewing tasks (necessary when using Docker Swarm)\n    - POST=0 # Disallow any POST operations (effectively read-only)\n  ports:\n    - 127.0.0.1:2375:2375\n  volumes:\n    - /var/run/docker.sock:/var/run/docker.sock:ro # Mounted as read-only\n  restart: unless-stopped\n\nhomepage:\n  image: ghcr.io/gethomepage/homepage:latest\n  container_name: homepage\n  volumes:\n    - /path/to/config:/app/config\n  ports:\n    - 3000:3000\n  restart: unless-stopped\n```\n\nThen, inside of your `docker.yaml` settings file, you'd configure the docker instance like so:\n\n```yaml\nmy-docker:\n  host: dockerproxy\n  port: 2375\n```\n\nUse `protocol: https` if you’re connecting through a reverse proxy (e.g., Traefik) that serves the Docker API over HTTPS:\n\n```yaml\nmy-docker:\n  host: dockerproxy\n  port: 443\n  protocol: https\n```\n\n!!! note\n\n    Note: This does not require TLS certificates if the proxy handles encryption. Do not use `protocol: https` unless you’re sure the target host supports HTTPS.\n\nYou can also include `headers` for the connection, for example, if you are using a reverse proxy that requires authentication:\n\n```yaml\nmy-docker:\n  host: dockerproxy\n  port: 443\n  protocol: https\n  headers:\n    Authorization: Basic <base64-encoded-credentials>\n```\n\n## Using Socket Directly\n\nIf you'd rather use the socket directly, first make sure that you're passing the local socket into the Docker container.\n\n!!! note\n\n    In order to use the socket directly homepage must be running as root\n\n```yaml\nhomepage:\n  image: ghcr.io/gethomepage/homepage:latest\n  container_name: homepage\n  volumes:\n    - /path/to/config:/app/config\n    - /var/run/docker.sock:/var/run/docker.sock # pass local proxy\n  ports:\n    - 3000:3000\n  restart: unless-stopped\n```\n\nIf you're using `docker run`, this would be `-v /var/run/docker.sock:/var/run/docker.sock`.\n\nThen, inside of your `docker.yaml` settings file, you'd configure the docker instance like so:\n\n```yaml\nmy-docker:\n  socket: /var/run/docker.sock\n```\n\n## Services\n\nOnce you've configured your docker instances, you can then apply them to your services, to get stats and status reporting shown.\n\nInside of the service you'd like to connect to docker:\n\n```yaml\n- Emby:\n  icon: emby.png\n  href: \"http://emby.home/\"\n  description: Media server\n  server: my-docker # The docker server that was configured\n  container: emby # The name of the container you'd like to connect\n```\n\n## Automatic Service Discovery\n\nHomepage features automatic service discovery for containers with the proper labels attached, all configuration options can be applied using dot notation, beginning with `homepage`.\n\nBelow is an example of the same service entry shown above, as docker labels.\n\n```yaml\nservices:\n  emby:\n    image: lscr.io/linuxserver/emby:latest\n    container_name: emby\n    ports:\n      - 8096:8096\n    restart: unless-stopped\n    labels:\n      - homepage.group=Media\n      - homepage.name=Emby\n      - homepage.icon=emby.png\n      - homepage.href=http://emby.home/\n      - homepage.description=Media server\n```\n\nWhen your Docker instance has been properly configured, this service will be automatically discovered and added to your Homepage. **You do not need to specify the `server` or `container` values, as they will be automatically inferred.**\n\n**When using docker swarm use _deploy/labels_**\n\n## Widgets\n\nYou may also configure widgets, along with the standard service entry, again, using dot notation.\n\n```yaml\nlabels:\n  - homepage.group=Media\n  - homepage.name=Emby\n  - homepage.icon=emby.png\n  - homepage.href=http://emby.home/\n  - homepage.description=Media server\n  - homepage.widget.type=emby\n  - homepage.widget.url=http://emby.home\n  - homepage.widget.key=yourembyapikeyhere\n  - homepage.widget.fields=[\"field1\",\"field2\"] # optional\n```\n\n!!! note\n\n    If you use mapping syntax (`:`) for labels instead of list syntax (`-`), array values like `fields` must be wrapped in single quotes so they are passed as a string:\n\n    ```yaml\n    labels:\n      ...\n      homepage.widget.fields: '[\"field1\",\"field2\"]'\n    ```\n\nMultiple widgets can be specified by incrementing the index, e.g.\n\n```yaml\nlabels: ...\n  - homepage.widgets[0].type=emby\n  - homepage.widgets[0].url=http://emby.home\n  - homepage.widgets[0].key=yourembyapikeyhere\n  - homepage.widgets[1].type=uptimekuma\n  - homepage.widgets[1].url=http://uptimekuma.home\n  - homepage.widgets[1].slug=youreventslughere\n```\n\nTo pass custom HTTP headers with a widget request when using labels, use the same dot-notation: `homepage.widget.headers.X-Auth-Key=secret` (or `homepage.widgets[0].headers.X-Auth-Key=secret` when multiple widgets are present).\n\nYou can add specify fields for e.g. the [CustomAPI](../widgets/services/customapi.md) widget by using array-style dot notation:\n\n```yaml\nlabels:\n  - homepage.group=Media\n  - homepage.name=Emby\n  - homepage.icon=emby.png\n  - homepage.href=http://emby.home/\n  - homepage.description=Media server\n  - homepage.widget.type=customapi\n  - homepage.widget.url=http://argus.service/api/v1/service/summary/emby\n  - homepage.widget.mappings[0].label=Deployed Version\n  - homepage.widget.mappings[0].field.status=deployed_version\n  - homepage.widget.mappings[1].label=Latest Version\n  - homepage.widget.mappings[1].field.status=latest_version\n```\n\n## Docker Swarm\n\nDocker swarm is supported and Docker services are specified with the same `server` and `container` notation. To enable swarm support you will need to include a `swarm` setting in your docker.yaml, e.g.\n\n```yaml\nmy-docker:\n  socket: /var/run/docker.sock\n  swarm: true\n```\n\nFor the automatic service discovery to discover all services it is important that homepage should be deployed on a manager node. Set deploy requirements to the master node in your stack yaml config, e.g.\n\n```yaml\n....\n  deploy:\n    placement:\n      constraints:\n        - node.role == manager\n...\n```\n\nIn order to detect every service within the Docker swarm it is necessary that service labels should be used and not container labels. Specify the homepage labels as:\n\n```yaml\n....\n  deploy:\n    labels:\n      - homepage.icon=foobar\n...\n```\n\n## Multiple Homepage Instances\n\nThe optional field `instanceName` can be configured in [settings.yaml](settings.md#instance-name) to differentiate between multiple homepage instances.\n\nTo limit a label to an instance, insert `.instance.{{instanceName}}` after the `homepage` prefix.\n\n```yaml\nlabels:\n  - homepage.group=Media\n  - homepage.name=Emby\n  - homepage.icon=emby.png\n  - homepage.instance.internal.href=http://emby.lan/\n  - homepage.instance.public.href=https://emby.mydomain.com/\n  - homepage.description=Media server\n```\n\n## Ordering\n\nAs of v0.6.4 discovered services can include an optional `weight` field to determine sorting such that:\n\n- Default weight for discovered services is 0\n- Default weight for configured services is their index within their group scaled by 100, i.e. (index + 1) \\* 100\n- If two items have the same weight value, then they will be sorted by name\n\n## Show stats\n\nYou can show the docker stats by clicking the status indicator but this can also be controlled per-service with:\n\n```yaml\n- Example Service:\n  ...\n  showStats: true\n```\n\nAlso see the settings for [show docker stats](settings.md#show-container-stats).\n"
  },
  {
    "path": "docs/configs/index.md",
    "content": "---\ntitle: Configuration\ndescription: Homepage Configuration\nicon: material/cog\n---\n\nHomepage uses YAML for configuration, YAML stands for \"YAML Ain't Markup Language.\". It's a human-readable data serialization format that's a superset of JSON. Great for config files, easy to read and write. Supports complex data types like lists and objects. **Indentation matters.** If you already use Docker Compose, you already use YAML.\n\nHere are some tips when writing YAML:\n\n1. **Use Indentation Carefully**: YAML relies on indentation, not brackets.\n2. Avoid Tabs: Stick to spaces for indentation to avoid parsing errors. 2 spaces are common.\n3. Quote Strings: Use single or double quotes for strings with special characters, this is especially important for API keys.\n4. Key-Value Syntax: Use key: value format. Colon must be followed by a space.\n5. Validate: Always validate your YAML with a linter before deploying.\n\nYou can find tons of online YAML validators, here's one: [https://codebeautify.org/yaml-validator](https://codebeautify.org/yaml-validator), heres another: [https://jsonformatter.org/yaml-validator](https://jsonformatter.org/yaml-validator).\n"
  },
  {
    "path": "docs/configs/info-widgets.md",
    "content": "---\ntitle: Information Widgets\ndescription: Homepage info widgets.\n---\n\nInformation widgets are widgets that provide information about your system or environment and are displayed at the top of the homepage. You can find a list of all available info widgets under the [Info Widgets](../widgets/info/index.md) section.\n\nInfo widgets are defined in the widgets.yaml\n\nEach widget has its own configuration options, which are detailed in the widget's documentation.\n\n## Layout\n\nInfo widgets are displayed in the order they are defined in the `widgets.yaml` file. You can change the order by moving the widgets around in the file. However, some widgets (weather, search and datetime) are aligned to the right side of the screen which can affect the layout of the widgets.\n\n## Adding A Link\n\nYou can add a link to an info widget such as the logo or text widgets by adding an `href` option, for example:\n\n```yaml\nlogo:\n  href: https://example.com\n  target: _blank # Optional, can be set in settings\n```\n"
  },
  {
    "path": "docs/configs/kubernetes.md",
    "content": "---\ntitle: Kubernetes\ndescription: Kubernetes Configuration\n---\n\nThe Kubernetes connectivity has the following requirements:\n\n- Kubernetes 1.19+\n- Metrics Service\n- An Ingress controller\n  - Optionally: Gateway-API\n\nThe Kubernetes connection is configured in the `kubernetes.yaml` file. There are 3 modes to choose from:\n\n- **disabled** - disables kubernetes connectivity\n- **default** - uses the default kubeconfig [resolution](https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/)\n- **cluster** - uses a service account inside the cluster\n\n```yaml\nmode: default\n```\n\nTo configure Kubernetes gateway-api, ingress or ingressRoute service discovery, add one or multiple of the following settings.\n\nExample settings:\n\n```yaml\ningress: true # default, enable ingress only\n```\n\nor\n\n```yaml\ningress: true # default, enable ingress\ntraefik: true # enable traefik ingressRoute\ngateway: true # enable gateway-api\n```\n\n## Services\n\nOnce the Kubernetes connection is configured, individual services can be configured to pull statistics. Only CPU and Memory are currently supported.\n\nInside of the service you'd like to connect to a pod:\n\n```yaml\n- Emby:\n  icon: emby.png\n  href: \"http://emby.home/\"\n  description: Media server\n  namespace: media # The kubernetes namespace the app resides in\n  app: emby # The name of the deployed app\n```\n\nThe `app` field is used to create a label selector, in this example case it would match pods with the label: `app.kubernetes.io/name=emby`.\n\nSometimes this is insufficient for complex or atypical application deployments. In these cases, the `podSelector` field can be used. Any field selector can be used with it, so it allows for some very powerful selection capabilities.\n\nFor instance, it can be utilized to roll multiple underlying deployments under one application to see a high-level aggregate:\n\n```yaml\n- Element Chat:\n    icon: matrix-light.png\n    href: https://chat.example.com\n    description: Matrix Synapse Powered Chat\n    app: matrix-element\n    namespace: comms\n    podSelector: >-\n      app.kubernetes.io/instance in (\n          matrix-element,\n          matrix-media-repo,\n          matrix-media-repo-postgresql,\n          matrix-synapse\n      )\n```\n\n!!! note\n\n    A blank string as a podSelector does not deactivate it, but will actually select all pods in the namespace. This is a useful way to capture the resource usage of a complex application siloed to a single namespace, like Longhorn.\n\n## Automatic Service Discovery\n\nHomepage features automatic service discovery by Ingress annotations. All configuration options can be applied using typical annotation syntax, beginning with `gethomepage.dev/`.\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: emby\n  annotations:\n    gethomepage.dev/enabled: \"true\"\n    gethomepage.dev/description: Media Server\n    gethomepage.dev/group: Media\n    gethomepage.dev/icon: emby.png\n    gethomepage.dev/name: Emby\n    gethomepage.dev/widget.type: \"emby\"\n    gethomepage.dev/widget.url: \"https://emby.example.com\"\n    gethomepage.dev/widget.headers.X-Auth-Key: \"your-secret-here\"\n    gethomepage.dev/pod-selector: \"\"\n    gethomepage.dev/weight: 10 # optional\n    gethomepage.dev/instance: \"public\" # optional\nspec:\n  rules:\n    - host: emby.example.com\n      http:\n        paths:\n          - backend:\n              service:\n                name: emby\n                port:\n                  number: 8080\n            path: /\n            pathType: Prefix\n```\n\nWhen the Kubernetes cluster connection has been properly configured, this service will be automatically discovered and added to your Homepage. **You do not need to specify the `namespace` or `app` values, as they will be automatically inferred.**\n\nIf you are using multiple instances of homepage, an `instance` annotation can be specified to limit services to a specific instance. If no instance is provided, the service will be visible on all instances.\n\nIf you have a single service that needs to be shown on multiple specific instances of homepage (but not on all of them), the service can be annotated by multiple `instance.name` annotations, where `name` can be the names of your specific multiple homepage instances. For example, a service that is annotated with `gethomepage.dev/instance.public: \"\"` and `gethomepage.dev/instance.internal: \"\"` will be shown on `public` and `internal` homepage instances.\n\nUse the `gethomepage.dev/pod-selector` selector to specify the pod used for the health check. For example, a service that is annotated with `gethomepage.dev/pod-selector: app.kubernetes.io/name=deployment` would link to a pod with the label `app.kubernetes.io/name: deployment`.\n\n### Traefik IngressRoute support\n\nHomepage can also read ingresses defined using the Traefik IngressRoute custom resource definition. Due to the complex nature of Traefik routing rules, it is required for the `gethomepage.dev/href` annotation to be set:\n\n```yaml\napiVersion: traefik.io/v1alpha1\nkind: IngressRoute\nmetadata:\n  name: emby\n  annotations:\n    gethomepage.dev/href: \"https://emby.example.com\"\n    gethomepage.dev/enabled: \"true\"\n    gethomepage.dev/description: Media Server\n    gethomepage.dev/group: Media\n    gethomepage.dev/icon: emby.png\n    gethomepage.dev/app: emby-app # optional, may be needed if app.kubernetes.io/name != ingress metadata.name\n    gethomepage.dev/name: Emby\n    gethomepage.dev/widget.type: \"emby\"\n    gethomepage.dev/widget.url: \"https://emby.example.com\"\n    gethomepage.dev/pod-selector: \"\"\n    gethomepage.dev/weight: 10 # optional\n    gethomepage.dev/instance: \"public\" # optional\nspec:\n  entryPoints:\n    - websecure\n  routes:\n    - kind: Rule\n      match: Host(`emby.example.com`)\n      services:\n        - kind: Service\n          name: emby\n          namespace: emby\n          port: 8080\n          scheme: http\n          strategy: RoundRobin\n          weight: 10\n```\n\nIf the `href` attribute is not present, Homepage will ignore the specific IngressRoute.\n\n### Gateway API HttpRoute support\n\nHomepage also features automatic service discovery for Gateway API. Service definitions are read by annotating the HttpRoute custom resource definition and are indentical to the Ingress example as defined in [Automatic Service Discovery](#automatic-service-discovery).\n\nTo enable Gateway API HttpRoute update `kubernetes.yaml` to include:\n\n```\ngateway: true # enable gateway-api\n```\n\n#### Using the unoffocial helm chart?\n\nIf you are using the unofficial helm chart ensure that the `ClusterRole` has required permissions for `gateway.networking.k8s.io`.\n\nSee [ClusterRole and ClusterRoleBinding](../installation/k8s.md#clusterrole-and-clusterrolebinding)\n\n## Caveats\n\nSimilarly to Docker service discovery, there currently is no rigid ordering to discovered services and discovered services will be displayed above those specified in the `services.yaml`.\n\n## Adding extra configuration files\n\nSome Homepage features (for example, [Proxmox](../configs/proxmox.md)) require additional configuration files such as `proxmox.yaml`.\nWhen running Homepage on Kubernetes, these files must be provided via a `ConfigMap` and mounted into the container at `/app/config`.\n\n### ConfigMap example\n\n```yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: homepage\ndata:\n  proxmox.yaml: |\n    pve:\n      url: https://proxmox.host.or.ip:8006\n      token: username@pam!Token ID\n      secret: secret\n```\n\nMount the file into `/app/config` by updating the `Deployment`:\n\n```yaml\nvolumeMounts:\n  - mountPath: /app/config/proxmox.yaml\n    name: homepage-config\n    subPath: proxmox.yaml\n```\n"
  },
  {
    "path": "docs/configs/proxmox.md",
    "content": "---\ntitle: Proxmox\ndescription: Proxmox Configuration\n---\n\nThe Proxmox connection is configured in the `proxmox.yaml` file. See [Create token](#create-token) section below for details on how to generate the required API token.\nTo configure multiple nodes, ensure the key name in the `proxmox.yaml` matches the `proxmoxNode` field used in your service configuration.\n\n```yaml\npve: # must match your actual Proxmox node name\n  url: https://proxmox.host.or.ip:8006\n  token: username@pam!Token ID\n  secret: secret\n```\n\n## Services\n\nOnce the Proxmox connection is configured, individual services can be configured to pull statistics of VMs or LXCs. Only CPU and Memory are currently supported.\n\n### Configuration Options\n\n- `proxmoxNode`: The name of the Proxmox node where your VM/LXC is running, must match with a node configured in the `proxmox.yaml`\n- `proxmoxVMID`: The ID of the Proxmox VM or LXC container\n- `proxmoxType`: (Optional) The type of Proxmox virtual machine. Defaults to `qemu` for VMs, but can be set to `lxc` for LXC containers\n\n#### Examples\n\nFor a QEMU VM (default):\n\n```yaml\n- HomeAssistant:\n  icon: home-assistant.png\n  href: http://homeassistant.local/\n  description: Home automation\n  proxmoxNode: pve\n  proxmoxVMID: 101\n  # proxmoxType: qemu # This is the default, so it can be omitted\n```\n\nFor an LXC container:\n\n```yaml\n- Nginx:\n  icon: nginx.png\n  href: http://nginx.local/\n  description: Web server\n  proxmoxNode: pve\n  proxmoxVMID: 200\n  proxmoxType: lxc\n```\n\n## Create token\n\nYou will need to generate an API Token for new or an existing user. Here is an example of how to do this for a new user.\n\n1.  Navigate to the Proxmox portal, click on Datacenter\n2.  Expand Permissions, click on Groups\n3.  Click the Create button\n4.  Name the group something informative, like api-ro-users\n5.  Click on the Permissions \"folder\"\n6.  Click Add -> Group Permission\n    - Path: /\n    - Group: group from bullet 4 above\n    - Role: PVEAuditor\n    - Propagate: Checked\n7.  Expand Permissions, click on Users\n8.  Click the Add button\n    - User name: something informative like `api`\n    - Realm: Linux PAM standard authentication\n    - Group: group from bullet 4 above\n9.  Expand Permissions, click on API Tokens\n10. Click the Add button\n    - User: user from bullet 8 above\n    - Token ID: something informative like the application or purpose like `homepage`\n    - Privilege Separation: Checked\n11. Go back to the \"Permissions\" menu\n12. Click Add -> API Token Permission\n    - Path: /\n    - API Token: select the Token ID created in Step 10\n    - Role: PVE Auditor\n    - Propagate: Checked\n"
  },
  {
    "path": "docs/configs/services.md",
    "content": "---\ntitle: Services\ndescription: Service Configuration\n---\n\nServices are configured inside the `services.yaml` file. You can have any number of groups, and any number of services per group.\n\n## Groups\n\nGroups are defined as top-level array entries.\n\n```yaml\n- Group A:\n    - Service A:\n        href: http://localhost/\n\n- Group B:\n    - Service B:\n        href: http://localhost/\n```\n\n<img width=\"1038\" alt=\"Service Groups\" src=\"https://user-images.githubusercontent.com/82196/187040754-28065242-4534-4409-881c-93d1921c6141.png\">\n\n### Nested Groups\n\nGroups can be nested by using the same format as the top-level groups.\n\n```yaml\n- Group A:\n    - Service A:\n        href: http://localhost/\n\n    - Group B:\n        - Service B:\n            href: http://localhost/\n\n        - Service C:\n            href: http://localhost/\n```\n\n## Services\n\nServices are defined as array entries on groups,\n\n```yaml\n- Group A:\n    - Service A:\n        href: http://localhost/\n\n    - Service B:\n        href: http://localhost/\n\n    - Service C:\n        href: http://localhost/\n\n- Group B:\n    - Service D:\n        href: http://localhost/\n```\n\n<img width=\"1038\" alt=\"Service Services\" src=\"https://user-images.githubusercontent.com/82196/187040763-038023a2-8bee-4d87-b5cc-13447e7365a4.png\">\n\n### Service Widgets\n\nEach service can have widgets attached to it (often matching the service type, but that's not forced).\n\nIn addition to the href of the service, you can also specify the target location in which to open that link. See [Link Target](settings.md#link-target) for more details.\n\nUsing Emby as an example, this is how you would attach the Emby service widget.\n\n```yaml\n- Emby:\n    icon: emby.png\n    href: http://emby.host.or.ip/\n    description: Movies & TV Shows\n    widget:\n      type: emby\n      url: http://emby.host.or.ip\n      key: apikeyapikeyapikeyapikeyapikey\n```\n\n#### Multiple Widgets\n\nEach service can have multiple widgets attached to it, for example:\n\n```yaml\n- Emby:\n    icon: emby.png\n    href: http://emby.host.or.ip/\n    description: Movies & TV Shows\n    widgets:\n      - type: emby\n        url: http://emby.host.or.ip\n        key: apikeyapikeyapikeyapikeyapikey\n      - type: uptimekuma\n        url: http://uptimekuma.host.or.ip:port\n        slug: statuspageslug\n```\n\n!!! note\n\n      Multiple widgets per service are not yet supported with Kubernetes ingress annotations.\n\n#### Custom HTTP headers\n\nWidgets that make HTTP calls support extra request headers via `headers`. This is useful when a reverse proxy expects a secret header.\n\n```yaml\n- UptimeRobot:\n    icon: uptimekuma.png\n    href: https://uptimerobot.com/\n    widget:\n      type: uptimerobot\n      url: https://api.uptimerobot.com\n      key: ${UPTIMEROBOT_API_KEY}\n      headers:\n        User-Agent: homepage\n        X-Auth-Key: your-secret-here\n```\n\nIf you define services via Docker labels or Kubernetes annotations, use the same key with dot-notation (for example `homepage.widget.headers.X-Auth-Key=secret` or `gethomepage.dev/widget.headers.X-Auth-Key: \"secret\"`).\n\n#### Field Visibility\n\nEach widget can optionally provide a list of which fields should be visible via the `fields` widget property. If no fields are specified, then all fields will be displayed. The `fields` property must be a valid YAML array of strings. As an example, here is the entry for Sonarr showing only a couple of fields.\n\n**In all cases a widget will work and display all fields without specifying the `fields` property.**\n\n```yaml\n- Sonarr:\n    icon: sonarr.png\n    href: http://sonarr.host.or.ip\n    widget:\n      type: sonarr\n      fields: [\"wanted\", \"queued\"]\n      url: http://sonarr.host.or.ip\n      key: apikeyapikeyapikeyapikeyapikey\n```\n\n### Block Highlighting\n\nWidgets can tint their metric block text automatically based on rules defined alongside the service. Attach a `highlight` section to the widget configuration and map each block to one or more numeric or string rules using the field key (for example, `queued`, `lan_users`).\n\n```yaml\n- Sonarr:\n    icon: sonarr.png\n    href: http://sonarr.host.or.ip\n    widget:\n      type: sonarr\n      url: http://sonarr.host.or.ip\n      key: ${SONARR_API_KEY}\n      highlight:\n        queued:\n          numeric:\n            - level: danger\n              when: gte\n              value: 20\n            - level: warn\n              when: gte\n              value: 5\n            - level: good\n              when: eq\n              value: 0\n        status:\n          string:\n            - level: danger\n              when: regex\n              value: \"(failed|import) pending\"\n            - level: good\n              when: equals\n              value: \"All good\"\n        status_code:\n          string:\n            - level: warn\n              when: regex\n              value: \"^5\\\\d{2}$\"\n```\n\nSupported numeric operators for the `when` property are `gt`, `gte`, `lt`, `lte`, `eq`, `ne`, `between`, and `outside`. String rules support `equals`, `includes`, `startsWith`, `endsWith`, and `regex`. Each rule can be inverted with `negate: true`, and string rules may pass `caseSensitive: true` or custom regex `flags`. The highlight engine does its best to coerce formatted values, but you will get the most reliable results when you pass plain numbers or strings into `<Block>`.\n\n#### Value Only Highlighting\n\nYou can optionally apply highlighting only to the value portion of a block (not the label) by setting `valueOnly: true` on the field configuration. This keeps the label visible while highlighting only the metric value itself.\n\n```yaml\n- Sonarr:\n    ...\n      highlight:\n        queued:\n          valueOnly: true\n          ...\n```\n\n## Descriptions\n\nServices may have descriptions,\n\n```yaml\n- Group A:\n    - Service A:\n        href: http://localhost/\n        description: This is my service\n\n- Group B:\n    - Service B:\n        href: http://localhost/\n        description: This is another service\n```\n\n<img width=\"1038\" alt=\"Service Descriptions\" src=\"https://user-images.githubusercontent.com/82196/187040817-11a3d0eb-c997-4ef9-8f06-2d03a11332b6.png\">\n\n## Icons\n\nServices may have an icon attached to them, you can use icons from [Dashboard Icons](https://github.com/homarr-labs/dashboard-icons) automatically, by passing the name of the icon, with, or without `.png`, `.webp` or `.svg` to specify the desired version.\n\nYou can also specify prefixed icons from:\n\n- [Material Design Icons](https://pictogrammers.com/library/mdi/) with `mdi-XX`\n- [Simple Icons](https://simpleicons.org/) with `si-XX`\n- [selfh.st/icons](https://selfh.st/icons/) with `sh-XX` to use the png version or `sh-XX.svg/png/webp` for a specific version\n\nYou can specify a custom color for `mdi` and `si` icons by adding a hex color code as a suffix e.g. `mdi-XX-#f0d453` or `si-XX-#a712a2`.\n\nTo use a remote icon, use the absolute URL (e.g. `https://...`).\n\nTo use a local icon, first create a Docker mount to `/app/public/icons` and then reference your icon as `/icons/myicon.png`. You will need to restart the container when adding new icons.\n\n!!! warning\n\n      Material Design Icons for **brands** were deprecated and may be removed in the future. Using Simple Icons for brand icons will prevent any issues if / when the Material Design Icons are removed.\n\n```yaml\n- Group A:\n    - Sonarr:\n        icon: sonarr.png\n        href: http://sonarr.host/\n        description: Series management\n\n- Group B:\n    - Radarr:\n        icon: radarr.png\n        href: http://radarr.host/\n        description: Movie management\n\n- Group C:\n    - Service:\n        icon: mdi-flask-outline\n        href: http://service.host/\n        description: My cool service\n```\n\n<img width=\"1038\" alt=\"Service Icons\" src=\"https://user-images.githubusercontent.com/82196/187040777-da1361d7-f0c4-4531-95db-136cd00a1611.png\">\n\n## Ping\n\nServices may have an optional `ping` property that allows you to monitor the availability of an external host. As of v0.8.0, the ping feature attempts to use a true (ICMP) ping command on the underlying host. Currently, only IPv4 is supported.\n\n!!! note\n\n      Because ping uses the ping command on the underlying host, in some cases you may need to install e.g. the `iputils-ping` package on the host system.\n\n```yaml\n- Group A:\n    - Sonarr:\n        icon: sonarr.png\n        href: http://sonarr.host/\n        ping: sonarr.host\n\n- Group B:\n    - Radarr:\n        icon: radarr.png\n        href: http://radarr.host/\n        ping: some.other.host\n```\n\n<img width=\"1038\" alt=\"Ping\" src=\"https://github.com/gethomepage/homepage/assets/88257202/7bc13bd3-0d0b-44e3-888c-a20e069a3233\">\n\nYou can also apply different styles to the ping indicator by using the `statusStyle` property, see [settings](settings.md#status-style).\n\n## Site Monitor\n\nServices may have an optional `siteMonitor` property (formerly `ping`) that allows you to monitor the availability of a URL you chose and have the response time displayed. You do not need to set your monitor URL equal to your href or ping URL.\n\n!!! note\n\n    The site monitor feature works by making an http `HEAD` request to the URL, and falls back to `GET` in case that fails. It will not, for example, login if the URL requires auth or is behind e.g. Authelia. In the case of a reverse proxy and/or auth this usually requires the use of an 'internal' URL to make the site monitor feature correctly display status.\n\n```yaml\n- Group A:\n    - Sonarr:\n        icon: sonarr.png\n        href: http://sonarr.host/\n        siteMonitor: http://sonarr.host/\n\n- Group B:\n    - Radarr:\n        icon: radarr.png\n        href: http://radarr.host/\n        siteMonitor: http://some.other.host/\n```\n\nYou can also apply different styles to the site monitor indicator by using the `statusStyle` property, see [settings](settings.md#status-style).\n\n## Docker Integration\n\nServices may be connected to a Docker container, either running on the local machine, or a remote machine.\n\n```yaml\n- Group A:\n    - Service A:\n        href: http://localhost/\n        description: This is my service\n        server: my-server\n        container: my-container\n\n- Group B:\n    - Service B:\n        href: http://localhost/\n        description: This is another service\n        server: other-server\n        container: other-container\n```\n\n<img width=\"1038\" alt=\"Service Containers\" src=\"https://github.com/gethomepage/homepage/assets/88257202/4c685783-52c6-4e55-afb3-affe9baac09b\">\n\n**Clicking on the status label of a service with Docker integration enabled will expand the container stats, where you can see CPU, Memory, and Network activity.**\n\n!!! note\n\n      This can also be controlled with `showStats`. See [show docker stats](docker.md#show-stats) for more information\n\n<img width=\"1038\" alt=\"Docker Stats Expanded\" src=\"https://github.com/gethomepage/homepage/assets/88257202/f95fd595-449e-48ae-af67-fd89618904ec\">\n\n## Service Integrations\n\nServices may also have a service widget (or integration) attached to them, this works independently of the Docker integration.\n\nYou can find information and configuration for each of the supported integrations on the [Widgets](../widgets/index.md) page.\n\nHere is an example of a Radarr & Sonarr service, with their respective integrations.\n\n```yaml\n- Group A:\n    - Sonarr:\n        icon: sonarr.png\n        href: http://sonarr.host/\n        description: Series management\n        widget:\n          type: sonarr\n          url: http://sonarr.host\n          key: apikeyapikeyapikeyapikeyapikey\n\n- Group B:\n    - Radarr:\n        icon: radarr.png\n        href: http://radarr.host/\n        description: Movie management\n        widget:\n          type: radarr\n          url: http://radarr.host\n          key: apikeyapikeyapikeyapikeyapikey\n```\n\n<img width=\"1038\" alt=\"Service Integrations\" src=\"https://user-images.githubusercontent.com/82196/187040838-6cd518c2-4f08-41ef-8aa6-364df5e2660e.png\">\n"
  },
  {
    "path": "docs/configs/settings.md",
    "content": "---\ntitle: Settings\ndescription: Service Configuration\n---\n\nThe `settings.yaml` file allows you to define application level options. For changes made to this file to take effect, you will need to regenerate the static HTML, this can be done by clicking the refresh icon in the bottom right of the page.\n\n## Title\n\nYou can customize the title of the page if you'd like.\n\n```yaml\ntitle: My Awesome Homepage\n```\n\n## Description\n\nYou can customize the description of the page if you'd like.\n\n```yaml\ndescription: A description of my awesome homepage\n```\n\n## Start URL\n\nYou can customize the start_url as required for installable apps. The default is \"/\".\n\n```yaml\nstartUrl: https://custom.url\n```\n\n## Background Image\n\n!!! warning \"Heads Up!\"\n\n    You will need to restart the container any time you add new images, this is a limitation of the Next.js static site server.\n\n!!! warning \"Heads Up!\"\n\n    Do not create a bind mount to the entire `/app/public/` directory.\n\nIf you'd like to use a background image instead of the solid theme color, you may provide a full URL to an image of your choice.\n\n```yaml\nbackground: https://images.unsplash.com/photo-1502790671504-542ad42d5189?auto=format&fit=crop&w=2560&q=80\n```\n\nOr you may pass the path to a local image relative to e.g. `/app/public/images` directory.\n\nFor example, inside of your Docker Compose file, mount a path to where your images are kept:\n\n```yaml\nvolumes:\n  - /my/homepage/images:/app/public/images\n```\n\nand then reference that image:\n\n```yaml\nbackground: /images/background.png\n```\n\n### Background Opacity & Filters\n\nYou can specify filters to apply over your background image for blur, saturation and brightness as well as opacity to blend with the background color. The first three filter settings use tailwind CSS classes, see notes below regarding the options for each. You do not need to specify all options.\n\n```yaml\nbackground:\n  image: /images/background.png\n  blur: sm # sm, \"\", md, xl... see https://tailwindcss.com/docs/backdrop-blur\n  saturate: 50 # 0, 50, 100... see https://tailwindcss.com/docs/backdrop-saturate\n  brightness: 50 # 0, 50, 75... see https://tailwindcss.com/docs/backdrop-brightness\n  opacity: 50 # 0-100\n```\n\n### Card Background Blur\n\nYou can apply a blur filter to the service & bookmark cards. Note this option is incompatible with the background blur, saturate and brightness filters.\n\n```yaml\ncardBlur: xs # xs, md, etc... see https://tailwindcss.com/docs/backdrop-blur\n```\n\n## Favicon\n\nIf you'd like to use a custom favicon instead of the included one, you may provide a full URL to an image of your choice.\n\n```yaml\nfavicon: https://www.google.com/favicon.ico\n```\n\nOr you may pass the path to a local image relative to the `/app/public` directory. See [Background Image](#background-image) for more detailed information on how to provide your own files.\n\n## Theme\n\nYou can configure a fixed theme (and disable the theme switcher) by passing the `theme` option, like so:\n\n```yaml\ntheme: dark # or light\n```\n\n## Color Palette\n\nYou can configure a fixed color palette (and disable the palette switcher) by passing the `color` option, like so:\n\n```yaml\ncolor: slate\n```\n\nSupported colors are: `slate`, `gray`, `zinc`, `neutral`, `stone`, `amber`, `yellow`, `lime`, `green`, `emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`, `fuchsia`, `pink`, `rose`, `red`, `white`\n\n## Block Highlight Levels\n\nYou can override the default Tailwind classes applied when a widget highlight rule resolves to the `good`, `warn`, or `danger` level.\n\n```yaml\nblockHighlights:\n  levels:\n    good: \"bg-emerald-500/40 text-emerald-950 dark:bg-emerald-900/60 dark:text-emerald-400\"\n    warn: \"bg-amber-300/30 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200\"\n    danger: \"bg-rose-700/45 text-rose-200 dark:bg-rose-950/70 dark:text-rose-400\"\n```\n\nAny unspecified level falls back to the built-in defaults.\n\n## Progressive Web App (PWA)\n\nA progressive web app is an app that can be installed on a device and provide user experience like a native app. Homepage comes with built-in support for PWA with some default configurations, but you can customize them.\n\nMore information on PWAs can be found in [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps).\n\n## App icons\n\nYou can set custom icons for installable apps. More information about how you can set them can be found in the [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/icons).\n\nThe default value is the Homepage icon in sizes 192x192 and 512x512.\n\n```yaml\npwa:\n  icons:\n    - src: https://developer.mozilla.org/favicon-192x192.png\n      type: image/png\n      sizes: 192x192\n    - src: https://developer.mozilla.org/favicon-512x512.png\n      type: image/png\n      sizes: 512x512\n```\n\nFor icon `src` you can pass either full URL or a local path relative to the `/app/public` directory. See [Background Image](#background-image) for more detailed information on how to provide your own files.\n\n### Shortcuts\n\nShortcuts can e used to specify links to tabs, to be preselected when the homepage is opened as an app.\nMore information about how you can set them can be found in the [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/shortcuts).\n\n```yaml\npwa:\n  shortcuts:\n    - name: First\n      url: \"/#first\" # opens the first tab\n    - name: Second\n      url: \"/#second\" # opens the second tab\n    - name: Third\n      url: \"/#third\" # opens the third tab\n```\n\n### Other PWA configurations\n\nHomepage sets few other PWA configurations, that are based on global settings in `settings.yaml`:\n\n- `name`, `short_name` - Both equal to the [`title`](#title) setting.\n- `theme_color`, `background_color` - Both based on the [`color`](#color-palette) and [`theme`](#theme) settings.\n- `display` - It is always set to \"standalone\".\n- `start_url` - Equal to the [`startUrl`](#start-url) setting.\n\nMore information for wach of the PWA configurations can be found in the [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference).\n\n## Layout\n\nYou can configure service and bookmarks sections to be either \"column\" or \"row\" based layouts, like so:\n\nAssuming you have a group named `Media` in your `services.yaml` or `bookmarks.yaml` file,\n\n```yaml\nlayout:\n  Media:\n    style: row\n    columns: 4\n```\n\nAs an example, this would produce the following layout:\n\n<img width=\"1260\" alt=\"Screenshot 2022-09-15 at 8 03 57 PM\" src=\"https://user-images.githubusercontent.com/82196/190466646-8ca94505-0fcf-4964-9687-3a6c7cd3144f.png\">\n\n### Icons-Only Layout\n\nYou can also specify the an icon-only layout for bookmarks, either like so:\n\n```yaml\nlayout:\n  Media:\n    iconsOnly: true\n```\n\nor globally:\n\n```yaml\nbookmarksStyle: icons\n```\n\n### Sorting\n\nService groups and bookmark groups can be mixed in order, **but should use different group names**. If you do not specify any bookmark groups they will all show at the bottom of the page.\n\n**_Using the same name for a service and bookmark group can cause unexpected behavior like a bookmark group being hidden_**\n\nGroups will sort based on the order in the layout block. You can also mix in groups defined by docker labels, e.g.\n\n```yaml\nlayout:\n  - Auto-Discovered1:\n  - Configured1:\n  - Configured2:\n  - Auto-Discovered2:\n  - Configured3:\n      style: row\n      columns: 3\n```\n\n### Nested Groups\n\nIf your services config has nested groups, you can apply settings to these groups by nesting them in the layout block\nand using the same settings. For example\n\n```yaml\nlayout:\n  Group A:\n    style: row\n    columns: 4\n  Group C:\n    style: row\n    columns: 2\n    Nested Group A:\n      style: row\n      columns: 2\n    Nested Group B:\n      style: row\n      columns: 2\n```\n\n### Headers\n\nYou can hide headers for each section in the layout as well by passing `header` as false, like so:\n\n```yaml\nlayout:\n  Section A:\n    header: false\n  Section B:\n    style: row\n    columns: 3\n    header: false\n```\n\n### Category Icons\n\nYou can also add an icon to a category under the `layout` setting similar to the [options for service icons](services.md#icons), e.g.\n\n```yaml\n  Home Management & Info:\n    icon: home-assistant.png\n  Server Tools:\n    icon: https://cdn-icons-png.flaticon.com/512/252/252035.png\n  ...\n```\n\n### Icon Style\n\nThe default style for icons (e.g. `icon: mdi-XXXX`) is a gradient, or you can specify that prefixed icons match your theme with a 'flat' style using the setting below.\nMore information about prefixed icons can be found in [options for service icons](services.md#icons).\n\n```yaml\niconStyle: theme # optional, defaults to gradient\n```\n\n### Tabs\n\nVersion 0.6.30 introduced a tabbed view to layouts which can be optionally specified in the layout. Tabs is only active if you set the `tab` field on at least one layout group.\n\nTabs are sorted based on the order in the layout block. If a group has no tab specified (and tabs are set on other groups), services and bookmarks will be shown on all tabs.\n\nEvery tab can be accessed directly by visiting Homepage URL with `#Group` (name lowercase and URI-encoded) at the end of the URL.\n\nFor example, the following would create four tabs:\n\n```yaml\nlayout:\n  ...\n  Bookmark Group on First Tab:\n    tab: First\n\n  First Service Group:\n    tab: First\n    style: row\n    columns: 4\n\n  Second Service Group:\n    tab: Second\n    columns: 4\n\n  Third Service Group:\n    tab: Third\n    style: row\n\n  Bookmark Group on Fourth Tab:\n    tab: Fourth\n\n  Service Group on every Tab:\n    style: row\n    columns: 4\n```\n\n### Full Width\n\nYou can make homepage take up the entire window width by adding:\n\n```yaml\nfullWidth: true\n```\n\n### Maximum Group Columns\n\nYou can set the maximum number of columns of groups on larger screen sizes (note this is only for groups with the default `style: columns`, not groups with `style: row`) by adding:\n\n```yaml\nmaxGroupColumns: 8 # default is 4 for services, 6 for bookmarks, max 8\n```\n\nBy default homepage will max out at 4 columns for services and 6 for bookmarks, thus the minimum for this setting is _5_. Of course, if you're setting this to higher numbers, you may want to consider enabling the [fullWidth](#full-width) option as well.\n\nIf you want to set the maximum columns for bookmark groups separately, you can do so by adding:\n\n```yaml\nmaxBookmarkGroupColumns: 6 # default is 6, max 8\n```\n\n### Collapsible sections\n\nYou can disable the collapsible feature of services & bookmarks by adding:\n\n```yaml\ndisableCollapse: true\n```\n\nBy default the feature is enabled.\n\n### Initially collapsed sections\n\nYou can initially collapse sections by adding the `initiallyCollapsed` option to the layout group.\n\n```yaml\nlayout:\n  Section A:\n    initiallyCollapsed: true\n```\n\nThis can also be set globaly using the `groupsInitiallyCollapsed` option.\n\n```yaml\ngroupsInitiallyCollapsed: true\n```\n\nThe value set on a group will overwrite the global setting.\n\nBy default the feature is disabled.\n\n### Use Equal Height Cards\n\nYou can enable equal height cards for groups of services, this will make all cards in a row the same height.\n\nGlobal setting in `settings.yaml`:\n\n```yaml\nuseEqualHeights: true\n```\n\nPer layout group in `settings.yaml`:\n\n```yaml\nuseEqualHeights: false\nlayout:\n  ...\n  Group Name:\n    useEqualHeights: true # overrides global setting\n```\n\nBy default the feature is disabled\n\n## Header Style\n\nThere are currently 4 options for header styles, you can see each one below.\n\n<img width=\"1000\" alt=\"underlined\" src=\"https://user-images.githubusercontent.com/82196/194725622-39ce271c-34e5-414d-be53-62d221811f88.png\">\n\n```yaml\nheaderStyle: underlined # default style\n```\n\n---\n\n<img width=\"1000\" alt=\"boxed\" src=\"https://user-images.githubusercontent.com/82196/194725645-abcb8ed9-d017-416f-9e74-cc5642fa982c.png\">\n\n```yaml\nheaderStyle: boxed\n```\n\n---\n\n<img width=\"1000\" alt=\"clean\" src=\"https://user-images.githubusercontent.com/82196/194725650-7a86e818-172d-4d0f-9861-5eae7fecb50a.png\">\n\n```yaml\nheaderStyle: clean\n```\n\n---\n\n<img width=\"1000\" alt=\"boxedWidgets\" src=\"https://user-images.githubusercontent.com/5442891/232258758-ed5262d6-f940-462c-b39e-00e54c19b9ce.png\">\n\n```yaml\nheaderStyle: boxedWidgets\n```\n\n## Base URL\n\nIn some proxy configurations, it may be necessary to set the documents base URL. You can do this by providing a `base` value, like so:\n\n```yaml\nbase: http://host.local/homepage\n```\n\n**_The URL must be a full, absolute URL, or it will be ignored by the browser._**\n\n## Language\n\nSet your desired language using:\n\n```yaml\nlanguage: fr\n```\n\nCurrently supported languages: ca, de, en, es, fr, he, hr, hu, it, nb-NO, nl, pt, ru, sv, vi, zh-Hans (Simplified), zh-Hant (Traditional)\n\n`zh-CN` will still work and is automatically mapped to `zh-Hans` for backwards compatibility.\n\nYou can also specify locales e.g. for the DateTime widget, e.g. en-AU, en-GB, etc.\n\n## Link Target\n\nChanges the behaviour of links on the homepage,\n\n```yaml\ntarget: _blank # Possible options include _blank, _self, and _top\n```\n\nUse `_blank` to open links in a new tab, `_self` to open links in the same tab, and `_top` to open links in a new window.\n\nThis can also be set for individual services. Note setting this at the service level overrides any setting in settings.json, e.g.:\n\n```yaml\n- Example Service:\n    href: https://example.com/\n    ...\n    target: _self\n```\n\n## Providers\n\nThe `providers` section allows you to define shared API provider options and secrets.\n\n```yaml\nproviders:\n  openweathermap: openweathermapapikey\n  finnhub: yourfinnhubapikeyhere\n  longhorn:\n    url: https://longhorn.example.com\n    username: admin\n    password: LonghornPassword\n```\n\nYou can then pass `provider` instead of `apiKey` in your widget configuration.\n\n```yaml\n- openweathermap:\n    latitude: 50.449684\n    longitude: 30.525026\n    provider: openweathermap\n```\n\n## Quick Launch\n\nYou can use the 'Quick Launch' feature to search services, perform a web search or open a URL. To use Quick Launch, just start typing while on your homepage (as long as the search widget doesn't have focus).\n\n<img width=\"1000\" alt=\"quicklaunch\" src=\"https://user-images.githubusercontent.com/4887959/216880811-90ff72cb-2990-4475-889b-7c3a31e6beef.png\">\n\nThere are a few optional settings for the Quick Launch feature:\n\n- `searchDescriptions`: which lets you control whether item descriptions are included in searches. This is false by default. When enabled, results that match the item name will be placed above those that only match the description.\n- `hideInternetSearch`: disable automatically including the currently-selected web search (e.g. from the widget) as a Quick Launch option. This is false by default, enabling the feature.\n- `showSearchSuggestions`: show search suggestions for the internet search. If this is not specified then the setting will be inherited from the search widget. If it is not specified there either, it will default to false. For custom providers the `suggestionUrl` needs to be set in order for this to work.\n- `provider`: search engine provider. If none is specified it will try to use the provider set for the Search Widget, if neither are present then internet search will be disabled.\n- `hideVisitURL`: disable detecting and offering an option to open URLs. This is false by default, enabling the feature.\n- `mobileButtonPosition`: enables and sets the position of the mobile quicklaunch button. Options are `top-left`, `top-right`, `bottom-left`, `bottom-right`. This is empty by default, disabling the feature.\n\n```yaml\nquicklaunch:\n  searchDescriptions: true\n  hideInternetSearch: true\n  showSearchSuggestions: true\n  hideVisitURL: true\n  provider: google # google, duckduckgo, bing, baidu, brave or custom\n```\n\nor for a custom search:\n\n```yaml\nquicklaunch:\n  provider: custom\n  url: https://www.ecosia.org/search?q=\n  target: _blank\n  suggestionUrl: https://ac.ecosia.org/autocomplete?type=list&q=\n```\n\n## Homepage Version & Update Checking\n\nBy default the release version is displayed at the bottom of the page. To hide this, use the `hideVersion` setting, like so:\n\n```yaml\nhideVersion: true\n```\n\nYou can disable checking for new versions from GitHub (enabled by default) with:\n\n```yaml\ndisableUpdateCheck: true\n```\n\n## Log Path\n\nBy default the homepage logfile is written to the a `logs` subdirectory of the `config` folder. In order to customize this path, you can set the `logpath` setting. A `logs` folder will be created in that location where the logfile will be written.\n\n```yaml\nlogpath: /logfile/path\n```\n\nBy default, logs are sent both to `stdout` and to a file at the path specified. This can be changed by setting the `LOG_TARGETS` environment variable to one of `both` (default), `stdout` or `file`.\n\n## Show Container Stats\n\nYou can show all docker or proxmox stats expanded in `settings.yaml`:\n\n```yaml\nshowStats: true\n```\n\nor per-service (`services.yaml`) with:\n\n```yaml\n- Example Service:\n    ...\n    showStats: true\n```\n\nIf you have both set the per-service settings take precedence.\n\n## Status Style\n\nYou can choose from the following styles for docker or k8s status, site monitor and ping: `dot` or `basic`\n\n- The default is no value, and displays the monitor and ping response time in ms and the docker / k8s container status\n- `dot` shows a green dot for a successful monitor ping or healthy status.\n- `basic` shows either UP or DOWN for monitor & ping\n\nFor example:\n\n```yaml\nstatusStyle: \"dot\"\n```\n\nor per-service (`services.yaml`) with:\n\n```yaml\n- Example Service:\n    ...\n    statusStyle: 'dot'\n```\n\nIf you have both set, the per-service settings take precedence.\n\n## Instance Name\n\nName used by automatic docker service discovery to differentiate between multiple homepage instances.\n\nFor example:\n\n```yaml\ninstanceName: public\n```\n\n## Hide Widget Error Messages\n\nHide the visible API error messages either globally in `settings.yaml`:\n\n```yaml\nhideErrors: true\n```\n\nor per service widget (`services.yaml`) with:\n\n```yaml\n- Example Service:\n    ...\n    widget:\n    ...\n        hideErrors: true\n```\n\nIf either value is set to true, the error message will be hidden.\n\n## Disable Search Engine Indexing\n\nYou can request that search engines not to index your Homepage instance by enabling the `disableIndexing` setting.\n\n```yaml\ndisableIndexing: true\n```\n\nWhen enabled, this will:\n\n- Disallow all crawlers in `robots.txt`\n- Add `<meta name=\"robots\" content=\"noindex, nofollow\">` tags to prevent indexing\n\nBy default this feature is disabled.\n"
  },
  {
    "path": "docs/index.md",
    "content": "---\ntitle: Home\ndescription: A modern, fully static, fast, secure, fully proxied, highly customizable application dashboard with integrations for over 100 services and translations into multiple languages.\nicon: material/home\nhide:\n  - navigation\n  - toc\n  - path\n---\n\n#\n\n<div style=\"margin-top: -100px;\"></div>\n\n<div style=\"max-width: 70%; margin: 0 auto; display: block;\">\n\n<img src=\"assets/banner_light@2x.webp\" alt=\"homepage\" style=\"max-width: 100%; max-height: 175px; margin: 0 auto; display: block;\" />\n\n<img src=\"assets/homepage_demo_clip.webp\" alt=\"homepage\" style=\"max-width: 100%; margin: 0 auto; display: block;\" />\n\n<p style=\"margin: 0 0 30px;\">A modern, <em>fully static, fast</em>, secure <em>fully proxied</em>, highly customizable application dashboard with integrations for over 100 services and translations into multiple languages. Easily configured via YAML files or through docker label discovery.</p>\n\n</div>\n\n<style>\n  .md-header__source {\n    display: none;\n  }\n  .md-typeset img, .md-typeset svg, .md-typeset video {\n    box-shadow: none;\n  }\n</style>\n"
  },
  {
    "path": "docs/installation/docker.md",
    "content": "---\ntitle: Docker Installation\ndescription: Install and run homepage from Docker\n---\n\nUsing docker compose:\n\n```yaml\nservices:\n  homepage:\n    image: ghcr.io/gethomepage/homepage:latest\n    container_name: homepage\n    ports:\n      - 3000:3000\n    volumes:\n      - /path/to/config:/app/config # Make sure your local config directory exists\n      - /var/run/docker.sock:/var/run/docker.sock:ro # (optional) For docker integrations\n    environment:\n      HOMEPAGE_ALLOWED_HOSTS: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts\n```\n\n### Running as non-root\n\nBy default, the Homepage container runs as root. Homepage also supports running your container as non-root via the standard `PUID` and `PGID` environment variables. When using these variables, make sure that any volumes mounted in to the container have the correct ownership and permissions set.\n\n_Using the docker socket directly is not the recommended method of integration and requires either running homepage as root or that the user be part of the docker group_\n\nIn the docker compose example below, the environment variables `$PUID` and `$PGID` are set in a `.env` file.\n\n```yaml\nservices:\n  homepage:\n    image: ghcr.io/gethomepage/homepage:latest\n    container_name: homepage\n    ports:\n      - 3000:3000\n    volumes:\n      - /path/to/config:/app/config # Make sure your local config directory exists\n      - /var/run/docker.sock:/var/run/docker.sock:ro # (optional) For docker integrations, see alternative methods\n    environment:\n      HOMEPAGE_ALLOWED_HOSTS: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts\n      PUID: $PUID\n      PGID: $PGID\n```\n\n### With Docker Run\n\n```bash\ndocker run -p 3000:3000 -e HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev -v /path/to/config:/app/config -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/gethomepage/homepage:latest\n```\n\n### Using Environment Secrets\n\nYou can also include environment variables in your config files to protect sensitive information. Note:\n\n- Environment variables must start with `HOMEPAGE_VAR_` or `HOMEPAGE_FILE_`\n- The value of env var `HOMEPAGE_VAR_XXX` will replace `{{HOMEPAGE_VAR_XXX}}` in any config\n- The value of env var `HOMEPAGE_FILE_XXX` must be a file path, the contents of which will be used to replace `{{HOMEPAGE_FILE_XXX}}` in any config\n"
  },
  {
    "path": "docs/installation/index.md",
    "content": "---\ntitle: Installation\ndescription: Docs intro\nicon: simple/docker\n---\n\nYou have a few options for deploying homepage, depending on your needs. We offer docker images for a majority of platforms. You can also install and run homepage from source if Docker is not your thing. It can even be installed on Kubernetes with Helm.\n\n!!! info\n\n    Please note that when using features such as widgets, Homepage can access personal information (for example from your home automation system) and Homepage currently does not (and is not planned to) include any authentication layer itself. Thus, we recommend homepage be deployed behind a reverse proxy including authentication, SSL etc, and / or behind a VPN.\n\n<br>\n\n<div class=\"grid cards\" style=\"margin: 0 auto;\" markdown>\n[:simple-docker: &nbsp; Install on Docker :octicons-arrow-right-24:](docker.md)\n{ .card }\n\n[:simple-kubernetes: &nbsp; Install on Kubernetes :octicons-arrow-right-24:](k8s.md)\n{ .card }\n\n[:simple-unraid: &nbsp; Install on UNRAID :octicons-arrow-right-24:](unraid.md)\n{ .card }\n\n[:simple-nextdotjs: &nbsp; Building from source :octicons-arrow-right-24:](source.md)\n{ .card }\n\n</div>\n\n### `HOMEPAGE_ALLOWED_HOSTS`\n\nAs of v1.0 there is one required environment variable to access homepage via a URL other than `localhost`, <code>HOMEPAGE_ALLOWED_HOSTS</code>. The setting helps prevent certain kinds of attacks when retrieving data from the homepage API proxy.\n\nThe value is a comma-separated (no spaces) list of allowed hosts (sometimes with the port) that can host your homepage install. See the [docker](docker.md), [kubernetes](k8s.md) and [source](source.md) installation pages for more information about where / how to set the variable.\n\n`localhost:3000` and `127.0.0.1:3000` are always included, but you can add a domain or IP address to this list to allow that host such as `HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev,192.168.1.2:1234`, etc.\n\nIf you are seeing errors about host validation, check the homepage logs and ensure that the host exactly as output in the logs is in the `HOMEPAGE_ALLOWED_HOSTS` list.\n\nThis can be disabled by setting `HOMEPAGE_ALLOWED_HOSTS` to `*` but this is not recommended. Public deployments must rely on a reverse proxy (and/or VPN) that enforces authentication, TLS, and unexpected Host headers; the built-in host check is a best-effort guard for local setups and is not a substitute for edge protections.\n"
  },
  {
    "path": "docs/installation/k8s.md",
    "content": "---\ntitle: Kubernetes Installation\ndescription: Install on Kubernetes\n---\n\n## Install with Kubernetes Manifests\n\nIf you don't want to use the unofficial Helm chart, you can also create your own Kubernetes manifest(s) and apply them with `kubectl apply -f filename.yaml`.\n\nHere's a working example of the resources you need:\n\n#### ServiceAccount\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: homepage\n  namespace: default\n  labels:\n    app.kubernetes.io/name: homepage\nsecrets:\n  - name: homepage\n```\n\n#### Secret\n\n```yaml\napiVersion: v1\nkind: Secret\ntype: kubernetes.io/service-account-token\nmetadata:\n  name: homepage\n  namespace: default\n  labels:\n    app.kubernetes.io/name: homepage\n  annotations:\n    kubernetes.io/service-account.name: homepage\n```\n\n#### ConfigMap\n\n```yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: homepage\n  namespace: default\n  labels:\n    app.kubernetes.io/name: homepage\ndata:\n  kubernetes.yaml: |\n    mode: cluster\n  settings.yaml: \"\"\n  #settings.yaml: |\n  #  providers:\n  #    longhorn:\n  #      url: https://longhorn.my.network\n  custom.css: \"\"\n  custom.js: \"\"\n  bookmarks.yaml: |\n    - Developer:\n        - Github:\n            - abbr: GH\n              href: https://github.com/\n  services.yaml: |\n    - My First Group:\n        - My First Service:\n            href: http://localhost/\n            description: Homepage is awesome\n\n    - My Second Group:\n        - My Second Service:\n            href: http://localhost/\n            description: Homepage is the best\n\n    - My Third Group:\n        - My Third Service:\n            href: http://localhost/\n            description: Homepage is 😎\n  widgets.yaml: |\n    - kubernetes:\n        cluster:\n          show: true\n          cpu: true\n          memory: true\n          showLabel: true\n          label: \"cluster\"\n        nodes:\n          show: true\n          cpu: true\n          memory: true\n          showLabel: true\n    - resources:\n        backend: resources\n        expanded: true\n        cpu: true\n        memory: true\n        network: default\n    - search:\n        provider: duckduckgo\n        target: _blank\n  docker.yaml: \"\"\n```\n\n#### ClusterRole and ClusterRoleBinding\n\n```yaml\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: homepage\n  labels:\n    app.kubernetes.io/name: homepage\nrules:\n  - apiGroups:\n      - \"\"\n    resources:\n      - namespaces\n      - pods\n      - nodes\n    verbs:\n      - get\n      - list\n  - apiGroups:\n      - extensions\n      - networking.k8s.io\n    resources:\n      - ingresses\n    verbs:\n      - get\n      - list\n  - apiGroups:\n      - traefik.io\n    resources:\n      - ingressroutes\n    verbs:\n      - get\n      - list\n  - apiGroups:\n      - gateway.networking.k8s.io\n    resources:\n      - httproutes\n      - gateways\n    verbs:\n      - get\n      - list\n  - apiGroups:\n      - metrics.k8s.io\n    resources:\n      - nodes\n      - pods\n    verbs:\n      - get\n      - list\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: homepage\n  labels:\n    app.kubernetes.io/name: homepage\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: homepage\nsubjects:\n  - kind: ServiceAccount\n    name: homepage\n    namespace: default\n```\n\n#### Service\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: homepage\n  namespace: default\n  labels:\n    app.kubernetes.io/name: homepage\n  annotations:\nspec:\n  type: ClusterIP\n  ports:\n    - port: 3000\n      targetPort: http\n      protocol: TCP\n      name: http\n  selector:\n    app.kubernetes.io/name: homepage\n```\n\n#### Deployment\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: homepage\n  namespace: default\n  labels:\n    app.kubernetes.io/name: homepage\nspec:\n  revisionHistoryLimit: 3\n  replicas: 1\n  strategy:\n    type: RollingUpdate\n  selector:\n    matchLabels:\n      app.kubernetes.io/name: homepage\n  template:\n    metadata:\n      labels:\n        app.kubernetes.io/name: homepage\n    spec:\n      serviceAccountName: homepage\n      automountServiceAccountToken: true\n      dnsPolicy: ClusterFirst\n      enableServiceLinks: true\n      containers:\n        - name: homepage\n          image: \"ghcr.io/gethomepage/homepage:latest\"\n          imagePullPolicy: Always\n          securityContext:\n            allowPrivilegeEscalation: false\n            capabilities:\n              drop:\n                - ALL\n            runAsNonRoot: true\n            runAsUser: 1000\n            runAsGroup: 1000\n            seccompProfile:\n              type: RuntimeDefault\n          env:\n            - name: MY_POD_IP\n              valueFrom:\n                fieldRef:\n                  fieldPath: status.podIP\n            - name: HOMEPAGE_ALLOWED_HOSTS\n              value: \"$(MY_POD_IP):3000,gethomepage.dev\" # See gethomepage.dev/installation/#homepage_allowed_hosts . Value before the comma is required for the k8s probe\n          ports:\n            - name: http\n              containerPort: 3000\n              protocol: TCP\n          livenessProbe:\n            httpGet:\n              path: /api/healthcheck\n              port: http\n            initialDelaySeconds: 5\n            periodSeconds: 15\n          volumeMounts:\n            - mountPath: /app/config/custom.js\n              name: homepage-config\n              subPath: custom.js\n            - mountPath: /app/config/custom.css\n              name: homepage-config\n              subPath: custom.css\n            - mountPath: /app/config/bookmarks.yaml\n              name: homepage-config\n              subPath: bookmarks.yaml\n            - mountPath: /app/config/docker.yaml\n              name: homepage-config\n              subPath: docker.yaml\n            - mountPath: /app/config/kubernetes.yaml\n              name: homepage-config\n              subPath: kubernetes.yaml\n            - mountPath: /app/config/services.yaml\n              name: homepage-config\n              subPath: services.yaml\n            - mountPath: /app/config/settings.yaml\n              name: homepage-config\n              subPath: settings.yaml\n            - mountPath: /app/config/widgets.yaml\n              name: homepage-config\n              subPath: widgets.yaml\n            - mountPath: /app/config/logs\n              name: logs\n      volumes:\n        - name: homepage-config\n          configMap:\n            name: homepage\n        - name: logs\n          emptyDir: {}\n```\n\n#### Ingress\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: homepage\n  namespace: default\n  labels:\n    app.kubernetes.io/name: homepage\n  annotations:\n    gethomepage.dev/description: Dynamically Detected Homepage\n    gethomepage.dev/enabled: \"true\"\n    gethomepage.dev/group: Cluster Management\n    gethomepage.dev/icon: homepage.png\n    gethomepage.dev/name: Homepage\nspec:\n  rules:\n    - host: \"homepage.my.network\"\n      http:\n        paths:\n          - path: \"/\"\n            pathType: Prefix\n            backend:\n              service:\n                name: homepage\n                port:\n                  number: 3000\n```\n\n### Multiple Replicas\n\nIf you plan to deploy homepage with a replica count greater than 1, you may\nwant to consider enabling sticky sessions on the homepage route. This will\nprevent unnecessary re-renders on page loads and window / tab focusing. The\nprocedure for enabling sticky sessions depends on your Ingress controller. Below\nis an example using Traefik as the Ingress controller.\n\n```yaml\napiVersion: traefik.io/v1alpha1\nkind: IngressRoute\nmetadata:\n  name: homepage.example.com\nspec:\n  entryPoints:\n    - websecure\n  routes:\n    - kind: Rule\n      match: Host(`homepage.example.com`)\n      services:\n        - kind: Service\n          name: homepage\n          port: 3000\n          sticky:\n            cookie:\n              httpOnly: true\n              secure: true\n              sameSite: none\n```\n"
  },
  {
    "path": "docs/installation/source.md",
    "content": "---\ntitle: Source Installation\ndescription: Install and run homepage from source\n---\n\nFirst, clone the repository:\n\n```bash\ngit clone https://github.com/gethomepage/homepage.git\n```\n\nIf `pnpm` is not installed, install it:\n\n```bash\nnpm install -g pnpm\n```\n\nThen install dependencies and build the production bundle:\n\n```bash\npnpm install\npnpm build\n```\n\nIf this is your first time starting, copy the `src/skeleton` directory to `config/` to populate initial example config files.\n\nFinally, run the server:\n\n```bash\nHOMEPAGE_ALLOWED_HOSTS=gethomepage.dev:1234 pnpm start\n```\n\nWhen updating homepage versions you will need to re-build the static files i.e. repeat the process above.\n\nSee [HOMEPAGE_ALLOWED_HOSTS](index.md#homepage_allowed_hosts) for more information on this environment variable.\n"
  },
  {
    "path": "docs/installation/unraid.md",
    "content": "---\ntitle: UNRAID Installation\ndescription: Install and run homepage on UNRAID\n---\n\nHomepage has an UNRAID community package that you may use to install homepage. This is the easiest way to get started with homepage on UNRAID.\n\n## Install the Plugin\n\n- In the UNRAID webGUI, go to the **Apps** tab.\n- In the search bar, search for `homepage`.\n- Click on **Install**.\n- Change the parameters to your liking.\n  - Click on **APPLY**.\n\n## Run the Container\n\n- While the container is running, open the WebUI.\n  - Opening the page will generate the configuration files.\n\nYou may need to set the permissions of the folders to be able to edit the files.\n\n- Click on the Homepage icon.\n- Click on **Console**.\n  - Enter `chmod -R u-x,go-rwx,go+u,ugo+X /app/config` and press **Enter**.\n  - Enter `chmod -R u-x,go-rwx,go+u,ugo+X /app/public/icons` and press **Enter**.\n  - Enter `chown -R nobody:users /app/config` and press **Enter**.\n  - Enter `chown -R nobody:users /app/public/icons` and press **Enter**.\n\n## Some Other Notes\n\n- To use the [Docker integration](../configs/docker.md), you only need to use the `container:` parameter. There is no need to set the server.\n\n!!! note\n\n      To view detailed container statistics (CPU, RAM, etc.), or if you use a remote docker socket, `container:` will still need to be set. For example:\n\n```\n    - Plex:\n        icon: /icons/plex.png\n        href: https://app.plex.com\n        container: plex\n```\n\n- When you upload a new image into the **/images** folder, you will need to restart the container for it to show up in the WebUI. Please see the [service icons](../configs/services.md#icons) for more information.\n"
  },
  {
    "path": "docs/layouts/custom.yml",
    "content": "# Copyright (c) 2016-2024 Martin Donath <martin.donath@squidfunk.com>\n\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to\n# deal in the Software without restriction, including without limitation the\n# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or\n# sell copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n\n# The above copyright notice and this permission notice shall be included in\n# all copies or substantial portions of the Software.\n\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\n# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\n# IN THE SOFTWARE.\n\n# -----------------------------------------------------------------------------\n# Configuration\n# -----------------------------------------------------------------------------\n\n# Definitions\ndefinitions:\n  # Background image\n  - &background_image >-\n    {{ layout.background_image | x }}\n\n  # Background color (default: indigo)\n  - &background_color >-\n    {%- if layout.background_color -%}\n      {{ layout.background_color }}\n    {%- else -%}\n      {%- set palette = config.theme.palette or {} -%}\n      {%- if not palette is mapping -%}\n        {%- set list = palette | selectattr(\"primary\") | list + palette -%}\n        {%- set palette = list | first -%}\n      {%- endif -%}\n      {%- set primary = palette.get(\"primary\", \"indigo\") -%}\n      {%- set primary = primary.replace(\" \", \"-\") -%}\n      {{ {\n        \"red\":         \"#ef5552\",\n        \"pink\":        \"#e92063\",\n        \"purple\":      \"#ab47bd\",\n        \"deep-purple\": \"#7e56c2\",\n        \"indigo\":      \"#4051b5\",\n        \"blue\":        \"#2094f3\",\n        \"light-blue\":  \"#02a6f2\",\n        \"cyan\":        \"#00bdd6\",\n        \"teal\":        \"#009485\",\n        \"green\":       \"#4cae4f\",\n        \"light-green\": \"#8bc34b\",\n        \"lime\":        \"#cbdc38\",\n        \"yellow\":      \"#ffec3d\",\n        \"amber\":       \"#ffc105\",\n        \"orange\":      \"#ffa724\",\n        \"deep-orange\": \"#ff6e42\",\n        \"brown\":       \"#795649\",\n        \"grey\":        \"#757575\",\n        \"blue-grey\":   \"#546d78\",\n        \"black\":       \"#000000\",\n        \"white\":       \"#ffffff\"\n      }[primary] or \"#4051b5\" }}\n    {%- endif -%}\n\n  # Text color (default: white)\n  - &color >-\n    {%- if layout.color -%}\n      {{ layout.color }}\n    {%- else -%}\n      {%- set palette = config.theme.palette or {} -%}\n      {%- if not palette is mapping -%}\n        {%- set list = palette | selectattr(\"primary\") | list + palette -%}\n        {%- set palette = list | first -%}\n      {%- endif -%}\n      {%- set primary = palette.get(\"primary\", \"indigo\") -%}\n      {%- set primary = primary.replace(\" \", \"-\") -%}\n      {{ {\n        \"red\":         \"#ffffff\",\n        \"pink\":        \"#ffffff\",\n        \"purple\":      \"#ffffff\",\n        \"deep-purple\": \"#ffffff\",\n        \"indigo\":      \"#ffffff\",\n        \"blue\":        \"#ffffff\",\n        \"light-blue\":  \"#ffffff\",\n        \"cyan\":        \"#ffffff\",\n        \"teal\":        \"#ffffff\",\n        \"green\":       \"#ffffff\",\n        \"light-green\": \"#ffffff\",\n        \"lime\":        \"#000000\",\n        \"yellow\":      \"#000000\",\n        \"amber\":       \"#000000\",\n        \"orange\":      \"#000000\",\n        \"deep-orange\": \"#ffffff\",\n        \"brown\":       \"#ffffff\",\n        \"grey\":        \"#ffffff\",\n        \"blue-grey\":   \"#ffffff\",\n        \"black\":       \"#ffffff\",\n        \"white\":       \"#000000\"\n      }[primary] or \"#ffffff\" }}\n    {%- endif -%}\n\n  # Font family (default: Roboto)\n  - &font_family >-\n    {%- if layout.font_family -%}\n      {{ layout.font_family }}\n    {%- elif config.theme.font is mapping -%}\n      {{ config.theme.font.get(\"text\", \"Roboto\") }}\n    {%- else -%}\n      Roboto\n    {%- endif -%}\n\n  # Font variant\n  - &font_variant >-\n    {%- if layout.font_variant -%}\n      {{ layout.font_variant }}\n    {%- endif -%}\n\n  # Site name\n  - &site_name >-\n    {{ config.site_name }}\n\n  # Page title\n  - &page_title >-\n    {%- if layout.title -%}\n      {{ layout.title }}\n    {%- else -%}\n      {{ page.meta.get(\"title\", page.title) }}\n    {%- endif -%}\n\n  # Page title with site name\n  - &page_title_with_site_name >-\n    {%- if not page.is_homepage -%}\n      {{ page.meta.get(\"title\", page.title) }} - {{ config.site_name }}\n    {%- else -%}\n      {{ page.meta.get(\"title\", page.title) }}\n    {%- endif -%}\n\n  # Page description\n  - &page_description >-\n    {%- if layout.description -%}\n      {{ layout.description }}\n    {%- else -%}\n      {{ page.meta.get(\"description\", config.site_description) | x }}\n    {%- endif -%}\n\n  # Page icon\n  - &page_icon >-\n    {{ page.meta.icon | x }}\n\n  # Logo\n  - &logo >-\n    {%- if layout.logo -%}\n      {{ layout.logo }}\n    {%- elif config.theme.logo -%}\n      {{ config.docs_dir }}/{{ config.theme.logo }}\n    {%- endif -%}\n\n  # Logo (icon)\n  - &logo_icon >-\n    {%- if not layout.logo and config.theme.icon -%}\n      {{ config.theme.icon.logo | x }}\n    {%- endif -%}\n\n# Meta tags\ntags:\n  # Open Graph\n  og:type: website\n  og:title: *page_title_with_site_name\n  og:description: *page_description\n  og:image: \"{{ image.url }}\"\n  og:image:type: \"{{ image.type }}\"\n  og:image:width: \"{{ image.width }}\"\n  og:image:height: \"{{ image.height }}\"\n  og:url: \"{{ page.canonical_url }}\"\n\n  # Twitter\n  twitter:card: summary_large_image\n  twitter:title: *page_title_with_site_name\n  twitter:description: *page_description\n  twitter:image: \"{{ image.url }}\"\n\n# -----------------------------------------------------------------------------\n# Specification\n# -----------------------------------------------------------------------------\n\n# Card size and layers\nsize: { width: 1200, height: 630 }\nlayers:\n  # Background\n  - background:\n      image: *background_image\n      color: *background_color\n\n  # Page icon\n  - size: { width: 630, height: 630 }\n    offset: { x: 800, y: 0 }\n    icon:\n      value: *page_icon\n      color: \"#FFFFFF20\"\n\n  # Logo\n  - size: { width: 64, height: 64 }\n    offset: { x: 64, y: 64 }\n    background:\n      image: *logo\n    icon:\n      value: *logo_icon\n      color: *color\n\n  # Site name\n  - size: { width: 768, height: 42 }\n    offset: { x: 160, y: 74 }\n    typography:\n      content: *site_name\n      color: *color\n      font:\n        family: *font_family\n        variant: *font_variant\n        style: Bold\n\n  # Page title\n  - size: { width: 864, height: 256 }\n    offset: { x: 62, y: 192 }\n    typography:\n      content: *page_title\n      align: start\n      color: *color\n      line:\n        amount: 3\n        height: 1.25\n      font:\n        family: *font_family\n        variant: *font_variant\n        style: Bold\n\n  # Page description\n  - size: { width: 864, height: 64 }\n    offset: { x: 64, y: 512 }\n    typography:\n      content: *page_description\n      align: start\n      color: *color\n      line:\n        amount: 2\n        height: 1.5\n      font:\n        family: *font_family\n        variant: *font_variant\n        style: Regular\n"
  },
  {
    "path": "docs/more/coverage.md",
    "content": "---\ntitle: Community Coverage\ndescription: Homepage has been covered by quite a few YouTube channels, here are some of them.\n---\n\nHomepage has been covered by quite a few YouTube channels, here are some of them. If you have a video you'd like to add, please open a PR!\n\n## English\n\n<div class=\"grid\" markdown>\n\n[![Youtube Video](https://img.youtube.com/vi/mC3tjysJ01E/maxresdefault.jpg)](https://www.youtube.com/watch?v=mC3tjysJ01E)\n\n[![Youtube Video](https://img.youtube.com/vi/o9SLve4wBPY/maxresdefault.jpg)](https://www.youtube.com/watch?v=o9SLve4wBPY)\n\n[![Youtube Video](https://img.youtube.com/vi/j9kbQucNwlc/maxresdefault.jpg)](https://www.youtube.com/watch?v=j9kbQucNwlc)\n\n[![Youtube Video](https://img.youtube.com/vi/3Ux7zfCCM1A/maxresdefault.jpg)](https://www.youtube.com/watch?v=3Ux7zfCCM1A)\n\n[![Youtube Video](https://img.youtube.com/vi/4AwUNy2eztA/maxresdefault.jpg)](https://www.youtube.com/watch?v=4AwUNy2eztA)\n\n[![Youtube Video](https://img.youtube.com/vi/7mUUCB3kP0E/maxresdefault.jpg)](https://www.youtube.com/watch?v=7mUUCB3kP0E)\n\n[![Youtube Video](https://img.youtube.com/vi/a5-4u0qFKaE/maxresdefault.jpg)](https://www.youtube.com/watch?v=a5-4u0qFKaE)\n\n[![Youtube Video](https://img.youtube.com/vi/tV7-06FU4gQ/maxresdefault.jpg)](https://www.youtube.com/watch?v=tV7-06FU4gQ)\n\n[![Youtube Video](https://img.youtube.com/vi/X2ycbT7rPu4/maxresdefault.jpg)](https://www.youtube.com/watch?v=X2ycbT7rPu4)\n\n[![Youtube Video](https://img.youtube.com/vi/1jEWUJqL-eo/maxresdefault.jpg)](https://www.youtube.com/watch?v=1jEWUJqL-eo)\n\n</div>\n\n<div class=\"grid\" markdown>\n\n<div markdown>\n## French\n[![Youtube Video](https://img.youtube.com/vi/aGztk8you6o/maxresdefault.jpg)](https://www.youtube.com/watch?v=aGztk8you6o)\n[![Youtube Video](https://img.youtube.com/vi/pQfhWqZh7YE/maxresdefault.jpg)](https://www.youtube.com/watch?v=pQfhWqZh7YE)\n</div>\n\n<div markdown>\n## German\n[![Youtube Video](https://img.youtube.com/vi/DrDgg-WRA2g/maxresdefault.jpg)](https://www.youtube.com/watch?v=DrDgg-WRA2g)\n</div>\n\n<div markdown>\n## Chinese\n[![Youtube Video](https://img.youtube.com/vi/DAW15ckt4n4/mqdefault.jpg){: style=\"width: 100%\"}](https://www.youtube.com/watch?v=DAW15ckt4n4)\n</div>\n\n<div markdown>\n## Russian\n[![Youtube Video](https://img.youtube.com/vi/dk3Cp5ck8mY/maxresdefault.jpg)](https://www.youtube.com/watch?v=dk3Cp5ck8mY)\n</div>\n\n</div>\n"
  },
  {
    "path": "docs/more/homepage-move.md",
    "content": "---\ntitle: Homepage Move\ndescription: Homepage Container Deprecation\n---\n\nAs of v0.7.2 homepage migrated from benphelps/homepage to an \"organization\" repository located at [gethomepage/homepage](https://github.com/gethomepage/homepage/). The reason for this was to setup the project for longevity and allow for community maintenance.\n\nMigrating your installation should be as simple as changing `image: ghcr.io/benphelps/homepage:latest` to `image: ghcr.io/gethomepage/homepage:latest`.\n"
  },
  {
    "path": "docs/more/index.md",
    "content": "---\ntitle: More\ndescription: More homepage resources and guides.\nicon: material/information-slab-circle\n---\n\nHere you'll find resources and guides for Homepage, troubleshooting tips, and more.\n"
  },
  {
    "path": "docs/more/sponsors.md",
    "content": "---\ntitle: Sponsors\ndescription: Homepage is supported by these awesome people and companies.\n---\n\nIf you would like to support the Homepage project, you can do so by becoming a sponsor. Your sponsorship helps to keep the project running and growing.\n\n<div class=\"grid\" markdown>\n\n[:simple-github: GitHub Sponsors](https://github.com/sponsors/gethomepage){ .md-button }\n\n[:simple-opencollective: OpenCollective](https://opencollective.com/homepage){ .md-button }\n\n[:simple-patreon: Patreon](https://www.patreon.com/gethomepage){ .md-button .w-full }\n\n</div>\n\n<hr style=\"margin-top: 48px;\" />\n\nThese companies help the Homepage project by providing services, tools, and resources.\n\n<div class=\"grid\" markdown>\n  <div style=\"margin-bottom: 16px;\">\n    <a href=\"https://www.digitalocean.com/?refcode=df14bcb7c016&utm_campaign=Referral_Invite&utm_medium=Referral_Program&utm_source=badge\"><img src=\"https://web-platforms.sfo2.cdn.digitaloceanspaces.com/WWW/Badge%202.svg\" alt=\"DigitalOcean\" style=\"max-width: 100%; height: 64px; display: block;\" /></a>\n    <p>\n      DigitalOcean provides the GitHub Actions runner for the project.  Dramatically speeding up the CI/CD process.\n    </p>\n  </div>\n\n  <div style=\"margin-bottom: 16px;\">\n    <a href=\"https://crowdin.com/project/gethomepage\"><img src=\"https://support.crowdin.com/assets/logos/core-logo/png/crowdin-core-logo-cWhite.png\" alt=\"Crowdin\" style=\"max-width: 100%; height: 64px; display: block;\" /></a>\n    <p>\n      Crowdin provides the translation platform for the project.  Making it easy to translate the project into multiple languages.\n    </p>\n  </div>\n\n  <div style=\"margin-bottom: 16px;\">\n    <a href=\"https://www.jetbrains.com/\"><img src=\"https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.png\" alt=\"JetBrains\" style=\"max-width: 100%; height: 64px; display: block;\" /></a>\n    <p>\n      JetBrains provides the project with free licenses for their awesome tools.\n    </p>\n  </div>\n\n  <div style=\"margin-bottom: 16px;\">\n    <a href=\"https://www.buysellads.com/\"><img src=\"https://www.buysellads.com/hubfs/raw_assets/public/BSA-2023/images/logo.svg\" alt=\"BuySellAds\" style=\"max-width: 100%; height: 64px; display: block; filter: invert();\" /></a>\n    <p>\n      BuySellAds provides the project with the ability to monetize the website, with high quality ads from the CarbonAds network.  All earnings are sent directly to the projects OpenCollective.\n    </p>\n  </div>\n</div>\n\n<style>\n.md-typeset img,\n.md-typeset svg,\n.md-typeset video {\n  box-shadow: none;\n}\n</style>\n"
  },
  {
    "path": "docs/more/translations.md",
    "content": "---\ntitle: Translations\ndescription: Contributing Translations\n---\n\nHomepage is developed in English, component contributions must be in English. All translations are community provided, so a huge thanks go out to all those who have helped out so far!\n\n## Support Translations\n\nIf you'd like to lend a hand in translating Homepage into more languages, or to improve existing translations, the process is very simple:\n\n1. Create a free account at [Crowdin](https://crowdin.com/join)\n2. Visit the [Homepage project](https://crowdin.com/project/gethomepage)\n3. Select the language you'd like to translate\n4. Start translating!\n\n## Adding a new language\n\nIf you'd like to add a new language, please [create a new Discussion on Crowdin](https://crowdin.com/project/gethomepage/discussions), and we'll add it to the project.\n"
  },
  {
    "path": "docs/overrides/main.html",
    "content": "{% extends \"base.html\" %}\n\n{% block header %}\n  <div id=\"blur-overlay\" class=\"blur-overlay\"></div>\n  {% include \"partials/header.html\" %}\n{% endblock %}\n\n{% block site_nav %}\n  <!-- Navigation -->\n  {% if nav %}\n    {% if page.meta and page.meta.hide %}\n      {% set hidden = \"hidden\" if \"navigation\" in page.meta.hide %}\n    {% endif %}\n    <div\n      class=\"md-sidebar md-sidebar--primary\"\n      data-md-component=\"sidebar\"\n      data-md-type=\"navigation\"\n      {{ hidden }}\n    >\n      <div class=\"md-sidebar__scrollwrap\">\n        <div class=\"md-sidebar__inner\">\n          {% include \"partials/nav.html\" %}\n          {% if 'widgets/' not in page.url and 'more/' not in page.url %}\n            <script async type=\"text/javascript\" src=\"//cdn.carbonads.com/carbon.js?serve=CWYIL2JU&placement=gethomepagedev&format=cover\" id=\"_carbonads_js\"></script>\n          {% endif %}\n        </div>\n      </div>\n    </div>\n  {% endif %}\n\n  <!-- Table of contents -->\n  {% if \"toc.integrate\" not in features %}\n    {% if page.meta and page.meta.hide %}\n      {% set hidden = \"hidden\" if \"toc\" in page.meta.hide %}\n    {% endif %}\n    <div\n      class=\"md-sidebar md-sidebar--secondary\"\n      data-md-component=\"sidebar\"\n      data-md-type=\"toc\"\n      {{ hidden }}\n    >\n      <div class=\"md-sidebar__scrollwrap\" style=\"height: 200px;\">\n        <div class=\"md-sidebar__inner\">\n          {% include \"partials/toc.html\" %}\n          {% if 'widgets/' in page.url or 'more/' in page.url %}\n            <script async type=\"text/javascript\" src=\"//cdn.carbonads.com/carbon.js?serve=CWYIL2JU&placement=gethomepagedev&format=cover\" id=\"_carbonads_js\"></script>\n          {% endif %}\n        </div>\n      </div>\n    </div>\n  {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "docs/stylesheets/extra.css",
    "content": "[data-md-color-scheme=\"slate\"] {\n  --md-hue: 220;\n  --md-default-bg-color: hsla(0, 0%, 14%, 0.6);\n  --md-code-bg-color: hsla(0, 0%, 0%, 0.2);\n}\n\n[data-md-color-scheme=\"default\"] {\n  --md-hue: 220;\n  --md-default-fg-color--light: white;\n  --md-default-fg-color--lighter: hsla(0, 0%, 100%, 0.6);\n  --md-default-bg-color: hsla(0, 0%, 100%, 0.8);\n  --md-code-bg-color: hsla(0, 0%, 100%, 0.6);\n  --md-code-bg-color--lighter: hsla(0, 0%, 100%, 0.6);\n  --md-default-fg-color: white;\n}\n\n[data-md-color-scheme=\"default\"] .md-search__inner {\n  --md-default-fg-color--light: gray;\n  --md-default-fg-color--lighter: black;\n  --md-default-bg-color: hsla(0, 0%, 100%, 0.9);\n}\n\n[data-md-color-scheme=\"default\"] .md-search__inner .md-search__input {\n  color: var(--md-default-fg-color--light);\n}\n\n[data-md-toggle=\"search\"]:not(:checked) ~ .md-header .md-search__form::after {\n  position: absolute;\n  top: 0.3rem;\n  right: 0.3rem;\n  display: block;\n  padding: 0.1rem 0.4rem;\n  color: var(--md-default-fg-color--lighter);\n  font-weight: bold;\n  font-size: 0.8rem;\n  border: 0.05rem solid var(--md-default-fg-color--lighter);\n  border-radius: 0.1rem;\n  content: \"/\";\n}\n\n[data-md-color-scheme=\"default\"][data-md-color-primary=\"black\"] {\n  [data-md-toggle=\"search\"]:not(:checked) ~ .md-header .md-search__form::after {\n    color: var(--md-default-bg-color--lighter);\n    border-color: var(--md-default-bg-color--lighter);\n  }\n}\n\n#carbonads {\n  margin-top: 10px;\n}\n\n#carbon-responsive {\n  --carbon-padding: 1em;\n  --carbon-max-char: 20ch;\n  --carbon-bg-primary: var(--md-default-bg-color) !important;\n  --carbon-bg-secondary: var(--md-default-fg-color--lightest) !important;\n  --carbon-text-color: var(--md-typeset-color) !important;\n}\n\n[data-md-color-scheme=\"default\"] .carbon-text {\n  color: var(--md-code-fg-color) !important;\n  --carbon-text-color: #313131 !important;\n}\n\n.md-typeset__table {\n  width: 100%;\n}\n\n.md-typeset table:not([class]) {\n  display: table;\n}\n\n/* less than 1440px wide */\n@media (max-width: 1440px) {\n  .md-footer-meta__inner {\n    justify-content: center;\n  }\n}\n\n/* less than 740px wide */\n@media (max-width: 740px) {\n  .md-footer-meta__inner {\n    justify-content: left;\n    flex-direction: column;\n  }\n  .md-social {\n    padding-top: 0;\n  }\n}\n\n.md-header__button.md-logo {\n  padding: 0;\n  margin: 0;\n}\n\n.md-header__button.md-logo img,\n.md-header__button.md-logo svg {\n  height: 2rem;\n}\n\n.md-header__topic .md-ellipsis {\n  display: none;\n}\n\nbody {\n  background-color: transparent !important;\n  background-image: url(\"https://raw.githubusercontent.com/gethomepage/homepage/main/docs/assets/blossom_valley_blur.jpg\");\n  background-size: cover;\n  background-attachment: fixed;\n  background-position: center;\n  color: rgba(255, 255, 255, 0.8);\n}\n\n.md-typeset h1 {\n  color: #fff;\n}\n\nbody[data-md-color-scheme=\"default\"] {\n  color: rgba(255, 255, 255, 1);\n}\n\n.md-nav--lifted > .md-nav__list > .md-nav__item--active > .md-nav__link,\n.md-nav--secondary .md-nav__title {\n  background: none;\n  box-shadow: none;\n}\n\n[data-md-color-scheme=\"slate\"] .md-main,\n[data-md-color-scheme=\"slate\"] .md-tabs,\n[data-md-color-scheme=\"slate\"] .md-footer {\n  background-color: hsla(0, 0%, 0%, 0.3);\n}\n\n[data-md-color-scheme=\"default\"] .md-main,\n[data-md-color-scheme=\"default\"] .md-tabs,\n[data-md-color-scheme=\"default\"] .md-footer {\n  background-color: hsla(0, 0%, 100%, 0.1);\n}\n\n[data-md-color-scheme=\"slate\"] .md-header {\n  background-color: hsla(0, 0%, 0%, 0.3);\n  backdrop-filter: blur(16px);\n  -webkit-backdrop-filter: blur(16px);\n}\n\n[data-md-color-scheme=\"default\"] .md-header {\n  background-color: hsla(0, 0%, 100%, 0.1);\n  backdrop-filter: blur(16px);\n  -webkit-backdrop-filter: blur(16px);\n}\n\n.md-header:has(.md-search-result__item),\n.md-header:has(.md-search__input.focus-visible) {\n  backdrop-filter: none !important;\n  -webkit-backdrop-filter: none !important;\n}\n\n.md-footer-meta {\n  background-color: transparent;\n}\n\n[data-md-color-scheme=\"slate\"][data-md-color-primary=\"black\"],\n[data-md-color-scheme=\"default\"][data-md-color-primary=\"black\"] {\n  --md-typeset-a-color: #ffffff;\n}\n\n.md-content__inner a {\n  text-decoration: underline;\n  font-weight: bolder;\n}\n\n[data-md-color-scheme=\"default\"] .highlight .p,\n[data-md-color-scheme=\"default\"] .highlight .o,\n[data-md-color-scheme=\"default\"] .highlight .ow,\n[data-md-color-scheme=\"default\"] .highlight .c,\n[data-md-color-scheme=\"default\"] .highlight .c1,\n[data-md-color-scheme=\"default\"] .highlight .ch,\n[data-md-color-scheme=\"default\"] .highlight .cm,\n[data-md-color-scheme=\"default\"] .highlight .cs,\n[data-md-color-scheme=\"default\"] .highlight .sd {\n  color: #36464eaa;\n}\n\n[data-md-color-scheme=\"default\"] .md-annotation__index:after {\n  background-color: #36464ecc;\n}\n\n/* I know this is a farce, but I want it to look nice. */\n.css-9if7bc {\n  background-color: hsla(0, 0%, 0%, 0.3);\n  backdrop-filter: blur(16px);\n  -webkit-backdrop-filter: blur(16px);\n}\n\n@media screen and (max-width: 76.234375em) {\n  .md-nav--primary,\n  .md-nav--primary .md-nav {\n    background-color: hsla(0, 0%, 0%, 0.8);\n  }\n}\n\n@media screen and (max-width: 76.234375em) {\n  .md-nav--primary .md-nav__title ~ .md-nav__list {\n    background-color: hsla(0, 0%, 0%, 0.8);\n    backdrop-filter: blur(16px);\n    -webkit-backdrop-filter: blur(16px);\n  }\n}\n\n@media screen and (max-width: 76.234375em) {\n  .md-nav--primary .md-nav__title {\n    background-color: hsla(0, 0%, 0%, 0.8);\n    backdrop-filter: blur(16px);\n  }\n}\n\n.md-search__scrollwrap {\n  background-color: hsla(0, 0%, 0%, 0.8);\n  backdrop-filter: blur(16px);\n  -webkit-backdrop-filter: blur(16px);\n}\n\n.md-search-result .md-typeset h1 {\n  color: #fff;\n}\n\n[data-md-color-scheme=\"default\"] .highlight span.filename,\n[data-md-color-scheme=\"default\"] .linenodiv a {\n  color: #36464e;\n  font-weight: light;\n}\n\n.linenodiv a {\n  text-decoration: none;\n}\n\n.md-typeset .admonition,\n.md-typeset details {\n  background-color: transparent;\n}\n\n.md-typeset img,\n.md-typeset svg,\n.md-typeset video {\n  box-shadow: 0 0 1rem 0.25rem hsla(0, 0%, 0%, 0.1);\n}\n\n.highlight {\n  box-shadow: 0 0 1rem 0.25rem hsla(0, 0%, 0%, 0.1);\n}\n\n.md-typeset .admonition.tip,\n.md-typeset details.tip {\n  box-shadow: 0 0 1rem 0.25rem hsl(171.83deg 100% 37.45% / 20%);\n}\n\n.md-typeset .admonition.note,\n.md-typeset details.note {\n  box-shadow: 0 0 1rem 0.25rem hsl(214.29deg 100% 37.45% / 20%);\n}\n\n.md-typeset .admonition.warning,\n.md-typeset details.warning {\n  box-shadow: 0 0 1rem 0.25rem hsl(40.91deg 100% 37.45% / 20%);\n}\n\n.md-typeset .admonition.danger,\n.md-typeset details.danger {\n  box-shadow: 0 0 1rem 0.25rem hsl(0deg 100% 37.45% / 20%);\n}\n\n.md-tabs__link {\n  transform: translateZ(0);\n}\n\n.grid.cards .card {\n  padding: 0;\n}\n\n.grid.cards .card a {\n  display: block;\n  padding: 0.8rem;\n  text-decoration: none;\n}\n"
  },
  {
    "path": "docs/troubleshooting/index.md",
    "content": "---\ntitle: Troubleshooting\ndescription: Basic Troubleshooting\nicon: material/message-question\nhide:\n  - navigation\n---\n\n## General Troubleshooting Tips\n\n- For API errors, clicking the \"API Error Information\" button in the widget will usually show some helpful information as to whether the issue is reaching the service host, an authentication issue, etc.\n- Check config/logs/homepage.log, on docker simply e.g. `docker logs homepage`. This may provide some insight into the reason for an error.\n- Check the browser error console, this can also sometimes provide useful information.\n- Consider setting the `ENV` variable `LOG_LEVEL` to `debug`.\n\n## Service Widget Errors\n\nAll service widgets work essentially the same, that is, homepage makes a proxied call to an API made available by that service. The majority of the time widgets don't work it is a configuration issue. Of course, sometimes things do break. Some basic steps to check:\n\n1.  URLs should not end with a / or other API path. Each widget will handle the path on its own.\n\n2.  All services with a widget require a unique name as well as a unique group (and all subgroups) name.\n\n3.  Verify the homepage installation can connect to the IP address or host you are using for the widget `url`. This is most simply achieved by pinging the server from the homepage machine, in Docker this means _from inside the container_ itself, e.g.:\n\n    ```\n    docker exec homepage ping SERVICEIPORDOMAIN\n    ```\n\n    If your homepage install (container) cannot reach the service then you need to figure out why, for example in Docker this can mean putting the two containers on the same network, checking firewall issues, etc.\n\n4.  If you have verified that homepage can in fact reach the service then you can also check the API output using e.g. `curl`, which is often helpful if you do need to file a bug report. Again, depending on your networking setup this may need to be run from _inside the container_ as IP / hostname resolution can differ inside vs outside.\n\n    !!! note\n\n        `curl` is not installed in the base image by default but can be added inside the container with `apk add curl`.\n\n    The exact API endpoints and authentication vary of course, but in many cases instructions can be found by searching the web or if you feel comfortable looking at the homepage source code (e.g. `src/widgets/{widget}/widget.js`).\n\n    It is out of the scope of this to go into full detail about how to , but an example for PiHole would be:\n\n    ```\n    curl -L -k http://PIHOLEIPORHOST/admin/api.php\n    ```\n\n    Or for AdGuard:\n\n    ```\n    curl -L -k -u 'username:password' http://ADGUARDIPORHOST/control/stats\n    ```\n\n    Or for Portainer:\n\n    ```\n    curl -L -k -H 'X-Api-Key:YOURKEY' 'https://PORTAINERIPORHOST:PORT/api/endpoints/2/docker/containers/json'\n    ```\n\n    Sonarr:\n\n    ```\n    curl -L -k 'http://SONARRIPORHOST:PORT/api/v3/queue?apikey=YOURAPIKEY'\n    ```\n\n    This will return some data which may reveal an issue causing a true bug in the service widget.\n\n## Missing custom icons\n\nIf, after correctly adding and mapping your custom icons via the [Icons](../configs/services.md#icons) instructions, you are still unable to see your icons please try recreating your container.\n"
  },
  {
    "path": "docs/widgets/authoring/api.md",
    "content": "---\ntitle: API Guide\ndescription: Get comfortable with making API calls from inside your widget.\n---\n\nHomepage provides the `useWidgetAPI` hook to help you fetch data from an API. This hook insures that the data is fetched using a proxy, and is critical for security.\n\nHere is an example of how the `useWidgetAPI` hook looks:\n\n```js title=\"Fetch data from the stats endpoint\"\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { data, error } = useWidgetAPI(widget, \"stats\");\n}\n```\n\n## `useWidgetAPI`\n\n`useWidgetAPI` takes three possible arguments:\n\n- `widget`: The widget metadata object.\n- `endpoint`: The name of the endpoint to fetch data from.\n- `params`: An optional object containing query parameters to pass to the API.\n\n### `widget`\n\nThe `widget` argument is the metadata object for the widget. It contains information about the API endpoint, proxy handler, and mappings. This object is used by the `useWidgetAPI` hook to fetch data from the API. This is generally passed in as a prop from the parent component.\n\n### `endpoint`\n\nThe `endpoint` argument is the name of the endpoint to fetch data from. This is [defined in the widget metadata object](metadata.md#endpoint). The `useWidgetAPI` hook uses this argument to determine which endpoint to fetch data from.\n\nIf no endpoint is provided, the `useWidgetAPI` hook will call the API endpoint defined in the widget metadata object directly.\n\n### `params`\n\nThe `params` argument is an optional object containing query parameters to pass to the API. This is useful for filtering data or passing additional information to the API. This object is passed directly to the API endpoint as query parameters.\n\nHere is an example of how to use the `params` argument:\n\n```js title=\"Fetch data from the stats endpoint with query parameters\"\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { data, error } = useWidgetAPI(widget, \"stats\", { start: \"2021-01-01\", end: \"2021-12-31\" });\n}\n```\n\nThe `params` must be [whitelisted in the widget metadata object](metadata.md#params). This is done to prevent arbitrary query parameters from being passed to the API.\n"
  },
  {
    "path": "docs/widgets/authoring/component.md",
    "content": "---\ntitle: Component Guide\ndescription: Learn more about the widget component in Homepage, and how to build your widget UI.\n---\n\nHomepage widgets are built using React components. These components are responsible for fetching data from the API and rendering the widget UI. Homepage provides a set of hooks and utilities to help you build your widget component.\n\n## A Basic Widget Component\n\nHere is an example of a basic widget component:\n\n```js\nimport { useTranslation } from \"next-i18next\";\n\nimport Container from \"components/services/widget/container\";\nimport Block from \"components/services/widget/block\";\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const { data, error } = useWidgetAPI(widget, \"info\");\n\n  if (error) {\n    return <Container service={service} error={error} />;\n  }\n\n  if (!data) {\n    return (\n      <Container service={service}>\n        <Block label=\"yourwidget.key1\" />\n        <Block label=\"yourwidget.key2\" />\n        <Block label=\"yourwidget.key3\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"yourwidget.key1\" value={t(\"common.number\", { value: data.key1 })} />\n      <Block label=\"yourwidget.key2\" value={t(\"common.number\", { value: data.key2 })} />\n      <Block label=\"yourwidget.key3\" value={t(\"common.number\", { value: data.key3 })} />\n    </Container>\n  );\n}\n```\n\n### Breakdown\n\nWe'll cover two sections of the widget component: hooks and components.\n\n#### Hooks\n\n**`useTranslation`**\n\nThis hook is used to translate text and numerical content in widgets. Homepage provides a set of helpers to help you localize your widgets. You can learn more about translations in the [Translations Guide](translations.md).\n\n**`useWidgetAPI`**\n\nThis hook is used to fetch data from the API. We cover this hook in more detail in the [API Guide](api.md).\n\n#### Components\n\nHomepage provides a set of components to help you build your widget UI. These components are designed to provide a consistent layout, and all widgets are expected to use these components.\n\n![Component Sections](../../assets/sections.webp)\n\n**`<Container>`**\n\nThis component is a wrapper for the widget. It provides a consistent layout for all widgets.\n\n```js\n<Container service={service}></Container>\n```\n\n`service` is a prop that is passed to the widget component. It contains information about the service that the widget is displaying.\n\nIf there is an error fetching data from the API, the `error` prop can be passed to the `Container` component.\n\n```js\n<Container service={service} error={error}></Container>\n```\n\n**`<Block>`**\n\nThis component is used to display a key-value pair. It takes a label and value as props.\n\n```js\n<Block label=\"yourwidget.key1\" value={t(\"common.number\", { value: data.key1 })} />\n```\n\nThe `label` prop is used to look up the translation key in the translation files. The `value` prop is used to display the value of the block. To learn more about translations, please refer to the [Translations Guide](translations.md).\n\nIf there is no data available, the `Block` component can be used to display a placeholder layout.\n\n```js\n<Container service={service}>\n  <Block label=\"yourwidget.key1\" />\n  <Block label=\"yourwidget.key2\" />\n  <Block label=\"yourwidget.key3\" />\n</Container>\n```\n"
  },
  {
    "path": "docs/widgets/authoring/getting-started.md",
    "content": "---\ntitle: Getting Started\ndescription: Get started developing for Homepage.\n---\n\nWe'll cover getting homepage up and running on your local machine for development, as well as some guidelines for developing new features and widgets.\n\n## Development\n\nFirst, clone the homepage repository.\n\nFor installing NPM packages, this project uses [pnpm](https://pnpm.io/) (and so should you!):\n\n```bash\npnpm install\n```\n\nStart the development server:\n\n```bash\npnpm dev\n```\n\nOpen [http://localhost:3000](http://localhost:3000) to start.\n\nThis is a [Next.js](https://nextjs.org/) application, see their documentation for more information.\n\n## Code Linting\n\nOnce dependencies have been installed you can lint your code with\n\n```bash\npnpm lint\n```\n\n## Testing\n\nHomepage uses [Vitest](https://vitest.dev/) for unit and component tests.\n\nRun the test suite:\n\n```bash\npnpm test\n```\n\nRun the test suite with coverage:\n\n```bash\npnpm test:coverage\n```\n\n### What tests to include\n\n- New or updated widgets should generally include a component test near the widget component (for example `src/widgets/<widget>/component.test.jsx`) that covers realistic behavior: loading/placeholder state, error state, and a representative \"happy path\" render.\n- If you add or change a widget definition file (`src/widgets/<widget>/widget.js`), add/update its corresponding unit test (`src/widgets/<widget>/widget.test.js`) to cover the config/mapping behavior.\n- If your widget requires a custom proxy (`src/widgets/<widget>/proxy.js`), add a proxy unit test (`src/widgets/<widget>/proxy.test.js`) that validates:\n  - request construction (URL, query params, headers/auth)\n  - response mapping (what the widget consumes)\n  - error pathways (upstream error, unexpected payloads)\n- Avoid placing test files under `src/pages/**` (Next.js treats files there as routes). Page tests should live under `src/__tests__/pages/**`.\n\n## Code formatting with pre-commit hooks\n\nTo ensure a consistent style and formatting across the project source, the project utilizes Git [`pre-commit`](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) hooks to perform some formatting and linting before a commit is allowed.\n\nOnce installed, hooks will run when you commit. If the formatting isn't quite right, the commit will be rejected and you'll need to look at the output and fix the issue. Most hooks will automatically format failing files, so all you need to do is `git add` those files again and retry your commit.\n\nSee the [pre-commit documentation](https://pre-commit.com/#install) to get started.\n\n## Preferring self-hosted open-source software\n\nIn general, homepage is meant to be a dashboard for 'self-hosted' services and we believe it is a small way we can help showcase this kind of software. While exceptions are made, mostly when there is no viable\nself-hosted / open-source alternative, we ask that any widgets, etc. are developed primarily for a self-hosted tool.\n\n## New Feature or Enhancement Guidelines {#new-feature-guidelines}\n\n- New features or enhancements, **no matter how small**, must be linked to an existing feature request with some comments or 'up-votes' that demonstrate community interest. The purpose of this requirement is to avoid the addition (and maintenance) of features that might only benefit a small number of users.\n- If you have ideas for a larger feature you may want to open a discussion first.\n\n## Service Widget Guidelines\n\nTo ensure cohesiveness of various widgets, the following should be used as a guide for developing new widgets:\n\n- Please only submit widgets that target a feature request discussion with at least 20 'up-votes'. The purpose of this requirement is to avoid the addition (and maintenance) of service widgets that might only benefit a small number of users.\n- Note that we reserve the right to decline widgets for projects that are very young (eg < ~1y) or those with a small reach (eg low GitHub stars). Again, this is in an effort to keep overall widget maintenance under control.\n- Widgets should be only one row of blocks\n- Widgets should be no more than 4 blocks wide and generally conform to the styling / design choices of other widgets\n- Minimize the number of API calls\n- Avoid the use of custom proxy unless absolutely necessary\n- Widgets should be 'read-only', as in they should not make write changes using the relevant tool's API. Homepage widgets are designed to surface information, not to be a (usually worse) replacement for the tool itself.\n- Widgets should not allow manually overriding the \"refresh interval\" setting, as misconfigured refresh intervals can easily lead to performance issues for users.\n"
  },
  {
    "path": "docs/widgets/authoring/index.md",
    "content": "---\ntitle: Guides & Tutorials\ndescription: Learn how to create and customize widgets in Homepage. Explore translations, widget components, widget metadata, proxy handlers, and making API calls.\nicon: fontawesome/solid/graduation-cap\n---\n\nWidgets are a core component of Homepage. They are used to display information about your system, services, and environment.\n\n## Overview\n\nIf you are new to Homepage widgets, and are looking to create a new widget, please follow along with the guide here: [Widget Tutorial](tutorial.md).\n\n### Translations\n\nAll text and numerical content in widgets should be translated and localized. English is the default language, and other languages can be added via [Crowdin](https://crowdin.com/project/gethomepage).\n\nTo learn more about translations, please refer to the guide here: [Translations Guide](translations.md).\n\n### Widget Component\n\nThe widget component is the core of the widget. It is responsible for [fetching data from the API](api.md) and rendering the widget UI. Homepage provides a set of hooks and utilities to help you build your widget component.\n\nTo learn more about widget components, please refer to the guide here: [Component Guide](component.md).\n\n### Widget Metadata\n\nWidget metadata defines the configuration of the widget. It defines the API endpoint to fetch data from, the proxy handler to use, and any data mappings.\n\nTo learn more about widget metadata, endpoint and data mapping, please refer to the guide here: [Metadata Guide](metadata.md).\n\nTo learn more about proxy handlers, please refer to the guide here: [Proxies Guide](proxies.md).\n\nTo learn more about making API calls from inside your widget, please refer to the guide here: [API Guide](api.md).\n"
  },
  {
    "path": "docs/widgets/authoring/metadata.md",
    "content": "---\ntitle: Metadata Guide\ndescription: Explore all the metadata properties that can be used to configure a widget in Homepage.\n---\n\nHere, we will go over how to create and configure Homepage widget metadata. Metadata is a JS object that contains information about the widget, such as the API endpoint, proxy handler, and mappings. This metadata is used by Homepage to fetch data from the API and pass it to the widget.\n\n## Widgets Configuration\n\nHere are some examples of how to configure a widget's metadata object.\n\n=== \"Basic Example\"\n\n    ```js\n    import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\n    const widgetExample = {\n      api: \"{url}/api/{endpoint}\",\n      proxyHandler: genericProxyHandler,\n\n      mappings: {\n        stats: { endpoint: \"stats\" }\n      },\n    };\n    ```\n\n=== \"Advanced Example\"\n\n    ```js\n    import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n    import { asJson, jsonArrayFilter } from \"utils/proxy/api-helpers\";\n\n    const widgetExample = {\n      api: \"{url}/api/{endpoint}\",\n      proxyHandler: credentialedProxyHandler,\n\n      mappings: {\n        stats: {\n          endpoint: \"stats\",\n          validate: [\"total\", \"average\"],\n          params: [\"start\", \"end\"],\n        },\n        notices: {\n          endpoint: \"notices\",\n          map: (data) => {\n            total: asJson(data).length;\n          },\n        },\n        warnings: {\n          endpoint: \"notices\",\n          map: (data) => {\n            total: jsonArrayFilter(data, (alert) => alert.type === \"warning\").length;\n          },\n        },\n      },\n    };\n    ```\n\nA widget's metadata is quite powerful and can be configured in many different ways.\n\n## Configuration Properties\n\n### `api`\n\nThe `api` property is a string that represents the URL of the API endpoint that the widget will use to fetch data. The URL can contain placeholders that will be replaced with actual values at runtime. For example, the `{url}` placeholder will be replaced with the URL of the configured widget, and the `{endpoint}` placeholder will be replaced with the value of the `endpoint` property in the `mappings` object.\n\n```js\nconst widgetExample = {\n  api: \"{url}/api/{endpoint}\",\n};\n```\n\n### `proxyHandler`\n\nThe `proxyHandler` property is a function that will be used to make the API request. Homepage includes some built-in proxy handlers that can be used out of the box:\n\nHere is an example of the generic proxy handler that makes unauthenticated requests to the specified API endpoint.\n\n=== \"widget.js\"\n\n    ```js\n    const widgetExample = {\n      api: \"{url}/api/{endpoint}\",\n      proxyHandler: genericProxyHandler,\n    };\n    ```\n\n=== \"services.yaml\"\n\n    ```yaml\n    - Services:\n        - Your Widget:\n            icon: yourwidget.svg\n            href: https://example.com/\n            widget:\n              type: yourwidget\n              url: http://127.0.0.1:1337\n    ```\n\nIf you are looking to learn more about proxy handlers, please refer to the guide here: [Proxies Guide](proxies.md).\n\n### `mappings`\n\nThe `mappings` property is an object that contains information about the API endpoint, such as the endpoint name, validation rules, and parameter names. The `mappings` object can contain multiple endpoints, each with its own configuration.\n\n!!! note \"Security Note\"\n\n    The `mappings` or `allowedEndpoints` property is required for the widget to fetch data from more than a static URL. Homepage uses a whitelist approach to ensure that widgets only access allowed endpoints.\n\n```js\nimport { asJson } from \"utils/proxy/api-helpers\";\n\nconst widgetExample = {\n  api: \"{url}/api/{endpoint}\",\n  mappings: {\n    // `/api/stats?start=...&end=...`\n    stats: {\n      endpoint: \"stats\",\n      validate: [\"total\", \"average\"],\n      params: [\"start\", \"end\"],\n    },\n    // `/api/notices`\n    notices: {\n      endpoint: \"notices\",\n      map: (data) => {\n        total: asJson(data).length;\n      },\n    },\n  },\n};\n```\n\n#### `endpoint`\n\nThe `endpoint` property is a string that represents the name of the API endpoint that the widget will use to fetch data. This value will be used to replace the `{endpoint}` placeholder in the `api` property.\n\n```js\nconst widgetExample = {\n  api: \"{url}/api/{endpoint}\",\n  mappings: {\n    // `/api/stats`\n    stats: {\n      endpoint: \"stats\",\n    },\n  },\n};\n```\n\n#### `validate`\n\nThe `validate` property is an array of strings that represent the keys that should be validated in the API response. If the response does not contain all of the specified keys, the widget will not render.\n\n```js\nconst widgetExample = {\n  api: \"{url}/api/{endpoint}\",\n  mappings: {\n    // `/api/stats`\n    stats: {\n      endpoint: \"stats\",\n      validate: [\"total\", \"average\"],\n    },\n  },\n};\n```\n\nThis configuration will ensure that the API response contains the `total` and `average` keys before the widget is rendered.\n\n#### `params`\n\nThe `params` property is an array of strings that represent the keys that should be passed as parameters to the API endpoint. These keys will be replaced with the actual values at runtime.\n\n=== \"widget.js\"\n\n    ```js\n    const widgetExample = {\n      api: \"{url}/api/{endpoint}\",\n      mappings: {\n        // `/api/stats?start=...&end=...`\n        stats: {\n          endpoint: \"stats\",\n          params: [\"start\", \"end\"],\n        },\n      },\n    };\n    ```\n\n=== \"component.jsx\"\n\n      ```js\n      const { data: statsData, error: statsError } = useWidgetAPI(widget, \"stats\", {\n        start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),\n        end: new Date(),\n      });\n      ```\n\nThis configuration will pass the `start` and `end` keys as parameters to the API endpoint. The values are passed as an object to the `useWidgetAPI` hook.\n\n#### `map`\n\nThe `map` property is a function that will be used to transform the API response before it is passed to the widget. This function is passed the raw API response and should return the transformed data.\n\n```js\nimport { asJson } from \"utils/proxy/api-helpers\";\n\nconst widgetExample = {\n  api: \"{url}/api/{endpoint}\",\n  mappings: {\n    // `/api/notices`\n    notices: {\n      endpoint: \"notices\",\n      map: (data) => {\n        total: asJson(data).length;\n      },\n    },\n    // `/api/notices`\n    warnings: {\n      endpoint: \"notices\",\n      map: (data) => {\n        total: asJson(data).filter((alert) => alert.type === \"warning\").length;\n      },\n    },\n  },\n};\n```\n\n#### `method`\n\nThe `method` represents the HTTP method that should be used to make the API request. The default value is `GET`. Note that `POST` requests are not allowed via the\nwidget API and require the use of a custom proxy.\n\n#### `headers`\n\nThe `headers` property is an object that contains additional headers that should be included in the API request. If your endpoint requires specific headers, you can include them here.\n\n```js\nconst widgetExample = {\n  api: \"{url}/api/{endpoint}\",\n  mappings: {\n    // `/api/stats`\n    stats: {\n      endpoint: \"stats\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n    },\n  },\n};\n```\n\n#### `body`\n\nThe `body` property is an object that contains the data that should be sent in the request body. This property is only used when the `method` property is set to `POST` or `PUT`.\n\n```js\nconst widgetExample = {\n  api: \"{url}/api/{endpoint}\",\n  mappings: {\n    // `/api/graphql`\n    stats: {\n      endpoint: \"graphql\",\n      body: {\n        query: `\n          query {\n            stats {\n              total\n              average\n            }\n          }\n        `,\n      },\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n    },\n  },\n};\n```\n\n### `allowedEndpoints`\n\nThe `allowedEndpoints` property is a RegExp that represents the allowed endpoints that the widget can use. If the widget tries to access an endpoint that is not allowed, the request will be blocked.\n\n`allowedEndpoints` can be used when endpoint validation is simple and can be done using a regular expression, and more control is not required.\n\n!!! note \"Security Note\"\n\n    The `mappings` or `allowedEndpoints` property is required for the widget to fetch data from more than a static URL. Homepage uses a whitelist approach to ensure that widgets only access allowed endpoints.\n\n```js\nconst widgetExample = {\n  api: \"{url}/api/{endpoint}\",\n  allowedEndpoints: /^stats|notices$/,\n};\n```\n\nThis configuration will only allow the widget to access the `stats` and `notices` endpoints.\n"
  },
  {
    "path": "docs/widgets/authoring/proxies.md",
    "content": "---\ntitle: Proxies Guide\ndescription: Learn about proxy handlers in Homepage, and how to securely fetch data from an API.\n---\n\nHomepage includes a set of built-in proxy handlers that can be used to fetch data from an API. We will go over how to use these proxy handlers and briefly cover how to create your own.\n\n## Available Proxy Handlers\n\nHomepage comes with a few built-in proxy handlers that can be used to fetch data from an API. These handlers are located in the `utils/proxy/handlers` directory.\n\n### `genericProxyHandler`\n\nA proxy handler that makes generally unauthenticated requests to the specified API endpoint.\n\n```js\nimport genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widgetExample = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n};\n```\n\nYou can also pass API keys from the widget configuration to the proxy handler, for authenticated requests.\n\n=== \"widget.js\"\n\n    ```js\n    import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\n    const widgetExample = {\n      api: \"{url}/api/{endpoint}?key={key}\",\n      proxyHandler: genericProxyHandler,\n    };\n    ```\n\n=== \"services.yaml\"\n\n    ```yaml\n    # Widget Configuration\n    - Your Widget:\n        icon: yourwidget.svg\n        href: https://example.com/\n        widget:\n          type: yourwidget\n          url: http://example.com\n          key: your-api-key\n    ```\n\n### `credentialedProxyHandler`\n\nA proxy handler that makes authenticated requests by setting request headers. Credentials are pulled from the widgets configuration.\n\nBy default the key is passed as an `X-API-Key` header. If you need to pass the key as something else, either add a case to the credentialedProxyHandler or create a new proxy handler.\n\n=== \"widget.js\"\n\n    ```js\n    import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\n    const widgetExample = {\n      api: \"{url}/api/{endpoint}?key={key}\",\n      proxyHandler: credentialedProxyHandler,\n    };\n    ```\n\n=== \"services.yaml\"\n\n    ```yaml\n    - Your Widget:\n        icon: yourwidget.svg\n        href: https://example.com/\n        widget:\n          type: yourwidget\n          url: http://127.0.0.1:1337\n          key: your-api-key\n    ```\n\n### `jsonrpcProxyHandler`\n\nA proxy handler that makes authenticated JSON-RPC requests to the specified API endpoint, either using username + password or an API token.\nThe endpoint is the method to call and queryParams are used as the parameters.\n\n=== \"component.js\"\n\n    ```js\n    import Container from \"components/services/widget/container\";\n    import useWidgetAPI from \"utils/proxy/use-widget-api\";\n\n    export default function Component({ service }) {\n      const { widget } = service;\n\n      const { data, error } = useWidgetAPI(widget, 'trigger', { \"triggerids\": \"14062\", \"output\": \"extend\", \"selectFunctions\": \"extend\" });\n    }\n    ```\n\n=== \"widget.js\"\n\n    ```js\n    import jsonrpcProxyHandler from \"utils/proxy/handlers/jsonrpc\";\n\n    const widgetExample = {\n      api: \"{url}/api/jsonrpc\",\n      proxyHandler: jsonrpcProxyHandler,\n\n      mappings: {\n        total: { endpoint: \"total\" },\n        average: { endpoint: \"average\" },\n        trigger: { endpoint: \"trigger.get\" },\n      },\n    };\n    ```\n\n=== \"services.yaml\"\n\n    ```yaml\n    - Your Widget:\n        icon: yourwidget.svg\n        href: https://example.com/\n        widget:\n          type: yourwidget\n          url: http://127.0.0.1:1337\n          username: your-username\n          password: your-password\n    ```\n\n    ```yaml\n    - Your Widget:\n        icon: yourwidget.svg\n        href: https://example.com/\n        widget:\n          type: yourwidget\n          url: http://127.0.0.1:1337\n          key: your-api-token\n    ```\n\n### `synologyProxyHandler`\n\nA proxy handler that makes authenticated requests to the specified Synology API endpoint. This is used exclusively for Synology DSM services.\n\n=== \"widget.js\"\n\n    ```js\n    import synologyProxyHandler from \"utils/proxy/handlers/synology\";\n\n    const widgetExample = {\n      api: \"{url}/webapi/{cgiPath}?api={apiName}&version={maxVersion}&method={apiMethod}\",\n      proxyHandler: synologyProxyHandler,\n\n      mappings: {\n        system_storage: {\n          apiName: \"SYNO.Core.System\",\n          apiMethod: 'info&type=\"storage\"',\n          endpoint: \"system_storage\",\n        }\n      },\n    };\n    ```\n\n=== \"services.yaml\"\n\n    ```yaml\n    - Your Widget:\n        icon: yourwidget.svg\n        href: https://example.com/\n        widget:\n          type: yourwidget\n          url: http://127.0.0.1:1337\n          username: your-username\n          password: your-password\n    ```\n\n## Creating a Custom Proxy Handler\n\nYou can create your own proxy handler to fetch data from an API. A proxy handler is a function that takes a configuration object and returns a function that makes the API request.\n\nThe proxy handler function takes three arguments:\n\n- `req`: The request object.\n- `res`: The response object.\n- `map`: A function that maps the API response to the widget data.\n\nThe proxy handler function should return a promise that resolves to the API response.\n\nHere is an example of a simple proxy handler that fetches data from an API and passes it to the widget:\n\n```js\nimport createLogger from \"utils/logger\";\nimport { httpProxy } from \"utils/proxy/http\";\n\nconst logger = createLogger(\"customProxyHandler\");\n\nexport default async function customProxyHandler(req, res, map) {\n  const { url } = req.query;\n\n  const [status, contentType, data] = await httpProxy(url);\n\n  return res.status(status).send(data);\n}\n```\n\nProxy handlers are a complex topic and require a good understanding of JavaScript and the Homepage codebase. If you are new to Homepage, we recommend using the built-in proxy handlers.\n\n## Testing proxy handlers\n\nProxy handlers are a common source of regressions because they deal with authentication, request formatting, and sometimes odd upstream API behavior.\n\nWhen you add a new proxy handler or custom widget proxy, include tests that focus on behavior:\n\n- **Request construction:** the correct URL/path, query params, headers, and auth (and that secrets are not accidentally logged).\n- **Response mapping:** the payload shape expected by the widget/component (including optional/missing fields).\n- **Error handling:** upstream non-200s, invalid JSON, timeouts, and unexpected payloads should produce a predictable result.\n\nTest locations:\n\n- Shared handlers live in `src/utils/proxy/handlers/*.js` with tests alongside them (for example `src/utils/proxy/handlers/generic.test.js`).\n- Widget-specific proxies live in `src/widgets/<widget>/proxy.js` with tests in `src/widgets/<widget>/proxy.test.js`.\n"
  },
  {
    "path": "docs/widgets/authoring/translations.md",
    "content": "---\ntitle: Translations Guide\ndescription: Tips and tricks for translating and localizing Homepage widgets.\n---\n\nAll text and numerical content in widgets should be translated and localized. English is the default language, and other languages can be added via [Crowdin](https://crowdin.com/project/gethomepage).\n\n## Translations\n\nHomepage uses the [next-i18next](https://github.com/i18next/next-i18next) library to handle translations. This library provides a set of hooks and utilities to help you localize your widgets, and Homepage has extended this library to support additional features.\n\n=== \"component.jsx\"\n\n    ```js\n    import { useTranslation } from \"next-i18next\";\n\n    import Container from \"components/services/widget/container\";\n    import Block from \"components/services/widget/block\";\n\n    export default function Component() {\n      const { t } = useTranslation();\n\n      return (\n        <Container service={service}>\n          <Block label=\"yourwidget.key1\" />\n          <Block label=\"yourwidget.key2\" />\n          <Block label=\"yourwidget.key3\" />\n        </Container>\n      );\n    }\n    ```\n\n## Set up translation strings\n\nHomepage uses translated and localized strings for **all text and numerical content** in widgets. English is the default language, and other languages can be added via [Crowdin](https://crowdin.com/project/gethomepage). To add the English translations for your widget, follow these steps:\n\nOpen the `public/locales/en/common.json` file.\n\nAdd a new object for your widget to the bottom of the list, like this:\n\n```json\n\"yourwidget\": {\n  \"key1\": \"Value 1\",\n  \"key2\": \"Value 2\",\n  \"key3\": \"Value 3\"\n}\n```\n\n!!! note\n\n    Even if you natively speak another language, you should only add English translations. You can then add translations in your native language via [Crowdin](https://crowdin.com/project/gethomepage), once your widget is merged.\n\n## Common Translations\n\nHomepage provides a set of common translations that you can use in your widgets. These translations are used to format numerical content, dates, and other common elements.\n\n### Numbers\n\n| Key                   | Translation     | Description                      |\n| --------------------- | --------------- | -------------------------------- |\n| `common.bytes`        | `1,000 B`       | Format a number in bytes.        |\n| `common.bits`         | `1,000 bit`     | Format a number in bits.         |\n| `common.bbytes`       | `1 KiB`         | Format a number in binary bytes. |\n| `common.bbits`        | `1 Kibit`       | Format a number in binary bits.  |\n| `common.byterate`     | `1,000 B/s`     | Format a byte rate.              |\n| `common.bibyterate`   | `1 KiB/s`       | Format a binary byte rate.       |\n| `common.bitrate`      | `1,000 bit/s`   | Format a bit rate.               |\n| `common.bibitrate`    | `1 Kibit/s`     | Format a binary bit rate.        |\n| `common.percent`      | `50%`           | Format a percentage.             |\n| `common.number`       | `1,000`         | Format a number.                 |\n| `common.ms`           | `1,000 ms`      | Format a number in milliseconds. |\n| `common.date`         | `2024-01-01`    | Format a date.                   |\n| `common.relativeDate` | `1 day ago`     | Format a relative date.          |\n| `common.duration`     | `1 day, 1 hour` | Format an duration.              |\n\n### Text\n\n| Key                | Translation | Description        |\n| ------------------ | ----------- | ------------------ |\n| `resources.cpu`    | `CPU`       | CPU usage.         |\n| `resources.mem`    | `MEM`       | Memory usage.      |\n| `resources.total`  | `Total`     | Total resource.    |\n| `resources.free`   | `Free`      | Free resource.     |\n| `resources.used`   | `Used`      | Used resource.     |\n| `resources.load`   | `Load`      | Load value.        |\n| `resources.temp`   | `TEMP`      | Temperature value. |\n| `resources.max`    | `Max`       | Maximum value.     |\n| `resources.uptime` | `UP`        | Uptime.            |\n"
  },
  {
    "path": "docs/widgets/authoring/tutorial.md",
    "content": "---\ntitle: Widget Tutorial\ndescription: Follow along with this guide to learn how to create a custom widget for Homepage. We'll cover the basic structure of a widget, how to use translations, and how to fetch data from an API.\n---\n\nIn this guide, we'll walk through the process of creating a custom widget for Homepage. We'll cover the basic structure of a widget, how to use translations, and how to fetch data from an API. By the end of this guide, you'll have a solid understanding of how to build your own custom widget.\n\n**Prerequisites:**\n\n- Basic knowledge of React and JavaScript\n- Familiarity with the Homepage platform\n- Understanding of JSON and API interactions\n\nThroughout this guide, we'll use `yourwidget` as a placeholder for the unique name of your custom widget. Replace `yourwidget` with the actual name of your widget. It should contain only lowercase letters and no spaces.\n\nThis guide makes use of a fake API, which would return a JSON response as such, when called with the `v1/info` endpoint:\n\n```json\n{ \"key1\": 123, \"key2\": 456, \"key3\": 789 }\n```\n\n## Set up the widget definition\n\nCreate a new folder for your widget in the `src/widgets` directory. Name the folder `yourwidget`.\n\nInside the `yourwidget` folder, create a new file named `widget.js`. This file will contain the metadata for your widget.\n\nOpen the `widget.js` file and add the following code:\n\n```js title=\"src/widgets/yourwidget/widget.js\"\nimport genericProxyHandler from \"utils/proxy/handlers/generic\"; // (1)!\n\nconst widget = /* (2)! */ {\n  api: \"{url}/{endpoint}\" /* (3)! */,\n  proxyHandler: genericProxyHandler /* (1)! */,\n\n  mappings: /* (4)! */ {\n    info: /* (5)! */ {\n      endpoint: \"v1/info\" /* (6)! */,\n    },\n  },\n};\n\nexport default widget;\n```\n\n1. We import the `genericProxyHandler` from the `utils/proxy/handlers/generic` module. The `genericProxyHandler` is a generic handler that can be used to fetch data from an API. We then assign the `genericProxyHandler` to the `proxyHandler` property of the `widget` object. There are other handlers available that you can use depending on your requirements. You can also create custom handlers if needed.\n2. We define a `widget` object that contains the metadata for the widget.\n3. The API endpoint to fetch data from. You can use placeholders like `{url}` and `{endpoint}` to dynamically generate the API endpoint based on the widget configuration.\n4. An object that contains mappings for different endpoints. Each mapping should have an `endpoint` property that specifies the endpoint to fetch data from.\n5. A mapping named `info` that specifies the `v1/info` endpoint to fetch data from. This would be called from the component as such: `#!js useWidgetAPI(widget, \"info\");`\n6. The `endpoint` property of the `info` mapping specifies the endpoint to fetch data from. There are other properties you can pass to the mapping, such as `method`, `headers`, and `body`.\n\n!!! warning \"Important\"\n\n    All widgets that fetch data from dynamic endpoints should have either `mappings` or an `allowedEndpoints` property.\n\n## Including translation strings in your widget\n\nRefer to the [translations guide](translations.md) for more details. The Homepage community prides itself on being multilingual, and we strongly encourage you to add translations for your widgets.\n\n## Create the widget component\n\nCreate a new file for your widgets component, named `component.jsx`, in the `src/widgets/yourwidget` directory. We'll build the contents of the `component.jsx` file step by step.\n\nFirst, we'll import the necessary dependencies:\n\n```js title=\"src/widgets/yourwidget/component.jsx\" linenums=\"1\"\nimport { useTranslation } from \"next-i18next\"; // (1)!\n\nimport Container from \"components/services/widget/container\"; // (2)!\nimport Block from \"components/services/widget/block\"; // (3)!\nimport useWidgetAPI from \"utils/proxy/use-widget-api\"; // (4)!\n```\n\n1. `#!js useTranslation()` is a hook provided by `next-i18next` that allows us to access the translation strings\n2. `#!jsx <Container>` and `#!jsx <Block>` are custom components that we'll use to structure our widget.\n3. `#!jsx <Container>` and `#!jsx <Block>` are custom components that we'll use to structure our widget.\n4. `#!js useWidgetAPI(widget, endpoint)` is a custom hook that we'll use to fetch data from an API.\n\n---\n\nNext, we'll define a functional component named `Component` that takes a `service` prop.\n\n```js title=\"src/widgets/yourwidget/component.jsx\" linenums=\"7\"\nexport default function Component({ service }) {}\n```\n\n---\n\nWe grab the helper functions from the `useTranslation` hook.\n\n```js title=\"src/widgets/yourwidget/component.jsx\" linenums=\"8\"\nconst { t } = useTranslation();\n```\n\n---\n\nWe destructure the `widget` object from the `service` prop. The `widget` object contains the metadata for the widget, such as the API endpoint to fetch data from.\n\n```js title=\"src/widgets/yourwidget/component.jsx\" linenums=\"9\"\nconst { widget } = service;\n```\n\n---\n\nNow, the fun part! We use the `useWidgetAPI` hook to fetch data from an API. The `useWidgetAPI` hook takes two arguments: the `widget` object and the API endpoint to fetch data from. The `useWidgetAPI` hook returns an object with `data` and `error` properties.\n\n```js title=\"src/widgets/yourwidget/component.jsx\" linenums=\"10\"\nconst { data, error } = useWidgetAPI(widget, \"info\");\n```\n\n!!! tip \"API Tips\"\n\n    You'll see here how part of the API url is built using the `url` and `endpoint` properties from the widget definition.\n\n    In this case, we're fetching data from the `info` endpoint.  The `info` endpoint is defined in the `mappings` object.  So the full API endpoint will be `\"{url}/v1/info\"`.\n\n    The mapping and endpoint are often the same, but must be defined regardless.\n\n---\n\nNext, we check if there's an error or no data.\n\nIf there's an error, we return a `Container` and pass it the `service` and `error` as props. The `Container` component will handle displaying the error message.\n\n```js title=\"src/widgets/yourwidget/component.jsx\" linenums=\"12\"\nif (error) {\n  return <Container service={service} error={error} />;\n}\n```\n\n---\n\nIf there's no data, we return a `Container` component with three `Block` components, each with a `label`.\n\n```js title=\"src/widgets/yourwidget/component.jsx\" linenums=\"16\"\nif (!data) {\n  return (\n    <Container service={service}>\n      <Block label=\"yourwidget.key1\" />\n      <Block label=\"yourwidget.key2\" />\n      <Block label=\"yourwidget.key3\" />\n    </Container>\n  );\n}\n```\n\nThis will render the widget with placeholders for the data, i.e., a skeleton view.\n\n!!! tip \"Translation Tips\"\n\n      The `label` prop in the `Block` component corresponds to the translation key we defined earlier in the `common.json` file.  All text and numerical content should be translated.\n\n---\n\nIf there is data, we return a `Container` component with three `Block` components, each with a `label` and a `value`.\n\nHere we use the `t` function from the `useTranslation` hook to translate the data values. The `t` function takes the translation key and an object with variables to interpolate into the translation string.\n\nWe're using the `common.number` translation key to format the data values as numbers. This allows for easy localization of numbers, such as using commas or periods as decimal separators.\n\nThere are a large number of `common` numerical translation keys available, which you can learn more about in the [Translation Guide](translations.md).\n\n```js title=\"src/widgets/yourwidget/component.jsx\" linenums=\"26\"\nreturn (\n  <Container service={service}>\n    <Block label=\"yourwidget.key1\" value={t(\"common.number\", { value: data.key1 })} />\n    <Block label=\"yourwidget.key2\" value={t(\"common.number\", { value: data.key2 })} />\n    <Block label=\"yourwidget.key3\" value={t(\"common.number\", { value: data.key3 })} />\n  </Container>\n);\n```\n\n---\n\nHere's the complete `component.jsx` file:\n\n```js title=\"src/widgets/yourwidget/component.jsx\" linenums=\"1\"\nimport { useTranslation } from \"next-i18next\";\n\nimport Container from \"components/services/widget/container\";\nimport Block from \"components/services/widget/block\";\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const { data, error } = useWidgetAPI(widget, \"info\");\n\n  if (error) {\n    return <Container service={service} error={error} />;\n  }\n\n  if (!data) {\n    return (\n      <Container service={service}>\n        <Block label=\"yourwidget.key1\" />\n        <Block label=\"yourwidget.key2\" />\n        <Block label=\"yourwidget.key3\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"yourwidget.key1\" value={t(\"common.number\", { value: data.key1 })} />\n      <Block label=\"yourwidget.key2\" value={t(\"common.number\", { value: data.key2 })} />\n      <Block label=\"yourwidget.key3\" value={t(\"common.number\", { value: data.key3 })} />\n    </Container>\n  );\n}\n```\n\n## Add the widget to the Homepage\n\nTo add your widget to the Homepage, you need to register it in the `src/widgets/widgets.js` file.\n\nOpen the `src/widgets/widgets.js` file and import the `Component` from your widget's `component.jsx` file. Please keep the alphabetical order.\n\n```js\n// ...\nimport yourwidget from \"./yourwidget/widget\";\n// ...\n```\n\nAdd `yourwidget` to the `widgets` object. Please keep the alphabetical order.\n\n```js\nconst widgets = {\n  // ...\n  yourwidget: yourwidget,\n  // ...\n};\n```\n\nYou also need to add the widget to the `components` object in the `src/widgets/components.js` file.\n\nOpen the `src/widgets/components.js` file and import the `Component` from your widget's `component.jsx` file.\n\nPlease keep the alphabetical order.\n\n```js\nconst components = {\n  // ...\n  yourwidget: dynamic(() => import(\"./yourwidget/component\")),\n  // ...\n};\n```\n\n## Using the widget\n\nYou can now use your custom widget in your Homepage. Open your `services.yaml` file and add a new service with the `yourwidget` widget.\n\n```yaml\n- Services:\n    - Your Widget:\n        icon: yourwidget.svg\n        href: https://example.com/\n        widget:\n          type: yourwidget\n          url: http://127.0.0.1:1337\n```\n\n!!! tip \"API Tips\"\n\n    You'll see here how part of the API url is built using the `url` and `endpoint` properties from the widget definition.\n\n    We defined the api endpoint as `\"{url}/{endpoint}\"`.  This is where the `url` is defined.  So the full API endpoint will be `http://127.0.0.1:1337/{endpoint}`.\n\n---\n\nThat's it! You've successfully created a custom widget for Homepage. If you have any questions or need help, feel free to reach out to the Homepage community for assistance. Happy coding!\n"
  },
  {
    "path": "docs/widgets/index.md",
    "content": "---\ntitle: Widgets\ndescription: Find information on how to configure specific widgets in Homepage.\nicon: material/widgets\n---\n\nHomepage has two types of widgets: info and service. Below we'll cover each type and how to configure them.\n\nThe left navigation of this site contains links to all available widgets.\n\n## Service Widgets\n\nService widgets are used to display the status of a service, often a web service or API. Services (and their widgets) are defined in your `services.yaml` file. Here's an example:\n\n```yaml\n- Plex:\n    icon: plex.png\n    href: https://plex.my.host\n    description: Watch movies and TV shows.\n    server: localhost\n    container: plex\n    widgets:\n      - type: tautulli\n        url: http://172.16.1.1:8181\n        key: aabbccddeeffgghhiijjkkllmmnnoo\n      - type: uptimekuma\n        url: http://172.16.1.2:8080\n        slug: aaaaaaabbbbb\n```\n\nMore detail on configuring service widgets can be found in the [Service Widgets Config](../configs/services.md) section.\n\n## Info Widgets\n\nInfo widgets are used to display information in the header, often about your system or environment. Info widgets are defined in your `widgets.yaml` file. Here's an example:\n\n```yaml\n- openmeteo:\n    label: Current\n    latitude: 36.66\n    longitude: -117.51\n    cache: 5\n```\n\nMore detail on configuring info widgets can be found in the [Info Widgets Config](../configs/info-widgets.md) section.\n"
  },
  {
    "path": "docs/widgets/info/datetime.md",
    "content": "---\ntitle: Date & Time\ndescription: Date & Time Information Widget Configuration\n---\n\nThis allows you to display the date and/or time, can be heavily configured using [Intl.DateTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat).\n\nFormatting is locale aware and will present your date in the regional format you expect, for example, `9/16/22, 3:03 PM` for locale `en` and `16.09.22, 15:03` for `de`. You can also specify a locale just for the datetime widget with the `locale` option (see below).\n\n```yaml\n- datetime:\n    text_size: xl\n    format:\n      timeStyle: short\n```\n\nAny options passed to `format` are passed directly to [Intl.DateTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat), please reference the MDN documentation for all available options.\n\nValid text sizes are `4xl`, `3xl`, `2xl`, `xl`, `md`, `sm`, `xs`.\n\nA few examples,\n\n```yaml\n# 13:37\nformat:\n  timeStyle: short\n  hourCycle: h23\n```\n\n```yaml\n# 1:37 PM\nformat:\n  timeStyle: short\n  hour12: true\n```\n\n```yaml\n# 1/23/22, 1:37 PM\nformat:\n  dateStyle: short\n  timeStyle: short\n  hour12: true\n```\n\n```yaml\n# 4 januari 2023 om 13:51:25 PST\nlocale: nl\nformat:\n  dateStyle: long\n  timeStyle: long\n```\n"
  },
  {
    "path": "docs/widgets/info/glances.md",
    "content": "---\ntitle: Glances\ndescription: Glances Information Widget Configuration\n---\n\n_(Find the Glances service widget [here](../services/glances.md))_\n\nThe Glances widget allows you to monitor the resources (CPU, memory, storage, temp & uptime) of host or another machine, and is designed to match the `resources` info widget. You can have multiple instances by adding another configuration block. The `cputemp`, `uptime` & `disk` states require separate API calls and thus are not enabled by default. Glances needs to be running in \"web server\" mode to enable the API, see the [glances docs](https://glances.readthedocs.io/en/latest/quickstart.html#web-server-mode).\n\n```yaml\n- glances:\n    url: http://host.or.ip:port\n    username: user # optional if auth enabled in Glances\n    password: pass # optional if auth enabled in Glances\n    version: 4 # required only if running glances v4 or higher, defaults to 3\n    cpu: true # optional, enabled by default, disable by setting to false\n    mem: true # optional, enabled by default, disable by setting to false\n    cputemp: true # disabled by default\n    uptime: true # disabled by default\n    disk: / # disabled by default, use mount point of disk(s) in glances. Can also be a list (see below)\n    diskUnits: bytes # optional, bytes (default) or bbytes. Only applies to disk\n    expanded: true # show the expanded view\n    label: MyMachine # optional\n```\n\nMultiple disks can be specified as:\n\n```yaml\ndisk:\n  - /\n  - /boot\n  ...\n```\n\n_Added in v0.4.18, updated in v0.6.11, v0.6.21_\n"
  },
  {
    "path": "docs/widgets/info/greeting.md",
    "content": "---\ntitle: Greeting\ndescription: Greeting Information Widget Configuration\n---\n\nThis allows you to display simple text, can be configured like so:\n\n```yaml\n- greeting:\n    text_size: xl\n    text: Greeting Text\n```\n\nValid text sizes are `4xl`, `3xl`, `2xl`, `xl`, `md`, `sm`, `xs`.\n"
  },
  {
    "path": "docs/widgets/info/index.md",
    "content": "---\ntitle: Info Widgets\ndescription: Homepage info widgets.\nsearch:\n  exclude: true\n---\n\nYou can also find a list of all available info widgets in the sidebar navigation.\n\n- [Date & Time](datetime.md)\n- [Glances](glances.md)\n- [Greeting](greeting.md)\n- [Kubernetes](kubernetes.md)\n- [Logo](logo.md)\n- [Longhorn](longhorn.md)\n- [OpenMeteo](openmeteo.md)\n- [OpenWeatherMap](openweathermap.md)\n- [Resources](resources.md)\n- [Search](search.md)\n- [Stocks](stocks.md)\n- [UniFi Controller](unifi_controller.md)\n"
  },
  {
    "path": "docs/widgets/info/kubernetes.md",
    "content": "---\ntitle: Kubernetes\ndescription: Kubernetes Information Widget Configuration\n---\n\nThis is very similar to the Resources widget, but provides resource information about a Kubernetes cluster.\n\nIt provides CPU and Memory usage, by node and/or at the cluster level.\n\n```yaml\n- kubernetes:\n    cluster:\n      # Shows cluster-wide statistics\n      show: true\n      # Shows the aggregate CPU stats\n      cpu: true\n      # Shows the aggregate memory stats\n      memory: true\n      # Shows a custom label\n      showLabel: true\n      label: \"cluster\"\n    nodes:\n      # Shows node-specific statistics\n      show: true\n      # Shows the CPU for each node\n      cpu: true\n      # Shows the memory for each node\n      memory: true\n      # Shows the label, which is always the node name\n      showLabel: true\n```\n"
  },
  {
    "path": "docs/widgets/info/logo.md",
    "content": "---\ntitle: Logo\ndescription: Logo Information Widget Configuration\n---\n\nThis allows you to display the homepage logo, you can optionally specify your own icon using similar options as other icons, see [service icons](../../configs/services.md#icons).\n\n```yaml\n- logo:\n    icon: https://upload.wikimedia.org/wikipedia/commons/thumb/d/d5/I_Love_New_York.svg/1101px-I_Love_New_York.svg.png # optional\n```\n\n_Added in v0.4.18, updated in 0.X.X_\n"
  },
  {
    "path": "docs/widgets/info/longhorn.md",
    "content": "---\ntitle: Longhorn\ndescription: Longhorn Storage Widget Configuration\n---\n\nThe Longhorn widget pulls storage utilization metrics from the Longhorn storage driver on Kubernetes.\nIt is designed to appear similar to the Resource widget's disk representation.\n\nThe exact metrics should be very similar to what is seen on the Longhorn dashboard itself.\n\nIt can show the aggregate metrics and/or the individual node metrics.\n\n```yaml\n- longhorn:\n    # Show the expanded view\n    expanded: true\n    # Shows a node representing the aggregate values\n    total: true\n    # Shows the node names as labels\n    labels: true\n    # Show the nodes\n    nodes: true\n    # An explicit list of nodes to show. All are shown by default if \"nodes\" is true\n    include:\n      - node1\n      - node2\n```\n\nThe Longhorn URL and credentials are stored in the `providers` section of the `settings.yaml`. e.g.:\n\n```yaml\nproviders:\n  longhorn:\n    username: \"longhorn-username\" # optional\n    password: \"very-secret-longhorn-password\" # optional\n    url: https://longhorn.aesop.network\n```\n"
  },
  {
    "path": "docs/widgets/info/openmeteo.md",
    "content": "---\ntitle: Open-Meteo\ndescription: Open-Meteo Information Widget Configuration\n---\n\nHomepage's recommended weather widget. No registration is required at all! See [https://open-meteo.com/en/docs](https://open-meteo.com/en/docs)\n\n```yaml\n- openmeteo:\n    label: Kyiv # optional\n    latitude: 50.449684\n    longitude: 30.525026\n    timezone: Europe/Kiev # optional\n    units: metric # or imperial\n    cache: 5 # Time in minutes to cache API responses, to stay within limits\n    format: # optional, Intl.NumberFormat options\n      maximumFractionDigits: 1\n```\n\nYou can optionally not pass a `latitude` and `longitude` and the widget will use your current location (requires a secure context, eg. HTTPS).\n"
  },
  {
    "path": "docs/widgets/info/openweathermap.md",
    "content": "---\ntitle: OpenWeatherMap\ndescription: OpenWeatherMap Information Widget Configuration\n---\n\nThe free tier \"One Call API\" is all that's required, you will need to [subscribe](https://home.openweathermap.org/subscriptions/unauth_subscribe/onecall_30/base) and grab your API key.\n\n```yaml\n- openweathermap:\n    label: Kyiv #optional\n    latitude: 50.449684\n    longitude: 30.525026\n    units: metric # or imperial\n    provider: openweathermap\n    apiKey: youropenweathermapkey # required only if not using provider, this reveals api key in requests\n    cache: 5 # Time in minutes to cache API responses, to stay within limits\n    format: # optional, Intl.NumberFormat options\n      maximumFractionDigits: 1\n```\n\nYou can optionally not pass a `latitude` and `longitude` and the widget will use your current location (requires a secure context, eg. HTTPS).\n"
  },
  {
    "path": "docs/widgets/info/resources.md",
    "content": "---\ntitle: Resources\ndescription: Resources Information Widget Configuration\n---\n\nYou can include all or some of the available resources. If you do not want to see that resource, simply pass `false`.\n\nThe disk path is the path reported by `df` (Mounted On), or the mount point of the disk.\n\n!!! note\n\n    Any disk you wish to access must be mounted to your container as a volume.\n\nThe cpu and memory resource information are the container's usage while [glances](glances.md) displays statistics for the host machine on which it is installed.\n\nThe resources widget primarily relies on a popular tool called [systeminformation](https://systeminformation.io). Thus, any limitiations of that software apply, for example, BRTFS RAID is not supported for the disk usage. In this case users may want to use the [glances widget](glances.md) instead.\n\n!!! warning\n\n    The package used for getting CPU temp ([systeminformation](https://systeminformation.io)) is not compatible with some setups and will not report any value(s) for CPU temp.\n\n```yaml\n- resources:\n    cpu: true\n    memory: true\n    disk: /disk/mount/path\n    cputemp: true\n    tempmin: 0 # optional, minimum cpu temp\n    tempmax: 100 # optional, maximum cpu temp\n    uptime: true\n    units: imperial # only used by cpu temp, options: 'imperial' or 'metric'\n    refresh: 3000 # optional, in ms\n    diskUnits: bytes # optional, bytes (default) or bbytes. Only applies to disk\n    network: true # optional, uses 'default' if true or specify a network interface name\n```\n\nYou can also pass a `label` option, which allows you to group resources under named sections,\n\n```yaml\n- resources:\n    label: System\n    cpu: true\n    memory: true\n\n- resources:\n    label: Storage\n    disk: /mnt/storage\n```\n\nWhich produces something like this,\n\n<img width=\"373\" alt=\"Resource Groups\" src=\"https://user-images.githubusercontent.com/82196/189524699-e9005138-e049-4a9c-8833-ac06e39882da.png\">\n\nIf you have more than a single disk and would like to group them together under the same label, you can pass an array of paths instead,\n\n```yaml\n- resources:\n    label: Storage\n    disk:\n      - /mnt/storage\n      - /mnt/backup\n      - /mnt/media\n```\n\nTo produce something like this,\n\n<img width=\"369\" alt=\"Screenshot 2022-09-11 at 2 15 42 PM\" src=\"https://user-images.githubusercontent.com/82196/189524583-abdf4cc6-99da-430c-b316-16c567db5639.png\">\n\nYou can additionally supply an optional `expanded` property set to true in order to show additional details about the resources. By default the expanded property is set to false when not supplied.\n\n```yaml\n- resources:\n    label: Array Disks\n    expanded: true\n    disk:\n      - /disk1\n      - /disk2\n      - /disk3\n```\n\n![194136533-c4238c82-4d67-41a4-b3c8-18bf26d33ac2](https://user-images.githubusercontent.com/3441425/194728642-a9885274-922b-4027-acf5-a746f58fdfce.png)\n\nTo monitor a named host network interface in Docker (for example `network: eno1`), mount host `/sys` (read-only):\n\n```yaml\nvolumes:\n  - /sys:/sys:ro\n```\n"
  },
  {
    "path": "docs/widgets/info/search.md",
    "content": "---\ntitle: Search\ndescription: Search Information Widget Configuration\n---\n\nYou can add a search bar to your top widget area that can search using Google, Duckduckgo, Bing, Baidu, Brave or any other custom provider that supports the basic `?q=` search query param.\n\n```yaml\n- search:\n    provider: google # google, duckduckgo, bing, baidu, brave or custom\n    focus: true # Optional, will set focus to the search bar on page load\n    showSearchSuggestions: true # Optional, will show search suggestions. Defaults to false\n    target: _blank # One of _self, _blank, _parent or _top\n```\n\nor for a custom search:\n\n```yaml\n- search:\n    provider: custom\n    url: https://www.ecosia.org/search?q=\n    target: _blank\n    suggestionUrl: https://ac.ecosia.org/autocomplete?type=list&q= # Optional\n    showSearchSuggestions: true # Optional\n```\n\nmultiple providers is also supported via a dropdown (excluding custom):\n\n```yaml\n- search:\n    provider: [brave, google, duckduckgo]\n```\n\nThe response body for the URL provided with the `suggestionUrl` option should look like this:\n\n```json\n[\n  \"home\",\n  [\n    \"home depot\",\n    \"home depot near me\",\n    \"home equity loan\",\n    \"homeworkify\",\n    \"homedepot.com\",\n    \"homebase login\",\n    \"home depot credit card\",\n    \"home goods\"\n  ]\n]\n```\n\nThe first entry of the array contains the search query, the second one is an array of the suggestions.\nIn the example above, the search query was **home**.\n\n_Added in v0.1.6, updated in 0.6.0_\n"
  },
  {
    "path": "docs/widgets/info/stocks.md",
    "content": "---\ntitle: Stocks\ndescription: Stocks Information Widget Configuration\n---\n\n_(Find the Stocks service widget [here](../services/stocks.md))_\n\nThe Stocks Information Widget allows you to include basic stock market data in\nyour Homepage header. The widget includes the current price of a stock, and the\nchange in price for the day.\n\nFinnhub.io is currently the only supported provider for the stocks widget.\nYou can sign up for a free api key at [finnhub.io](https://finnhub.io).\nYou are encouraged to read finnhub.io's\n[terms of service/privacy policy](https://finnhub.io/terms-of-service) before\nsigning up. The documentation for the endpoint that is utilized can be viewed\n[here](https://finnhub.io/docs/api/quote).\n\nYou must set `finnhub` as a provider in your `settings.yaml` like below:\n\n```yaml\nproviders:\n  finnhub: yourfinnhubapikeyhere\n```\n\nNext, configure the stocks widget in your `widgets.yaml`:\n\nThe information widget allows for up to 8 items in the watchlist.\n\n```yaml\n- stocks:\n    provider: finnhub\n    color: true # optional, defaults to true\n    cache: 1 # optional, default caches results for 1 minute\n    watchlist:\n      - GME\n      - AMC\n      - NVDA\n      - AMD\n      - TSM\n      - MSFT\n      - AAPL\n      - BRK.A\n```\n\nThe above configuration would result in something like this:\n\n![Example of Stocks Widget](../../assets/widget_stocks_demo.png)\n"
  },
  {
    "path": "docs/widgets/info/unifi_controller.md",
    "content": "---\ntitle: Unifi Controller\ndescription: Unifi Controller Information Widget Configuration\n---\n\n_(Find the Unifi Controller service widget [here](../services/unifi-controller.md))_\n\nYou can display general connectivity status from your Unifi (Network) Controller.\n\n!!! warning\n\n    When authenticating you will want to use a local account that has at least read privileges.\n\nAn optional 'site' parameter can be supplied, if it is not the widget will use the default site for the controller.\n\n!!! hint\n\n    If you enter e.g. incorrect credentials and receive an \"API Error\", you may need to recreate the container to clear the cache.\n\n<img width=\"162\" alt=\"unifi_infowidget\" src=\"https://user-images.githubusercontent.com/4887959/197706832-f5a8706b-7282-4892-a666-b7d999752562.png\">\n\n```yaml\n- unifi_console:\n    url: https://unifi.host.or.ip:port\n    site: Site Name # optional\n    username: user\n    password: pass\n    key: unifiapikey # required if using API key instead of username/password\n```\n"
  },
  {
    "path": "docs/widgets/services/adguard-home.md",
    "content": "---\ntitle: Adguard Home\ndescription: Adguard Home Widget Configuration\n---\n\nLearn more about [Adguard Home](https://github.com/AdguardTeam/AdGuardHome).\n\nThe username and password are the same as used to login to the web interface.\n\nAllowed fields: `[\"queries\", \"blocked\", \"filtered\", \"latency\"]`.\n\n```yaml\nwidget:\n  type: adguard\n  url: http://adguard.host.or.ip\n  username: admin\n  password: password\n```\n"
  },
  {
    "path": "docs/widgets/services/apcups.md",
    "content": "---\ntitle: APC UPS Monitoring\ndescription: Lightweight monitoring widget for APC UPSs using apcupsd daemon\n---\n\nThis widget extracts UPS information from an apcupsd daemon.\nOnly works for [APC/Schneider](https://www.se.com/us/en/product-range/61915-smartups/#products) UPS products.\n\n[!NOTE]\nBy default apcupsd daemon is bound to 127.0.0.1. Edit `/etc/apcupsd.conf` and change `NISIP` to an IP accessible from your homepage docker (usually your internal LAN interface).\n\n```yaml\nwidget:\n  type: apcups\n  url: tcp://your.acpupsd.host:3551\n```\n"
  },
  {
    "path": "docs/widgets/services/arcane.md",
    "content": "---\ntitle: Arcane\ndescription: Arcane Widget Configuration\n---\n\nLearn more about [Arcane](https://github.com/getarcaneapp/arcane).\n\n**Allowed fields** (max 4): `running`, `stopped`, `total`, `images`, `images_used`, `images_unused`, `image_updates`.\n**Default fields**: `running`, `stopped`, `total`, `image_updates`.\n\n```yaml\nwidget:\n  type: arcane\n  url: http://localhost:3552\n  env: 0 # required, 0 is Arcane default local environment\n  key: your-api-key\n  fields: [\"running\", \"stopped\", \"total\", \"image_updates\"] # optional\n```\n"
  },
  {
    "path": "docs/widgets/services/argocd.md",
    "content": "---\ntitle: ArgoCD\ndescription: ArgoCD Widget Configuration\n---\n\nLearn more about [ArgoCD](https://argo-cd.readthedocs.io/en/stable/).\n\nAllowed fields (limited to a max of 4): `[\"apps\", \"synced\", \"outOfSync\", \"healthy\", \"progressing\", \"degraded\", \"suspended\", \"missing\"]`\n\n```yaml\nwidget:\n  type: argocd\n  url: http://argocd.host.or.ip:port\n  key: argocdapikey\n```\n\nYou can generate an API key either by creating a bearer token for an existing account, see [Authorization](https://argo-cd.readthedocs.io/en/latest/developer-guide/api-docs/#authorization) (not recommended) or create a new local user account with limited privileges and generate an authentication token for this account. To do this the steps are:\n\n- [Create a new local user](https://argo-cd.readthedocs.io/en/stable/operator-manual/user-management/#create-new-user) and give it the `apiKey` capability\n- Setup [RBAC configuration](https://argo-cd.readthedocs.io/en/stable/operator-manual/rbac/#rbac-configuration) for your the user and give it readonly access to your ArgoCD resources, e.g. by giving it the `role:readonly` role.\n- In your ArgoCD project under _Settings / Accounts_ open the newly created account and in the _Tokens_ section click on _Generate New_ to generate an access token, optionally specifying an expiry date.\n\nIf you installed ArgoCD via the official Helm chart, the account creation and rbac config can be achived by overriding these helm values:\n\n```yaml\nconfigs:\n  cm:\n    accounts.readonly: apiKey\n  rbac:\n    policy.csv: \"g, readonly, role:readonly\"\n```\n\nThis creates a new account called `readonly` and attaches the `role:readonly` role to it.\n"
  },
  {
    "path": "docs/widgets/services/atsumeru.md",
    "content": "---\ntitle: Atsumeru\ndescription: Atsumeru Widget Configuration\n---\n\nLearn more about [Atsumeru](https://github.com/AtsumeruDev/Atsumeru).\n\nDefine same username and password that is used for login from web or supported apps\n\nAllowed fields: `[\"series\", \"archives\", \"chapters\", \"categories\"]`.\n\n```yaml\nwidget:\n  type: atsumeru\n  url: http://atsumeru.host.or.ip:port\n  username: username\n  password: password\n```\n"
  },
  {
    "path": "docs/widgets/services/audiobookshelf.md",
    "content": "---\ntitle: Audiobookshelf\ndescription: Audiobookshelf Widget Configuration\n---\n\nLearn more about [Audiobookshelf](https://github.com/advplyr/audiobookshelf).\n\nYou can find your API token by logging into the Audiobookshelf web app as an admin, go to the config → users page, and click on your account.\n\nAllowed fields: `[\"podcasts\", \"podcastsDuration\", \"books\", \"booksDuration\"]`\n\n```yaml\nwidget:\n  type: audiobookshelf\n  url: http://audiobookshelf.host.or.ip:port\n  key: audiobookshelflapikey\n```\n"
  },
  {
    "path": "docs/widgets/services/authentik.md",
    "content": "---\ntitle: Authentik\ndescription: Authentik Widget Configuration\n---\n\nLearn more about [Authentik](https://github.com/goauthentik/authentik).\n\nThis widget reads the number of active users in the system, as well as logins for the last 24 hours.\n\nYou will need to generate an API token for an existing user under `Admin Portal` > `Directory` > `Tokens & App passwords`.\nMake sure to set Intent to \"API Token\".\n\nThe account you made the API token for also needs the following **Assigned global permissions** in Authentik:\n\n- authentik Core -> Can view User (Model: User)\n- authentik Events -> Can view Event (Model: Event)\n\nAllowed fields: `[\"users\", \"loginsLast24H\", \"failedLoginsLast24H\"]`.\n\n| Authentik Version | Homepage Widget Version |\n| ----------------- | ----------------------- |\n| < 2025.8.0        | 1 (default)             |\n| >= 2025.8.0       | 2                       |\n\n```yaml\nwidget:\n  type: authentik\n  url: http://authentik.host.or.ip:port\n  key: api_token\n  version: 2 # optional, default is 1\n```\n"
  },
  {
    "path": "docs/widgets/services/autobrr.md",
    "content": "---\ntitle: Autobrr\ndescription: Autobrr Widget Configuration\n---\n\nLearn more about [Autobrr](https://github.com/autobrr/autobrr).\n\nFind your API key under `Settings > API Keys`.\n\nAllowed fields: `[\"approvedPushes\", \"rejectedPushes\", \"filters\", \"indexers\"]`.\n\n```yaml\nwidget:\n  type: autobrr\n  url: http://autobrr.host.or.ip\n  key: apikeyapikeyapikeyapikeyapikey\n```\n"
  },
  {
    "path": "docs/widgets/services/azuredevops.md",
    "content": "---\ntitle: Azure DevOps\ndescription: Azure DevOps Widget Configuration\n---\n\nLearn more about [Azure DevOps](https://azure.microsoft.com/en-us/products/devops).\n\nThis widget has 2 functions:\n\n1. Pipelines: checks if the relevant pipeline is running or not, and if not, reports the last status.<br>\n   Allowed fields: `[\"result\", \"status\"]`.\n\n2. Pull Requests: returns the amount of open PRs, the amount of the PRs you have open, and how many PRs that you open are marked as 'Approved' by at least 1 person and not yet completed.<br>\n   Allowed fields: `[\"totalPrs\", \"myPrs\", \"approved\"]`.\n\nYou will need to generate a personal access token for an existing user, see the [azure documentation](https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=Windows#create-a-pat)\n\n```yaml\nwidget:\n  type: azuredevops\n  organization: myOrganization\n  project: myProject\n  definitionId: pipelineDefinitionId # required for pipelines\n  branchName: branchName # optional for pipelines, leave empty for all\n  userEmail: email # required for pull requests\n  repositoryId: prRepositoryId # required for pull requests\n  key: personalaccesstoken\n```\n"
  },
  {
    "path": "docs/widgets/services/backrest.md",
    "content": "---\ntitle: Backrest\ndescription: Backrest Widget Configuration\n---\n\n[Backrest](https://garethgeorge.github.io/backrest/) is a web-based frontend for\nthe [Restic](https://restic.net/) backup tool.\n\n**Allowed fields:** `[\"num_success_latest\",\"num_failure_latest\",\"num_success_30\",\"num_plans\",\"num_failure_30\",\"bytes_added_30\"]`\n\n```yaml\nwidget:\n  type: backrest\n  url: http://backrest.host.or.ip\n  username: admin # optional if auth is enabled in Backrest\n  password: admin # optional if auth is enabled in Backrest\n```\n"
  },
  {
    "path": "docs/widgets/services/bazarr.md",
    "content": "---\ntitle: Bazarr\ndescription: Bazarr Widget Configuration\n---\n\nLearn more about [Bazarr](https://github.com/morpheus65535/bazarr).\n\nFind your API key under `Settings > General`.\n\nAllowed fields: `[\"missingEpisodes\", \"missingMovies\"]`.\n\n```yaml\nwidget:\n  type: bazarr\n  url: http://bazarr.host.or.ip\n  key: apikeyapikeyapikeyapikeyapikey\n```\n"
  },
  {
    "path": "docs/widgets/services/beszel.md",
    "content": "---\ntitle: Beszel\ndescription: Beszel Widget Configuration\n---\n\nLearn more about [Beszel](https://github.com/henrygd/beszel)\n\nThe widget has two modes, a single system with detailed info if `systemId` is provided, or an overview of all systems if `systemId` is not provided.\n\nThe `systemID` is the `id` field on the collections page of Beszel under the PocketBase admin panel. You can also use the 'nice name' from the Beszel UI.\n\nA \"superuser\" is currently required to access the data from the Beszel API.\n\nAllowed fields for 'overview' mode: `[\"systems\", \"up\"]`\nAllowed fields for a single system: `[\"name\", \"status\", \"updated\", \"cpu\", \"memory\", \"disk\", \"network\"]`\n\n| Beszel Version | Homepage Widget Version |\n| -------------- | ----------------------- |\n| < 0.9.0        | 1 (default)             |\n| >= 0.9.0       | 2                       |\n\n```yaml\nwidget:\n  type: beszel\n  url: http://beszel.host.or.ip\n  username: username # email\n  password: password\n  systemId: systemId # optional\n  version: 2 # optional, default is 1\n```\n"
  },
  {
    "path": "docs/widgets/services/booklore.md",
    "content": "---\ntitle: Booklore\ndescription: Booklore Widget Configuration\n---\n\nLearn more about [Booklore](https://github.com/booklore-app/booklore).\n\nThe widget authenticates with your Booklore credentials to surface total libraries, books, and reading progress counts for your account.\n\n```yaml\nwidget:\n  type: booklore\n  url: https://booklore.host.or.ip\n  username: username\n  password: password\n```\n"
  },
  {
    "path": "docs/widgets/services/caddy.md",
    "content": "---\ntitle: Caddy\ndescription: Caddy Widget Configuration\n---\n\nLearn more about [Caddy](https://github.com/caddyserver/caddy).\n\nAllowed fields: `[\"upstreams\", \"requests\", \"requests_failed\"]`.\n\n```yaml\nwidget:\n  type: caddy\n  url: http://caddy.host.or.ip:adminport # default admin port is 2019\n```\n"
  },
  {
    "path": "docs/widgets/services/calendar.md",
    "content": "---\ntitle: Calendar\ndescription: Calendar widget\n---\n\n## Monthly view\n\n<img alt=\"calendar\" src=\"https://user-images.githubusercontent.com/5442891/271131282-6767a3ea-573e-4005-aeb9-6e14ee01e845.png\">\n\nThis widget shows monthly calendar, with optional integrations to show events from supported widgets.\n\n```yaml\nwidget:\n  type: calendar\n  firstDayInWeek: sunday # optional - defaults to monday\n  view: monthly # optional - possible values monthly, agenda\n  maxEvents: 10 # optional - defaults to 10\n  showTime: true # optional - show time for event happening today - defaults to false\n  timezone: America/Los_Angeles # optional and only when timezone is not detected properly (slightly slower performance) - force timezone for ical events (if it's the same - no change, if missing or different in ical - will be converted to this timezone)\n  integrations: # optional\n    - type: sonarr # active widget type that is currently enabled on homepage - possible values: radarr, sonarr, lidarr, readarr, ical\n      service_group: Media # group name where widget exists\n      service_name: Sonarr # service name for that widget\n      color: teal # optional - defaults to pre-defined color for the service (teal for sonarr)\n      baseUrl: https://sonarr.domain.url # optional - adds links to sonarr/radarr pages\n      params: # optional - additional params for the service\n        unmonitored: true # optional - defaults to false, used with *arr stack\n    - type: ical # Show calendar events from another service\n      url: https://domain.url/with/link/to.ics # URL with calendar events\n      name: My Events # required - name for these calendar events\n      color: zinc # optional - defaults to pre-defined color for the service (zinc for ical)\n      params: # optional - additional params for the service\n        showName: true # optional - show name before event title in event line - defaults to false\n```\n\n## Agenda\n\nThis view shows only list of events from configured integrations\n\n```yaml\nwidget:\n  type: calendar\n  view: agenda\n  maxEvents: 10 # optional - defaults to 10\n  showTime: true # optional - show time for event happening today - defaults to false\n  previousDays: 3 # optional - shows events since three days ago - defaults to 0\n  integrations: # same as in Monthly view example\n```\n\n## Integrations\n\nCurrently integrated widgets are [sonarr](sonarr.md), [radarr](radarr.md), [lidarr](lidarr.md) and [readarr](readarr.md).\n\nSupported colors can be found on [color palette](../../configs/settings.md#color-palette).\n\n### iCal\n\nThis custom integration allows you to show events from any calendar that supports iCal format, for example, Google Calendar (go to `Settings`, select specific calendar, go to `Integrate calendar`, copy URL from `Public Address in iCal format`).\n"
  },
  {
    "path": "docs/widgets/services/calibre-web.md",
    "content": "---\ntitle: Calibre-web\ndescription: Calibre-web Widget Configuration\n---\n\nLearn more about [Calibre-web](https://github.com/janeczku/calibre-web).\n\n**Note: widget requires calibre-web ≥ v0.6.21.**\n\nAllowed fields: `[\"books\", \"authors\", \"categories\", \"series\"]`.\n\n```yaml\nwidget:\n  type: calibreweb\n  url: http://your.calibreweb.host:port\n  username: username\n  password: password\n```\n"
  },
  {
    "path": "docs/widgets/services/changedetectionio.md",
    "content": "---\ntitle: Changedetection.io\ndescription: Changedetection.io Widget Configuration\n---\n\nLearn more about [Changedetection.io](https://github.com/dgtlmoon/changedetection.io).\n\nFind your API key under `Settings > API`.\n\nAllowed fields: `[\"diffsDetected\", \"totalObserved\"]`.\n\n```yaml\nwidget:\n  type: changedetectionio\n  url: http://changedetection.host.or.ip:port\n  key: apikeyapikeyapikeyapikeyapikey\n```\n"
  },
  {
    "path": "docs/widgets/services/channelsdvrserver.md",
    "content": "---\ntitle: Channels DVR Server\ndescription: Channels DVR Server Widget Configuration\n---\n\nLearn more about [Channels DVR Server](https://getchannels.com/dvr-server/).\n\n```yaml\nwidget:\n  type: channelsdvrserver\n  url: http://server.host.or.ip:port\n```\n"
  },
  {
    "path": "docs/widgets/services/checkmk.md",
    "content": "---\ntitle: Checkmk\ndescription: Checkmk Widget Configuration\n---\n\nLearn more about [Checkmk](https://github.com/Checkmk/checkmk).\n\nTo setup authentication, follow the official [Checkmk API](https://docs.checkmk.com/latest/en/rest_api.html?lquery=api#bearerauth) documentation.\n\n```yaml\nwidget:\n  type: checkmk\n  url: http://checkmk.host.or.ip:port\n  site: your-site-name-cla-by-default\n  username: username\n  password: password\n```\n"
  },
  {
    "path": "docs/widgets/services/cloudflared.md",
    "content": "---\ntitle: Cloudflare Tunnels\ndescription: Cloudflare Tunnels Widget Configuration\n---\n\nLearn more about [Cloudflare Tunnels](https://www.cloudflare.com/products/tunnel/).\n\n_As of v0.6.10 this widget no longer accepts a Cloudflare global API key (or account email) due to security concerns. Instead, you should setup an API token which only requires the permissions `Account.Cloudflare Tunnel:Read`._\n\nAllowed fields: `[\"status\", \"origin_ip\"]`.\n\n```yaml\nwidget:\n  type: cloudflared\n  accountid: accountid # from zero trust dashboard url e.g. https://one.dash.cloudflare.com/<accountid>/home/quick-start\n  tunnelid: tunnelid # found in tunnels dashboard under the tunnel name\n  key: cloudflareapitoken # api token with `Account.Cloudflare Tunnel:Read` https://dash.cloudflare.com/profile/api-tokens\n```\n"
  },
  {
    "path": "docs/widgets/services/coin-market-cap.md",
    "content": "---\ntitle: Coin Market Cap\ndescription: Coin Market Cap Widget Configuration\n---\n\nLearn more about [Coin Market Cap](https://coinmarketcap.com/api).\n\nGet your API key from your [CoinMarketCap Pro Dashboard](https://pro.coinmarketcap.com/account).\n\nAllowed fields: no configurable fields for this widget.\n\n```yaml\nwidget:\n  type: coinmarketcap\n  currency: GBP # Optional\n  symbols: [BTC, LTC, ETH]\n  key: apikeyapikeyapikeyapikeyapikey\n  defaultinterval: 7d # Optional\n```\n\nYou can also specify slugs instead of symbols (since symbols aren't guaranteed to be unique). If you supply both, slugs will be used. For example:\n\n```yaml\nwidget:\n  type: coinmarketcap\n  slugs: [chia-network, uniswap]\n  key: apikeyapikeyapikeyapikeyapikey\n```\n"
  },
  {
    "path": "docs/widgets/services/crowdsec.md",
    "content": "---\ntitle: Crowdsec\ndescription: Crowdsec Widget Configuration\n---\n\nLearn more about [Crowdsec](https://crowdsec.net).\n\nSee the [crowdsec docs](https://docs.crowdsec.net/docs/local_api/intro/#machines) for information about registering a machine,\nin most instances you can use the default credentials (`/etc/crowdsec/local_api_credentials.yaml`).\n\n!!! note\nWithout the `limit24h` option, the widget will fetch all alerts which is limited to 100 by the API to avoid performance issues.\n\nAllowed fields: `[\"alerts\", \"bans\"]`.\n\n```yaml\nwidget:\n  type: crowdsec\n  url: http://crowdsechostorip:port\n  username: localhost # machine_id in crowdsec\n  password: password\n  limit24h: true # optional, limits alerts to last 24h. Default: false\n```\n"
  },
  {
    "path": "docs/widgets/services/customapi.md",
    "content": "---\ntitle: Custom API\ndescription: Custom Widget Configuration from the API\n---\n\nThis widget can show information from custom self-hosted or third party API.\n\nFields need to be defined in the `mappings` section YAML object to correlate with the value in the APIs JSON object. Final field definition needs to be the key with the desired value information.\n\n```yaml\nwidget:\n  type: customapi\n  url: http://custom.api.host.or.ip:port/path/to/exact/api/endpoint\n  refreshInterval: 10000 # optional - in milliseconds, defaults to 10s\n  username: username # auth - optional\n  password: password # auth - optional\n  method: GET # optional, e.g. POST\n  headers: # optional, must be object, see below\n  requestBody: # optional, can be string or object, see below\n  display: # optional, default to block, see below\n  mappings:\n    - field: key\n      label: Field 1\n      format: text # optional - defaults to text\n    - field: path.to.key2\n      format: number # optional - defaults to text\n      label: Field 2\n    - field: path.to.another.key3\n      label: Field 3\n      format: percent # optional - defaults to text\n    - field: key\n      label: Field 4\n      format: date # optional - defaults to text\n      locale: nl # optional\n      dateStyle: long # optional - defaults to \"long\". Allowed values: `[\"full\", \"long\", \"medium\", \"short\"]`.\n      timeStyle: medium # optional - Allowed values: `[\"full\", \"long\", \"medium\", \"short\"]`.\n    - field: key\n      label: Field 5\n      format: relativeDate # optional - defaults to text\n      locale: nl # optional\n      style: short # optional - defaults to \"long\". Allowed values: `[\"long\", \"short\", \"narrow\"]`.\n      numeric: auto # optional - defaults to \"always\". Allowed values `[\"always\", \"auto\"]`.\n    - field: key\n      label: Field 6\n      format: text\n      additionalField: # optional\n        field: hourly.time.key\n        color: theme # optional - defaults to \"\". Allowed values: `[\"theme\", \"adaptive\", \"black\", \"white\"]`.\n        format: date # optional\n    - field: key\n      label: Number of things in array\n      format: size\n    # This (no field) will take the root of the API response, e.g. when APIs return an array:\n    - label: Number of items\n      format: size\n```\n\nSupported formats for the values are `text`, `number`, `float`, `percent`, `duration`, `bytes`, `bitrate`, `size`, `date` and `relativeDate`.\n\nThe `dateStyle` and `timeStyle` options of the `date` format are passed directly to [Intl.DateTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat) and the `style` and `numeric` options of `relativeDate` are passed to [Intl.RelativeTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/RelativeTimeFormat).\n\nThe `duration` format expects the duration to be specified in seconds. The `scale` transformation tool can be used if a conversion is required.\n\nThe `size` format will return the length of the array or string, or the number of keys in an object. This is then formatted as `number`.\n\n## Example\n\nFor the following JSON object from the API:\n\n```json\n{\n  \"id\": 1,\n  \"name\": \"Rick Sanchez\",\n  \"status\": \"Alive\",\n  \"species\": \"Human\",\n  \"gender\": \"Male\",\n  \"origin\": {\n    \"name\": \"Earth (C-137)\"\n  },\n  \"locations\": [\n    {\n      \"name\": \"Earth (C-137)\"\n    },\n    {\n      \"name\": \"Citadel of Ricks\"\n    }\n  ]\n}\n```\n\nDefine the `mappings` section as an array, for example:\n\n```yaml\nmappings:\n  - field: name # Rick Sanchez\n    label: Name\n  - field: status # Alive\n    label: Status\n  - field: origin.name # Earth (C-137)\n    label: Origin\n  - field: locations.1.name # Citadel of Ricks\n    label: Location\n```\n\nNote that older versions of the widget accepted fields as a yaml object, which is still supported. E.g.:\n\n```yaml\nmappings:\n  - field:\n      locations:\n        1: name # Citadel of Ricks\n    label: Location\n```\n\n## Data Transformation\n\nYou can manipulate data with the following tools `remap`, `scale`, `prefix` and `suffix`, for example:\n\n```yaml\n- field: key4\n  label: Field 4\n  format: text\n  remap:\n    - value: 0\n      to: None\n    - value: 1\n      to: Connected\n    - any: true # will map all other values\n      to: Unknown\n- field: key5\n  label: Power\n  format: float\n  scale: 0.001 # can be number or string e.g. 1/16\n  suffix: \"kW\"\n- field: key6\n  label: Price\n  format: float\n  prefix: \"$\"\n```\n\n## Display Options\n\nThe widget supports different display modes that can be set using the `display` property.\n\n### Block View (Default)\n\nThe default display mode is `block`, which shows fields in a block format.\n\n### List View\n\nYou can change the default block view to a list view by setting the `display` option to `list`.\n\nThe list view can optionally display an additional field next to the primary field.\n\n`additionalField`: Similar to `field`, but only used in list view. Displays additional information for the mapping object on the right.\n\n`field`: Defined the same way as other custom api widget fields.\n\n`color`: Allowed options: `\"theme\", \"adaptive\", \"black\", \"white\"`. The option `adaptive` will apply a color using the value of the `additionalField`, green for positive numbers, red for negative numbers.\n\n```yaml\n- field: key\n  label: Field\n  format: text\n  remap:\n    - value: 0\n      to: None\n    - value: 1\n      to: Connected\n    - any: true # will map all other values\n      to: Unknown\n  additionalField:\n    field: hourly.time.key\n    color: theme\n    format: date\n```\n\n### Dynamic List View\n\nTo display a list of items from an array in the API response, set the `display` property to `dynamic-list` and configure the `mappings` object with the following properties:\n\n```yaml\nwidget:\n  type: customapi\n  url: https://example.com/api/servers\n  display: dynamic-list\n  mappings:\n    items: data # optional, the path to the array in the API response. Omit this option if the array is at the root level\n    name: id # required, field in each item to use as the item name (left side)\n    label: ip_address # required, field in each item to use as the item label (right side)\n    limit: 5 # optional, limit the number of items to display\n    format: text # optional - format of the label field\n    target: https://example.com/server/{id} # optional, makes items clickable with template support\n```\n\nThis configuration would work with an API that returns a response like:\n\n```json\n{\n  \"data\": [\n    { \"id\": \"server1\", \"name\": \"Server 1\", \"ip_address\": \"192.168.0.1\" },\n    { \"id\": \"server2\", \"name\": \"Server 2\", \"ip_address\": \"192.168.0.2\" }\n  ]\n}\n```\n\nThe widget would display a list with two items:\n\n- \"Server 1\" on the left and \"192.168.0.1\" on the right, clickable to \"https://example.com/server/server1\"\n- \"Server 2\" on the left and \"192.168.0.2\" on the right, clickable to \"https://example.com/server/server2\"\n\nFor nested fields in the items, you can use dot notation:\n\n```yaml\nmappings:\n  items: data.results.servers\n  name: details.id\n  label: details.name\n```\n\n## Custom Headers\n\nPass custom headers using the `headers` option, for example:\n\n```yaml\nheaders:\n  X-API-Token: token\n```\n\n## Custom Request Body\n\nPass custom request body using the `requestBody` option in either a string or object format. Objects will automatically be converted to a JSON string.\n\n```yaml\nrequestBody:\n  foo: bar\n# or\nrequestBody: \"{\\\"foo\\\":\\\"bar\\\"}\"\n```\n\nBoth formats result in `{\"foo\":\"bar\"}` being sent as the request body. Don't forget to set your `Content-Type` headers!\n"
  },
  {
    "path": "docs/widgets/services/deluge.md",
    "content": "---\ntitle: Deluge\ndescription: Deluge Widget Configuration\n---\n\nLearn more about [Deluge](https://deluge-torrent.org/).\n\nUses the same password used to login to the webui, see [the deluge FAQ](https://dev.deluge-torrent.org/wiki/Faq#Whatisthedefaultpassword).\n\nAllowed fields: `[\"leech\", \"download\", \"seed\", \"upload\"]`.\n\n```yaml\nwidget:\n  type: deluge\n  url: http://deluge.host.or.ip\n  password: password # webui password\n  enableLeechProgress: true # optional, defaults to false\n```\n"
  },
  {
    "path": "docs/widgets/services/develancacheui.md",
    "content": "---\ntitle: DeveLanCacheUI\ndescription: DeveLanCacheUI Widget Configuration\n---\n\nLearn more about [DeveLanCacheUI](https://github.com/devedse/DeveLanCacheUI_Backend).\n\n```yaml\nwidget:\n  type: develancacheui\n  url: http://your.develancacheui_backend.host:port\n```\n\nThe url should point to the DeveLanCacheUI Backend (API)\n"
  },
  {
    "path": "docs/widgets/services/diskstation.md",
    "content": "---\ntitle: Synology Disk Station\ndescription: Synology Disk Station Widget Configuration\n---\n\nLearn more about [Synology Disk Station](https://www.synology.com/en-global/dsm).\n\nNote: the widget is not compatible with 2FA.\n\nAn optional 'volume' parameter can be supplied to specify which volume's free space to display when more than one volume exists. The value of the parameter must be in form of `volume_N`, e.g. to display free space for volume2, `volume_2` should be set as 'volume' value. If omitted, first returned volume's free space will be shown (not guaranteed to be volume1).\n\nAllowed fields: `[\"uptime\", \"volumeAvailable\", \"resources.cpu\", \"resources.mem\"]`.\n\nTo access these system metrics you need to connect to the DiskStation (`DSM`) with an account that is a member of the default `Administrators` group. That is because these metrics are requested from the API's `SYNO.Core.System` part that is only available to admin users. In order to keep the security impact as small as possible we can set the account in DSM up to limit the user's permissions inside the Synology system. In DSM 7.x, for instance, follow these steps:\n\n1. Create a new user, i.e. `remote_stats`.\n2. Set up a strong password for the new user\n3. Under the `User Groups` tab of the user config dialogue check the box for `Administrators`.\n4. On the `Permissions` tab check the top box for `No Access`, effectively prohibiting the user from accessing anything in the shared folders.\n5. Under `Applications` check the box next to `Deny` in the header to explicitly prohibit login to all applications.\n6. Now _only_ allow login to the `DSM` and `Download Station` applications, either by\n   - unchecking `Deny` in the respective row, or (if inheriting permission doesn't work because of other group settings)\n   - checking `Allow` for this app, or\n   - checking `By IP` for this app to limit the source of login attempts to one or more IP addresses/subnets.\n7. When the `Preview` column shows `Allow` in the `DSM` row, click `Save`.\n\nNow configure the widget with the correct login information and test it.\n\nIf you encounter issues during testing:\n\n1. Make sure to uncheck the option for automatic blocking due to invalid logins under `Control Panel > Security > Protection`.\n   - If desired, this setting can be reactivated once the login is established working.\n2. Login to your Synology DSM with the newly created account and accept terms and conditions.\n3. Reattempt\n\n```yaml\nwidget:\n  type: diskstation\n  url: http://diskstation.host.or.ip:port\n  username: username\n  password: password\n  volume: volume_N # optional\n```\n"
  },
  {
    "path": "docs/widgets/services/dispatcharr.md",
    "content": "---\ntitle: Dispatcharr\ndescription: Dispatcharr Widget Configuration\n---\n\nLearn more about [Dispatcharr](https://github.com/Dispatcharr/Dispatcharr).\n\nAllowed fields: `[\"channels\", \"streams\"]`.\n\n```yaml\nwidget:\n  type: dispatcharr\n  url: http://dispatcharr.host.or.ip\n  username: username\n  password: password\n  enableActiveStreams: true # optional, defaults to false\n```\n"
  },
  {
    "path": "docs/widgets/services/dockhand.md",
    "content": "---\ntitle: Dockhand\ndescription: Dockhand Widget Configuration\n---\n\nLearn more about [Dockhand](https://dockhand.pro/).\n\nNote: The widget currently supports Dockhand's **local** authentication only.\n\n**Allowed fields:** (max 4): `running`, `stopped`, `paused`, `total`, `cpu`, `memory`, `images`, `volumes`, `events_today`, `pending_updates`, `stacks`.\n**Default fields:** `running`, `total`, `cpu`, `memory`.\n\n```yaml\nwidget:\n  type: dockhand\n  url: http://localhost:3001\n  environment: local # optional: name or id; aggregates all when omitted\n  username: your-user # required for local auth\n  password: your-pass # required for local auth\n```\n"
  },
  {
    "path": "docs/widgets/services/downloadstation.md",
    "content": "---\ntitle: Synology Download Station\ndescription: Synology Download Station Widget Configuration\n---\n\nLearn more about [Synology Download Station](https://www.synology.com/en-us/dsm/packages/DownloadStation).\n\nNote: the widget is not compatible with 2FA.\n\nAllowed fields: `[\"leech\", \"download\", \"seed\", \"upload\"]`.\n\n```yaml\nwidget:\n  type: downloadstation\n  url: http://downloadstation.host.or.ip:port\n  username: username\n  password: password\n```\n"
  },
  {
    "path": "docs/widgets/services/emby.md",
    "content": "---\ntitle: Emby\ndescription: Emby Widget Configuration\n---\n\nLearn more about [Emby](https://github.com/MediaBrowser/Emby).\n\nYou can create an API key from inside Emby at `Settings > Advanced > Api Keys`.\n\nAs of v0.6.11 the widget supports fields `[\"movies\", \"series\", \"episodes\", \"songs\"]`. These blocks are disabled by default but can be enabled with the `enableBlocks` option, and the \"Now Playing\" feature (enabled by default) can be disabled with the `enableNowPlaying` option.\n\n```yaml\nwidget:\n  type: emby\n  url: http://emby.host.or.ip\n  key: apikeyapikeyapikeyapikeyapikey\n  enableBlocks: true # optional, defaults to false\n  enableNowPlaying: true # optional, defaults to true\n  enableUser: true # optional, defaults to false\n  enableMediaControl: false # optional, defaults to true\n  showEpisodeNumber: true # optional, defaults to false\n  expandOneStreamToTwoRows: false # optional, defaults to true\n```\n"
  },
  {
    "path": "docs/widgets/services/esphome.md",
    "content": "---\ntitle: ESPHome\ndescription: ESPHome Widget Configuration\n---\n\nLearn more about [ESPHome](https://esphome.io/).\n\nShow the number of ESPHome devices based on their state.\n\nAllowed fields: `[\"total\", \"online\", \"offline\", \"offline_alt\", \"unknown\"]` (maximum of 4).\n\nBy default ESPHome will only mark devices as `offline` if their address cannot be pinged. If it has an invalid config or its name cannot be resolved (by DNS) its status will be marked as `unknown`.\nTo group both `offline` and `unknown` devices together, users should use the `offline_alt` field instead. This sums all devices that are _not_ online together.\n\n```yaml\nwidget:\n  type: esphome\n  url: http://esphome.host.or.ip:port\n  username: myesphomeuser # only if auth enabled\n  password: myesphomepass # only if auth enabled\n```\n"
  },
  {
    "path": "docs/widgets/services/evcc.md",
    "content": "---\ntitle: EVCC\ndescription: EVCC Widget Configuration\n---\n\nLearn more about [EVCC](https://github.com/evcc-io/evcc).\n\nAllowed fields: `[\"pv_power\", \"grid_power\", \"home_power\", \"charge_power]`.\n\n```yaml\nwidget:\n  type: evcc\n  url: http://evcc.host.or.ip:port\n```\n"
  },
  {
    "path": "docs/widgets/services/filebrowser.md",
    "content": "---\ntitle: Filebrowser\ndescription: Filebrowser Widget Configuration\n---\n\nLearn more about [Filebrowser](https://filebrowser.org).\n\nIf you are using [Proxy header authentication](https://filebrowser.org/configuration/authentication-method#proxy-header) you have to set `authHeader` and `username`.\n\nAllowed fields: `[\"available\", \"used\", \"total\"]`.\n\n```yaml\nwidget:\n  type: filebrowser\n  url: http://filebrowserhostorip:port\n  username: username\n  password: password\n  authHeader: X-My-Header # If using Proxy header authentication\n```\n"
  },
  {
    "path": "docs/widgets/services/fileflows.md",
    "content": "---\ntitle: Fileflows\ndescription: Fileflows Widget Configuration\n---\n\nLearn more about [FileFlows](https://github.com/revenz/FileFlows).\n\nAllowed fields: `[\"queue\", \"processing\", \"processed\", \"time\"]`.\n\n```yaml\nwidget:\n  type: fileflows\n  url: http://your.fileflows.host:port\n```\n"
  },
  {
    "path": "docs/widgets/services/firefly.md",
    "content": "---\ntitle: Firefly III\ndescription: Firefly III Widget Configuration\n---\n\nLearn more about [Firefly III](https://www.firefly-iii.org/).\n\nFind your API key under `Options > Profile > OAuth > Personal Access Tokens`.\n\nAllowed fields: `[\"networth\" ,\"budget\"]`.\n\n```yaml\nwidget:\n  type: firefly\n  url: https://firefly.host.or.ip\n  key: personalaccesstoken.personalaccesstoken.personalaccesstoken\n```\n"
  },
  {
    "path": "docs/widgets/services/flood.md",
    "content": "---\ntitle: Flood\ndescription: Flood Widget Configuration\n---\n\nLearn more about [Flood](https://github.com/jesec/flood).\n\nAllowed fields: `[\"leech\", \"download\", \"seed\", \"upload\"]`.\n\n```yaml\nwidget:\n  type: flood\n  url: http://flood.host.or.ip\n  username: username # if set\n  password: password # if set\n```\n"
  },
  {
    "path": "docs/widgets/services/freshrss.md",
    "content": "---\ntitle: FreshRSS\ndescription: FreshRSS Widget Configuration\n---\n\nLearn more about [FreshRSS](https://github.com/FreshRSS/FreshRSS).\n\nPlease refer to [Enable the API in FreshRSS](https://freshrss.github.io/FreshRSS/en/users/06_Mobile_access.html#enable-the-api-in-freshrss) for the \"API password\" to be entered in the password field.\n\nAllowed fields: `[\"subscriptions\", \"unread\"]`.\n\n```yaml\nwidget:\n  type: freshrss\n  url: http://freshrss.host.or.ip:port\n  username: username\n  password: password\n```\n"
  },
  {
    "path": "docs/widgets/services/frigate.md",
    "content": "---\ntitle: Frigate\ndescription: Frigate Widget Configuration\n---\n\nLearn more about [Frigate](https://frigate.video/).\n\nAllowed fields: `[\"cameras\", \"uptime\", \"version\"]`.\n\nA recent event listing is disabled by default, but can be enabled with the `enableRecentEvents` option.\n\n```yaml\nwidget:\n  type: frigate\n  url: http://frigate.host.or.ip:port\n  enableRecentEvents: true # Optional, defaults to false\n  username: username # optional\n  password: password # optional\n```\n"
  },
  {
    "path": "docs/widgets/services/fritzbox.md",
    "content": "---\ntitle: FRITZ!Box\ndescription: FRITZ!Box Widget Configuration\n---\n\nApplication access & UPnP must be activated on your device:\n\n```\nHome Network > Network > Network Settings > Access Settings in the Home Network\n[x] Allow access for applications\n[x] Transmit status information over UPnP\n```\n\nCredentials are not needed and, as such, you may want to consider using `http` instead of `https` as those requests are significantly faster.\n\nAllowed fields (limited to a max of 4): `[\"connectionStatus\", \"uptime\", \"maxDown\", \"maxUp\", \"down\", \"up\", \"received\", \"sent\", \"externalIPAddress\", \"externalIPv6Address\", \"externalIPv6Prefix\"]`.\n\n```yaml\nwidget:\n  type: fritzbox\n  url: http://192.168.178.1\n```\n"
  },
  {
    "path": "docs/widgets/services/gamedig.md",
    "content": "---\ntitle: GameDig\ndescription: GameDig Widget Configuration\n---\n\nLearn more about [GameDig](https://github.com/gamedig/node-gamedig).\n\nUses the [GameDig](https://www.npmjs.com/package/gamedig) library to get game server information for any supported server type.\n\nAllowed fields (limited to a max of 4): `[\"status\", \"name\", \"map\", \"currentPlayers\", \"players\", \"maxPlayers\", \"bots\", \"ping\"]`.\n\n```yaml\nwidget:\n  type: gamedig\n  serverType: csgo # see https://github.com/gamedig/node-gamedig#games-list\n  url: udp://server.host.or.ip:port\n  gameToken: # optional, a token used by gamedig with certain games\n```\n"
  },
  {
    "path": "docs/widgets/services/gatus.md",
    "content": "---\ntitle: Gatus\ndescription: Gatus Widget Configuration\n---\n\nLearn more about [Gatus](https://github.com/TwiN/gatus).\n\nAllowed fields: `[\"up\", \"down\", \"uptime\"]`.\n\n```yaml\nwidget:\n  type: gatus\n  url: http://gatus.host.or.ip:port\n```\n"
  },
  {
    "path": "docs/widgets/services/ghostfolio.md",
    "content": "---\ntitle: Ghostfolio\ndescription: Ghostfolio Widget Configuration\n---\n\nLearn more about [Ghostfolio](https://github.com/ghostfolio/ghostfolio).\n\nAuthentication requires manually obtaining a Bearer token which can be obtained by make a POST request to the API e.g.\n\n```\ncurl -X POST http://localhost:3333/api/v1/auth/anonymous -H 'Content-Type: application/json' -d '{ \"accessToken\": \"SECURITY_TOKEN_OF_ACCOUNT\" }'\n```\n\nSee the [official docs](https://github.com/ghostfolio/ghostfolio#authorization-bearer-token).\n\n_Note that the Bearer token is valid for 6 months, after which a new one must be generated._\n\nAllowed fields: `[\"gross_percent_today\", \"gross_percent_1y\", \"gross_percent_max\", \"net_worth\"]`\n\n```yaml\nwidget:\n  type: ghostfolio\n  url: http://ghostfoliohost:port\n  key: ghostfoliobearertoken\n```\n"
  },
  {
    "path": "docs/widgets/services/gitea.md",
    "content": "---\ntitle: Gitea\ndescription: Gitea Widget Configuration\n---\n\nLearn more about [Gitea](https://gitea.com).\n\nAPI token requires `notifications`, `repository` and `issue` permissions. See the [gitea documentation](https://docs.gitea.com/development/api-usage#generating-and-listing-api-tokens) for details on generating tokens.\n\nAllowed fields: `[\"repositories\", \"notifications\", \"issues\", \"pulls\"]`.\n\n```yaml\nwidget:\n  type: gitea\n  url: http://gitea.host.or.ip:port\n  key: giteaapitoken\n```\n"
  },
  {
    "path": "docs/widgets/services/gitlab.md",
    "content": "---\ntitle: Gitlab\ndescription: Gitlab Widget Configuration\n---\n\nLearn more about [Gitlab](https://gitlab.com).\n\nAPI requires a personal access token with either `read_api` or `api` permission. See the [gitlab documentation](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#create-a-personal-access-token) for details on generating one.\n\nYour Gitlab user ID can be found on [your profile page](https://support.circleci.com/hc/en-us/articles/20761157174043-How-to-find-your-GitLab-User-ID).\n\nAllowed fields: `[\"events\", \"issues\", \"merges\", \"projects\"]`.\n\n```yaml\nwidget:\n  type: gitlab\n  url: http://gitlab.host.or.ip:port\n  key: personal-access-token\n  user_id: 123456\n```\n"
  },
  {
    "path": "docs/widgets/services/glances.md",
    "content": "---\ntitle: Glances\ndescription: Glances Widget Configuration\n---\n\nLearn more about [Glances](https://github.com/nicolargo/glances).\n\n<img width=\"1614\" alt=\"glances\" src=\"https://github-production-user-asset-6210df.s3.amazonaws.com/82196/257382012-25648c97-2c1b-4db0-b5a5-f1509806079c.png\">\n\n_(Find the Glances information widget [here](../info/glances.md))_\n\nThe Glances widget allows you to monitor the resources (cpu, memory, diskio, sensors & processes) of host or another machine. You can have multiple instances by adding another service block.\n\n```yaml\nwidget:\n  type: glances\n  url: http://glances.host.or.ip:port\n  username: user # optional if auth enabled in Glances\n  password: pass # optional if auth enabled in Glances\n  version: 4 # required only if running glances v4 or higher, defaults to 3\n  metric: cpu\n  diskUnits: bytes # optional, bytes (default) or bbytes. Only applies to disk\n  refreshInterval: 5000 # optional - in milliseconds, defaults to 1000 or more, depending on the metric\n  pointsLimit: 15 # optional, defaults to 15\n```\n\n_Please note, this widget does not need an `href`, `icon` or `description` on its parent service. To achieve the same effect as the examples above, see as an example:_\n\n```yaml\n- CPU Usage:\n    widget:\n      type: glances\n      url: http://glances.host.or.ip:port\n      metric: cpu\n- Network Usage:\n    widget:\n      type: glances\n      url: http://glances.host.or.ip:port\n      metric: network:enp0s25\n```\n\n## Metrics\n\nThe metric field in the configuration determines the type of system monitoring data to be displayed. Here are the supported metrics:\n\n`info`: System information. Shows the system's hostname, OS, kernel version, CPU type, CPU usage, RAM usage and SWAP usage.\n\n`cpu`: CPU usage. Shows how much of the system's computational resources are currently being used.\n\n`memory`: Memory usage. Shows how much of the system's RAM is currently being used.\n\n`process`: Top 5 processes based on CPU usage. Gives an overview of which processes are consuming the most resources.\n\n`containers`: Docker or Kubernetes containers list. Shows up to 5 containers running on the system and their resource usage.\n\n`network:<interface_name>`: Network data usage for the specified interface. Replace `<interface_name>` with the name of your network interface, e.g., `network:enp0s25`, as specified in glances.\n\n`sensor:<sensor_id>`: Temperature of the specified sensor, typically used to monitor CPU temperature. Replace `<sensor_id>` with the name of your sensor, e.g., `sensor:Package id 0` as specified in glances.\n\n`disk:<disk_id>`: Disk I/O data for the specified disk. Replace `<disk_id>` with the id of your disk, e.g., `disk:sdb`, as specified in glances.\n\n`gpu:<gpu_id>`: GPU usage for the specified GPU. Replace `<gpu_id>` with the id of your GPU, e.g., `gpu:0`, as specified in glances.\n\n`fs:<mnt_point>`: Disk usage for the specified mount point. Replace `<mnt_point>` with the path of your disk, e.g., `/mnt/storage`, as specified in glances.\n\n## Views\n\nAll widgets offer an alternative to the full or \"graph\" view, which is the compact, or \"graphless\" view.\n\n<img width=\"970\" alt=\"Screenshot 2023-09-06 at 1 51 48 PM\" src=\"https://github-production-user-asset-6210df.s3.amazonaws.com/82196/265985295-cc6b9adc-4218-4274-96ca-36c3e64de5d0.png\">\n\nTo switch to the alternative \"graphless\" view, simply pass `chart: false` as an option to the widget, like so:\n\n```yaml\n- Network Usage:\n    widget:\n      type: glances\n      url: http://glances.host.or.ip:port\n      metric: network:enp0s25\n      chart: false\n```\n"
  },
  {
    "path": "docs/widgets/services/gluetun.md",
    "content": "---\ntitle: Gluetun\ndescription: Gluetun Widget Configuration\n---\n\nLearn more about [Gluetun](https://github.com/qdm12/gluetun).\n\n!!! note\n\n    Requires [HTTP control server options](https://github.com/qdm12/gluetun-wiki/blob/main/setup/advanced/control-server.md) to be enabled. By default this runs on port `8000`.\n\nAllowed fields: `[\"public_ip\", \"region\", \"country\", \"port_forwarded\"]`.\nDefault fields: `[\"public_ip\", \"region\", \"country\"]`.\n\nTo setup authentication, follow [the official Gluetun documentation](https://github.com/qdm12/gluetun-wiki/blob/main/setup/advanced/control-server.md#authentication). Note that to use the api key method, you must add the route `GET /v1/publicip/ip` to the `routes` array in your Gluetun config.toml. Similarly, if you want to include the `port_forwarded` field, you must add the route `GET /v1/openvpn/portforwarded` (or `/v1/portforward`) to your Gluetun config.toml.\n\n| Gluetun Version | Homepage Widget Version |\n| --------------- | ----------------------- |\n| < 3.40.1        | 1 (default)             |\n| >= 3.40.1       | 2                       |\n\n```yaml\nwidget:\n  type: gluetun\n  url: http://gluetun.host.or.ip:port\n  key: gluetunkey # Not required if /v1/publicip/ip endpoint is configured with `auth = none`\n  version: 2 # optional, default is 1\n```\n"
  },
  {
    "path": "docs/widgets/services/gotify.md",
    "content": "---\ntitle: Gotify\ndescription: Gotify Widget Configuration\n---\n\nLearn more about [Gotify](https://github.com/gotify/server).\n\nGet a Gotify client token from an existing client or create a new one on your Gotify admin page.\n\nAllowed fields: `[\"apps\", \"clients\", \"messages\"]`.\n\n```yaml\nwidget:\n  type: gotify\n  url: http://gotify.host.or.ip\n  key: clientoken\n```\n"
  },
  {
    "path": "docs/widgets/services/grafana.md",
    "content": "---\ntitle: Grafana\ndescription: Grafana Widget Configuration\n---\n\nLearn more about [Grafana](https://github.com/grafana/grafana).\n\n| Grafana Version | Homepage Widget Version |\n| --------------- | ----------------------- |\n| <= v10.4        | 1 (default)             |\n| > v10.4         | 2                       |\n\nAllowed fields: `[\"dashboards\", \"datasources\", \"totalalerts\", \"alertstriggered\"]`.\n\n```yaml\nwidget:\n  type: grafana\n  version: 2 # optional, default is 1\n  alerts: alertmanager # optional, default is grafana\n  url: http://grafana.host.or.ip:port\n  username: username\n  password: password\n```\n"
  },
  {
    "path": "docs/widgets/services/hdhomerun.md",
    "content": "---\ntitle: HDHomerun\ndescription: HDHomerun Widget Configuration\n---\n\nLearn more about [HDHomerun](https://www.silicondust.com/support/downloads/).\n\nAllowed fields: `[\"channels\", \"hd\", \"tunerCount\", \"channelNumber\", \"channelNetwork\", \"signalStrength\", \"signalQuality\", \"symbolQuality\", \"networkRate\", \"clientIP\" ]`.\n\nIf more than 4 fields are provided, only the first 4 are displayed.\n\n```yaml\nwidget:\n  type: hdhomerun\n  url: http://hdhomerun.host.or.ip\n  tuner: 0 # optional - defaults to 0, used for tuner-specific fields\n  fields: [\"channels\", \"hd\"] # optional - default fields shown\n```\n"
  },
  {
    "path": "docs/widgets/services/headscale.md",
    "content": "---\ntitle: Headscale\ndescription: Headscale Widget Configuration\n---\n\nLearn more about [Headscale](https://headscale.net/).\n\nYou will need to generate an API access token from the [command line](https://headscale.net/ref/remote-cli/#create-an-api-key) using `headscale apikeys create` command.\n\nTo find your node ID, you can use `headscale nodes list` command.\n\nAllowed fields: `[\"name\", \"address\", \"last_seen\", \"status\"]`.\n\n```yaml\nwidget:\n  type: headscale\n  url: http://headscale.host.or.ip:port\n  nodeId: nodeid\n  key: headscaleapiaccesstoken\n```\n"
  },
  {
    "path": "docs/widgets/services/healthchecks.md",
    "content": "---\ntitle: Health checks\ndescription: Health checks Widget Configuration\n---\n\nLearn more about [Health Checks](https://github.com/healthchecks/healthchecks).\n\nSpecify a single check by including the `uuid` field or show the total 'up' and 'down' for all\nchecks by leaving off the `uuid` field.\n\nTo use the Health Checks widget, you first need to generate an API key.\n\n1. In your project, go to project Settings on the navigation bar.\n2. Click on API key (read-only) and then click _Create_.\n3. Copy the API key that is generated for you.\n\nAllowed fields: `[\"status\", \"last_ping\"]` for single checks, `[\"up\", \"down\"]` for total stats.\n\n```yaml\nwidget:\n  type: healthchecks\n  url: http://healthchecks.host.or.ip:port\n  key: <YOUR_API_KEY>\n  uuid: <CHECK_UUID> # optional, if not included total statistics for all checks is shown\n```\n"
  },
  {
    "path": "docs/widgets/services/homeassistant.md",
    "content": "---\ntitle: Home Assistant\ndescription: Home Assistant Widget Configuration\n---\n\nLearn more about [Home Assistant](https://www.home-assistant.io/).\n\nYou will need to generate a long-lived access token for an existing Home Assistant user in its profile.\n\nAllowed fields: `[\"people_home\", \"lights_on\", \"switches_on\"]`.\n\n---\n\nUp to a maximum of four custom states and/or templates can be queried via the `custom` property like in the example below.\nThe `custom` property will have no effect as long as the `fields` property is defined.\n\n- `state` will query the state of the specified `entity_id`\n  - state labels and values can be user defined and may reference entity attributes in curly brackets\n  - if no state label is defined it will default to `\"{attributes.friendly_name}\"`\n  - if no state value is defined it will default to `\"{state} {attributes.unit_of_measurement}\"`\n- `template` will query the specified template, see [Home Assistant Templating](https://www.home-assistant.io/docs/configuration/templating)\n  - if no template label is defined it will be empty\n\n```yaml\nwidget:\n  type: homeassistant\n  url: http://homeassistant.host.or.ip:port\n  key: access_token\n  custom:\n    - state: sensor.total_power\n    - state: sensor.total_energy_today\n      label: energy today\n    - template: \"{{ states.switch|selectattr('state','equalto','on')|list|length }}\"\n      label: switches on\n    - state: weather.forecast_home\n      label: wind speed\n      value: \"{attributes.wind_speed} {attributes.wind_speed_unit}\"\n```\n"
  },
  {
    "path": "docs/widgets/services/homebox.md",
    "content": "---\ntitle: Homebox\ndescription: Homebox Widget Configuration\n---\n\nLearn more about [Homebox](https://github.com/hay-kot/homebox).\n\nUses the same username and password used to login from the web.\n\nThe `totalValue` field will attempt to format using the currency you have configured in Homebox.\n\nAllowed fields: `[\"items\", \"totalWithWarranty\", \"locations\", \"labels\", \"users\", \"totalValue\"]`.\n\nIf more than 4 fields are provided, only the first 4 are displayed.\n\n```yaml\nwidget:\n  type: homebox\n  url: http://homebox.host.or.ip:port\n  username: username\n  password: password\n  fields: [\"items\", \"locations\", \"totalValue\"] # optional - default fields shown\n```\n"
  },
  {
    "path": "docs/widgets/services/homebridge.md",
    "content": "---\ntitle: Homebridge\ndescription: Homebridge\n---\n\nLearn more about [Homebridge](https://github.com/homebridge/homebridge).\n\nThe Homebridge API is actually provided by the Config UI X plugin that has been included with Homebridge for a while, still it is required to be installed for this widget to work.\n\nAllowed fields: `[\"updates\", \"child_bridges\"]`.\n\n```yaml\nwidget:\n  type: homebridge\n  url: http://homebridge.host.or.ip:port\n  username: username\n  password: password\n```\n"
  },
  {
    "path": "docs/widgets/services/iframe.md",
    "content": "---\ntitle: iFrame\nDescription: Add a custom iFrame Widget\n---\n\nA basic iFrame widget to show external content, see the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe) for more details about some of the options.\n\n!!! warning\n\n    Requests made via the iFrame widget are inherently **not proxied** as they are made from the browser itself.\n\n## Basic Example\n\n```yaml\nwidget:\n  type: iframe\n  name: myIframe\n  src: http://example.com\n```\n\n## Full Example\n\n```yaml\nwidget:\n  type: iframe\n  name: myIframe\n  src: http://example.com\n  classes: h-60 sm:h-60 md:h-60 lg:h-60 xl:h-60 2xl:h-72 # optional, use tailwind height classes, see https://tailwindcss.com/docs/height\n  referrerPolicy: same-origin # optional, no default\n  allowPolicy: autoplay; fullscreen; gamepad # optional, no default\n  allowFullscreen: false # optional, default: true\n  loadingStrategy: eager # optional, default: eager\n  allowScrolling: no # optional, default: yes\n  refreshInterval: 2000 # optional, no default\n```\n"
  },
  {
    "path": "docs/widgets/services/immich.md",
    "content": "---\ntitle: Immich\ndescription: Immich Widget Configuration\n---\n\nLearn more about [Immich](https://github.com/immich-app/immich).\n\n| Immich Version | Homepage Widget Version |\n| -------------- | ----------------------- |\n| < v1.118       | 1 (default)             |\n| >= v1.118      | 2                       |\n\nFind your API key under `Account Settings > API Keys`. The key should have the\n`server.statistics` permission.\n\nAllowed fields: `[\"users\" ,\"photos\", \"videos\", \"storage\"]`.\n\n```yaml\nwidget:\n  type: immich\n  url: http://immich.host.or.ip\n  key: adminapikeyadminapikeyadminapikey\n  version: 2 # optional, default is 1\n```\n"
  },
  {
    "path": "docs/widgets/services/index.md",
    "content": "---\ntitle: Service Widgets\ndescription: Homepage service widgets.\nsearch:\n  exclude: true\n---\n\nYou can also find a list of all available service widgets in the sidebar navigation.\n\n- [Adguard Home](adguard-home.md)\n- [APC UPS](apcups.md)\n- [Arcane](arcane.md)\n- [ArgoCD](argocd.md)\n- [Atsumeru](atsumeru.md)\n- [Audiobookshelf](audiobookshelf.md)\n- [Authentik](authentik.md)\n- [Autobrr](autobrr.md)\n- [Azure DevOps](azuredevops.md)\n- [Backrest](backrest.md)\n- [Bazarr](bazarr.md)\n- [Booklore](booklore.md)\n- [Beszel](beszel.md)\n- [Caddy](caddy.md)\n- [Calendar](calendar.md)\n- [Calibre-Web](calibre-web.md)\n- [ChangeDetection.io](changedetectionio.md)\n- [Channels DVR Server](channelsdvrserver.md)\n- [Checkmk](checkmk.md)\n- [Cloudflared](cloudflared.md)\n- [Coin Market Cap](coin-market-cap.md)\n- [CrowdSec](crowdsec.md)\n- [Custom API](customapi.md)\n- [Deluge](deluge.md)\n- [DeveLanCacheUI](develancacheui.md)\n- [DiskStation](diskstation.md)\n- [Dispatcharr](dispatcharr.md)\n- [Dockhand](dockhand.md)\n- [DownloadStation](downloadstation.md)\n- [Emby](emby.md)\n- [ESPHome](esphome.md)\n- [EVCC](evcc.md)\n- [Filebrowser](filebrowser.md)\n- [Fileflows](fileflows.md)\n- [Firefly III](firefly.md)\n- [Flood](flood.md)\n- [FreshRSS](freshrss.md)\n- [Frigate](frigate.md)\n- [Fritz!Box](fritzbox.md)\n- [GameDig](gamedig.md)\n- [Gatus](gatus.md)\n- [Ghostfolio](ghostfolio.md)\n- [Gitea](gitea.md)\n- [Gitlab](gitlab.md)\n- [Glances](glances.md)\n- [Gluetun](gluetun.md)\n- [Gotify](gotify.md)\n- [Grafana](grafana.md)\n- [HDHomeRun](hdhomerun.md)\n- [Headscale](headscale.md)\n- [Healthchecks](healthchecks.md)\n- [Karakeep](karakeep.md)\n- [Home Assistant](homeassistant.md)\n- [HomeBox](homebox.md)\n- [Homebridge](homebridge.md)\n- [iFrame](iframe.md)\n- [Immich](immich.md)\n- [Jackett](jackett.md)\n- [JDownloader](jdownloader.md)\n- [Jellyfin](jellyfin.md)\n- [Seerr](seerr.md)\n- [Jellystat](jellystat.md)\n- [Kavita](kavita.md)\n- [Komga](komga.md)\n- [Komodo](komodo.md)\n- [Kopia](kopia.md)\n- [Lidarr](lidarr.md)\n- [Linkwarden](linkwarden.md)\n- [Lubelogger](lubelogger.md)\n- [Mastodon](mastodon.md)\n- [Mailcow](mailcow.md)\n- [Mealie](mealie.md)\n- [Medusa](medusa.md)\n- [Mikrotik](mikrotik.md)\n- [Minecraft](minecraft.md)\n- [Miniflux](miniflux.md)\n- [MJpeg](mjpeg.md)\n- [Moonraker](moonraker.md)\n- [Mylar](mylar.md)\n- [MySpeed](myspeed.md)\n- [Navidrome](navidrome.md)\n- [NetAlertX](netalertx.md)\n- [Netdata](netdata.md)\n- [Nextcloud](nextcloud.md)\n- [NextDNS](nextdns.md)\n- [NGINX Proxy Manager](nginx-proxy-manager.md)\n- [NZBGet](nzbget.md)\n- [OctoPrint](octoprint.md)\n- [Omada](omada.md)\n- [Ombi](ombi.md)\n- [OpenDTU](opendtu.md)\n- [OpenMediaVault](openmediavault.md)\n- [OpenWRT](openwrt.md)\n- [OPNsense](opnsense.md)\n- [PaperlessNGX](paperlessngx.md)\n- [Peanut](peanut.md)\n- [pfSense](pfsense.md)\n- [PhotoPrism](photoprism.md)\n- [Pi-hole](pihole.md)\n- [PlantIt](plantit.md)\n- [Plex & Tautulli](plex-tautulli.md)\n- [Plex](plex.md)\n- [Portainer](portainer.md)\n- [Prometheus](prometheus.md)\n- [Prometheus Metric](prometheusmetric.md)\n- [Prowlarr](prowlarr.md)\n- [Proxmox](proxmox.md)\n- [Proxmox Backup Server](proxmoxbackupserver.md)\n- [Pterodactyl](pterodactyl.md)\n- [PyLoad](pyload.md)\n- [qBittorrent](qbittorrent.md)\n- [QNAP](qnap.md)\n- [Radarr](radarr.md)\n- [Readarr](readarr.md)\n- [ROMM](romm.md)\n- [ruTorrent](rutorrent.md)\n- [SABnzbd](sabnzbd.md)\n- [Scrutiny](scrutiny.md)\n- [Slskd](slskd.md)\n- [Sonarr](sonarr.md)\n- [Speedtest Tracker](speedtest-tracker.md)\n- [Stash](stash.md)\n- [Stocks](stocks.md)\n- [SwagDashboard](swagdashboard.md)\n- [Syncthing Relay Server](syncthing-relay-server.md)\n- [Tailscale](tailscale.md)\n- [Tandoor](tandoor.md)\n- [Technitium DNS](technitium.md)\n- [TDarr](tdarr.md)\n- [Traefik](traefik.md)\n- [Transmission](transmission.md)\n- [Trilium](trilium.md)\n- [TrueNAS](truenas.md)\n- [TubeArchivist](tubearchivist.md)\n- [UniFi Controller](unifi-controller.md)\n- [Unmanic](unmanic.md)\n- [Unraid](unraid.md)\n- [Uptime Kuma](uptime-kuma.md)\n- [UptimeRobot](uptimerobot.md)\n- [UrBackup](urbackup.md)\n- [Vikunja](vikunja.md)\n- [Wallos](wallos.md)\n- [Watchtower](watchtower.md)\n- [WGEasy](wgeasy.md)\n- [WhatsUpDocker](whatsupdocker.md)\n- [xTeVe](xteve.md)\n- [Zabbix](zabbix.md)\n"
  },
  {
    "path": "docs/widgets/services/jackett.md",
    "content": "---\ntitle: Jackett\ndescription: Jackett Widget Configuration\n---\n\nLearn more about [Jackett](https://github.com/Jackett/Jackett).\n\nIf Jackett has an admin password set, you must set the `password` field for the widget to work.\n\nAllowed fields: `[\"configured\", \"errored\"]`.\n\n```yaml\nwidget:\n  type: jackett\n  url: http://jackett.host.or.ip\n  password: jackettadminpassword # optional\n```\n"
  },
  {
    "path": "docs/widgets/services/jdownloader.md",
    "content": "---\ntitle: JDownloader\ndescription: JDownloader Widget Configuration\n---\n\nLearn more about [JDownloader](https://jdownloader.org/).\n\nBasic widget to show number of items in download queue, along with the queue size and current download speed.\n\nAllowed fields: `[\"downloadCount\", \"downloadTotalBytes\",\"downloadBytesRemaining\", \"downloadSpeed\"]`.\n\n```yaml\nwidget:\n  type: jdownloader\n  username: JDownloader Username\n  password: JDownloader Password\n  client: Name of JDownloader Instance\n```\n"
  },
  {
    "path": "docs/widgets/services/jellyfin.md",
    "content": "---\ntitle: Jellyfin\ndescription: Jellyfin Widget Configuration\n---\n\nLearn more about [Jellyfin](https://github.com/jellyfin/jellyfin).\n\nYou can create an API key from inside the Jellyfin Administration Dashboard under `Advanced > API Keys`.\n\nAs of v0.6.11 the widget supports fields `[\"movies\", \"series\", \"episodes\", \"songs\"]`. These blocks are disabled by default but can be enabled with the `enableBlocks` option, and the \"Now Playing\" feature (enabled by default) can be disabled with the `enableNowPlaying` option.\n\n| Jellyfin Version | Homepage Widget Version |\n| ---------------- | ----------------------- |\n| < 10.12          | 1 (default)             |\n| >= 10.12         | 2                       |\n\n```yaml\nwidget:\n  type: jellyfin\n  url: http://jellyfin.host.or.ip:port\n  key: apikeyapikeyapikeyapikeyapikey\n  version: 2 # optional, default is 1\n  enableBlocks: true # optional, defaults to false\n  enableNowPlaying: true # optional, defaults to true\n  enableUser: true # optional, defaults to false\n  enableMediaControl: false # optional, defaults to true\n  showEpisodeNumber: true # optional, defaults to false\n  expandOneStreamToTwoRows: false # optional, defaults to true\n```\n"
  },
  {
    "path": "docs/widgets/services/jellystat.md",
    "content": "---\ntitle: Jellystat\ndescription: Jellystat Widget Configuration\n---\n\nLearn more about [Jellystat](https://github.com/CyferShepard/Jellystat). The widget supports (at least) Jellystat version 1.1.6\n\nYou can create an API key from inside Jellystat at `Settings > API Key`.\n\nAllowed fields: `[\"songs\", \"movies\", \"episodes\", \"other\"]`.\n\n```yaml\nwidget:\n  type: jellystat\n  url: http://jellystat.host.or.ip\n  key: apikeyapikeyapikeyapikeyapikey\n  days: 30 # optional, defaults to 30\n```\n"
  },
  {
    "path": "docs/widgets/services/karakeep.md",
    "content": "---\ntitle: Karakeep\ndescription: Karakeep Widget Configuration\n---\n\nLearn more about [Karakeep](https://karakeep.app) (formerly known as Hoarder).\n\nGenerate an API key for your user at `User Settings > API Keys`.\n\nAllowed fields: `[\"bookmarks\", \"favorites\", \"archived\", \"highlights\", \"lists\", \"tags\"]` (maximum of 4).\n\n```yaml\nwidget:\n  type: karakeep\n  url: http[s]://karakeep.host.or.ip[:port]\n  key: karakeep_api_key\n```\n"
  },
  {
    "path": "docs/widgets/services/kavita.md",
    "content": "---\ntitle: Kavita\ndescription: Kavita Widget Configuration\n---\n\nLearn more about [Kavita](https://github.com/Kareadita/Kavita).\n\nUses the same admin role username and password used to login from the web.\n\nAllowed fields: `[\"seriesCount\", \"totalFiles\"]`.\n\n```yaml\nwidget:\n  type: kavita\n  url: http://kavita.host.or.ip:port\n  username: username\n  password: password\n  key: kavitaapikey # Optional, e.g. if not using username and password\n```\n"
  },
  {
    "path": "docs/widgets/services/komga.md",
    "content": "---\ntitle: Komga\ndescription: Komga Widget Configuration\n---\n\nLearn more about [Komga](https://github.com/gotson/komga).\n\nUses the same username and password used to login from the web.\n\nAllowed fields: `[\"libraries\", \"series\", \"books\"]`.\n\n| Komga API Version | Homepage Widget Version |\n| ----------------- | ----------------------- |\n| < v2              | 1 (default)             |\n| >= v2             | 2                       |\n\n```yaml\nwidget:\n  type: komga\n  url: http://komga.host.or.ip:port\n  username: username\n  password: password\n  key: komgaapikey # optional\n```\n"
  },
  {
    "path": "docs/widgets/services/komodo.md",
    "content": "---\ntitle: Komodo\ndescription: Komodo Widget Configuration\n---\n\nThis widget shows either details about all containers or stacks (if `showStacks` is true) managed by [Komodo](https://komo.do/) or the number of running servers, containers and stacks when `showSummary` is enabled.\n\nThe api key and secret can be found in the Komodo settings.\n\nAllowed fields (max 4): `[\"total\", \"running\", \"stopped\", \"unhealthy\", \"unknown\"]`.\nAllowed fields with `showStacks` (max 4): `[\"total\", \"running\", \"down\", \"unhealthy\", \"unknown\"]`.\nAllowed fields with `showSummary`: `[\"servers\", \"stacks\", \"containers\"]`.\n\n```yaml\nwidget:\n  type: komodo\n  url: http://komodo.hostname.or.ip:port\n  key: K-xxxxxx...\n  secret: S-xxxxxx...\n  showSummary: true # optional, default: false. Takes precedence over showStacks\n  showStacks: true # optional, default: false\n```\n"
  },
  {
    "path": "docs/widgets/services/kopia.md",
    "content": "---\ntitle: Kopia\ndescription: Kopia Widget Configuration\n---\n\nLearn more about [Kopia](https://github.com/kopia/kopia).\n\nAllowed fields: `[\"status\", \"size\", \"lastrun\", \"nextrun\"]`.\n\nYou may optionally pass values for `snapshotHost` and / or `snapshotPath` to select a specific backup source for the widget.\n\n```yaml\nwidget:\n  type: kopia\n  url: http://kopia.host.or.ip:port\n  username: username\n  password: password\n  snapshotHost: hostname # optional\n  snapshotPath: path # optional\n```\n"
  },
  {
    "path": "docs/widgets/services/lidarr.md",
    "content": "---\ntitle: Lidarr\ndescription: Lidarr Widget Configuration\n---\n\nLearn more about [Lidarr](https://github.com/Lidarr/Lidarr).\n\nFind your API key under `Settings > General`.\n\nAllowed fields: `[\"wanted\", \"queued\", \"artists\"]`.\n\n```yaml\nwidget:\n  type: lidarr\n  url: http://lidarr.host.or.ip\n  key: apikeyapikeyapikeyapikeyapikey\n```\n"
  },
  {
    "path": "docs/widgets/services/linkwarden.md",
    "content": "---\ntitle: Linkwarden\ndescription: Linkwarden Widget Configuration\n---\n\nLearn more about [Linkwarden](https://linkwarden.app/).\n\nAllowed fields: `[\"links\", \"collections\", \"tags\"]`.\n\n```yaml\nwidget:\n  type: linkwarden\n  url: http://linkwarden.host.or.ip\n  key: myApiKeyHere # On your Linkwarden install, go to Settings > Access Tokens. Generate a token.\n```\n"
  },
  {
    "path": "docs/widgets/services/lubelogger.md",
    "content": "---\ntitle: LubeLogger\ndescription: LubeLogger Widget Configuration\n---\n\nLearn more about [LubeLogger](https://github.com/hargata/lubelog) (v1.3.7 or higher is required).\n\nThe widget comes in two 'flavors', one shows data for all vehicles or for just a specific vehicle with the `vehicleID` parameter.\n\nAllowed fields: `[\"vehicles\", \"serviceRecords\", \"reminders\"]`.\nFor the single-vehicle version: `[\"vehicle\", \"serviceRecords\", \"reminders\", \"nextReminder\"]`.\n\n```yaml\nwidget:\n  type: lubelogger\n  url: https://lubelogger.host.or.ip\n  username: lubeloggerusername\n  password: lubeloggerpassword\n  vehicleID: 1 # optional, changes to single-vehicle version\n```\n"
  },
  {
    "path": "docs/widgets/services/mailcow.md",
    "content": "---\ntitle: Mailcow\ndescription: Mailcow Widget Configuration\n---\n\nLearn more about [Mailcow](https://github.com/mailcow/mailcow-dockerized).\n\nAllowed fields: `[\"domains\", \"mailboxes\", \"mails\", \"storage\"]`.\n\n```yaml\nwidget:\n  type: mailcow\n  url: https://mailcow.host.or.ip\n  key: mailcowapikey\n```\n"
  },
  {
    "path": "docs/widgets/services/mastodon.md",
    "content": "---\ntitle: Mastodon\ndescription: Mastodon Widget Configuration\n---\n\nLearn more about [Mastodon](https://github.com/mastodon/mastodon).\n\nUse the base URL of the Mastodon instance you'd like to pull stats for. Does not require authentication as the stats are part of the public API endpoints.\n\nAllowed fields: `[\"user_count\", \"status_count\", \"domain_count\"]`.\n\n```yaml\nwidget:\n  type: mastodon\n  url: https://mastodon.host.name\n```\n"
  },
  {
    "path": "docs/widgets/services/mealie.md",
    "content": "---\ntitle: Mealie\ndescription: Mealie Widget Configuration\n---\n\nLearn more about [Mealie](https://github.com/mealie-recipes/mealie).\n\nGenerate a user API key under `Profile > Manage Your API Tokens > Generate`.\n\nAllowed fields: `[\"recipes\", \"users\", \"categories\", \"tags\"]`.\n\n```yaml\nwidget:\n  type: mealie\n  url: http://mealie-frontend.host.or.ip\n  key: mealieapitoken\n  version: 2 # only required if version > 1, defaults to 1\n```\n"
  },
  {
    "path": "docs/widgets/services/medusa.md",
    "content": "---\ntitle: Medusa\ndescription: Medusa Widget Configuration\n---\n\nLearn more about [Medusa](https://github.com/pymedusa/Medusa).\n\nAllowed fields: `[\"wanted\", \"queued\", \"series\"]`.\n\n```yaml\nwidget:\n  type: medusa\n  url: http://medusa.host.or.ip:port\n  key: medusaapikeyapikeyapikeyapikeyapikey\n```\n"
  },
  {
    "path": "docs/widgets/services/mikrotik.md",
    "content": "---\ntitle: Mikrotik\ndescription: Mikrotik Widget Configuration\n---\n\nHTTPS may be required, [per the documentation](https://help.mikrotik.com/docs/display/ROS/REST+API#RESTAPI-Overview)\n\nAllowed fields: `[\"uptime\", \"cpuLoad\", \"memoryUsed\", \"numberOfLeases\"]`.\n\n```yaml\nwidget:\n  type: mikrotik\n  url: https://mikrotik.host.or.ip\n  username: username\n  password: password\n```\n"
  },
  {
    "path": "docs/widgets/services/minecraft.md",
    "content": "---\ntitle: Minecraft\ndescription: Minecraft Widget Configuration\n---\n\nAllowed fields: `[\"players\", \"version\", \"status\"]`.\n\n```yaml\nwidget:\n  type: minecraft\n  url: udp://minecraftserveripordomain:port\n```\n"
  },
  {
    "path": "docs/widgets/services/miniflux.md",
    "content": "---\ntitle: Miniflux\ndescription: Miniflux Widget Configuration\n---\n\nLearn more about [Miniflux](https://github.com/miniflux/v2).\n\nApi key is found under Settings > API keys\n\nAllowed fields: `[\"unread\", \"read\"]`.\n\n```yaml\nwidget:\n  type: miniflux\n  url: http://miniflux.host.or.ip:port\n  key: minifluxapikey\n```\n"
  },
  {
    "path": "docs/widgets/services/mjpeg.md",
    "content": "---\ntitle: MJPEG\ndescription: MJPEG Stream Widget Configuration\n---\n\n![camera-preview](https://github.com/gethomepage/homepage/assets/4887959/dbc388d7-04a6-482c-8f36-f9534689b062)\n\nPass the stream URL from a service like [µStreamer](https://github.com/pikvm/ustreamer) or [camera-streamer](https://github.com/ayufan/camera-streamer).\n\n```yaml\nwidget:\n  type: mjpeg\n  stream: http://mjpeg.host.or.ip/webcam/stream\n```\n"
  },
  {
    "path": "docs/widgets/services/moonraker.md",
    "content": "---\ntitle: Moonraker (Klipper)\ndescription: Moonraker (Klipper) Widget Configuration\n---\n\nLearn more about [Moonraker](https://github.com/Arksine/moonraker).\n\nAllowed fields: `[\"printer_state\", \"print_status\", \"print_progress\", \"layers\"]`.\n\n```yaml\nwidget:\n  type: moonraker\n  url: http://moonraker.host.or.ip:port\n```\n\nIf your moonraker instance has an active authorization and your homepage ip isn't whitelisted you need to add your api key ([Authorization Documentation](https://moonraker.readthedocs.io/en/latest/web_api/#authorization)).\n\n```yaml\nwidget:\n  type: moonraker\n  url: http://moonraker.host.or.ip:port\n  key: api_keymoonraker\n```\n"
  },
  {
    "path": "docs/widgets/services/mylar.md",
    "content": "---\ntitle: Mylar3\ndescription: Mylar3 Widget Configuration\n---\n\nLearn more about [Mylar3](https://github.com/mylar3/mylar3).\n\nAPI must be enabled in Mylar3 settings.\n\nAllowed fields: `[\"series\", \"issues\", \"wanted\"]`.\n\n```yaml\nwidget:\n  type: mylar\n  url: http://mylar3.host.or.ip:port\n  key: yourmylar3apikey\n```\n"
  },
  {
    "path": "docs/widgets/services/myspeed.md",
    "content": "---\ntitle: MySpeed\ndescription: MySpeed Widget Configuration\n---\n\nLearn more about [MySpeed](https://myspeed.dev/).\n\nAllowed fields: `[\"ping\", \"download\", \"upload\"]`.\n\n```yaml\nwidget:\n  type: myspeed\n  url: http://myspeed.host.or.ip:port\n  password: password # only required if password is set\n```\n"
  },
  {
    "path": "docs/widgets/services/navidrome.md",
    "content": "---\ntitle: Navidrome\ndescription: Navidrome Widget Configuration\n---\n\nLearn more about [Navidrome](https://github.com/navidrome/navidrome).\n\nFor detailed information about how to generate the token see http://www.subsonic.org/pages/api.jsp.\n\nAllowed fields: no configurable fields for this widget.\n\n```yaml\nwidget:\n  type: navidrome\n  url: http://navidrome.host.or.ip:port\n  user: username\n  token: token #md5(password + salt)\n  salt: randomsalt\n```\n"
  },
  {
    "path": "docs/widgets/services/netalertx.md",
    "content": "---\ntitle: NetAlertX\ndescription: NetAlertX (formerly PiAlert) Widget Configuration\n---\n\nLearn more about [NetAlertX](https://github.com/jokob-sk/NetAlertX).\n\n_Note that the project was renamed from PiAlert to NetAlertX._\n\nAllowed fields: `[\"total\", \"connected\", \"new_devices\", \"down_alerts\"]`.\n\nProvide the `API_TOKEN` (f.k.a. `SYNC_api_token`) as the `key` in your config.\n\n| NetAlertX Version | Homepage Widget Version |\n| ----------------- | ----------------------- |\n| < v26.1.17        | 1 (default)             |\n| > v26.1.17        | 2                       |\n\n```yaml\nwidget:\n  type: netalertx\n  url: http://ip:port # use backend port for widget version 2+\n  key: yournetalertxapitoken\n  version: 2 # optional, default is 1\n```\n"
  },
  {
    "path": "docs/widgets/services/netdata.md",
    "content": "---\ntitle: Netdata\ndescription: Netdata Widget Configuration\n---\n\nLearn more about [Netdata](https://github.com/netdata/netdata).\n\nAllowed fields: `[\"warnings\", \"criticals\"]`.\n\n```yaml\nwidget:\n  type: netdata\n  url: http://netdata.host.or.ip\n```\n"
  },
  {
    "path": "docs/widgets/services/nextcloud.md",
    "content": "---\ntitle: Nextcloud\ndescription: Nextcloud Widget Configuration\n---\n\nLearn more about [Nextcloud](https://github.com/nextcloud).\n\nUse username & password, or the `NC-Token` key. Information about the token can be found under **Settings** > **System**. If both are provided, NC-Token will be used.\n\nAllowed fields: `[\"cpuload\", \"memoryusage\", \"freespace\", \"activeusers\", \"numfiles\", \"numshares\"]`.\n\nNote \"cpuload\" and \"memoryusage\" were deprecated in v0.6.18 and a maximum of 4 fields can be displayed.\n\n```yaml\nwidget:\n  type: nextcloud\n  url: https://nextcloud.host.or.ip:port\n  key: token\n```\n\n```yaml\nwidget:\n  type: nextcloud\n  url: https://nextcloud.host.or.ip:port\n  username: username\n  password: password\n```\n"
  },
  {
    "path": "docs/widgets/services/nextdns.md",
    "content": "---\ntitle: NextDNS\ndescription: NextDNS Widget Configuration\n---\n\nLearn more about [NextDNS](https://nextdns.io/).\n\nApi key is found under Account > API, profile ID is found under Setup > Endpoints > ID\n\n```yaml\nwidget:\n  type: nextdns\n  profile: profileid\n  key: yourapikeyhere\n```\n"
  },
  {
    "path": "docs/widgets/services/nginx-proxy-manager.md",
    "content": "---\ntitle: Nginx Proxy Manager\ndescription: Nginx Proxy Manager Widget Configuration\n---\n\nLearn more about [Nginx Proxy Manager](https://nginxproxymanager.com/).\n\nLogin with the same admin username and password used to access the web UI.\n\nAllowed fields: `[\"enabled\", \"disabled\", \"total\"]`.\n\n```yaml\nwidget:\n  type: npm\n  url: http://npm.host.or.ip\n  username: admin_username\n  password: admin_password\n```\n"
  },
  {
    "path": "docs/widgets/services/nzbget.md",
    "content": "---\ntitle: NZBget\ndescription: NZBget Widget Configuration\n---\n\nLearn more about [NZBget](https://github.com/nzbget/nzbget).\n\nThis widget uses the same authentication method as your browser when logging in (HTTP Basic Auth), and is often referred to as the ControlUsername and ControlPassword inside of Nzbget documentation.\n\nAllowed fields: `[\"rate\", \"remaining\", \"downloaded\"]`.\n\n```yaml\nwidget:\n  type: nzbget\n  url: http://nzbget.host.or.ip\n  username: controlusername\n  password: controlpassword\n```\n"
  },
  {
    "path": "docs/widgets/services/octoprint.md",
    "content": "---\ntitle: OctoPrint\ndescription: OctoPrintWidget Configuration\n---\n\nLearn more about [OctoPrint](https://octoprint.org/).\n\nAllowed fields: `[\"printer_state\", \"temp_tool\", \"temp_bed\", \"job_completion\"]`.\n\n```yaml\nwidget:\n  type: octoprint\n  url: http://octoprint.host.or.ip:port\n  key: youroctoprintapikey\n```\n"
  },
  {
    "path": "docs/widgets/services/omada.md",
    "content": "---\ntitle: Omada\ndescription: Omada Widget Configuration\n---\n\nThe widget supports controller versions 3, 4, 5 and 6.\n\nAllowed fields: `[\"connectedAp\", \"activeUser\", \"alerts\", \"connectedGateways\", \"connectedSwitches\"]`.\n\n```yaml\nwidget:\n  type: omada\n  url: http://omada.host.or.ip:port\n  username: username\n  password: password\n  site: sitename\n```\n"
  },
  {
    "path": "docs/widgets/services/ombi.md",
    "content": "---\ntitle: Ombi\ndescription: Ombi Widget Configuration\n---\n\nLearn more about [Ombi](https://github.com/Ombi-app/Ombi).\n\nFind your API key under `Settings > Configuration > General`.\n\nAllowed fields: `[\"pending\", \"approved\", \"available\"]`.\n\n```yaml\nwidget:\n  type: ombi\n  url: http://ombi.host.or.ip\n  key: apikeyapikeyapikeyapikeyapikey\n```\n"
  },
  {
    "path": "docs/widgets/services/opendtu.md",
    "content": "---\ntitle: OpenDTU\ndescription: OpenDTU Widget\n---\n\nLearn more about [OpenDTU](https://github.com/tbnobody/OpenDTU).\n\nAllowed fields: `[\"yieldDay\", \"relativePower\", \"absolutePower\", \"limit\"]`.\n\n```yaml\nwidget:\n  type: opendtu\n  url: http://opendtu.host.or.ip\n```\n"
  },
  {
    "path": "docs/widgets/services/openmediavault.md",
    "content": "---\ntitle: OpenMediaVault\ndescription: OpenMediaVault Widget Configuration\n---\n\nLearn more about [OpenMediaVault](https://www.openmediavault.org/).\n\nProvides useful information from your OpenMediaVault\n\n```yaml\nwidget:\n  type: openmediavault\n  url: http://omv.host.or.ip\n  username: admin\n  password: pass\n  method: services.getStatus # required\n```\n\n## Methods\n\nThe method field determines the type of data to be displayed and is required. Supported methods:\n\n`services.getStatus`: Shows status of running services. Allowed fields: `[\"running\", \"stopped\", \"total\"]`\n\n`smart.getListBg`: Shows S.M.A.R.T. status from disks. Allowed fields: `[\"passed\", \"failed\"]`\n\n`downloader.getDownloadList`: Displays the number of tasks from the Downloader plugin currently being downloaded and total. Allowed fields: `[\"downloading\", \"total\"]`\n"
  },
  {
    "path": "docs/widgets/services/openwrt.md",
    "content": "---\ntitle: OpenWRT\ndescription: OpenWRT widget configuration\n---\n\nLearn more about [OpenWRT](https://openwrt.org/).\n\nProvides information from OpenWRT\n\n```yaml\nwidget:\n  type: openwrt\n  url: http://host.or.ip\n  username: homepage\n  password: pass\n  interfaceName: eth0 # optional\n```\n\n## Interface\n\nSetting `interfaceName` (e.g. eth0) will display information for that particular device, otherwise the widget will display general system info.\n\n## Authorization\n\nIn order for homepage to access the OpenWRT RPC endpoints you will need to [create an ACL](https://openwrt.org/docs/techref/ubus#acls) and [new user](https://openwrt.org/docs/techref/ubus#authentication) in OpenWRT.\n\nCreate an ACL named `homepage.json` in `/usr/share/rpcd/acl.d/`, the following permissions will suffice:\n\n```json\n{\n  \"homepage\": {\n    \"description\": \"Homepage widget\",\n    \"read\": {\n      \"ubus\": {\n        \"network.interface.wan\": [\"status\"],\n        \"network.interface.lan\": [\"status\"],\n        \"network.device\": [\"status\"],\n        \"system\": [\"info\"]\n      }\n    }\n  }\n}\n```\n\nCreate a `crypt(5)` password hash using the following command in the OpenWRT shell:\n\n```sh\nuhttpd -m \"<somepassphrase>\"\n```\n\nThen add a user that will use the ACL and hashed password in `/etc/config/rpcd`:\n\n```\nconfig login\n        option username 'homepage'\n        option password '<hashedpassword>'\n        list read homepage\n```\n\nThis username and password will be used in Homepage's services.yaml to grant access.\n"
  },
  {
    "path": "docs/widgets/services/opnsense.md",
    "content": "---\ntitle: OPNSense\ndescription: OPNSense Widget Configuration\n---\n\nLearn more about [OPNSense](https://opnsense.org/).\n\nThe API key & secret can be generated via the webui by creating a new user at _System/Access/Users_. Ensure \"Generate a scrambled password to prevent local database logins for this user\" is checked and then edit the effective privileges selecting **only**:\n\n- Diagnostics: System Activity\n- Status: Traffic Graph / Reporting: Traffic (OPNSENSE 24.7.x)\n\nFinally, create a new API key which will download an `apikey.txt` file with your key and secret in it. Use the values as the username and password fields, respectively, in your homepage config.\n\nAllowed fields: `[\"cpu\", \"memory\", \"wanUpload\", \"wanDownload\"]`.\n\n```yaml\nwidget:\n  type: opnsense\n  url: http://opnsense.host.or.ip\n  username: key\n  password: secret\n  wan: opt1 # optional, defaults to wan\n```\n"
  },
  {
    "path": "docs/widgets/services/pangolin.md",
    "content": "---\ntitle: Pangolin\ndescription: Pangolin Widget Configuration\n---\n\nLearn more about [Pangolin](https://github.com/fosrl/pangolin).\n\nThis widget shows sites (online/total), resources (healthy/total), targets (healthy/total), and traffic statistics for a Pangolin organization. A resource is considered healthy if at least one of its targets is healthy, or if it has no targets.\n\nAllowed fields: `[\"sites\", \"resources\", \"targets\", \"traffic\", \"in\", \"out\"]` (maximum of 4).\n\n```yaml\nwidget:\n  type: pangolin\n  url: https://api.pangolin.net\n  key: your-api-key\n  org: your-org-id\n```\n\nFind your organization ID in the URL when logged in (e.g., `https://app.pangolin.net/{org-id}/...`).\n\n## API Key Setup\n\nCreate an API key with the following permissions:\n\n- **List Sites**\n- **List Resources**\n\n**Self-Hosted:** Enable the [Integration API](https://docs.pangolin.net/self-host/advanced/integration-api) in your Pangolin configuration before creating the key.\n"
  },
  {
    "path": "docs/widgets/services/paperlessngx.md",
    "content": "---\ntitle: Paperless-ngx\ndescription: Paperless-ngx Widget Configuration\n---\n\nLearn more about [Paperless-ngx](https://github.com/paperless-ngx/paperless-ngx).\n\nUse username & password, or the token key. Information about the token can be found in the [Paperless-ngx API documentation](https://docs.paperless-ngx.com/api/#authorization). If both are provided, the token will be used.\n\nAllowed fields: `[\"total\", \"inbox\"]`.\n\n```yaml\nwidget:\n  type: paperlessngx\n  url: http://paperlessngx.host.or.ip:port\n  username: username\n  password: password\n```\n\n```yaml\nwidget:\n  type: paperlessngx\n  url: http://paperlessngx.host.or.ip:port\n  key: token\n```\n"
  },
  {
    "path": "docs/widgets/services/peanut.md",
    "content": "---\ntitle: PeaNUT\ndescription: PeaNUT Widget Configuration\n---\n\nLearn more about [PeaNUT](https://github.com/Brandawg93/PeaNUT).\n\nThis widget adds support for [Network UPS Tools](https://networkupstools.org/) via a third party tool, [PeaNUT](https://github.com/Brandawg93/PeaNUT).\n\nThe default ups name is `ups`. To configure more than one ups, you must create multiple peanut services.\n\nAllowed fields: `[\"battery_charge\", \"ups_load\", \"ups_status\"]`.\n\n!!! note\n\n    This widget requires an additional tool, [PeaNUT](https://github.com/Brandawg93/PeaNUT), as noted. Other projects exist to achieve similar results using a `customapi` widget, for example [NUTCase](https://github.com/ArthurMitchell42/nutcase#using-nutcase-homepage).\n\n```yaml\nwidget:\n  type: peanut\n  url: http://peanut.host.or.ip:port\n  key: nameofyourups\n  username: username # only needed if set\n  password: password # only needed if set\n```\n"
  },
  {
    "path": "docs/widgets/services/pfsense.md",
    "content": "---\ntitle: pfSense\ndescription: pfSense Widget Configuration\n---\n\nLearn more about [pfSense](https://github.com/pfsense/pfsense).\n\nThis widget requires the installation of the [pfsense-api](https://github.com/jaredhendrickson13/pfsense-api) which is a 3rd party package for pfSense routers.\n\nOnce pfSense API is installed, you can set the API to be read-only in System > API > Settings.\n\nThere are two currently supported authentication modes: 'Local Database' and 'API Key' (v2) / 'API Token' (v1). For 'Local Database', use `username` and `password` with the credentials of an admin user. The specifics of using the API key / token depend on the version of the pfSense API, see the config examples below. Do not use both headers and username / password.\n\nThe interface to monitor is defined by updating the `wan` parameter. It should be referenced as it is shown under Interfaces > Assignments in pfSense.\n\nLoad is returned instead of cpu utilization. This is a limitation in the pfSense API due to the complexity of this calculation. This may become available in future versions.\n\nAllowed fields: `[\"load\", \"memory\", \"temp\", \"wanStatus\", \"wanIP\", \"disk\"]` (maximum of 4)\n\nFor version 2:\n\n```yaml\nwidget:\n  type: pfsense\n  url: http://pfsense.host.or.ip:port\n  username: user # optional, or API key\n  password: pass # optional, or API key\n  headers: # optional, or username/password\n    X-API-Key: key\n  wan: igb0\n  version: 2 # optional, defaults to 1 for api v1\n  fields: [\"load\", \"memory\", \"temp\", \"wanStatus\"] # optional\n```\n\nFor version 1:\n\n```yaml\nheaders: # optional, or username/password\n  Authorization: client_id client_token # obtained from pfSense API\nversion: 1\n```\n"
  },
  {
    "path": "docs/widgets/services/photoprism.md",
    "content": "---\ntitle: PhotoPrism\ndescription: PhotoPrism Widget Configuration\n---\n\nLearn more about [PhotoPrism](https://github.com/photoprism/photoprism).\n\nAuthentication is possible via [app passwords](https://docs.photoprism.app/user-guide/settings/account/#apps-and-devices) or username/password.\n\nAllowed fields: `[\"albums\", \"photos\", \"videos\", \"people\"]`.\n\n```yaml\nwidget:\n  type: photoprism\n  url: http://photoprism.host.or.ip:port\n  username: admin # required only if using username/password\n  password: password # required only if using username/password\n  key: # required only if using app passwords\n```\n"
  },
  {
    "path": "docs/widgets/services/pihole.md",
    "content": "---\ntitle: PiHole\ndescription: PiHole Widget Configuration\n---\n\nLearn more about [PiHole](https://github.com/pi-hole/pi-hole).\n\nAllowed fields: `[\"queries\", \"blocked\", \"blocked_percent\", \"gravity\"]`.\n\nNote: by default the \"blocked\" and \"blocked_percent\" fields are merged e.g. \"1,234 (15%)\" but explicitly including the \"blocked_percent\" field will change them to display separately.\n\n```yaml\nwidget:\n  type: pihole\n  url: http://pi.hole.or.ip\n  version: 6 # required if running v6 or higher, defaults to 5\n  key: yourpiholeapikey # optional, in v6 can be your password or app password\n```\n"
  },
  {
    "path": "docs/widgets/services/plantit.md",
    "content": "---\ntitle: Plant-it\ndescription: Plant-it Widget Configuration\n---\n\nLearn more about [Plantit](https://github.com/MDeLuise/plant-it).\n\nAPI key can be created from the REST API.\n\nAllowed fields: `[\"events\", \"plants\", \"photos\", \"species\"]`.\n\n```yaml\nwidget:\n  type: plantit\n  url: http://plant-it.host.or.ip:port # api port\n  key: plantit-api-key\n```\n"
  },
  {
    "path": "docs/widgets/services/plex-tautulli.md",
    "content": "---\ntitle: Tautulli (Plex)\ndescription: Tautulli Widget Configuration\n---\n\nLearn more about [Tautulli](https://github.com/Tautulli/Tautulli).\n\nProvides detailed information about currently active streams. You can find the API key from inside Tautulli at `Settings > Web Interface > API`.\n\nAllowed fields: no configurable fields for this widget.\n\n```yaml\nwidget:\n  type: tautulli\n  url: http://tautulli.host.or.ip:port\n  key: apikeyapikeyapikeyapikeyapikey\n  enableUser: true # optional, defaults to false\n  showEpisodeNumber: true # optional, defaults to false\n  expandOneStreamToTwoRows: false # optional, defaults to true\n```\n"
  },
  {
    "path": "docs/widgets/services/plex.md",
    "content": "---\ntitle: Plex\ndescription: Plex Widget Configuration\n---\n\nLearn more about [Plex](https://www.plex.tv/).\n\nThe core Plex API is somewhat limited but basic info regarding library sizes and the number of active streams is supported. For more detailed info regarding active streams see the [Plex Tautulli widget](plex-tautulli.md).\n\nAllowed fields: `[\"streams\", \"albums\", \"movies\", \"tv\"]`.\n\n```yaml\nwidget:\n  type: plex\n  url: http://plex.host.or.ip:32400\n  key: mytokenhere # see https://www.plexopedia.com/plex-media-server/general/plex-token/\n```\n"
  },
  {
    "path": "docs/widgets/services/portainer.md",
    "content": "---\ntitle: Portainer\ndescription: Portainer Widget Configuration\n---\n\nLearn more about [Portainer](https://github.com/portainer/portainer).\n\nYou'll need to make sure you have the correct environment set for the integration to work properly. From the Environments section inside of Portainer, click the one you'd like to connect to and observe the ID at the end of the URL (should be), something like `#!/endpoints/1`, here `1` is the value to set as the `env` value. In order to generate an API key, please follow the steps outlined here https://docs.portainer.io/api/access.\n\nAllowed fields:\n\n- For Docker mode (default): `[\"running\", \"stopped\", \"total\"]`\n- For Kubernetes mode (`kubernetes: true`): `[\"applications\", \"services\", \"namespaces\"]`\n\n```yaml\nwidget:\n  type: portainer\n  url: https://portainer.host.or.ip:9443\n  env: 1\n  kubernetes: true # optional, defaults to false\n  key: ptr_accesskeyaccesskeyaccesskeyaccesskey\n```\n"
  },
  {
    "path": "docs/widgets/services/prometheus.md",
    "content": "---\ntitle: Prometheus\ndescription: Prometheus Widget Configuration\n---\n\nLearn more about [Prometheus](https://github.com/prometheus/prometheus).\n\nAllowed fields: `[\"targets_up\", \"targets_down\", \"targets_total\"]`.\n\n```yaml\nwidget:\n  type: prometheus\n  url: http://prometheushost:port\n```\n"
  },
  {
    "path": "docs/widgets/services/prometheusmetric.md",
    "content": "---\ntitle: Prometheus Metric\ndescription: Prometheus Metric Widget Configuration\n---\n\nLearn more about [Querying Prometheus](https://prometheus.io/docs/prometheus/latest/querying/basics/).\n\nThis widget can show metrics for your service defined by PromQL queries which are requested from a running Prometheus instance.\n\nQuries can be defined in the `metrics` array of the widget along with a label to be used to present the metric value. You can optionally specify a global `refreshInterval` in milliseconds and/or define the `refreshInterval` per metric. Inside the optional `format` object of a metric various formatting styles and transformations can be applied (see below).\n\n```yaml\nwidget:\n  type: prometheusmetric\n  url: https://prometheus.host.or.ip\n  refreshInterval: 10000 # optional - in milliseconds, defaults to 10s\n  metrics:\n    - label: Metric 1\n      query: alertmanager_alerts{state=\"active\"}\n    - label: Metric 2\n      query: apiserver_storage_size_bytes{node=\"mynode\"}\n      format:\n        type: bytes\n    - label: Metric 3\n      query: avg(prometheus_notifications_latency_seconds)\n      format:\n        type: number\n        suffix: s\n        options:\n          maximumFractionDigits: 4\n    - label: Metric 4\n      query: time()\n      refreshInterval: 1000 # will override global refreshInterval\n      format:\n        type: date\n        scale: 1000\n        options:\n          timeStyle: medium\n```\n\n## Formatting\n\nSupported values for `format.type` are `text`, `number`, `percent`, `bytes`, `bits`, `bbytes`, `bbits`, `byterate`, `bibyterate`, `bitrate`, `bibitrate`, `date`, `duration`, `relativeDate`, and `text` which is the default.\n\nThe `dateStyle` and `timeStyle` options of the `date` format are passed directly to [Intl.DateTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat) and the `style` and `numeric` options of `relativeDate` are passed to [Intl.RelativeTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/RelativeTimeFormat). For the `number` format, options of [Intl.NumberFormat](https://developer.mozilla.org/de/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat) can be used, e.g. `maximumFractionDigits` or `minimumFractionDigits`.\n\n### Data Transformation\n\nYou can manipulate your metric value with the following tools: `scale`, `prefix` and `suffix`, for example:\n\n```yaml\n- query: my_custom_metric{}\n  label: Metric 1\n  format:\n    type: number\n    scale: 1000 # multiplies value by a number or fraction string e.g. 1/16\n- query: my_custom_metric{}\n  label: Metric 2\n  format:\n    type: number\n    prefix: \"$\" # prefixes value with given string\n- query: my_custom_metric{}\n  label: Metric 3\n  format:\n    type: number\n    suffix: \"€\" # suffixes value with given string\n```\n"
  },
  {
    "path": "docs/widgets/services/prowlarr.md",
    "content": "---\ntitle: Prowlarr\ndescription: Prowlarr Widget Configuration\n---\n\nLearn more about [Prowlarr](https://github.com/Prowlarr/Prowlarr).\n\nFind your API key under `Settings > General`.\n\nAllowed fields: `[\"numberOfGrabs\", \"numberOfQueries\", \"numberOfFailGrabs\", \"numberOfFailQueries\"]`.\n\n```yaml\nwidget:\n  type: prowlarr\n  url: http://prowlarr.host.or.ip\n  key: apikeyapikeyapikeyapikeyapikey\n```\n"
  },
  {
    "path": "docs/widgets/services/proxmox.md",
    "content": "---\ntitle: Proxmox\ndescription: Proxmox Widget Configuration\n---\n\nLearn more about [Proxmox](https://www.proxmox.com/en/).\n\nThis widget shows the running and total counts of both QEMU VMs and LX Containers in the Proxmox cluster. It also shows the CPU and memory usage of the first node in the cluster.\n\nSee the [Proxmox configuration documentation](../../configs/proxmox.md#create-token) for details on creating API tokens.\n\nUse `username@pam!Token ID` as the `username` (e.g `api@pam!homepage`) setting and `Secret` as the `password` setting.\n\nAllowed fields: `[\"vms\", \"lxc\", \"resources.cpu\", \"resources.mem\"]`.\n\nYou can set the optional `node` setting when you want to show metrics for a single node. By default it will show the average for the complete cluster.\n\n```yaml\nwidget:\n  type: proxmox\n  url: https://proxmox.host.or.ip:8006\n  username: api_token_id\n  password: api_token_secret\n  node: pve-1 # optional\n```\n"
  },
  {
    "path": "docs/widgets/services/proxmoxbackupserver.md",
    "content": "---\ntitle: Proxmox Backup Server\ndescription: Proxmox Backup Server Widget Configuration\n---\n\nLearn more about [Proxmox Backup Server](https://www.proxmox.com/en/proxmox-backup-server/overview).\n\nCreate a user and an API token similar to the [Proxmox VE description](proxmox.md). The \"Audit\" role is required for both the user and token (not group).\n\nAllowed fields: `[\"datastore_usage\", \"failed_tasks_24h\", \"cpu_usage\", \"memory_usage\"]`.\n\n```yaml\nwidget:\n  type: proxmoxbackupserver\n  url: https://proxmoxbackupserver.host:port\n  username: api_token_id\n  password: api_token_secret\n  datastore: datastore_name #optional; if ommitted, will display a combination of all datastores used / total\n```\n"
  },
  {
    "path": "docs/widgets/services/pterodactyl.md",
    "content": "---\ntitle: Pterodactyl\ndescription: Pterodactyl Widget Configuration\n---\n\nLearn more about [Pterodactyl](https://github.com/pterodactyl).\n\nAllowed fields: `[\"nodes\", \"servers\"]`.\n\n```yaml\nwidget:\n  type: pterodactyl\n  url: http://pterodactylhost:port\n  key: pterodactylapikey\n```\n"
  },
  {
    "path": "docs/widgets/services/pyload.md",
    "content": "---\ntitle: Pyload\ndescription: Pyload Widget Configuration\n---\n\nLearn more about [Pyload](https://github.com/pyload/pyload).\n\nAllowed fields: `[\"speed\", \"active\", \"queue\", \"total\"]`.\n\n```yaml\nwidget:\n  type: pyload\n  url: http://pyload.host.or.ip:port\n  username: username\n  password: password # only needed if set\n```\n"
  },
  {
    "path": "docs/widgets/services/qbittorrent.md",
    "content": "---\ntitle: qBittorrent\ndescription: qBittorrent Widget Configuration\n---\n\nLearn more about [qBittorrent](https://github.com/qbittorrent/qBittorrent).\n\nUses the same username and password used to login from the web.\n\nAllowed fields: `[\"leech\", \"download\", \"seed\", \"upload\"]`.\n\n```yaml\nwidget:\n  type: qbittorrent\n  url: http://qbittorrent.host.or.ip\n  username: username\n  password: password\n  enableLeechProgress: true # optional, defaults to false\n  enableLeechSize: true # optional, defaults to false\n```\n"
  },
  {
    "path": "docs/widgets/services/qnap.md",
    "content": "---\ntitle: QNAP\ndescription: QNAP Widget Configuration\n---\n\nLearn more about [QNAP](https://www.qnap.com).\n\nAllowed fields: `[\"cpuUsage\", \"memUsage\", \"systemTempC\", \"poolUsage\", \"volumeUsage\"]`.\n\n```yaml\nwidget:\n  type: qnap\n  url: http://qnap.host.or.ip:port\n  username: user\n  password: pass\n```\n\nIf the QNAP device has multiple volumes, the _poolUsage_ will be a sum of all volumes.\n\nIf only a single volume needs to be tracked, add the following to your configuration and the Widget will track this as _volumeUsage_:\n\n```yaml\nvolume: Volume Name From QNAP\n```\n"
  },
  {
    "path": "docs/widgets/services/radarr.md",
    "content": "---\ntitle: Radarr\ndescription: Radarr Widget Configuration\n---\n\nLearn more about [Radarr](https://github.com/Radarr/Radarr).\n\nFind your API key under `Settings > General`.\n\nAllowed fields: `[\"wanted\", \"missing\", \"queued\", \"movies\"]`.\n\nA detailed queue listing is disabled by default, but can be enabled with the `enableQueue` option.\n\n```yaml\nwidget:\n  type: radarr\n  url: http://radarr.host.or.ip\n  key: apikeyapikeyapikeyapikeyapikey\n  enableQueue: true # optional, defaults to false\n```\n"
  },
  {
    "path": "docs/widgets/services/readarr.md",
    "content": "---\ntitle: Readarr\ndescription: Readarr Widget Configuration\n---\n\nLearn more about [Readarr](https://github.com/Readarr/Readarr).\n\nFind your API key under `Settings > General`.\n\nAllowed fields: `[\"wanted\", \"queued\", \"books\"]`.\n\n```yaml\nwidget:\n  type: readarr\n  url: http://readarr.host.or.ip\n  key: apikeyapikeyapikeyapikeyapikey\n```\n"
  },
  {
    "path": "docs/widgets/services/romm.md",
    "content": "---\ntitle: Romm\ndescription: Romm Widget Configuration\n---\n\nAllowed fields: `[\"platforms\", \"totalRoms\", \"saves\", \"states\", \"screenshots\", \"totalfilesize\"]`.\nIf more than (4) fields are provided, only the first (4) will be used.\n\n```yaml\nwidget:\n  type: romm\n  url: http://romm.host.or.ip\n  fields: [\"platforms\", \"totalRoms\", \"saves\", \"states\"] # optional - default fields shown\n```\n"
  },
  {
    "path": "docs/widgets/services/rutorrent.md",
    "content": "---\ntitle: ruTorrent\ndescription: ruTorrent Widget Configuration\n---\n\nLearn more about [ruTorrent](https://github.com/Novik/ruTorrent).\n\nThis requires the `httprpc` plugin to be installed and enabled, and is part of the default ruTorrent plugins. If you have not explicitly removed or disable this plugin, it should be available.\n\nAllowed fields: `[\"active\", \"upload\", \"download\"]`.\n\n```yaml\nwidget:\n  type: rutorrent\n  url: http://rutorrent.host.or.ip\n  username: username # optional, false if not used\n  password: password # optional, false if not used\n```\n"
  },
  {
    "path": "docs/widgets/services/sabnzbd.md",
    "content": "---\ntitle: SABnzbd\ndescription: SABnzbd Widget Configuration\n---\n\nLearn more about [SABnzbd](https://github.com/sabnzbd/sabnzbd).\n\nFind your API key under `Config > General`.\n\nAllowed fields: `[\"rate\", \"queue\", \"timeleft\"]`.\n\n```yaml\nwidget:\n  type: sabnzbd\n  url: http://sabnzbd.host.or.ip\n  key: apikeyapikeyapikeyapikeyapikey\n```\n"
  },
  {
    "path": "docs/widgets/services/scrutiny.md",
    "content": "---\ntitle: Scrutiny\ndescription: Scrutiny Widget Configuration\n---\n\nLearn more about [Scrutiny](https://github.com/AnalogJ/scrutiny).\n\nAllowed fields: `[\"passed\", \"failed\", \"unknown\"]`.\n\n```yaml\nwidget:\n  type: scrutiny\n  url: http://scrutiny.host.or.ip\n```\n"
  },
  {
    "path": "docs/widgets/services/seerr.md",
    "content": "---\ntitle: Seerr Widget\ndescription: Seerr Widget Configuration\n---\n\nLearn more about [Seerr](https://github.com/seerr-team/seerr).\n\nFind your API key under `Settings > General > API Key`.\n\n_Jellyseerr and Overseerr merged into Seerr. Use `type: seerr` (legacy `type: jellyseerr` and `type: overseerr` are aliased)._\n\nAllowed fields: `[\"pending\", \"approved\", \"available\", \"completed\", \"processing\", \"issues\"]`.\nDefault fields: `[\"pending\", \"approved\", \"completed\"]`.\n\n```yaml\nwidget:\n  type: seerr\n  url: http://seerr.host.or.ip\n  key: apikeyapikeyapikeyapikeyapikey\n```\n"
  },
  {
    "path": "docs/widgets/services/slskd.md",
    "content": "---\ntitle: Slskd\ndescription: Slskd Widget Configuration\n---\n\nLearn more about [Slskd](https://github.com/slskd/slskd).\n\nGenerate an API key for slskd with `openssl rand -base64 48`.\nAdd it to your `path/to/config/slskd.yml` in `web > authentication > api_keys`:\n\n```yaml\nhomepage_widget:\n  key: <generated key>\n  role: readonly\n  cidr: <homepage subnet>\n```\n\nAllowed fields: `[\"slskStatus\", \"updateStatus\", \"downloads\", \"uploads\", \"sharedFiles\"]` (maximum of 4).\n\n```yaml\nwidget:\n  type: slskd\n  url: http[s]://slskd.host.or.ip[:5030]\n  key: generatedapikey\n```\n"
  },
  {
    "path": "docs/widgets/services/sonarr.md",
    "content": "---\ntitle: Sonarr\ndescription: Sonarr Widget Configuration\n---\n\nLearn more about [Sonarr](https://github.com/Sonarr/Sonarr).\n\nFind your API key under `Settings > General`.\n\nAllowed fields: `[\"wanted\", \"queued\", \"series\"]`.\n\nA detailed queue listing is disabled by default, but can be enabled with the `enableQueue` option.\n\n```yaml\nwidget:\n  type: sonarr\n  url: http://sonarr.host.or.ip\n  key: apikeyapikeyapikeyapikeyapikey\n  enableQueue: true # optional, defaults to false\n```\n"
  },
  {
    "path": "docs/widgets/services/sparkyfitness.md",
    "content": "---\ntitle: SparkyFitness\ndescription: SparkyFitness Widget Configuration\n---\n\nLearn more about [SparkyFitness](https://github.com/CodeWithCJ/SparkyFitness).\n\nAllowed fields: `[\"eaten\", \"burned\", \"remaining\", \"steps\"]`.\n\n```yaml\nwidget:\n  type: sparkyfitness\n  url: http://sparkyfitness.host.or.ip\n  key: apikeyapikeyapikeyapikeyapikey\n```\n"
  },
  {
    "path": "docs/widgets/services/speedtest-tracker.md",
    "content": "---\ntitle: Speedtest Tracker\ndescription: Speedtest Tracker Widget Configuration\n---\n\nLearn more about [Speedtest Tracker](https://github.com/alexjustesen/speedtest-tracker). or\n[Speedtest Tracker](https://github.com/henrywhitaker3/Speedtest-Tracker)\n\nNo extra configuration is required.\n\nVersion 1 of the widget is compatible with both [alexjustesen/speedtest-tracker](https://github.com/alexjustesen/speedtest-tracker) and [henrywhitaker3/Speedtest-Tracker](https://github.com/henrywhitaker3/Speedtest-Tracker), while version 2 is only compatible with [alexjustesen/speedtest-tracker](https://github.com/alexjustesen/speedtest-tracker).\n\n| Speedtest Version (AJ) | Speedtest Version (HW) | Homepage Widget Version |\n| ---------------------- | ---------------------- | ----------------------- |\n| < 1.2.1                | ≤ 1.12.0               | 1 (default)             |\n| >= 1.2.1               | N/A                    | 2                       |\n\nAllowed fields: `[\"download\", \"upload\", \"ping\"]`.\n\n```yaml\nwidget:\n  type: speedtest\n  url: http://speedtest.host.or.ip\n  version: 1 # optional, default is 1\n  key: speedtestapikey # required for version 2\n  bitratePrecision: 3 # optional, default is 0\n```\n"
  },
  {
    "path": "docs/widgets/services/spoolman.md",
    "content": "---\ntitle: Spoolman\ndescription: Spoolman Widget Configuration\n---\n\nLearn more about [Spoolman](https://github.com/Donkie/Spoolman).\n\n4 spools are displayed by default. If more than 4 spools are configured in spoolman you can use the spoolIds configuration option to control which are displayed.\n\n```yaml\nwidget:\n  type: spoolman\n  url: http://spoolman.host.or.ip\n  spoolIds: [1, 2, 3, 4] # optional\n```\n"
  },
  {
    "path": "docs/widgets/services/stash.md",
    "content": "---\ntitle: Stash\ndescription: Stash Widget Configuration\n---\n\nLearn more about [Stash](https://github.com/stashapp/stash).\n\nFind your API key from inside Stash at `Settings > Security > API Key`. Note that the API key is only required if your Stash instance has login credentials.\n\nAllowed fields: `[\"scenes\", \"scenesPlayed\", \"playCount\", \"playDuration\", \"sceneSize\", \"sceneDuration\", \"images\", \"imageSize\", \"galleries\", \"performers\", \"studios\", \"movies\", \"tags\", \"oCount\"]`.\n\nIf more than 4 fields are provided, only the first 4 are displayed.\n\n```yaml\nwidget:\n  type: stash\n  url: http://stash.host.or.ip\n  key: stashapikey\n  fields: [\"scenes\", \"images\"] # optional - default fields shown\n```\n"
  },
  {
    "path": "docs/widgets/services/stocks.md",
    "content": "---\ntitle: Stocks\ndescription: Stocks Service Widget Configuration\n---\n\n_(Find the Stocks information widget [here](../info/stocks.md))_\n\nThe widget includes:\n\n- US stock market status\n- Current price of provided stock symbol\n- Change in price of stock symbol for the day.\n\nFinnhub.io is currently the only supported provider for the stocks widget.\nYou can sign up for a free api key at [finnhub.io](https://finnhub.io).\nYou are encouraged to read finnhub.io's\n[terms of service/privacy policy](https://finnhub.io/terms-of-service) before\nsigning up.\n\nAllowed fields: no configurable fields for this widget.\n\nYou must set `finnhub` as a provider in your `settings.yaml`:\n\n```yaml\nproviders:\n  finnhub: yourfinnhubapikeyhere\n```\n\nNext, configure the stocks widget in your `services.yaml`:\n\nThe service widget allows for up to 28 items in the watchlist. You may get rate\nlimited if using the information and service widgets together.\n\n```yaml\nwidget:\n  type: stocks\n  provider: finnhub\n  showUSMarketStatus: true # optional, defaults to true\n  watchlist:\n    - GME\n    - AMC\n    - NVDA\n    - TSM\n    - BRK.A\n    - TSLA\n    - AAPL\n    - MSFT\n    - AMZN\n    - BRK.B\n```\n"
  },
  {
    "path": "docs/widgets/services/suwayomi.md",
    "content": "---\ntitle: Suwayomi\ndescription: Suwayomi Widget Configuration\n---\n\nLearn more about [Suwayomi](https://github.com/Suwayomi/Suwayomi-Server).\n\nAllowed fields: [\"download\", \"nondownload\", \"read\", \"unread\", \"downloadedread\", \"downloadedunread\", \"nondownloadedread\", \"nondownloadedunread\"]\n\nThe widget defaults to the first four above. If more than four fields are provided, only the first 4 are displayed.\nCategory IDs can be obtained from the url when navigating to it, `?tab={categoryID}`.\n\n```yaml\nwidget:\n  type: suwayomi\n  url: http://suwayomi.host.or.ip\n  username: username #optional\n  password: password #optional\n  category: 0 #optional, defaults to all categories\n```\n"
  },
  {
    "path": "docs/widgets/services/swagdashboard.md",
    "content": "---\ntitle: SWAG Dashboard\ndescription: SWAG Dashboard Widget Configuration\n---\n\nLearn more about [SWAG Dashboard](https://github.com/linuxserver/docker-mods/tree/swag-dashboard).\n\nAllowed fields: `[\"proxied\", \"auth\", \"outdated\", \"banned\"]`.\n\n```yaml\nwidget:\n  type: swagdashboard\n  url: http://swagdashboard.host.or.ip:adminport # default port is 81\n```\n"
  },
  {
    "path": "docs/widgets/services/syncthing-relay-server.md",
    "content": "---\ntitle: Syncthing Relay Server\ndescription: Syncthing Relay Server Widget Configuration\n---\n\nLearn more about [Syncthing Relay Server](https://github.com/syncthing/syncthing).\n\nPulls stats from the [relay server](https://docs.syncthing.net/users/strelaysrv.html). [See here](https://github.com/gethomepage/homepage/pull/230#issuecomment-1253053472) for more information on configuration.\n\nAllowed fields: `[\"numActiveSessions\", \"numConnections\", \"bytesProxied\"]`.\n\n```yaml\nwidget:\n  type: strelaysrv\n  url: http://syncthing.host.or.ip:22070\n```\n"
  },
  {
    "path": "docs/widgets/services/tailscale.md",
    "content": "---\ntitle: Tailscale\ndescription: Tailscale Widget Configuration\n---\n\nLearn more about [Tailscale](https://github.com/tailscale/tailscale).\n\nYou will need to generate an API access token from the [keys page](https://login.tailscale.com/admin/settings/keys) on the Tailscale dashboard.\n\nTo find your device ID, go to the [machine overview page](https://login.tailscale.com/admin/machines) and select your machine. In the \"Machine Details\" section, copy your `ID`. It will end with `CNTRL`.\n\nAllowed fields: `[\"address\", \"last_seen\", \"expires\"]`.\n\n```yaml\nwidget:\n  type: tailscale\n  deviceid: deviceid\n  key: tailscalekey\n```\n"
  },
  {
    "path": "docs/widgets/services/tandoor.md",
    "content": "---\ntitle: Tandoor\ndescription: Tandoor Widget Configuration\n---\n\nGenerate a user API key under `Settings > API  > Generate`. For the token's scope, use `read`.\n\nAllowed fields: `[\"users\", \"recipes\", \"keywords\"]`.\n\n```yaml\nwidget:\n  type: tandoor\n  url: http://tandoor-frontend.host.or.ip\n  key: tandoor-api-token\n```\n"
  },
  {
    "path": "docs/widgets/services/tdarr.md",
    "content": "---\ntitle: Tdarr\ndescription: Tdarr Widget Configuration\n---\n\nLearn more about [Tdarr](https://github.com/HaveAGitGat/Tdarr).\n\nAllowed fields: `[\"queue\", \"processed\", \"errored\", \"saved\"]`.\n\n```yaml\nwidget:\n  type: tdarr\n  url: http://tdarr.host.or.ip\n  key: tdarrapikey # optional\n```\n"
  },
  {
    "path": "docs/widgets/services/technitium.md",
    "content": "---\ntitle: Technitium DNS Server\ndescription: Technitium DNS Server Widget Configuration\n---\n\nLearn more about [Technitium DNS Server](https://technitium.com/dns/).\n\nAllowed fields (up to 4): `[\"totalQueries\",\"totalNoError\",\"totalServerFailure\",\"totalNxDomain\",\"totalRefused\",\"totalAuthoritative\",\"totalRecursive\",\"totalCached\",\"totalBlocked\",\"totalDropped\",\"totalClients\"]`.\n\nDefaults to: `[\"totalQueries\", \"totalAuthoritative\", \"totalCached\", \"totalServerFailure\"]`\n\n```yaml\nwidget:\n  type: technitium\n  url: <url to dns server>\n  key: biglongapitoken\n  range: LastDay # optional, defaults to LastHour\n```\n\n#### API Key\n\nThis can be generated via the Technitium DNS Dashboard, and should be generated from a special API specific user.\n\n#### Range\n\n`range` value determines how far back of statistics to pull data for. The value comes directly from Technitium API documentation found [here](https://github.com/TechnitiumSoftware/DnsServer/blob/master/APIDOCS.md#dashboard-api-calls), defined as `\"type\"`. The value can be one of: `LastHour`, `LastDay`, `LastWeek`, `LastMonth`, `LastYear`.\n"
  },
  {
    "path": "docs/widgets/services/tracearr.md",
    "content": "---\ntitle: Tracearr\ndescription: Tracearr Widget Configuration\n---\n\nLearn more about [Tracearr](https://www.tracearr.com/).\n\nProvides detailed information about currently active streams across multiple servers.\n\nAllowed fields (for summary view): `[\"streams\", \"transcodes\", \"directplay\", \"bitrate\"]`.\n\n```yaml\nwidget:\n  type: tracearr\n  url: http://tracearr.host.or.ip:3000\n  key: apikeyapikeyapikeyapikeyapikey\n  view: both # optional, \"summary\", \"details\", or \"both\", defaults to \"details\"\n  enableUser: true # optional, defaults to false\n  showEpisodeNumber: true # optional, defaults to false\n  expandOneStreamToTwoRows: false # optional, defaults to true\n```\n"
  },
  {
    "path": "docs/widgets/services/traefik.md",
    "content": "---\ntitle: Traefik\ndescription: Traefik Widget Configuration\n---\n\nLearn more about [Traefik](https://github.com/traefik/traefik).\n\nNo extra configuration is required.\nIf your traefik install requires authentication, include the username and password used to login to the web interface.\n\nAllowed fields: `[\"routers\", \"services\", \"middleware\"]`.\n\n```yaml\nwidget:\n  type: traefik\n  url: http://traefik.host.or.ip\n  username: username # optional\n  password: password # optional\n```\n"
  },
  {
    "path": "docs/widgets/services/transmission.md",
    "content": "---\ntitle: Transmission\ndescription: Transmission Widget Configuration\n---\n\nLearn more about [Transmission](https://github.com/transmission/transmission).\n\nUses the same username and password used to login from the web.\n\nAllowed fields: `[\"leech\", \"download\", \"seed\", \"upload\"]`.\n\n```yaml\nwidget:\n  type: transmission\n  url: http://transmission.host.or.ip\n  username: username\n  password: password\n  rpcUrl: /transmission/ # Optional. Matches the value of \"rpc-url\" in your Transmission's settings.json file\n```\n"
  },
  {
    "path": "docs/widgets/services/trilium.md",
    "content": "---\ntitle: Trilium\ndescription: Trilium Widget Configuration\n---\n\nLearn more about [Trilium](https://github.com/TriliumNext/Notes).\n\nThis widget is compatible with [TriliumNext](https://github.com/TriliumNext/Notes) versions >= [v0.94.0](https://github.com/TriliumNext/Notes/releases/tag/v0.94.0).\n\nFind (or create) your ETAPI key under `Options > ETAPI > Create new ETAPI token`.\n\nAllowed fields: `[\"version\", \"notesCount\", \"dbSize\"]`\n\n```yaml\nwidget:\n  type: trilium\n  url: https://trilium.host.or.ip\n  key: etapi_token\n```\n"
  },
  {
    "path": "docs/widgets/services/truenas.md",
    "content": "---\ntitle: TrueNas\ndescription: TrueNas Scale Widget Configuration\n---\n\nLearn more about [TrueNas](https://www.truenas.com/).\n\n| TrueNAS Version         | Homepage widget version |\n| ----------------------- | ----------------------- |\n| < 26.04 (REST API)      | 1 (default)             |\n| > 25.04 (Websocket API) | 2                       |\n\nAllowed fields: `[\"load\", \"uptime\", \"alerts\"]`.\n\nTo create an API Key, follow [the official TrueNAS documentation](https://www.truenas.com/docs/scale/scaletutorials/toptoolbar/managingapikeys/).\n\nA detailed pool listing is disabled by default, but can be enabled with the `enablePools` option.\n\nTo use the `enablePools` option with TrueNAS Core, the `nasType` parameter is required.\n\n```yaml\nwidget:\n  type: truenas\n  url: http://truenas.host.or.ip\n  version: 2 # optional, defaults to 1\n  username: user # not required if using api key\n  password: pass # not required if using api key\n  key: yourtruenasapikey # not required if using username / password\n  enablePools: true # optional, defaults to false\n  nasType: scale # defaults to scale, must be set to 'core' if using enablePools with TrueNAS Core\n```\n"
  },
  {
    "path": "docs/widgets/services/tubearchivist.md",
    "content": "---\ntitle: Tube Archivist\ndescription: Tube Archivist Widget Configuration\n---\n\nLearn more about [Tube Archivist](https://github.com/tubearchivist/tubearchivist).\n\nYou must be running at least version 0.4.4\n\nAllowed fields: `[\"downloads\", \"videos\", \"channels\", \"playlists\"]`.\n\n```yaml\nwidget:\n  type: tubearchivist\n  url: http://tubearchivist.host.or.ip\n  key: tubearchivistapikey\n```\n"
  },
  {
    "path": "docs/widgets/services/unifi-controller.md",
    "content": "---\ntitle: Unifi Controller\ndescription: Unifi Controller Widget Configuration\n---\n\nLearn more about [Unifi Controller](https://ui.com/).\n\n_(Find the Unifi Controller information widget [here](../info/unifi_controller.md))_\n\nYou can display general connectivity status from your Unifi (Network) Controller.\n\n!!! warning\n\n    When authenticating you will want to use a local account that has at least read privileges.\n\nAn optional 'site' parameter can be supplied, if it is not the widget will use the default site for the controller.\n\nAllowed fields: `[\"uptime\", \"wan\", \"lan\", \"lan_users\", \"lan_devices\", \"wlan\", \"wlan_users\", \"wlan_devices\"]` (maximum of four). Fields unsupported by the unifi device will not be shown.\n\n!!! hint\n\n    If you enter e.g. incorrect credentials and receive an \"API Error\", you may need to recreate the container or restart the service to clear the cache.\n\n```yaml\nwidget:\n  type: unifi\n  url: https://unifi.host.or.ip:port\n  site: Site Name # optional\n  username: user\n  password: pass\n  key: unifiapikey # required if using API key instead of username/password\n```\n"
  },
  {
    "path": "docs/widgets/services/unmanic.md",
    "content": "---\ntitle: Unmanic\ndescription: Unmanic Widget Configuration\n---\n\nLearn more about [Unmanic](https://github.com/Unmanic/unmanic).\n\nAllowed fields: `[\"active_workers\", \"total_workers\", \"records_total\"]`.\n\n```yaml\nwidget:\n  type: unmanic\n  url: http://unmanic.host.or.ip:port\n```\n"
  },
  {
    "path": "docs/widgets/services/unraid.md",
    "content": "---\ntitle: Unraid\ndescription: Unraid Widget Configuration\n---\n\nLearn more about [Unraid](https://unraid.net/).\n\nThe Unraid widget allows you to monitor the resources of an Unraid server.\n\n**Minimum Requirements:**\n\n- Unraid 7.2 -or- Unraid Connect plugin 2025.08.19.1850\n- API key with the **ADMIN** role: [Managing API Keys](https://docs.unraid.net/go/managing-api-keys)\n\nThe widget can display metrics for selected Unraid pools. If using one of the \"pool\" fields, you must also add the pool name to the settings.\n\n**Allowed fields:** `[\"cpu\",\"memoryPercent\",\"memoryAvailable\",\"memoryUsed\",\"notifications\",\"arrayFree\",\"arrayUsedSpace\",\"arrayUsedPercent\",\"status\",\"pool1UsedSpace\",\"pool1FreeSpace\",\"pool1UsedPercent\",\"pool2UsedSpace\",\"pool2FreeSpace\",\"pool2UsedPercent\",\"pool3UsedSpace\",\"pool3FreeSpace\",\"pool3UsedPercent\",\"pool4UsedSpace\",\"pool4FreeSpace\",\"pool4UsedPercent\"]`\n\n```yaml\nwidget:\n  type: unraid\n  url: https://unraid.host.or.ip\n  key: api-key\n  pool1: pool1name # required only if using pool1 fields\n  pool2: pool2name # required only if using pool2 fields\n  pool3: pool3name # required only if using pool3 fields\n  pool4: pool4name # required only if using pool4 fields\n```\n"
  },
  {
    "path": "docs/widgets/services/uptime-kuma.md",
    "content": "---\ntitle: Uptime Kuma\ndescription: Uptime Kuma Widget Configuration\n---\n\nLearn more about [Uptime Kuma](https://github.com/louislam/uptime-kuma).\n\nAs Uptime Kuma does not yet have a full API the widget uses data from a single \"status page\". As such you will need a status page setup with a group of monitored sites, which is where you get the slug (the url without the `/status/` portion). E.g. if your status page is URL http://uptimekuma.host/status/statuspageslug, insert `slug: statuspageslug`.\n\nAllowed fields: `[\"up\", \"down\", \"uptime\", \"incident\"]`.\n\n```yaml\nwidget:\n  type: uptimekuma\n  url: http://uptimekuma.host.or.ip:port\n  slug: statuspageslug\n```\n"
  },
  {
    "path": "docs/widgets/services/uptimerobot.md",
    "content": "---\ntitle: UptimeRobot\ndescription: UptimeRobot Widget Configuration\n---\n\nLearn more about [UptimeRobot](https://uptimerobot.com/).\n\nTo generate an API key, select `My Settings`, and either `Monitor-Specific API Key` or `Read-Only API Key`.\n\nA `Monitor-Specific API Key` will provide the following detailed information\nfor the selected monitor:\n\n- Current status\n- Current uptime\n- Date/time of last downtime\n- Duration of last downtime\n\nAllowed fields: `[\"status\", \"uptime\", \"lastDown\", \"downDuration\"]`.\n\nA `Read-Only API Key` will provide a summary of all monitors in your account:\n\n- Number of 'Up' monitors\n- Number of 'Down' monitors\n\nAllowed fields: `[\"sitesUp\", \"sitesDown\"]`.\n\n```yaml\nwidget:\n  type: uptimerobot\n  url: https://api.uptimerobot.com\n  key: uptimerobotapitoken\n```\n"
  },
  {
    "path": "docs/widgets/services/urbackup.md",
    "content": "---\ntitle: UrBackup\ndescription: UrBackup Widget Configuration\n---\n\nLearn more about [UrBackup](https://github.com/uroni/urbackup_backend).\n\nThe UrBackup widget retrieves the total number of clients that currently have no errors, have errors, or haven't backed up recently. Clients are considered \"Errored\" or \"Out of Date\" if either the file or image backups for that client have errors/are out of date, unless the client does not support image backups.\n\nThe default number of days that can elapse before a client is marked Out of Date is 3, but this value can be customized by setting the `maxDays` value in the config.\n\nOptionally, the widget can also report the total amount of disk space consumed by backups. This is disabled by default, because it requires a second API call.\n\nNote: client status is only shown for backups that the specified user has access to. Disk Usage shown is the total for all backups, regardless of permissions.\n\nAllowed fields: `[\"ok\", \"errored\", \"noRecent\", \"totalUsed\"]`. _Note that `totalUsed` will not be shown unless explicitly included in `fields`._\n\n```yaml\nwidget:\n  type: urbackup\n  username: urbackupUsername\n  password: urbackupPassword\n  url: http://urbackupUrl:55414\n  maxDays: 5 # optional\n```\n"
  },
  {
    "path": "docs/widgets/services/vikunja.md",
    "content": "---\ntitle: Vikunja\ndescription: Vikunja Widget Configuration\n---\n\nLearn more about [Vikunja](https://vikunja.io).\n\nAllowed fields: `[\"projects\", \"tasks7d\", \"tasksOverdue\", \"tasksInProgress\"]`.\n\nA list of the next 5 tasks ordered by due date is disabled by default, but can be enabled with the `enableTaskList` option.\n\n| Vikunja Version | Homepage Widget Version |\n| --------------- | ----------------------- |\n| < v1.0.0-rc4    | 1 (default)             |\n| >= v1.0.0-rc4   | 2                       |\n\n```yaml\nwidget:\n  type: vikunja\n  url: http[s]://vikunja.host.or.ip[:port]\n  key: vikunjaapikey\n  enableTaskList: true # optional, defaults to false\n  version: 2 # optional, defaults to 1\n```\n"
  },
  {
    "path": "docs/widgets/services/wallos.md",
    "content": "---\ntitle: Wallos\ndescription: Wallos Widget Configuration\n---\n\nLearn more about [Wallos](https://github.com/ellite/wallos).\n\nIf you're using more than one currency to record subscriptions then you should also have your \"Fixer API\" key set-up (`Settings > Fixer API Key`).\n\n> **Please Note:** The monthly cost displayed is the total cost of subscriptions in that month, **not** the _\"monthly\"_ average cost.\n\nGet your API key under `Profile > API Key`.\n\nAllowed fields: `[\"activeSubscriptions\", \"nextRenewingSubscription\", \"previousMonthlyCost\", \"thisMonthlyCost\", \"nextMonthlyCost\"]`.\n\nDefault fields: `[\"activeSubscriptions\", \"nextRenewingSubscription\", \"thisMonthlyCost\", \"nextMonthlyCost\"]`.\n\n```yaml\nwidget:\n  type: wallos\n  url: http://wallos.host.or.ip\n  key: apikeyapikeyapikeyapikeyapikey\n```\n"
  },
  {
    "path": "docs/widgets/services/watchtower.md",
    "content": "---\ntitle: Watchtower\ndescription: Watchtower Widget Configuration\n---\n\nLearn more about [Watchtower](https://github.com/containrrr/watchtower).\n\nTo use this widget, Watchtower needs to be configured to [enable metrics](https://containrrr.dev/watchtower/metrics/).\n\nAllowed fields: `[\"containers_scanned\", \"containers_updated\", \"containers_failed\"]`.\n\n```yaml\nwidget:\n  type: watchtower\n  url: http://your-ip-address:8080\n  key: demotoken\n```\n"
  },
  {
    "path": "docs/widgets/services/wgeasy.md",
    "content": "---\ntitle: Wg-Easy\ndescription: Wg-Easy Widget Configuration\n---\n\nLearn more about [Wg-Easy](https://github.com/wg-easy/wg-easy).\n\nAllowed fields: `[\"connected\", \"enabled\", \"disabled\", \"total\"]`.\n\nNote: by default `[\"connected\", \"enabled\", \"total\"]` are displayed.\n\nTo detect if a device is connected the time since the last handshake is queried. `threshold` is the time to wait in minutes since the last handshake to consider a device connected. Default is 2 minutes.\n\n| Wg-Easy API Version | Homepage Widget Version |\n| ------------------- | ----------------------- |\n| < v15               | 1 (default)             |\n| >= v15              | 2                       |\n\n```yaml\nwidget:\n  type: wgeasy\n  url: http://wg.easy.or.ip\n  version: 2 # optional, default is 1\n  username: yourwgusername # required for v15 and above\n  password: yourwgeasypassword\n  threshold: 2 # optional\n```\n"
  },
  {
    "path": "docs/widgets/services/whatsupdocker.md",
    "content": "---\ntitle: What's Up Docker\ndescription: What's Up Docker Widget Configuration\n---\n\nLearn more about [What's Up Docker](https://github.com/fmartinou/whats-up-docker).\n\nAllowed fields: `[\"monitoring\", \"updates\"]`.\n\n```yaml\nwidget:\n  type: whatsupdocker\n  url: http://whatsupdocker:port\n  username: username # optional\n  password: password # optional\n```\n"
  },
  {
    "path": "docs/widgets/services/xteve.md",
    "content": "---\ntitle: Xteve\ndescription: Xteve Widget Configuration\n---\n\nLearn more about [Xteve](https://github.com/xteve-project/xTeVe).\n\nAllowed fields: `[\"streams_all\", \"streams_active\", \"streams_xepg\"]`.\n\n```yaml\nwidget:\n  type: xteve\n  url: http://xteve.host.or.ip\n  username: username # optional\n  password: password # optional\n```\n"
  },
  {
    "path": "docs/widgets/services/yourspotify.md",
    "content": "---\ntitle: Your Spotify\ndescription: Your Spotify Widget Configuration\n---\n\nLearn more about [Your Spotify](https://github.com/Yooooomi/your_spotify).\n\nFind your API key under `Settings > Account > Public token`, click `Generate` if not yet generated, copy key after\n`?token=`.\n\nAllowed fields: `[\"songs\", \"time\", \"artists\"]`.\n\n```yaml\nwidget:\n  type: yourspotify\n  url: http://your-spotify-server.host.or.ip # if using lsio image, add /api/\n  key: apikeyapikeyapikeyapikeyapikey\n  interval: month # optional, defaults to week\n```\n\n#### Interval\n\nAllowed values for `interval`: `day`, `week`, `month`, `year`, `all`.\n\n!!! note\n\n    `interval` is different from predefined intervals you see in `Your Spotify`'s UI.\n    For example, `This week` in UI means _from the start of this week_, here `week` means _past 7 days_.\n"
  },
  {
    "path": "docs/widgets/services/zabbix.md",
    "content": "---\ntitle: Zabbix\ndescription: Zabbix Widget Configuration\n---\n\nLearn more about [Zabbix](https://github.com/zabbix/zabbix). The widget supports (at least) Zabbix server version 7.0.\n\n---\n\nAllowed fields: `[\"unclassified\", \"information\", \"warning\", \"average\", \"high\", \"disaster\"]`.\n\nOnly 4 fields can be shown at a time, with the default being: `[\"warning\", \"average\", \"high\", \"disaster\"]`.\n\n```yaml\nwidget:\n  type: zabbix\n  url: http://zabbix.host.or.ip/zabbix\n  key: your-api-key\n```\n\nSee the [Zabbix documentation](https://www.zabbix.com/documentation/current/en/manual/web_interface/frontend_sections/users/api_tokens) for details on generating API tokens.\n"
  },
  {
    "path": "eslint.config.mjs",
    "content": "import path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nimport { fixupConfigRules } from \"@eslint/compat\";\nimport { FlatCompat } from \"@eslint/eslintrc\";\nimport js from \"@eslint/js\";\nimport prettier from \"eslint-plugin-prettier\";\nimport { defineConfig, globalIgnores } from \"eslint/config\";\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\nconst compat = new FlatCompat({\n  baseDirectory: __dirname,\n  recommendedConfig: js.configs.recommended,\n  allConfig: js.configs.all,\n});\n\nexport default defineConfig([\n  {\n    extends: fixupConfigRules(compat.extends(\"next/core-web-vitals\", \"prettier\", \"plugin:react-hooks/recommended\")),\n\n    plugins: {\n      prettier,\n    },\n\n    languageOptions: {\n      ecmaVersion: 6,\n      sourceType: \"module\",\n\n      parserOptions: {\n        ecmaFeatures: {\n          modules: true,\n        },\n      },\n    },\n\n    settings: {\n      \"import/resolver\": {\n        node: {\n          paths: [\"src\"],\n        },\n      },\n    },\n\n    rules: {\n      \"import/no-cycle\": [\n        \"error\",\n        {\n          maxDepth: 1,\n        },\n      ],\n\n      \"import/order\": [\n        \"error\",\n        {\n          \"newlines-between\": \"always\",\n        },\n      ],\n\n      \"no-else-return\": [\n        \"error\",\n        {\n          allowElseIf: true,\n        },\n      ],\n    },\n  },\n  // Vitest tests often intentionally place imports after `vi.mock(...)` to ensure\n  // modules under test see the mocked dependencies. `import/order` can't safely\n  // auto-fix those cases, so disable it for test files.\n  {\n    files: [\"src/**/*.test.{js,jsx}\", \"src/**/*.spec.{js,jsx}\"],\n    rules: {\n      \"import/order\": \"off\",\n    },\n  },\n  globalIgnores([\"./config/\", \"./coverage/\", \"./.venv/\", \"./.next/\", \"./site/\"]),\n]);\n"
  },
  {
    "path": "jsconfig.json",
    "content": "{\n    \"compilerOptions\": {\n      \"baseUrl\": \"./src/\",\n    },\n    \"include\": [\"src/**/*\"]\n}\n"
  },
  {
    "path": "k3d/.envrc",
    "content": "#shellcheck disable=SC2148,SC2155\nexport KUBECONFIG=$(readlink -f ./kubeconfig)\n"
  },
  {
    "path": "k3d/.gitignore",
    "content": "kubeconfig\n"
  },
  {
    "path": "k3d/README.md",
    "content": "# Kubernetes Development\n\nThese configs and scripts attempt to simplify spinning up a kubernetes cluster\nfor development and testing purposes. It leverages [k3d](https://k3d.io) to create\na [k3s](https://k3s.io) cluster in Docker. Homepage can then be deployed either via\nthe `k3d-deploy.sh` script, or [tilt](https://tilt.dev) can be used to spin up a\nlocal CI loop that will automatically update the deployment.\n\nAll the commands in the document should be run from the `k3d` directory.\n\n## Requisite Tools\n\n| Tool                                                        | Description                                              |\n| ----------------------------------------------------------- | -------------------------------------------------------- |\n| [docker](https://docker.io)                                 | Docker container runtime                                 |\n| [kubectl](https://kubernetes.io/releases/download/#kubectl) | Kubernetes CLI                                           |\n| [helm](https://helm.sh)                                     | Kubernetes package manager                               |\n| [k3d](https://k3d.io)                                       | Kubernetes on Docker - used to create the cluster        |\n| [k9s](https://k9scli.io)                                    | (Optional) Command line view for kubernetes cluster      |\n| [tilt](https://tilt.dev)                                    | (Optional) Local CI loop for kubernetes deployment       |\n| [direnv](https://direnv.net/)                               | (Optional) Automatically loads `kubeconfig` via `.envrc` |\n\n## One-off Test Deployments\n\nCreate a cluster:\n\n```sh\n./k3d-up.sh\n```\n\nBuild and deploy:\n\n```sh\n./k3d-deploy.sh\n```\n\nOpen the Homepage deployment:\n\n```sh\nxdg-open http://homepage.k3d.localhost:8080/\n```\n\n## Continuous Deployment\n\nCreate a cluster:\n\n```sh\n./k3d-up.sh\n```\n\nKick off tilt:\n\n```sh\ntilt up\n```\n\nPress space bar to open the tilt web UI, which is quite informative.\n\nFinally, open the Homepage deployment:\n\n```sh\nxdg-open http://homepage.k3d.localhost:8080/\n```\n"
  },
  {
    "path": "k3d/Tiltfile",
    "content": "docker_build('k3d-registry.localhost:55000/homepage:local', '..',\n  dockerfile = \"../Dockerfile-tilt\",\n  build_args={'node_env': 'development'},\n  #entrypoint='pnpm run nodemon /app/server.js',\n  live_update=[\n    sync('.', '/app'),\n    run('cd /app && pnpm install', trigger=['.package.json', './pnpm-lock.yaml'])\n  ]\n)\nload('ext://helm_resource', 'helm_resource', 'helm_repo')\nhelm_repo('jameswynn', 'https://jameswynn.github.io/helm-charts')\n\nhelm_resource('homepage', 'jameswynn/homepage',\n  image_deps=[\n    \"k3d-registry.localhost:55000/homepage:local\"\n  ],\n  image_keys=[\n    (\"image.repository\", \"image.tag\")\n  ],\n#  image_selector= \"k3d-registry.localhost:55000/homepage:local\",\n  flags=[\n    \"-f\", \"k3d-helm-values.yaml\",\n    \"--set\", \"persistence.dotnext.enabled=true\"\n  ]\n)\n"
  },
  {
    "path": "k3d/k3d-deploy.sh",
    "content": "#!/bin/bash\n\nDOCKER_BUILDKIT=1 docker build -t k3d-registry.localhost:55000/homepage:local ..\ndocker push k3d-registry.localhost:55000/homepage:local\n\nHELM_REPO_NAME=jameswynn\nHELM_REPO_URL=https://jameswynn.github.io/helm-charts\n\nif ! helm repo list | grep $HELM_REPO_URL > /dev/null; then\n  helm repo add $HELM_REPO_NAME $HELM_REPO_URL\n  helm repo update\nfi\n\nhelm upgrade --install homepage jameswynn/homepage -f k3d-helm-values.yaml\n"
  },
  {
    "path": "k3d/k3d-down.sh",
    "content": "#!/bin/bash\n\nk3d cluster delete homepage\nrm kubeconfig\n"
  },
  {
    "path": "k3d/k3d-helm-values.yaml",
    "content": "image:\n  repository: k3d-registry.localhost:55000/homepage\n  tag: local\n  pullPolicy: Always\n\nconfig:\n  bookmarks:\n    - Developer:\n        - Github:\n            - abbr: GH\n              href: https://github.com/\n  services:\n    - My First Group:\n        - My First Service:\n            href: http://localhost/\n            description: Homepage is awesome\n\n    - My Second Group:\n        - My Second Service:\n            href: http://localhost/\n            description: Homepage is the best\n\n    - My Third Group:\n        - My Third Service:\n            href: http://localhost/\n            description: Homepage is 😎\n  widgets:\n    # show the kubernetes widget, with the cluster summary and individual nodes\n    - kubernetes:\n        cluster:\n          show: true\n          cpu: true\n          memory: true\n          showLabel: true\n          label: \"cluster\"\n        nodes:\n          show: true\n          cpu: true\n          memory: true\n          showLabel: true\n    - search:\n        provider: duckduckgo\n        target: _blank\n  kubernetes:\n    mode: cluster\n  docker:\n  settings:\n\nenv:\n  - name: HOMEPAGE_ALLOWED_HOSTS\n    value: \"homepage.k3d.localhost:8080\"\n\nserviceAccount:\n  create: true\n  name: homepage\n\nenableRbac: true\n\ningress:\n  main:\n    enabled: true\n    annotations:\n      gethomepage.dev/enabled: \"true\"\n      gethomepage.dev/name: \"Homepage\"\n      gethomepage.dev/description: \"Dynamically Detected Homepage\"\n      gethomepage.dev/group: \"Dynamic\"\n      gethomepage.dev/icon: \"homepage.png\"\n    hosts:\n      - host: homepage.k3d.localhost\n        paths:\n          - path: /\n            pathType: Prefix\n\npersistence:\n  # this persists the .next directory which greatly improves successive pod startup times in Tilt,\n  # but it breaks normal deployments, so it is disabled by default\n  dotnext:\n    enabled: false\n    type: pvc\n    accessMode: ReadWriteOnce\n    size: 1Gi\n    mountPath: /app/.next\n"
  },
  {
    "path": "k3d/k3d-up.sh",
    "content": "#!/bin/bash\n\nk3d cluster create --config k3d.yaml --wait\nk3d kubeconfig get homepage > kubeconfig\nchmod 600 kubeconfig\nexport KUBECONFIG=$(pwd)/kubeconfig\n\necho \"Waiting for traefik install job to complete (CTRL+C is safe if you're impatient)\"\nkubectl wait jobs/helm-install-traefik -n kube-system --for condition=complete --timeout 90s && echo \"Completed\" || echo \"Timed out (but it should still come up eventually)\"\n"
  },
  {
    "path": "k3d/k3d.yaml",
    "content": "kind: Simple\napiVersion: k3d.io/v1alpha3\nname: homepage\nservers: 1\nagents: 2\nkubeAPI:\n  hostIP: 0.0.0.0\n  hostPort: \"6443\"\nimage: rancher/k3s:v1.25.5-k3s1\nvolumes:\n  - volume: /tmp:/tmp/k3d-homepage\n    nodeFilters:\n      - all\nports:\n  - port: 8080:80\n    nodeFilters:\n      - loadbalancer\n  - port: 0.0.0.0:8443:443\n    nodeFilters:\n      - loadbalancer\noptions:\n  k3d:\n    wait: true\n    timeout: 6m0s\n    disableLoadbalancer: false\n    disableImageVolume: false\n    disableRollback: false\n  k3s:\n    extraArgs:\n      - arg: --tls-san=127.0.0.1\n        nodeFilters:\n          - server:*\n    nodeLabels: []\n  kubeconfig:\n    updateDefaultKubeconfig: false\n    switchCurrentContext: false\n  runtime:\n    gpuRequest: \"\"\n    serversMemory: \"1024MiB\"\n    agentsMemory: \"1024MiB\"\n    labels:\n      - label: foo=bar\n        nodeFilters:\n          - server:0\n          - loadbalancer\nenv:\n  - envVar: bar=baz\n    nodeFilters:\n      - all\nregistries:\n  create:\n    name: k3d-registry\n#    host: 0.0.0.0\n    hostPort: \"55000\"\n  config: |\n    mirrors:\n      \"k3d-registry.localhost:55000\":\n        endpoint:\n          - http://k3d-registry:5000\n"
  },
  {
    "path": "kubernetes.md",
    "content": "# Kubernetes Support\n\n## Requirements\n\n- Kubernetes 1.19+\n- Metrics service\n- An Ingress controller\n\n## Deployment\n\nUse the unofficial helm chart: https://github.com/jameswynn/helm-charts/tree/main/charts/homepage\n\n```sh\nhelm repo add jameswynn https://jameswynn.github.io/helm-charts\nhelm install my-release jameswynn/homepage\n```\n\n### Configuration\n\nSet the `mode` in the `kubernetes.yaml` to `cluster`.\n\n```yaml\nmode: default\n```\n\nTo enable Kubernetes gateway-api compatibility, set `route` to `gateway`.\n\n```yaml\nroute: gateway\n```\n\n## Widgets\n\nThe Kubernetes widget can show a high-level overview of the cluster,\nindividual nodes, or both.\n\n```yaml\n- kubernetes:\n    cluster:\n      # Shows the cluster node\n      show: true\n      # Shows the aggregate CPU stats\n      cpu: true\n      # Shows the aggregate memory stats\n      memory: true\n      # Shows a custom label\n      showLabel: true\n      label: \"cluster\"\n    nodes:\n      # Shows the clusters\n      show: true\n      # Shows the CPU for each node\n      cpu: true\n      # Shows the memory for each node\n      memory: true\n      # Shows the label, which is always the node name\n      showLabel: true\n```\n\n## Service Discovery\n\nSample yaml:\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: homepage\n  annotations:\n    gethomepage.dev/enabled: \"true\"\n    gethomepage.dev/description: Dynamically Detected Homepage\n    gethomepage.dev/group: Operations\n    gethomepage.dev/icon: homepage.png\n    gethomepage.dev/name: Homepage\nspec:\n  rules:\n    - host: homepage.example.com\n      http:\n        paths:\n          - backend:\n              service:\n                name: homepage\n                port:\n                  number: 3000\n            path: /\n            pathType: Prefix\n```\n\n## Service Widgets\n\nTo manually configure a Service Widget the `namespace` and `app` fields must\nbe configured on the service entry.\n\n```yaml\n- Home Automation\n    - Home-Assistant:\n        icon: home-assistant.png\n        href: https://home.example.com\n        description: Home Automation\n        app: home-assistant\n        namespace: home\n```\n\nThis works by creating a label selector `app.kubernetes.io/name=home-assistant`,\nwhich typically will be the same both for the ingress and the deployment. However,\nsome deployments can be complex and will not conform to this rule. In such\ncases the `pod-selector` variable can bridge the gap. Any field selector can\nbe used in it which allows for some powerful selection capabilities.\n\nFor instance, it can be utilized to roll multiple underlying deployments under\none application to see a high-level aggregate:\n\n```yaml\n- Comms\n    - Element Chat:\n        icon: matrix-light.png\n        href: https://chat.example.com\n        description: Matrix Synapse Powered Chat\n        app: matrix-element\n        namespace: comms\n        pod-selector: >-\n            app.kubernetes.io/instance in (\n                matrix-element,\n                matrix-media-repo,\n                matrix-media-repo-postgresql,\n                matrix-synapse\n            )\n```\n\n## Longhorn Widget\n\nThere is a widget for showing storage stats from [Longhorn](https://longhorn.io).\nConfigure it from the `widgets.yaml`.\n\n```yaml\n- longhorn:\n    # Show the expanded\n    expanded: true\n    # Shows a node representing the aggregate values\n    total: true\n    # Shows the node names as labels\n    labels: true\n    # Show the nodes\n    nodes: true\n    # An explicit list of nodes to show. All are shown by default if \"nodes\" is true\n    include:\n      - node1\n      - node2\n```\n\n## Testing\n\nRefer to the [k3d readme](k3d/README.md).\n"
  },
  {
    "path": "mkdocs.yml",
    "content": "site_name: Homepage\n\n# Project information\nsite_url: https://gethomepage.dev/\n\n# Repository\nrepo_name: gethomepage/homepage\nrepo_url: https://github.com/gethomepage/homepage\nedit_uri: https://github.com/gethomepage/homepage/tree/main/docs/\n\nnav:\n  - \"Home\":\n      - index.md\n  - \"Installation\":\n      - installation/index.md\n      - installation/docker.md\n      - installation/k8s.md\n      - installation/unraid.md\n      - installation/source.md\n  - \"Configuration\":\n      - configs/index.md\n      - configs/settings.md\n      - configs/bookmarks.md\n      - configs/info-widgets.md\n      - configs/services.md\n      - configs/kubernetes.md\n      - configs/docker.md\n      - configs/proxmox.md\n      - configs/custom-css-js.md\n  - \"Widgets\":\n      - widgets/index.md\n      - \"Service Widgets\":\n          - widgets/services/index.md\n          - widgets/services/adguard-home.md\n          - widgets/services/apcups.md\n          - widgets/services/arcane.md\n          - widgets/services/argocd.md\n          - widgets/services/atsumeru.md\n          - widgets/services/audiobookshelf.md\n          - widgets/services/authentik.md\n          - widgets/services/autobrr.md\n          - widgets/services/azuredevops.md\n          - widgets/services/backrest.md\n          - widgets/services/bazarr.md\n          - widgets/services/booklore.md\n          - widgets/services/beszel.md\n          - widgets/services/caddy.md\n          - widgets/services/calendar.md\n          - widgets/services/calibre-web.md\n          - widgets/services/changedetectionio.md\n          - widgets/services/channelsdvrserver.md\n          - widgets/services/checkmk.md\n          - widgets/services/cloudflared.md\n          - widgets/services/coin-market-cap.md\n          - widgets/services/crowdsec.md\n          - widgets/services/customapi.md\n          - widgets/services/deluge.md\n          - widgets/services/develancacheui.md\n          - widgets/services/diskstation.md\n          - widgets/services/dispatcharr.md\n          - widgets/services/dockhand.md\n          - widgets/services/downloadstation.md\n          - widgets/services/emby.md\n          - widgets/services/esphome.md\n          - widgets/services/evcc.md\n          - widgets/services/filebrowser.md\n          - widgets/services/fileflows.md\n          - widgets/services/firefly.md\n          - widgets/services/flood.md\n          - widgets/services/freshrss.md\n          - widgets/services/frigate.md\n          - widgets/services/fritzbox.md\n          - widgets/services/gamedig.md\n          - widgets/services/gatus.md\n          - widgets/services/ghostfolio.md\n          - widgets/services/gitea.md\n          - widgets/services/gitlab.md\n          - widgets/services/glances.md\n          - widgets/services/gluetun.md\n          - widgets/services/gotify.md\n          - widgets/services/grafana.md\n          - widgets/services/hdhomerun.md\n          - widgets/services/headscale.md\n          - widgets/services/healthchecks.md\n          - widgets/services/karakeep.md\n          - widgets/services/homeassistant.md\n          - widgets/services/homebox.md\n          - widgets/services/homebridge.md\n          - widgets/services/iframe.md\n          - widgets/services/immich.md\n          - widgets/services/jackett.md\n          - widgets/services/jdownloader.md\n          - widgets/services/jellyfin.md\n          - widgets/services/jellystat.md\n          - widgets/services/kavita.md\n          - widgets/services/komga.md\n          - widgets/services/komodo.md\n          - widgets/services/kopia.md\n          - widgets/services/lidarr.md\n          - widgets/services/linkwarden.md\n          - widgets/services/lubelogger.md\n          - widgets/services/mastodon.md\n          - widgets/services/mailcow.md\n          - widgets/services/mealie.md\n          - widgets/services/medusa.md\n          - widgets/services/mikrotik.md\n          - widgets/services/minecraft.md\n          - widgets/services/miniflux.md\n          - widgets/services/mjpeg.md\n          - widgets/services/moonraker.md\n          - widgets/services/mylar.md\n          - widgets/services/myspeed.md\n          - widgets/services/navidrome.md\n          - widgets/services/netdata.md\n          - widgets/services/netalertx.md\n          - widgets/services/nextcloud.md\n          - widgets/services/nextdns.md\n          - widgets/services/nginx-proxy-manager.md\n          - widgets/services/nzbget.md\n          - widgets/services/octoprint.md\n          - widgets/services/omada.md\n          - widgets/services/ombi.md\n          - widgets/services/opendtu.md\n          - widgets/services/openmediavault.md\n          - widgets/services/opnsense.md\n          - widgets/services/openwrt.md\n          - widgets/services/pangolin.md\n          - widgets/services/paperlessngx.md\n          - widgets/services/peanut.md\n          - widgets/services/pfsense.md\n          - widgets/services/photoprism.md\n          - widgets/services/pihole.md\n          - widgets/services/plantit.md\n          - widgets/services/plex-tautulli.md\n          - widgets/services/plex.md\n          - widgets/services/portainer.md\n          - widgets/services/prometheus.md\n          - widgets/services/prometheusmetric.md\n          - widgets/services/prowlarr.md\n          - widgets/services/proxmox.md\n          - widgets/services/proxmoxbackupserver.md\n          - widgets/services/pterodactyl.md\n          - widgets/services/pyload.md\n          - widgets/services/qbittorrent.md\n          - widgets/services/qnap.md\n          - widgets/services/radarr.md\n          - widgets/services/readarr.md\n          - widgets/services/romm.md\n          - widgets/services/rutorrent.md\n          - widgets/services/sabnzbd.md\n          - widgets/services/scrutiny.md\n          - widgets/services/seerr.md\n          - widgets/services/slskd.md\n          - widgets/services/sonarr.md\n          - widgets/services/sparkyfitness.md\n          - widgets/services/speedtest-tracker.md\n          - widgets/services/spoolman.md\n          - widgets/services/stash.md\n          - widgets/services/stocks.md\n          - widgets/services/suwayomi.md\n          - widgets/services/swagdashboard.md\n          - widgets/services/syncthing-relay-server.md\n          - widgets/services/tailscale.md\n          - widgets/services/tandoor.md\n          - widgets/services/technitium.md\n          - widgets/services/tdarr.md\n          - widgets/services/traefik.md\n          - widgets/services/tracearr.md\n          - widgets/services/transmission.md\n          - widgets/services/trilium.md\n          - widgets/services/truenas.md\n          - widgets/services/tubearchivist.md\n          - widgets/services/unifi-controller.md\n          - widgets/services/unmanic.md\n          - widgets/services/unraid.md\n          - widgets/services/uptime-kuma.md\n          - widgets/services/uptimerobot.md\n          - widgets/services/urbackup.md\n          - widgets/services/vikunja.md\n          - widgets/services/wallos.md\n          - widgets/services/watchtower.md\n          - widgets/services/wgeasy.md\n          - widgets/services/whatsupdocker.md\n          - widgets/services/xteve.md\n          - widgets/services/yourspotify.md\n          - widgets/services/zabbix.md\n      - \"Information Widgets\":\n          - widgets/info/index.md\n          - widgets/info/datetime.md\n          - widgets/info/glances.md\n          - widgets/info/greeting.md\n          - widgets/info/kubernetes.md\n          - widgets/info/logo.md\n          - widgets/info/longhorn.md\n          - widgets/info/openmeteo.md\n          - widgets/info/openweathermap.md\n          - widgets/info/resources.md\n          - widgets/info/search.md\n          - widgets/info/stocks.md\n          - widgets/info/unifi_controller.md\n  - \"Learn\":\n      - widgets/authoring/index.md\n      - \"Getting Started\": widgets/authoring/getting-started.md\n      - \"Tutorials\":\n          - widgets/authoring/tutorial.md\n      - \"Guides\":\n          - widgets/authoring/component.md\n          - widgets/authoring/metadata.md\n          - widgets/authoring/proxies.md\n          - widgets/authoring/api.md\n          - widgets/authoring/translations.md\n  - \"Troubleshooting\":\n      - troubleshooting/index.md\n  - \"More\":\n      - more/index.md\n      - more/translations.md\n      - more/coverage.md\n      - more/sponsors.md\n      - more/homepage-move.md\n\ntheme:\n  name: material\n  custom_dir: docs/overrides\n  language: en\n  palette:\n    - media: \"(prefers-color-scheme)\"\n      toggle:\n        icon: material/brightness-auto\n        name: Switch to light mode\n    - media: \"(prefers-color-scheme: light)\"\n      scheme: default\n      primary: black\n      accent: black\n      toggle:\n        icon: material/brightness-7\n        name: Switch to dark mode\n    - media: \"(prefers-color-scheme: dark)\"\n      scheme: slate\n      primary: black\n      accent: blue\n      toggle:\n        icon: material/brightness-4\n        name: Switch to system preference\n  logo: assets/banner_light@2x.webp\n\n  favicon: assets/favicon.ico\n  features:\n    - navigation.instant\n    - content.action.edit\n    - search.suggest\n    - search.share\n    - content.code.copy\n    - content.code.select\n    - navigation.tracking\n    - navigation.tabs\n    - navigation.sections\n    - navigation.indexes\n    - content.code.annotate\n\nextra_css:\n  - \"stylesheets/extra.css\"\n\nextra:\n  social:\n    - icon: fontawesome/brands/discord\n      link: https://discord.gg/k4ruYNrudu\n    - icon: fontawesome/brands/github\n      link: https://github.com/gethomepage/homepage\n    - icon: simple/opencollective\n      link: https://opencollective.com/homepage\n    - icon: simple/patreon\n      link: https://www.patreon.com/gethomepage\n\nmarkdown_extensions:\n  - pymdownx.highlight:\n      anchor_linenums: true\n      line_spans: __span\n      pygments_lang_class: true\n  - pymdownx.emoji:\n      emoji_index: !!python/name:material.extensions.emoji.twemoji\n      emoji_generator: !!python/name:material.extensions.emoji.to_svg\n  - pymdownx.inlinehilite\n  - pymdownx.snippets\n  - pymdownx.superfences\n  - pymdownx.tabbed:\n      alternate_style: true\n  - pymdownx.critic\n  - pymdownx.caret\n  - pymdownx.keys\n  - pymdownx.mark\n  - pymdownx.tilde\n  - pymdownx.details\n  - attr_list\n  - md_in_html\n  - admonition\n\nplugins:\n  - group:\n      enabled: !ENV MKINSIDERS\n      plugins:\n        - typeset\n        - social:\n            cards_layout: default\n            cards_layout_options:\n              background_image: docs/assets/blossom_valley_blur.jpg\n              background_color: \"rgba(13, 29, 41, 128)\"\n              color: \"#ffffff\"\n              logo: docs/assets/light_squircle@2x.webp\n  - tags\n  - search:\n      pipeline:\n        - stemmer\n        - stopWordFilter\n        - trimmer\n  - redirects:\n      redirect_maps:\n        \"more/troubleshooting.md\": \"troubleshooting/index.md\"\n        \"more/development.md\": \"widgets/authoring/getting-started.md\"\n"
  },
  {
    "path": "next-i18next.config.js",
    "content": "// prettyBytes taken from https://github.com/sindresorhus/pretty-bytes\n\nconst BYTE_UNITS = [\"B\", \"kB\", \"MB\", \"GB\", \"TB\", \"PB\", \"EB\", \"ZB\", \"YB\"];\n\nconst BIBYTE_UNITS = [\"B\", \"kiB\", \"MiB\", \"GiB\", \"TiB\", \"PiB\", \"EiB\", \"ZiB\", \"YiB\"];\n\nconst BIT_UNITS = [\"b\", \"kbit\", \"Mbit\", \"Gbit\", \"Tbit\", \"Pbit\", \"Ebit\", \"Zbit\", \"Ybit\"];\n\nconst BIBIT_UNITS = [\"b\", \"kibit\", \"Mibit\", \"Gibit\", \"Tibit\", \"Pibit\", \"Eibit\", \"Zibit\", \"Yibit\"];\n\n/*\nFormats the given number using `Number#toLocaleString`.\n- If locale is a string, the value is expected to be a locale-key (for example: `de`).\n- If locale is true, the system default locale is used for translation.\n- If no value for locale is specified, the number is returned unmodified.\n*/\nconst toLocaleString = (number, locale, options) => {\n  let result = number;\n  if (typeof locale === \"string\" || Array.isArray(locale)) {\n    result = number.toLocaleString(locale, options);\n  } else if (locale === true || options !== undefined) {\n    result = number.toLocaleString(undefined, options);\n  }\n\n  return result;\n};\n\nfunction prettyBytes(number, options) {\n  if (!Number.isFinite(number)) {\n    throw new TypeError(`Expected a finite number, got ${typeof number}: ${number}`);\n  }\n\n  options = {\n    bits: false,\n    binary: false,\n    ...options,\n  };\n\n  const UNITS = options.bits ? (options.binary ? BIBIT_UNITS : BIT_UNITS) : options.binary ? BIBYTE_UNITS : BYTE_UNITS;\n\n  if (options.signed && number === 0) {\n    return ` 0 ${UNITS[0]}`;\n  }\n\n  const isNegative = number < 0;\n\n  const prefix = isNegative ? \"-\" : options.signed ? \"+\" : \"\";\n\n  if (isNegative) {\n    number = -number;\n  }\n\n  let localeOptions;\n\n  if (options.minimumFractionDigits !== undefined) {\n    localeOptions = { minimumFractionDigits: options.minimumFractionDigits };\n  }\n\n  if (options.maximumFractionDigits !== undefined) {\n    localeOptions = { maximumFractionDigits: options.maximumFractionDigits, ...localeOptions };\n  }\n\n  if (number < 1) {\n    const numberString = toLocaleString(number, options.locale, localeOptions);\n    return `${prefix + numberString} ${UNITS[0]}`;\n  }\n\n  const exponent = Math.min(\n    Math.floor(options.binary ? Math.log(number) / Math.log(1024) : Math.log10(number) / 3),\n    UNITS.length - 1,\n  );\n  number /= (options.binary ? 1024 : 1000) ** exponent;\n\n  if (!localeOptions) {\n    number = number.toPrecision(3);\n  }\n\n  const numberString = toLocaleString(Number(number), options.locale, localeOptions);\n\n  const unit = UNITS[exponent];\n\n  return `${prefix + numberString} ${unit}`;\n}\n\nfunction duration(durationInSeconds, i18next) {\n  const mo = Math.floor(durationInSeconds / (3600 * 24 * 31));\n  const d = Math.floor((durationInSeconds % (3600 * 24 * 31)) / (3600 * 24));\n  const h = Math.floor((durationInSeconds % (3600 * 24)) / 3600);\n  const m = Math.floor((durationInSeconds % 3600) / 60);\n  const s = Math.floor(durationInSeconds % 60);\n\n  const moDisplay = mo > 0 ? mo + i18next.t(\"common.months\") : \"\";\n  const dDisplay = d > 0 ? d + i18next.t(\"common.days\") : \"\";\n  const hDisplay = h > 0 && mo === 0 ? h + i18next.t(\"common.hours\") : \"\";\n  const mDisplay = m > 0 && mo === 0 && d === 0 ? m + i18next.t(\"common.minutes\") : \"\";\n  const sDisplay = s > 0 && mo === 0 && d === 0 && h === 0 ? s + i18next.t(\"common.seconds\") : \"\";\n\n  return (moDisplay + dDisplay + hDisplay + mDisplay + sDisplay).replace(/,\\s*$/, \"\");\n}\n\nfunction relativeDate(date, formatter) {\n  const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity];\n  const units = [\"second\", \"minute\", \"hour\", \"day\", \"week\", \"month\", \"year\"];\n\n  const delta = Math.round((date.getTime() - Date.now()) / 1000);\n  const unitIndex = cutoffs.findIndex((cutoff) => cutoff > Math.abs(delta));\n  const divisor = unitIndex ? cutoffs[unitIndex - 1] : 1;\n\n  return formatter.format(Math.floor(delta / divisor), units[unitIndex]);\n}\n\nmodule.exports = {\n  i18n: {\n    defaultLocale: \"en\",\n    locales: [\"en\"],\n  },\n  serializeConfig: false,\n  use: [\n    {\n      init: (i18next) => {\n        i18next.services.formatter.add(\"bytes\", (value, lng, options) =>\n          prettyBytes(parseFloat(value), { locale: lng, ...options }),\n        );\n\n        i18next.services.formatter.add(\"rate\", (value, lng, options) => {\n          const k = options.binary ? 1024 : 1000;\n          const sizes = options.bits\n            ? options.binary\n              ? BIBIT_UNITS\n              : BIT_UNITS\n            : options.binary\n              ? BIBYTE_UNITS\n              : BYTE_UNITS;\n\n          if (value === 0) return `0 ${sizes[0]}/s`;\n\n          const dm = options.decimals ? options.decimals : 0;\n\n          const i = options.binary ? 2 : Math.floor(Math.log(value) / Math.log(k));\n\n          const formatted = new Intl.NumberFormat(lng, { maximumFractionDigits: dm, minimumFractionDigits: dm }).format(\n            parseFloat(value / k ** i),\n          );\n\n          return `${formatted} ${sizes[i]}/s`;\n        });\n\n        i18next.services.formatter.add(\"percent\", (value, lng, options) =>\n          new Intl.NumberFormat(lng, { style: \"percent\", ...options }).format(parseFloat(value) / 100.0),\n        );\n        i18next.services.formatter.add(\"date\", (value, lng, options) =>\n          new Intl.DateTimeFormat(lng, { ...options }).format(new Date(value)),\n        );\n        i18next.services.formatter.add(\"relativeDate\", (value, lng, options) =>\n          relativeDate(new Date(value), new Intl.RelativeTimeFormat(lng, { ...options })),\n        );\n        i18next.services.formatter.add(\"duration\", (value, lng) => duration(value, i18next));\n      },\n      type: \"3rdParty\",\n    },\n  ],\n};\n"
  },
  {
    "path": "next.config.js",
    "content": "const { i18n } = require(\"./next-i18next.config\");\n\n/** @type {import('next').NextConfig} */\nconst nextConfig = {\n  reactStrictMode: true,\n  output: \"standalone\",\n  images: {\n    remotePatterns: [\n      {\n        protocol: \"https\",\n        hostname: \"cdn.jsdelivr.net\",\n      },\n    ],\n    unoptimized: true,\n  },\n  i18n,\n};\n\nmodule.exports = nextConfig;\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"homepage\",\n  \"version\": \"1.10.1\",\n  \"private\": true,\n  \"scripts\": {\n    \"preinstall\": \"npx only-allow pnpm\",\n    \"dev\": \"next dev\",\n    \"build\": \"next build --webpack\",\n    \"start\": \"next start\",\n    \"lint\": \"eslint .\",\n    \"test\": \"vitest run\",\n    \"test:coverage\": \"vitest run --coverage\",\n    \"test:watch\": \"vitest\",\n    \"telemetry\": \"next telemetry disable\"\n  },\n  \"dependencies\": {\n    \"@headlessui/react\": \"^2.2.9\",\n    \"@kubernetes/client-node\": \"^1.0.0\",\n    \"classnames\": \"^2.5.1\",\n    \"compare-versions\": \"^6.1.1\",\n    \"dockerode\": \"^4.0.7\",\n    \"follow-redirects\": \"^1.15.11\",\n    \"gamedig\": \"^5.3.2\",\n    \"i18next\": \"^25.8.0\",\n    \"ical.js\": \"^2.2.1\",\n    \"js-yaml\": \"^4.1.1\",\n    \"json-rpc-2.0\": \"^1.7.0\",\n    \"luxon\": \"^3.6.1\",\n    \"memory-cache\": \"^0.2.0\",\n    \"minecraftstatuspinger\": \"^1.2.2\",\n    \"next\": \"^16.1.7\",\n    \"next-i18next\": \"^15.4.3\",\n    \"ping\": \"^0.4.4\",\n    \"pretty-bytes\": \"^7.1.0\",\n    \"raw-body\": \"^3.0.2\",\n    \"react\": \"^19.2.4\",\n    \"react-dom\": \"^19.2.4\",\n    \"react-i18next\": \"^15.5.3\",\n    \"react-icons\": \"^5.6.0\",\n    \"recharts\": \"^3.1.2\",\n    \"swr\": \"^2.4.1\",\n    \"systeminformation\": \"^5.30.8\",\n    \"tough-cookie\": \"^6.0.0\",\n    \"urbackup-server-api\": \"^0.92.2\",\n    \"winston\": \"^3.19.0\",\n    \"ws\": \"^8.18.3\",\n    \"xml-js\": \"^1.6.11\"\n  },\n  \"devDependencies\": {\n    \"@eslint/compat\": \"^2.0.2\",\n    \"@eslint/eslintrc\": \"^3.3.3\",\n    \"@eslint/js\": \"^9.39.2\",\n    \"@tailwindcss/forms\": \"^0.5.10\",\n    \"@tailwindcss/postcss\": \"^4.1.18\",\n    \"@testing-library/jest-dom\": \"^6.8.0\",\n    \"@testing-library/react\": \"^16.3.0\",\n    \"@vitest/coverage-v8\": \"^3.2.4\",\n    \"eslint\": \"^9.25.1\",\n    \"eslint-config-next\": \"^15.5.11\",\n    \"eslint-config-prettier\": \"^10.1.8\",\n    \"eslint-plugin-import\": \"^2.32.0\",\n    \"eslint-plugin-jsx-a11y\": \"^6.10.2\",\n    \"eslint-plugin-prettier\": \"^5.5.5\",\n    \"eslint-plugin-react\": \"^7.37.4\",\n    \"eslint-plugin-react-hooks\": \"^5.2.0\",\n    \"jsdom\": \"^28.1.0\",\n    \"postcss\": \"^8.5.6\",\n    \"prettier\": \"^3.8.1\",\n    \"prettier-plugin-organize-imports\": \"^4.3.0\",\n    \"tailwind-scrollbar\": \"^4.0.2\",\n    \"tailwindcss\": \"^4.1.18\",\n    \"typescript\": \"^5.7.3\",\n    \"vitest\": \"^3.2.4\"\n  },\n  \"optionalDependencies\": {\n    \"osx-temperature-sensor\": \"^1.0.8\"\n  },\n  \"pnpm\": {\n    \"onlyBuiltDependencies\": [\n      \"@tailwindcss/oxide\",\n      \"osx-temperature-sensor\",\n      \"sharp\"\n    ]\n  }\n}\n"
  },
  {
    "path": "postcss.config.js",
    "content": "module.exports = {\n  plugins: {\n    \"@tailwindcss/postcss\": {},\n  },\n};\n"
  },
  {
    "path": "public/locales/af/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"ma\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Ontbrekende legstuk-tipe: {{type}}\",\n        \"api_error\": \"API Fout\",\n        \"information\": \"Informasie\",\n        \"status\": \"Status\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Onverwerkte Fout\",\n        \"response_data\": \"Reaksie Data\"\n    },\n    \"weather\": {\n        \"current\": \"Huidige Ligging\",\n        \"allow\": \"Klik om toe te laat\",\n        \"updating\": \"Bywerking\",\n        \"wait\": \"Wag asseblief\"\n    },\n    \"search\": {\n        \"placeholder\": \"Soek…\"\n    },\n    \"resources\": {\n        \"cpu\": \"SVE\",\n        \"mem\": \"GEH\",\n        \"total\": \"Totaal\",\n        \"free\": \"Beskikbaar\",\n        \"used\": \"Gebruik\",\n        \"load\": \"Las\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Mak\",\n        \"uptime\": \"OP\"\n    },\n    \"unifi\": {\n        \"users\": \"Gebruikers\",\n        \"uptime\": \"Optyd\",\n        \"days\": \"Daë\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Toestelle\",\n        \"lan_devices\": \"LAN Toestelle\",\n        \"wlan_devices\": \"WLAN Toestelle\",\n        \"lan_users\": \"LAN Gebruikers\",\n        \"wlan_users\": \"WLAN Gebruikers\",\n        \"up\": \"OP\",\n        \"down\": \"AF\",\n        \"wait\": \"Wag asseblief\",\n        \"empty_data\": \"Substelsel status onbekend\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"GEH\",\n        \"cpu\": \"SVE\",\n        \"running\": \"Lopend\",\n        \"offline\": \"Vanlyn\",\n        \"error\": \"Fout\",\n        \"unknown\": \"Onbekend\",\n        \"healthy\": \"Gesond\",\n        \"starting\": \"Begin\",\n        \"unhealthy\": \"Ongesond\",\n        \"not_found\": \"Nie Gevind Nie\",\n        \"exited\": \"Verlaat\",\n        \"partial\": \"Gedeeltelik\"\n    },\n    \"ping\": {\n        \"error\": \"Fout\",\n        \"ping\": \"Pieng\",\n        \"down\": \"Af\",\n        \"up\": \"Op\",\n        \"not_available\": \"Onbeskikbaar\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP status\",\n        \"error\": \"Fout\",\n        \"response\": \"Reaksie\",\n        \"down\": \"Af\",\n        \"up\": \"Op\",\n        \"not_available\": \"Onbeskikbaar\"\n    },\n    \"emby\": {\n        \"playing\": \"Speel\",\n        \"transcoding\": \"Transkodering\",\n        \"bitrate\": \"Bistempo\",\n        \"no_active\": \"Geen aktiewe strome nie\",\n        \"movies\": \"Flieks\",\n        \"series\": \"Reekse\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Liedjies\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Speel\",\n        \"transcoding\": \"Transkodering\",\n        \"bitrate\": \"Bistempo\",\n        \"no_active\": \"Geen Aktiewe Strome\",\n        \"movies\": \"Movies\",\n        \"series\": \"Reekse\",\n        \"episodes\": \"Episode\",\n        \"songs\": \"Liedjies\"\n    },\n    \"esphome\": {\n        \"offline\": \"Vanlyn af\",\n        \"offline_alt\": \"Vanlyn af\",\n        \"online\": \"Aanlyn\",\n        \"total\": \"Totaal\",\n        \"unknown\": \"Onbekend\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Produksie\",\n        \"battery_soc\": \"Battery\",\n        \"grid_power\": \"Rooster\",\n        \"home_power\": \"Verbruik\",\n        \"charge_power\": \"Laaier\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Aflaai\",\n        \"upload\": \"Laai Op\",\n        \"leech\": \"Seier\",\n        \"seed\": \"Vul\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Intekenings\",\n        \"unread\": \"Ongelees\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Ongekonfigureer\",\n        \"connectionStatusConnecting\": \"Verbind\",\n        \"connectionStatusAuthenticating\": \"Stel geldigheid vas\",\n        \"connectionStatusPendingDisconnect\": \"Hangende Ontkoppel\",\n        \"connectionStatusDisconnecting\": \"Ontkoppel\",\n        \"connectionStatusDisconnected\": \"Ontkoppel\",\n        \"connectionStatusConnected\": \"Gekoppel\",\n        \"uptime\": \"Optyd\",\n        \"maxDown\": \"Maks. Af\",\n        \"maxUp\": \"Maks. Op\",\n        \"down\": \"Af\",\n        \"up\": \"Op\",\n        \"received\": \"Ontvang\",\n        \"sent\": \"Gestuur\",\n        \"externalIPAddress\": \"Ext. IP\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Voorvoegsel\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Stroomop\",\n        \"requests\": \"Huidige versoeke\",\n        \"requests_failed\": \"Mislukte versoeke\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Totaal Waargeneem\",\n        \"diffsDetected\": \"Verskille Bespeur\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Vertone\",\n        \"recordings\": \"Opnames\",\n        \"scheduled\": \"Geskeduleerd\",\n        \"passes\": \"Passe\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Speel\",\n        \"transcoding\": \"Transkodering\",\n        \"bitrate\": \"Bistempo\",\n        \"no_active\": \"Geen aktiewe strome nie\",\n        \"plex_connection_error\": \"Gaan Plex-verbinding Na\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"Geen Aktiewe Strome\",\n        \"streams\": \"Uitsendings\",\n        \"transcodes\": \"Transkodering\",\n        \"directplay\": \"Direkte Speel\",\n        \"bitrate\": \"Bistempo\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Gekoppelde APs\",\n        \"activeUser\": \"Aktiewe toestelle\",\n        \"alerts\": \"Waarskuwings\",\n        \"connectedGateways\": \"Gekoppelde poorte\",\n        \"connectedSwitches\": \"Gekoppelde skakelaars\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Koers\",\n        \"remaining\": \"Oorblywende\",\n        \"downloaded\": \"Afgelaai\"\n    },\n    \"plex\": {\n        \"streams\": \"Aktiewe Strome\",\n        \"albums\": \"Albums\",\n        \"movies\": \"Movies\",\n        \"tv\": \"TV Programme\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Koers\",\n        \"queue\": \"Tou\",\n        \"timeleft\": \"Oorblywende Tyd\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Aktief\",\n        \"upload\": \"Laai Op\",\n        \"download\": \"Aflaai\"\n    },\n    \"transmission\": {\n        \"download\": \"Aflaai\",\n        \"upload\": \"Laai Op\",\n        \"leech\": \"Seier\",\n        \"seed\": \"Vul\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Aflaai\",\n        \"upload\": \"Laai Op\",\n        \"leech\": \"Seier\",\n        \"seed\": \"Vul\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"SVE Gebruik\",\n        \"memUsage\": \"MEM Gebruik\",\n        \"systemTempC\": \"Stelsel Temp\",\n        \"poolUsage\": \"Poel Gebruik\",\n        \"volumeUsage\": \"Volume Gebruik\",\n        \"invalid\": \"Ongeldig\"\n    },\n    \"deluge\": {\n        \"download\": \"Aflaai\",\n        \"upload\": \"Laai Op\",\n        \"leech\": \"Seier\",\n        \"seed\": \"Vul\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Kas Tref Grepe\",\n        \"cachemissbytes\": \"Kas Mis Grepe\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Aflaai\",\n        \"upload\": \"Laai Op\",\n        \"leech\": \"Seier\",\n        \"seed\": \"Vul\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Gesoek\",\n        \"queued\": \"In ry\",\n        \"series\": \"Reekse\",\n        \"queue\": \"Toustaan\",\n        \"unknown\": \"Onbekend\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Gesoek\",\n        \"missing\": \"Vermis\",\n        \"queued\": \"In ry\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Toustaan\",\n        \"unknown\": \"Onbekend\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Gesoek\",\n        \"queued\": \"In ry\",\n        \"artists\": \"Kunstenaars\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Gesoek\",\n        \"queued\": \"In ry\",\n        \"books\": \"Boeke\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Ontbrekende Episodes\",\n        \"missingMovies\": \"Ontbrekende Flieke\"\n    },\n    \"ombi\": {\n        \"pending\": \"Afwagtend\",\n        \"approved\": \"Goedgekeur\",\n        \"available\": \"Beskikbaar\"\n    },\n    \"seerr\": {\n        \"pending\": \"Afwagtend\",\n        \"approved\": \"Goedgekeur\",\n        \"available\": \"Beskikbaar\",\n        \"completed\": \"Afgehandel\",\n        \"processing\": \"Verwerking\",\n        \"issues\": \"Oop Kwessies\"\n    },\n    \"netalertx\": {\n        \"total\": \"Totaal\",\n        \"connected\": \"Gekoppel\",\n        \"new_devices\": \"Nuwe Toestelle\",\n        \"down_alerts\": \"Aflyn Waarskuwings\"\n    },\n    \"pihole\": {\n        \"queries\": \"Navraë\",\n        \"blocked\": \"Geblok\",\n        \"blocked_percent\": \"Geblok %\",\n        \"gravity\": \"Swaartekrag\"\n    },\n    \"adguard\": {\n        \"queries\": \"Navraë\",\n        \"blocked\": \"Geblok\",\n        \"filtered\": \"Gefiltreer\",\n        \"latency\": \"Latensie\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Oplaai\",\n        \"download\": \"Aflaai\",\n        \"ping\": \"Pieng\"\n    },\n    \"portainer\": {\n        \"running\": \"Lopend\",\n        \"stopped\": \"Gestop\",\n        \"total\": \"Totaal\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Afgelaai\",\n        \"nondownload\": \"Nie-Afgelaai\",\n        \"read\": \"Gelees\",\n        \"unread\": \"Ongelees\",\n        \"downloadedread\": \"Afgelaai & Gelees\",\n        \"downloadedunread\": \"Afgelaai en Ongelees\",\n        \"nondownloadedread\": \"Nie-Afgelaai & Gelees\",\n        \"nondownloadedunread\": \"Nie-Afgelaai & Ongelees\"\n    },\n    \"tailscale\": {\n        \"address\": \"Adres\",\n        \"expires\": \"Verval\",\n        \"never\": \"Nooit\",\n        \"last_seen\": \"Laaste Gesien\",\n        \"now\": \"Nou\",\n        \"years\": \"{{number}}y\",\n        \"weeks\": \"{{number}}w\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Terug\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Navraë\",\n        \"totalNoError\": \"Sukses\",\n        \"totalServerFailure\": \"Mislukkings\",\n        \"totalNxDomain\": \"NX-domeine\",\n        \"totalRefused\": \"Geweier\",\n        \"totalAuthoritative\": \"Gesaghebbend\",\n        \"totalRecursive\": \"Rekursief\",\n        \"totalCached\": \"Gekas\",\n        \"totalBlocked\": \"Geblok\",\n        \"totalDropped\": \"Geval\",\n        \"totalClients\": \"Kliënte\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Tou\",\n        \"processed\": \"Verwerk\",\n        \"errored\": \"Fout\",\n        \"saved\": \"Gestoor\"\n    },\n    \"traefik\": {\n        \"routers\": \"Roeteerders\",\n        \"services\": \"Dienste\",\n        \"middleware\": \"Filtreerprogramme\"\n    },\n    \"trilium\": {\n        \"version\": \"Weergawe\",\n        \"notesCount\": \"Notas\",\n        \"dbSize\": \"Databasis Grootte\",\n        \"unknown\": \"Onbekend\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"Geen Aktiewe Strome\",\n        \"please_wait\": \"Wag Asseblief\"\n    },\n    \"npm\": {\n        \"enabled\": \"Geaktiveer\",\n        \"disabled\": \"Onaktief\",\n        \"total\": \"Totaal\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Stel een of meer kripto-geldeenhede op om na te spoor\",\n        \"1hour\": \"1 Uur\",\n        \"1day\": \"1 Dag\",\n        \"7days\": \"7 Dae\",\n        \"30days\": \"30 Dae\"\n    },\n    \"gotify\": {\n        \"apps\": \"Toepassings\",\n        \"clients\": \"Kliënte\",\n        \"messages\": \"Boodskappe\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indekseerders\",\n        \"numberOfGrabs\": \"Grype\",\n        \"numberOfQueries\": \"Navraë\",\n        \"numberOfFailGrabs\": \"Mislukte Grype\",\n        \"numberOfFailQueries\": \"Mislukte Navrae\"\n    },\n    \"jackett\": {\n        \"configured\": \"Opgestel\",\n        \"errored\": \"Gefout\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sessies\",\n        \"numConnections\": \"Konneksies\",\n        \"dataRelayed\": \"Oorgedra\",\n        \"transferRate\": \"Koers\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Gebruikers\",\n        \"status_count\": \"Plasings\",\n        \"domain_count\": \"Domeine\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Gesoek\",\n        \"queued\": \"In ry\",\n        \"series\": \"Reekse\"\n    },\n    \"minecraft\": {\n        \"players\": \"Spelers\",\n        \"version\": \"Weergawe\",\n        \"status\": \"Status\",\n        \"up\": \"Aanlyn\",\n        \"down\": \"Vanlyn af\"\n    },\n    \"miniflux\": {\n        \"read\": \"Gelees\",\n        \"unread\": \"Ongelees\"\n    },\n    \"authentik\": {\n        \"users\": \"Gebruikers\",\n        \"loginsLast24H\": \"Aantekenings (24h)\",\n        \"failedLoginsLast24H\": \"Mislukte Aantekenings (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"GEH\",\n        \"cpu\": \"SVE\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMe\"\n    },\n    \"glances\": {\n        \"cpu\": \"SVE\",\n        \"load\": \"Las\",\n        \"wait\": \"Wag asseblief\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temp\",\n        \"warn\": \"Warn\",\n        \"uptime\": \"OP\",\n        \"total\": \"Totaal\",\n        \"free\": \"Beskikbaar\",\n        \"used\": \"Gebruik\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Crit\",\n        \"read\": \"Gelees\",\n        \"write\": \"Skryf\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Boekmerk\",\n        \"service\": \"Diens\",\n        \"search\": \"Soek\",\n        \"custom\": \"Pasgemaak\",\n        \"visit\": \"Besoek\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Voorstelling\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Sonnig\",\n        \"0-night\": \"Helder\",\n        \"1-day\": \"Hoofsaaklik sonnig\",\n        \"1-night\": \"Hoofsaaklik Helder\",\n        \"2-day\": \"Gedeeltelik Bewolk\",\n        \"2-night\": \"Gedeeltelik Bewolk\",\n        \"3-day\": \"Bewolk\",\n        \"3-night\": \"Bewolk\",\n        \"45-day\": \"Mistig\",\n        \"45-night\": \"Mistig\",\n        \"48-day\": \"Mistig\",\n        \"48-night\": \"Mistig\",\n        \"51-day\": \"Ligte Motrëen\",\n        \"51-night\": \"Ligte Motrëen\",\n        \"53-day\": \"Motrëen\",\n        \"53-night\": \"Motrëen\",\n        \"55-day\": \"Swaar Motrëen\",\n        \"55-night\": \"Swaar Motrëen\",\n        \"56-day\": \"Ligte Ysige Motreën\",\n        \"56-night\": \"Ligte Ysige Motreën\",\n        \"57-day\": \"Ysige Motreën\",\n        \"57-night\": \"Ysige Motreën\",\n        \"61-day\": \"Ligte Rëen\",\n        \"61-night\": \"Ligte Rëen\",\n        \"63-day\": \"Rëen\",\n        \"63-night\": \"Rëen\",\n        \"65-day\": \"Swaar Rëen\",\n        \"65-night\": \"Swaar Rëen\",\n        \"66-day\": \"Ysige Rëen\",\n        \"66-night\": \"Ysige Rëen\",\n        \"67-day\": \"Ysige Rëen\",\n        \"67-night\": \"Ysige Rëen\",\n        \"71-day\": \"Ligte Sneeu\",\n        \"71-night\": \"Ligte Sneeu\",\n        \"73-day\": \"Sneeu\",\n        \"73-night\": \"Sneeu\",\n        \"75-day\": \"Swaar Sneeu\",\n        \"75-night\": \"Swaar Sneeu\",\n        \"77-day\": \"Sneeu Korrels\",\n        \"77-night\": \"Sneeu Korrels\",\n        \"80-day\": \"Ligte Buie\",\n        \"80-night\": \"Ligte Buie\",\n        \"81-day\": \"Buie\",\n        \"81-night\": \"Buie\",\n        \"82-day\": \"Swaar Buie\",\n        \"82-night\": \"Swaar Buie\",\n        \"85-day\": \"Sneeu Buie\",\n        \"85-night\": \"Sneeu Buie\",\n        \"86-day\": \"Sneeu Buie\",\n        \"86-night\": \"Sneeu Buie\",\n        \"95-day\": \"Donderstorm\",\n        \"95-night\": \"Donderstorm\",\n        \"96-day\": \"Donderstorm Met Hael\",\n        \"96-night\": \"Donderstorm Met Hael\",\n        \"99-day\": \"Donderstorm Met Hael\",\n        \"99-night\": \"Donderstorm Met Hael\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Stelsel\",\n        \"updates\": \"Opdatering\",\n        \"update_available\": \"Opdatering Beskikbaar\",\n        \"up_to_date\": \"Op Datum\",\n        \"child_bridges\": \"Kinderbrug\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Op\",\n        \"pending\": \"Afwagtend\",\n        \"down\": \"Af\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Nuut\",\n        \"up\": \"Op\",\n        \"grace\": \"In Grasietydperk\",\n        \"down\": \"Af\",\n        \"paused\": \"Onderbreek\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Laaste Pieng\",\n        \"never\": \"Nog geen pienge nie\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Geskandeer\",\n        \"containers_updated\": \"Opgedateer\",\n        \"containers_failed\": \"Misluk\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Goedgekeur\",\n        \"rejectedPushes\": \"Verwerp\",\n        \"filters\": \"Filters\",\n        \"indexers\": \"Indekseerders\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Toustaan\",\n        \"videos\": \"Videos\",\n        \"channels\": \"Kanale\",\n        \"playlists\": \"Snitlyste\"\n    },\n    \"truenas\": {\n        \"load\": \"Stelsellading\",\n        \"uptime\": \"Optyd\",\n        \"alerts\": \"Opletberigte\"\n    },\n    \"pyload\": {\n        \"speed\": \"Spoed\",\n        \"active\": \"Aktief\",\n        \"queue\": \"Tou\",\n        \"total\": \"Totaal\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Publieke IP\",\n        \"region\": \"Streek\",\n        \"country\": \"Land\",\n        \"port_forwarded\": \"Poort Aangestuur\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Kanale\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tuners\",\n        \"channelNumber\": \"Kanaal\",\n        \"channelNetwork\": \"Netwerk\",\n        \"signalStrength\": \"Sterkte\",\n        \"signalQuality\": \"Kwaliteit\",\n        \"symbolQuality\": \"Kwaliteit\",\n        \"networkRate\": \"Bistempo\",\n        \"clientIP\": \"Kliënt\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Geslaag\",\n        \"failed\": \"Misluk\",\n        \"unknown\": \"Onbekend\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Inmandjie\",\n        \"total\": \"Totaal\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Bronne\",\n        \"targets\": \"Teikens\",\n        \"traffic\": \"Verkeer\",\n        \"in\": \"In\",\n        \"out\": \"Uit\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Batterylading\",\n        \"ups_load\": \"SVE-lading\",\n        \"ups_status\": \"SVE Status\",\n        \"online\": \"Aanlyn\",\n        \"on_battery\": \"Op Battery\",\n        \"low_battery\": \"Battery Laag\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Wag Asseblief\",\n        \"no_devices\": \"Geen Toesteldata Ontvang Nie\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"SVE-lading\",\n        \"memoryUsed\": \"Geheue Gebruik\",\n        \"uptime\": \"Optyd\",\n        \"numberOfLeases\": \"Leases\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Alle Strome\",\n        \"streams_active\": \"Aktiewe Strome\",\n        \"streams_xepg\": \"XEPG Kanale\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Vandag\",\n        \"absolutePower\": \"Krag\",\n        \"relativePower\": \"Krag %\",\n        \"limit\": \"Limiet\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"SVE-lading\",\n        \"memory\": \"Aktiewe Geheue\",\n        \"wanUpload\": \"WAN Oplaai\",\n        \"wanDownload\": \"WAN Aflaai\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Staat van Bladsydrukker\",\n        \"print_status\": \"Staat Van Druk\",\n        \"print_progress\": \"Vordering\",\n        \"layers\": \"Lae\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Gereedskap Temperatuur\",\n        \"temp_bed\": \"Bed Temperatuur\",\n        \"job_completion\": \"Afhandeling\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Oorsprong IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Las Gem\",\n        \"memory\": \"Mem Gebruik\",\n        \"wanStatus\": \"WAN Status\",\n        \"up\": \"Op\",\n        \"down\": \"Af\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Skyfgebruik\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datastoor\",\n        \"failed_tasks_24h\": \"Mislukte Take 24h\",\n        \"cpu_usage\": \"SVE\",\n        \"memory_usage\": \"Geheue\"\n    },\n    \"immich\": {\n        \"users\": \"Gebruikers\",\n        \"photos\": \"Foto's\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Bergplek\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Werwe Op\",\n        \"down\": \"Werwe Af\",\n        \"uptime\": \"Optyd\",\n        \"incident\": \"Voorval\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Reekse\",\n        \"archives\": \"Argiewe\",\n        \"chapters\": \"Hoofstukke\",\n        \"categories\": \"Kategorieë\"\n    },\n    \"komga\": {\n        \"libraries\": \"Biblioteke\",\n        \"series\": \"Reekse\",\n        \"books\": \"Boeke\"\n    },\n    \"diskstation\": {\n        \"days\": \"Daë\",\n        \"uptime\": \"Optyd\",\n        \"volumeAvailable\": \"Beskikbaar\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Kanale\",\n        \"streams\": \"Uitsendings\"\n    },\n    \"mylar\": {\n        \"series\": \"Reekse\",\n        \"issues\": \"Kwessies\",\n        \"wanted\": \"Gesoek\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Foto's\",\n        \"videos\": \"Videos\",\n        \"people\": \"Mense\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Tou\",\n        \"processing\": \"Verwerking\",\n        \"processed\": \"Verwerk\",\n        \"time\": \"Tyd\"\n    },\n    \"firefly\": {\n        \"networth\": \"Netto Waarde\",\n        \"budget\": \"Begroting\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Dashboards\",\n        \"datasources\": \"Databronne\",\n        \"totalalerts\": \"Totale Waarskuwings\",\n        \"alertstriggered\": \"Waarskuwings Geaktiveer\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Cpu Las\",\n        \"memoryusage\": \"Geheuegebruik\",\n        \"freespace\": \"Gratis Spasie\",\n        \"activeusers\": \"Aktiewe Gebruikers\",\n        \"numfiles\": \"Lêers\",\n        \"numshares\": \"Gedeelde Items\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Grootte\",\n        \"lastrun\": \"Laaste Iterasie\",\n        \"nextrun\": \"Volgende Iterasie\",\n        \"failed\": \"Misluk\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Aktiewe Werkers\",\n        \"total_workers\": \"Totale Werkers\",\n        \"records_total\": \"Toulengte\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Bedieners\",\n        \"nodes\": \"Nodusse\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Teikens Op\",\n        \"targets_down\": \"Teikens Af\",\n        \"targets_total\": \"Totale Teikens\"\n    },\n    \"gatus\": {\n        \"up\": \"Werwe Op\",\n        \"down\": \"Werwe Af\",\n        \"uptime\": \"Optyd\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Vandag\",\n        \"gross_percent_1y\": \"Een jaar\",\n        \"gross_percent_max\": \"Alle tyd\",\n        \"net_worth\": \"Netto Waarde\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podsendinge\",\n        \"books\": \"Boeke\",\n        \"podcastsDuration\": \"Duur\",\n        \"booksDuration\": \"Duur\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Mense Tuis\",\n        \"lights_on\": \"Ligte Aan\",\n        \"switches_on\": \"Skakels Aan\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Monitering\",\n        \"updates\": \"Opdaterings\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Boeke\",\n        \"authors\": \"Skrywers\",\n        \"categories\": \"Kategorieë\",\n        \"series\": \"Reekse\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Biblioteke\",\n        \"books\": \"Boeke\",\n        \"reading\": \"Lees\",\n        \"finished\": \"Klaar\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Tou\",\n        \"downloadBytesRemaining\": \"Oorblywende\",\n        \"downloadTotalBytes\": \"Grootte\",\n        \"downloadSpeed\": \"Spoed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Reekse\",\n        \"totalFiles\": \"Lêers\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Uitslag\",\n        \"status\": \"Status\",\n        \"buildId\": \"Bou ID\",\n        \"succeeded\": \"Suksesvol\",\n        \"notStarted\": \"Nie Begin Nie\",\n        \"failed\": \"Misluk\",\n        \"canceled\": \"Gekanselleer\",\n        \"inProgress\": \"Besig\",\n        \"totalPrs\": \"Totale PRs\",\n        \"myPrs\": \"My PRs\",\n        \"approved\": \"Goedgekeur\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Aanlyn\",\n        \"offline\": \"Vanlyn af\",\n        \"name\": \"Naam\",\n        \"map\": \"Kaart\",\n        \"currentPlayers\": \"Huidige Spelers\",\n        \"players\": \"Spelers\",\n        \"maxPlayers\": \"Maks spelers\",\n        \"bots\": \"Bots\",\n        \"ping\": \"Pieng\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Foute\",\n        \"noRecent\": \"Verouderd\",\n        \"totalUsed\": \"Gebruikte Bergplek\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Resepte\",\n        \"users\": \"Gebruikers\",\n        \"categories\": \"Kategorieë\",\n        \"tags\": \"Merkers\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Aflaai\",\n        \"total\": \"Totaal\",\n        \"running\": \"Lopend\",\n        \"stopped\": \"Gestop\",\n        \"passed\": \"Geslaag\",\n        \"failed\": \"Misluk\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Optyd\",\n        \"cpuLoad\": \"SVE-lading gemiddelde (5m)\",\n        \"up\": \"Op\",\n        \"down\": \"Af\",\n        \"bytesTx\": \"Oorgedra\",\n        \"bytesRx\": \"Ontvang\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Optyd\",\n        \"lastDown\": \"Laaste Stilstand\",\n        \"downDuration\": \"Stilstand Duur\",\n        \"sitesUp\": \"Werwe Op\",\n        \"sitesDown\": \"Werwe Af\",\n        \"paused\": \"Onderbreek\",\n        \"notyetchecked\": \"Nog Nie Nagegaan Nie\",\n        \"up\": \"Op\",\n        \"seemsdown\": \"Lyk Af\",\n        \"down\": \"Af\",\n        \"unknown\": \"Onbekend\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"In fliekteaters\",\n        \"physicalRelease\": \"Fisiese Vrylating\",\n        \"digitalRelease\": \"Digitale Vrylating\",\n        \"noEventsToday\": \"Geen gebeure vir vandag nie!\",\n        \"noEventsFound\": \"Geen gebeure gevind nie\",\n        \"errorWhenLoadingData\": \"Fout tydens laai van kalenderdata\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platform\",\n        \"totalRoms\": \"Spelle\",\n        \"saves\": \"Beware\",\n        \"states\": \"Toestande\",\n        \"screenshots\": \"Skermskote\",\n        \"totalfilesize\": \"Totale Grootte\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domeine\",\n        \"mailboxes\": \"Posbusse\",\n        \"mails\": \"E-posse\",\n        \"storage\": \"Stoor plek\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Waarskuwings\",\n        \"criticals\": \"Kritici\"\n    },\n    \"plantit\": {\n        \"events\": \"Gebeure\",\n        \"plants\": \"Plante\",\n        \"photos\": \"Foto's\",\n        \"species\": \"Spesies\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Kennisgewings\",\n        \"issues\": \"Kwessies\",\n        \"pulls\": \"Trek Versoeke\",\n        \"repositories\": \"Bewaarplekke\"\n    },\n    \"stash\": {\n        \"scenes\": \"Tonele\",\n        \"scenesPlayed\": \"Tonele Gekyk\",\n        \"playCount\": \"Totale Toneelstukke\",\n        \"playDuration\": \"Tyd Gekyk\",\n        \"sceneSize\": \"Toneel Grootte\",\n        \"sceneDuration\": \"Tonele Duur\",\n        \"images\": \"Beelde\",\n        \"imageSize\": \"Beeldgrootte\",\n        \"galleries\": \"Galerye\",\n        \"performers\": \"Kunstenaars\",\n        \"studios\": \"Ateljees\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Merkers\",\n        \"oCount\": \"O Tel\"\n    },\n    \"tandoor\": {\n        \"users\": \"Gebruikers\",\n        \"recipes\": \"Resepte\",\n        \"keywords\": \"Sleutelwoorde\"\n    },\n    \"homebox\": {\n        \"items\": \"Items\",\n        \"totalWithWarranty\": \"Met Waarborg\",\n        \"locations\": \"Plekke\",\n        \"labels\": \"Etikette\",\n        \"users\": \"Gebruikers\",\n        \"totalValue\": \"Totale Waarde\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Opletberigte\",\n        \"bans\": \"Verbanne\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Gekoppel\",\n        \"enabled\": \"Geaktiveer\",\n        \"disabled\": \"Gediaktiveer\",\n        \"total\": \"Totaal\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Gevolmagtig\",\n        \"auth\": \"Met Aut\",\n        \"outdated\": \"Verouderd\",\n        \"banned\": \"Verban\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Pieng\",\n        \"download\": \"Aflaai\",\n        \"upload\": \"Oplaai\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Aandele\",\n        \"loading\": \"Laai\",\n        \"open\": \"Oop - VS Mark\",\n        \"closed\": \"Toe - VS Mark\",\n        \"invalidConfiguration\": \"Ongeldige opstelling\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Kameras\",\n        \"uptime\": \"Optyd\",\n        \"version\": \"Weergawe\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Skakels\",\n        \"collections\": \"Versamelings\",\n        \"tags\": \"Merkers\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Nie geklassifiseer nie\",\n        \"information\": \"Inligting\",\n        \"warning\": \"Waarskuwing\",\n        \"average\": \"Gemiddeld\",\n        \"high\": \"Hoog\",\n        \"disaster\": \"Ramp\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Voertuig\",\n        \"vehicles\": \"Voertuie\",\n        \"serviceRecords\": \"Diensrekords\",\n        \"reminders\": \"Herinneringe\",\n        \"nextReminder\": \"Volgende Herinnering\",\n        \"none\": \"Geen\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Aktiewe Projekte\",\n        \"tasks7d\": \"Take Hierdie week\",\n        \"tasksOverdue\": \"Agterstallige Take\",\n        \"tasksInProgress\": \"Take Aan Die Gang\"\n    },\n    \"headscale\": {\n        \"name\": \"Naam\",\n        \"address\": \"Adres\",\n        \"last_seen\": \"Laas gesien\",\n        \"status\": \"Status\",\n        \"online\": \"Aanlyn\",\n        \"offline\": \"Vanlyn\"\n    },\n    \"beszel\": {\n        \"name\": \"Naam\",\n        \"systems\": \"Stelsels\",\n        \"up\": \"Op\",\n        \"down\": \"Af\",\n        \"paused\": \"Onderbreek\",\n        \"pending\": \"Afwagtend\",\n        \"status\": \"Status\",\n        \"updated\": \"Opgedateer\",\n        \"cpu\": \"SVE\",\n        \"memory\": \"GEH\",\n        \"disk\": \"Skyf\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Programme\",\n        \"synced\": \"Gesinkroniseer\",\n        \"outOfSync\": \"Nie Gesinchroniseer Nie\",\n        \"healthy\": \"Gesond\",\n        \"degraded\": \"Gedegradeer\",\n        \"progressing\": \"Vorderend\",\n        \"missing\": \"Afwesig\",\n        \"suspended\": \"Geskors\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Laai\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Groepe\",\n        \"issues\": \"Kwessies\",\n        \"merges\": \"Saamvleg Versoeke\",\n        \"projects\": \"Projekte\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Las\",\n        \"bcharge\": \"Batterylading\",\n        \"timeleft\": \"Oorblywende Tyd\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Boekmerke\",\n        \"favorites\": \"Gunstelinge\",\n        \"archived\": \"Geargiveer\",\n        \"highlights\": \"Hoogtepunte\",\n        \"lists\": \"Lyste\",\n        \"tags\": \"Merkers\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Netwerk\",\n        \"connected\": \"Gekoppel\",\n        \"disconnected\": \"Ontkoppel\",\n        \"updateStatus\": \"Opdateer\",\n        \"update_yes\": \"Beskikbaar\",\n        \"update_no\": \"Op Datum\",\n        \"downloads\": \"Aflaaie\",\n        \"uploads\": \"Oplaaie\",\n        \"sharedFiles\": \"Lêers\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Liedjies\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episode\",\n        \"other\": \"Ander\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Diensprobleme\",\n        \"hostErrors\": \"Gasheerprobleme\"\n    },\n    \"komodo\": {\n        \"total\": \"Totaal\",\n        \"running\": \"Lopend\",\n        \"stopped\": \"Gestop\",\n        \"down\": \"Af\",\n        \"unhealthy\": \"Ongesond\",\n        \"unknown\": \"Onbekend\",\n        \"servers\": \"Bedieners\",\n        \"stacks\": \"Stapels\",\n        \"containers\": \"Houers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Beskikbaar\",\n        \"used\": \"Gebruik\",\n        \"total\": \"Totaal\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Intekeninge\",\n        \"thisMonthlyCost\": \"Hierdie Maand\",\n        \"nextMonthlyCost\": \"Volgende Maand\",\n        \"previousMonthlyCost\": \"Vorige Maand\",\n        \"nextRenewingSubscription\": \"Volgende paaiement\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Begin\",\n        \"STOPPED\": \"Gestop\",\n        \"NEW_ARRAY\": \"Nuwe Skikking\",\n        \"RECON_DISK\": \"Rekonstruksie van Skyf\",\n        \"DISABLE_DISK\": \"Skyf Gedeaktiveer\",\n        \"SWAP_DSBL\": \"Ruil Gedeaktiveer\",\n        \"INVALID_EXPANSION\": \"Ongeldige Uitbreiding\",\n        \"PARITY_NOT_BIGGEST\": \"Pariteit nie die Grootste nie\",\n        \"TOO_MANY_MISSING_DISKS\": \"Te Veel Ontbrekende Skywe\",\n        \"NEW_DISK_TOO_SMALL\": \"Nuwe Skyf te Klein\",\n        \"NO_DATA_DISKS\": \"Geen Data Skywe\",\n        \"notifications\": \"Kennisgewings\",\n        \"status\": \"Status\",\n        \"cpu\": \"SVE\",\n        \"memoryUsed\": \"Geheue Gebruik\",\n        \"memoryAvailable\": \"Geheue Beskikbaar\",\n        \"arrayUsed\": \"Skikking Gebruik\",\n        \"arrayFree\": \"Skikking Vry\",\n        \"poolUsed\": \"{{pool}} Gebruik\",\n        \"poolFree\": \"{{pool}} Vry\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Planne\",\n        \"num_success_30\": \"Suksesse\",\n        \"num_failure_30\": \"Mislukkings\",\n        \"num_success_latest\": \"Slaag\",\n        \"num_failure_latest\": \"Mislukking\",\n        \"bytes_added_30\": \"Grepe bygevoeg\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Liedjies\",\n        \"time\": \"Tyd\",\n        \"artists\": \"Kunstenaars\"\n    },\n    \"arcane\": {\n        \"containers\": \"Houers\",\n        \"images\": \"Beelde\",\n        \"image_updates\": \"Beeldopdaterings\",\n        \"images_unused\": \"Ongebruik\",\n        \"environment_required\": \"Omgewings-ID Vereis\"\n    },\n    \"dockhand\": {\n        \"running\": \"Lopend\",\n        \"stopped\": \"Gestop\",\n        \"cpu\": \"SVE\",\n        \"memory\": \"Geheue\",\n        \"images\": \"Beelde\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Vandag se byeenkomste\",\n        \"pending_updates\": \"Hangende opdaterings\",\n        \"stacks\": \"Stapels\",\n        \"paused\": \"Onderbreek\",\n        \"total\": \"Totaal\",\n        \"environment_not_found\": \"Omgewing Nie Gevind Nie\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Geëet\",\n        \"burned\": \"Verbrand\",\n        \"remaining\": \"Oorblywende\",\n        \"steps\": \"Stappe\"\n    }\n}\n"
  },
  {
    "path": "public/locales/ar/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"ش\",\n        \"days\": \"ي\",\n        \"hours\": \"س\",\n        \"minutes\": \"د\",\n        \"seconds\": \"ث\"\n    },\n    \"widget\": {\n        \"missing_type\": \"نوع القطعة مفقود: {{type}}\",\n        \"api_error\": \"API خطأ\",\n        \"information\": \"معلومات\",\n        \"status\": \"الحالة\",\n        \"url\": \"الرابط\",\n        \"raw_error\": \"خطأ خام\",\n        \"response_data\": \"بيانات الاستجابة\"\n    },\n    \"weather\": {\n        \"current\": \"الموقع الحالي\",\n        \"allow\": \"أنقر للسماح\",\n        \"updating\": \"جاري التحديث\",\n        \"wait\": \"الرجاء الإنتظار\"\n    },\n    \"search\": {\n        \"placeholder\": \"بحث …\"\n    },\n    \"resources\": {\n        \"cpu\": \"المعالج\",\n        \"mem\": \"الذاكرة\",\n        \"total\": \"المجموع\",\n        \"free\": \"متاح\",\n        \"used\": \"مستخدم\",\n        \"load\": \"الضغط\",\n        \"temp\": \"مؤقت\",\n        \"max\": \"الحد الأقصى\",\n        \"uptime\": \"تعمل\"\n    },\n    \"unifi\": {\n        \"users\": \"المستخدمون\",\n        \"uptime\": \"مدة التشغيل\",\n        \"days\": \"أيام\",\n        \"wan\": \"الشبكة الواسعة\",\n        \"lan\": \"الشبكة المحلية\",\n        \"wlan\": \"الشبكة المحلية اللاسلكية\",\n        \"devices\": \"الأجهزة\",\n        \"lan_devices\": \"LAN أجهزة\",\n        \"wlan_devices\": \"WLAN أجهزة\",\n        \"lan_users\": \"LAN مستخدمين\",\n        \"wlan_users\": \"WLAN مستخدمين\",\n        \"up\": \"UP\",\n        \"down\": \"لا يعمل\",\n        \"wait\": \"Please wait\",\n        \"empty_data\": \"حالة النظام الفرعي غير معروفة\"\n    },\n    \"docker\": {\n        \"rx\": \"استقبال\",\n        \"tx\": \"ارسال\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"قيد التشغيل\",\n        \"offline\": \"غير متصل\",\n        \"error\": \"خطأ\",\n        \"unknown\": \"مجهول\",\n        \"healthy\": \"سليم\",\n        \"starting\": \"يبدأ التشغيل\",\n        \"unhealthy\": \"غير صحّي\",\n        \"not_found\": \"غير موجود\",\n        \"exited\": \"خرجت\",\n        \"partial\": \"جزئي\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"بينغ\",\n        \"down\": \"لا يعمل\",\n        \"up\": \"يعمل\",\n        \"not_available\": \"غير مُـتوفـّر\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"حالة HTTP\",\n        \"error\": \"Error\",\n        \"response\": \"الرد\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"emby\": {\n        \"playing\": \"يعمل الآن\",\n        \"transcoding\": \"التحويل\",\n        \"bitrate\": \"معدل البت\",\n        \"no_active\": \"لا يوجد بث نشط\",\n        \"movies\": \"أفلام\",\n        \"series\": \"مسلسلات\",\n        \"episodes\": \"حلقات\",\n        \"songs\": \"أغاني\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"مُتّصل\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"إنتاج\",\n        \"battery_soc\": \"البطارية\",\n        \"grid_power\": \"شبكة\",\n        \"home_power\": \"الاستهلاك\",\n        \"charge_power\": \"شاحن\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"التنزيل\",\n        \"upload\": \"التحميل\",\n        \"leech\": \"القرناء\",\n        \"seed\": \"البذور\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"الاشتراكات\",\n        \"unread\": \"غير مقروءة\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"لم تتم تهيئته\",\n        \"connectionStatusConnecting\": \"جاري الاتصال\",\n        \"connectionStatusAuthenticating\": \"جار المصادقة\",\n        \"connectionStatusPendingDisconnect\": \"في انتظار قطع الاتصال\",\n        \"connectionStatusDisconnecting\": \"جار قطع الاتصال\",\n        \"connectionStatusDisconnected\": \"غير متصل\",\n        \"connectionStatusConnected\": \"متصل\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"أقصى حد للتنزيل\",\n        \"maxUp\": \"أقصى حد للتحميل\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"تم الإستلام\",\n        \"sent\": \"تم الإرسال\",\n        \"externalIPAddress\": \"IP الخارجي\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"تدفق\",\n        \"requests\": \"طلبات الحالية\",\n        \"requests_failed\": \"طلبات فشلت\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"مجموع الملاحظات\",\n        \"diffsDetected\": \"الاختلافات المكتشفة\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"برامج\",\n        \"recordings\": \"التسجيلات\",\n        \"scheduled\": \"مجدولة\",\n        \"passes\": \"تمريرات\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"تحقق من الاتصال بـ Plex\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"المتصلة APs\",\n        \"activeUser\": \"الأجهزة النشطة\",\n        \"alerts\": \"تنبيهات\",\n        \"connectedGateways\": \"Connected gateways\",\n        \"connectedSwitches\": \"مفاتيح التبديل المتصلة\"\n    },\n    \"nzbget\": {\n        \"rate\": \"معدل\",\n        \"remaining\": \"متبقي\",\n        \"downloaded\": \"مُنزل\"\n    },\n    \"plex\": {\n        \"streams\": \"بث نشيطٌ\",\n        \"albums\": \"ألبومات\",\n        \"movies\": \"Movies\",\n        \"tv\": \"مسلسلات\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"إنتظار\",\n        \"timeleft\": \"الوقت المتبقي\"\n    },\n    \"rutorrent\": {\n        \"active\": \"نشط\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"استهلاك المعالج\",\n        \"memUsage\": \"استخدام الذاكرة العشوائية\",\n        \"systemTempC\": \"درجة حرارة النظام\",\n        \"poolUsage\": \"استخدام التجمع\",\n        \"volumeUsage\": \"استخدام حجم القرص\",\n        \"invalid\": \"غير صحيح\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"مطلوب\",\n        \"queued\": \"في الإنتظار\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"مفقود\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"فنانين\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"كتب\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"حلقات مفقودة\",\n        \"missingMovies\": \"أفلام مفقودة\"\n    },\n    \"ombi\": {\n        \"pending\": \"معلق\",\n        \"approved\": \"مصدق\",\n        \"available\": \"متاح\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"أجهزة جديدة\",\n        \"down_alerts\": \"تنبيهات تعطل\"\n    },\n    \"pihole\": {\n        \"queries\": \"الاستعلامات\",\n        \"blocked\": \"محظور\",\n        \"blocked_percent\": \"تم حظر %\",\n        \"gravity\": \"الجاذبية\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"مرشح\",\n        \"latency\": \"الإستجابة\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"متوقف\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Non-Downloaded\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Downloaded & Read\",\n        \"downloadedunread\": \"Downloaded & Unread\",\n        \"nondownloadedread\": \"Non-Downloaded & Read\",\n        \"nondownloadedunread\": \"Non-Downloaded & Unread\"\n    },\n    \"tailscale\": {\n        \"address\": \"عنوان\",\n        \"expires\": \"تنتهي\",\n        \"never\": \"مطلقاً\",\n        \"last_seen\": \"آخر ظهور\",\n        \"now\": \"الآن\",\n        \"years\": \"{{number}}س\",\n        \"weeks\": \"{{number}}أ\",\n        \"days\": \"{{number}}ي\",\n        \"hours\": \"{{number}}س\",\n        \"minutes\": \"{{number}}د\",\n        \"seconds\": \"{{number}}ث\",\n        \"ago\": \"منذ {{value}}\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"تم بنجاح\",\n        \"totalServerFailure\": \"فشل\",\n        \"totalNxDomain\": \"مجالات NX\",\n        \"totalRefused\": \"مرفوض\",\n        \"totalAuthoritative\": \"موثوقة\",\n        \"totalRecursive\": \"عودي\",\n        \"totalCached\": \"مخبأ\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"أسقطت\",\n        \"totalClients\": \"العملاء\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"معالجة\",\n        \"errored\": \"خطأ\",\n        \"saved\": \"حفظ\"\n    },\n    \"traefik\": {\n        \"routers\": \"راوتر\",\n        \"services\": \"خدمات\",\n        \"middleware\": \"الوسيطة\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"الرجاء الإنتظار\"\n    },\n    \"npm\": {\n        \"enabled\": \"مفعل\",\n        \"disabled\": \"معطل\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"قم بأنشاء عملة تشفير واحدة أو أكثر للتتبع\",\n        \"1hour\": \"١ ساعة\",\n        \"1day\": \"١ يوم\",\n        \"7days\": \"٧ أيام\",\n        \"30days\": \"30 يوماً\"\n    },\n    \"gotify\": {\n        \"apps\": \"التطبيقات\",\n        \"clients\": \"Clients\",\n        \"messages\": \"الرسائل\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"مفهرسات\",\n        \"numberOfGrabs\": \"مساكات\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"إخفاقات في الالتقاط\",\n        \"numberOfFailQueries\": \"فشل الاستعلامات\"\n    },\n    \"jackett\": {\n        \"configured\": \"مهيأ\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"الجلسات\",\n        \"numConnections\": \"التوصيلات\",\n        \"dataRelayed\": \"منقول(ة)\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"منشورات\",\n        \"domain_count\": \"مجالات\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"مشغلات\",\n        \"version\": \"الإصدار\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"قراءة\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"تسجيلات الدخول (٢٤س)\",\n        \"failedLoginsLast24H\": \"فشل تسجيلات الدخول (٢٤س)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"حاويات لينكس\",\n        \"vms\": \"أجهزة ظاهرية\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"درجة الحرارة\",\n        \"warn\": \"تنبية\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"حساس\",\n        \"read\": \"Read\",\n        \"write\": \"الكتابة\",\n        \"gpu\": \"كرت الشاشة\",\n        \"mem\": \"الذاكرة\",\n        \"swap\": \"ذاكرة سواب\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"مفضلة\",\n        \"service\": \"خدمة\",\n        \"search\": \"البحث\",\n        \"custom\": \"مُخصّص\",\n        \"visit\": \"زيارة\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"الإقتراحات\"\n    },\n    \"wmo\": {\n        \"0-day\": \"مشمس\",\n        \"0-night\": \"صافي\",\n        \"1-day\": \"مشمس غالباً\",\n        \"1-night\": \"صافي غالباً\",\n        \"2-day\": \"غائم جزئياً\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"غائم\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"ضبابي\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"رذاذ خفيف\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"رذاذ\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"رذاذ كثيف\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"رذاذ متجمد خفيف\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"رذاذ متجمد\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"مطر خفيف\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"مطر\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"مطر شديد\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"مطر متجمد\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"ثلج خفيف\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"ثلج\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"ثلج شديد\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"حبيبات الثلج\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"أمطار خفيفة\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"أمطار\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"أمطار شديدة\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"زخات الثلوج\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"عاصفة رعدية\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"عاصفة رعدية مع مطر\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"نظام\",\n        \"updates\": \"تحديثات\",\n        \"update_available\": \"تحديث متاح\",\n        \"up_to_date\": \"حتى الآن\",\n        \"child_bridges\": \"الجسور الأطفال\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"جديد(ة)\",\n        \"up\": \"Up\",\n        \"grace\": \"في فترة السماح\",\n        \"down\": \"Down\",\n        \"paused\": \"متوقف\",\n        \"status\": \"Status\",\n        \"last_ping\": \"آخر Ping\",\n        \"never\": \"لا توجد بنغات بعد\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"مفحوصة\",\n        \"containers_updated\": \"محدث\",\n        \"containers_failed\": \"فشل\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"مرفوض\",\n        \"filters\": \"المرشحات\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"الفيديوهات\",\n        \"channels\": \"القنوات\",\n        \"playlists\": \"قوائم التشغيل\"\n    },\n    \"truenas\": {\n        \"load\": \"حمل النظام\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"السرعة\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"العام IP\",\n        \"region\": \"منطقة\",\n        \"country\": \"الدولة\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"جودة HD\",\n        \"tunerCount\": \"التونز\",\n        \"channelNumber\": \"القناة\",\n        \"channelNetwork\": \"الشبكة\",\n        \"signalStrength\": \"القوة\",\n        \"signalQuality\": \"الجودة\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"العميل\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"إجتاز\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"صندوق الوارد\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"شحن البطارية\",\n        \"ups_load\": \"حمل UPS\",\n        \"ups_status\": \"حالة UPS\",\n        \"online\": \"Online\",\n        \"on_battery\": \"على البطارية\",\n        \"low_battery\": \"البطارية منخفضة\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"لم يتم استلام بيانات الجهاز\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"حمل المعالج\",\n        \"memoryUsed\": \"الذاكرة الستخدمة\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"إيجارات\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"جميع البث\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG قنوات\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"اليوم\",\n        \"absolutePower\": \"القوة\",\n        \"relativePower\": \"قوة %\",\n        \"limit\": \"الحد الأقصى\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"الذاكرة النشطة\",\n        \"wanUpload\": \"WAN التحميل\",\n        \"wanDownload\": \"WAN التنزيل\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"حالة الطابعة\",\n        \"print_status\": \"حالة الطابعة\",\n        \"print_progress\": \"تقدم\",\n        \"layers\": \"طبقات\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"أداة درجة الحرارة\",\n        \"temp_bed\": \"درجة حرارة السرير\",\n        \"job_completion\": \"إتمام\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"IP الأصل\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"معدل التحميل\",\n        \"memory\": \"استخدام الذاكرة العشوائية\",\n        \"wanStatus\": \"حالة الشبكة الواسعة\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"استخدام القرص\",\n        \"wanIP\": \"IP الشبكة الواسعة\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"مخزن البيانات\",\n        \"failed_tasks_24h\": \"المهام الفاشلة 24 ساعة\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"الذاكرة\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"الصور\",\n        \"videos\": \"Videos\",\n        \"storage\": \"التخزين\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"المواقع تعمل\",\n        \"down\": \"مواقع لا تعمل\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"حادثة\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"الأرشيف\",\n        \"chapters\": \"الفصول\",\n        \"categories\": \"التصنيفات\"\n    },\n    \"komga\": {\n        \"libraries\": \"المكتبات\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"المُشكِلات\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"أشخاص\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"الوقت\"\n    },\n    \"firefly\": {\n        \"networth\": \"Net Worth\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"لوحات المعلومات\",\n        \"datasources\": \"مصادر البيانات\",\n        \"totalalerts\": \"إجمالي التنبيهات\",\n        \"alertstriggered\": \"تنبيهات مفعلة\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"حمل المعالج\",\n        \"memoryusage\": \"استخدام الذاكرة\",\n        \"freespace\": \"مساحة فارغة\",\n        \"activeusers\": \"مستخدمين نشطين\",\n        \"numfiles\": \"ملفات\",\n        \"numshares\": \"عناصر مشتركة\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"حجم\",\n        \"lastrun\": \"آخر تشغيل\",\n        \"nextrun\": \"التشغيل التالي\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"العمال النشطون\",\n        \"total_workers\": \"مجموع العمال\",\n        \"records_total\": \"طول الصف\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"السيرفرات\",\n        \"nodes\": \"عقد\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"أهداف تعمل\",\n        \"targets_down\": \"الأهداف لا تعمل\",\n        \"targets_total\": \"الأهداف الإجمالية\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"سنة\",\n        \"gross_percent_max\": \"كل الوقت\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"بودكاست\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"المدة\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"أشخاص في المنزل\",\n        \"lights_on\": \"أضواء مضاءة\",\n        \"switches_on\": \"مفاتيح قيد التشغيل\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"المراقبة\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"المؤلفون\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"نتيجة\",\n        \"status\": \"Status\",\n        \"buildId\": \"معرف البناء\",\n        \"succeeded\": \"تم بنجاح\",\n        \"notStarted\": \"لم يبدأ\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"ملغى\",\n        \"inProgress\": \"قيد التنفيذ\",\n        \"totalPrs\": \"المجموع الكلي للPRs\",\n        \"myPrs\": \"الPRs الشخصية\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"الاسم\",\n        \"map\": \"خريطة\",\n        \"currentPlayers\": \"المشغلات الحالية\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"الحد الأقصى للمشغلات\",\n        \"bots\": \"بوتات\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"تمام\",\n        \"errored\": \"أخطاء\",\n        \"noRecent\": \"غير محدّث\",\n        \"totalUsed\": \"التخزين المستخدم\"\n    },\n    \"mealie\": {\n        \"recipes\": \"وصفات\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"التصنيفات\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"جاري التنزيل\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"متوسط حمولة المعالج (5دق)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"مرسلة\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"فترة التعطّل الأخيرة\",\n        \"downDuration\": \"مدة التعطل\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"لم يتم التحقق بعد\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"يبدو أنه معطل\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"في دور السينما\",\n        \"physicalRelease\": \"الإصدار المادي\",\n        \"digitalRelease\": \"الإصدار الرقمي\",\n        \"noEventsToday\": \"لا توجد أحداث اليوم!\",\n        \"noEventsFound\": \"لم يتم العثور على أحداث\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"المِنصات\",\n        \"totalRoms\": \"ألعاب\",\n        \"saves\": \"نُقَط حفظ\",\n        \"states\": \"حالات\",\n        \"screenshots\": \"لقطات شاشة\",\n        \"totalfilesize\": \"الحجم الكلي\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Mailboxes\",\n        \"mails\": \"Mails\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"تحذيرات\",\n        \"criticals\": \"حرج\"\n    },\n    \"plantit\": {\n        \"events\": \"أحداث\",\n        \"plants\": \"نباتات\",\n        \"photos\": \"Photos\",\n        \"species\": \"الأنواع\"\n    },\n    \"gitea\": {\n        \"notifications\": \"الإشعارات\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"طلبات السحب\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"المشاهد\",\n        \"scenesPlayed\": \"مشاهد شغلت\",\n        \"playCount\": \"إجمالي المشغلات\",\n        \"playDuration\": \"وقت المشاهدة\",\n        \"sceneSize\": \"حجم المشاهد\",\n        \"sceneDuration\": \"مدة المشهد\",\n        \"images\": \"صور\",\n        \"imageSize\": \"حجم الصور\",\n        \"galleries\": \"المعارض\",\n        \"performers\": \"ممثلين\",\n        \"studios\": \"استوديوهات\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"عدد O\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"كلمات مفتاح\"\n    },\n    \"homebox\": {\n        \"items\": \"عناصر\",\n        \"totalWithWarranty\": \"بالضمان\",\n        \"locations\": \"Locations\",\n        \"labels\": \"Labels\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Total Value\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Bans\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxied\",\n        \"auth\": \"With Auth\",\n        \"outdated\": \"Outdated\",\n        \"banned\": \"Banned\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Stocks\",\n        \"loading\": \"تحميل\",\n        \"open\": \"مفتوحة - السوق الأمريكية\",\n        \"closed\": \"مغلقة - السوق الأمريكية\",\n        \"invalidConfiguration\": \"إعدادات غير صحيحة\"\n    },\n    \"frigate\": {\n        \"cameras\": \"كاميرات\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"روابط\",\n        \"collections\": \"مجموعات\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"غير مصنفة\",\n        \"information\": \"Information\",\n        \"warning\": \"تحذيرات\",\n        \"average\": \"متوسط\",\n        \"high\": \"عالي\",\n        \"disaster\": \"كارثة\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vehicle\",\n        \"vehicles\": \"Vehicles\",\n        \"serviceRecords\": \"Service Records\",\n        \"reminders\": \"Reminders\",\n        \"nextReminder\": \"Next Reminder\",\n        \"none\": \"None\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Active Projects\",\n        \"tasks7d\": \"Tasks Due This Week\",\n        \"tasksOverdue\": \"Overdue Tasks\",\n        \"tasksInProgress\": \"Tasks In Progress\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Systems\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Disk\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Apps\",\n        \"synced\": \"Synced\",\n        \"outOfSync\": \"Out Of Sync\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Degraded\",\n        \"progressing\": \"Progressing\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Suspended\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Groups\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Projects\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bookmarks\",\n        \"favorites\": \"Favorites\",\n        \"archived\": \"Archived\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Lists\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Other\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/bg/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"м\",\n        \"days\": \"д\",\n        \"hours\": \"ч\",\n        \"minutes\": \"мин\",\n        \"seconds\": \"сек\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Липсваща приставка: {{type}}\",\n        \"api_error\": \"API Грешка\",\n        \"information\": \"Информация\",\n        \"status\": \"Статус\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Грешка\",\n        \"response_data\": \"Отговор\"\n    },\n    \"weather\": {\n        \"current\": \"Текущо местоположение\",\n        \"allow\": \"Разреши\",\n        \"updating\": \"Обновяване\",\n        \"wait\": \"Моля изчакайте\"\n    },\n    \"search\": {\n        \"placeholder\": \"Търсене…\"\n    },\n    \"resources\": {\n        \"cpu\": \"Процесор\",\n        \"mem\": \"Памет\",\n        \"total\": \"Общо\",\n        \"free\": \"Свободни\",\n        \"used\": \"Заети\",\n        \"load\": \"Натоварване\",\n        \"temp\": \"Температура\",\n        \"max\": \"Макс.\",\n        \"uptime\": \"Онлайн\"\n    },\n    \"unifi\": {\n        \"users\": \"Потребители\",\n        \"uptime\": \"Ъптайм\",\n        \"days\": \"Дни\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Устройства\",\n        \"lan_devices\": \"LAN Устройства\",\n        \"wlan_devices\": \"WLAN Устройства\",\n        \"lan_users\": \"LAN Потребители\",\n        \"wlan_users\": \"WLAN Потребители\",\n        \"up\": \"UP\",\n        \"down\": \"DOWN\",\n        \"wait\": \"Моля изчакайте\",\n        \"empty_data\": \"Неизвестен статус на подсистема\"\n    },\n    \"docker\": {\n        \"rx\": \"ПЧ\",\n        \"tx\": \"ИЗ\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Работи\",\n        \"offline\": \"Изключен\",\n        \"error\": \"Грешка\",\n        \"unknown\": \"Неизв.\",\n        \"healthy\": \"Здрав\",\n        \"starting\": \"Стартиращ\",\n        \"unhealthy\": \"Нездравословен\",\n        \"not_found\": \"Не е открит\",\n        \"exited\": \"Напуснал\",\n        \"partial\": \"Частично\"\n    },\n    \"ping\": {\n        \"error\": \"Грешка\",\n        \"ping\": \"Пинг\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Не е налично\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP статус\",\n        \"error\": \"Грешка\",\n        \"response\": \"Отговор\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Не е налично\"\n    },\n    \"emby\": {\n        \"playing\": \"Възпроизвежда\",\n        \"transcoding\": \"Конвертира\",\n        \"bitrate\": \"Битрейт\",\n        \"no_active\": \"Няма активни потоци\",\n        \"movies\": \"Филми\",\n        \"series\": \"Сериали\",\n        \"episodes\": \"Епизоди\",\n        \"songs\": \"Песни\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Онлайн\",\n        \"total\": \"Общо\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Произведено\",\n        \"battery_soc\": \"Батерия\",\n        \"grid_power\": \"Мрежа\",\n        \"home_power\": \"Потребление\",\n        \"charge_power\": \"Зарядно\",\n        \"kilowatt\": \"кВ\"\n    },\n    \"flood\": {\n        \"download\": \"Изтегляне\",\n        \"upload\": \"Качване\",\n        \"leech\": \"Лийч\",\n        \"seed\": \"Сийд\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Абонаменти\",\n        \"unread\": \"Непрочетени\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Статус\",\n        \"connectionStatusUnconfigured\": \"Неконфигуриран\",\n        \"connectionStatusConnecting\": \"Свързване\",\n        \"connectionStatusAuthenticating\": \"Удостоверяване\",\n        \"connectionStatusPendingDisconnect\": \"Изчава прекратяване на връзка\",\n        \"connectionStatusDisconnecting\": \"Прекъсване на връзката\",\n        \"connectionStatusDisconnected\": \"Не е свързан\",\n        \"connectionStatusConnected\": \"Свързан\",\n        \"uptime\": \"Време на работа\",\n        \"maxDown\": \"Макс сваляне\",\n        \"maxUp\": \"Макс качване\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"Получени\",\n        \"sent\": \"Изпратени\",\n        \"externalIPAddress\": \"Външно IP\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Текущи заявки\",\n        \"requests_failed\": \"Провалени заявки\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Общо наблюдавани\",\n        \"diffsDetected\": \"Открити разлики\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Шоута\",\n        \"recordings\": \"Записи\",\n        \"scheduled\": \"Планирано\",\n        \"passes\": \"Passes\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Битрейт\",\n        \"no_active\": \"Няма активни потоци\",\n        \"plex_connection_error\": \"Провери връзка с Plex\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Свързани точки\",\n        \"activeUser\": \"Активни устройства\",\n        \"alerts\": \"Предупреждения\",\n        \"connectedGateways\": \"Connected gateways\",\n        \"connectedSwitches\": \"Свързани суичове\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Скорост\",\n        \"remaining\": \"Остава\",\n        \"downloaded\": \"Изтеглени\"\n    },\n    \"plex\": {\n        \"streams\": \"Активни Потоци\",\n        \"albums\": \"Албуми\",\n        \"movies\": \"Филми\",\n        \"tv\": \"Сериали\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"Опашка\",\n        \"timeleft\": \"Оставащо Време\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Акитивен\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU употреба\",\n        \"memUsage\": \"Упортеба памет\",\n        \"systemTempC\": \"Системна темп\",\n        \"poolUsage\": \"Употреба на пул\",\n        \"volumeUsage\": \"Volume Usage\",\n        \"invalid\": \"Невалидни\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Търсени\",\n        \"queued\": \"В изчакване\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"Липсващи\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"Изпълнители\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"Книги\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Липсващи Епизоди\",\n        \"missingMovies\": \"Липсващи Филми\"\n    },\n    \"ombi\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Одобрен\",\n        \"available\": \"Наличен\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"New Devices\",\n        \"down_alerts\": \"Down Alerts\"\n    },\n    \"pihole\": {\n        \"queries\": \"Заявки\",\n        \"blocked\": \"Блокирани\",\n        \"blocked_percent\": \"Blocked %\",\n        \"gravity\": \"Gravity\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"Филтрирани\",\n        \"latency\": \"Latency\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Спрян\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Non-Downloaded\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Downloaded & Read\",\n        \"downloadedunread\": \"Изтеглени и непрочетени\",\n        \"nondownloadedread\": \"Не-изтеглени и прочетени\",\n        \"nondownloadedunread\": \"Не-изтеглени и непрочетени\"\n    },\n    \"tailscale\": {\n        \"address\": \"Адрес\",\n        \"expires\": \"Изтича\",\n        \"never\": \"Никога\",\n        \"last_seen\": \"Последно видян\",\n        \"now\": \"Сега\",\n        \"years\": \"{{number}}г\",\n        \"weeks\": \"{{number}}с\",\n        \"days\": \"{{number}}д\",\n        \"hours\": \"{{number}}ч\",\n        \"minutes\": \"{{number}}м\",\n        \"seconds\": \"{{number}}сек\",\n        \"ago\": \"преди {{value}}\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"Успех\",\n        \"totalServerFailure\": \"Грешки\",\n        \"totalNxDomain\": \"NX домейни\",\n        \"totalRefused\": \"Отказани\",\n        \"totalAuthoritative\": \"Authoritative\",\n        \"totalRecursive\": \"Рекурсивни\",\n        \"totalCached\": \"Кеширани\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"Отпаднали\",\n        \"totalClients\": \"Клиенти\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Обработени\",\n        \"errored\": \"С грешки\",\n        \"saved\": \"Запазени\"\n    },\n    \"traefik\": {\n        \"routers\": \"Рутери\",\n        \"services\": \"Услуги\",\n        \"middleware\": \"Middleware\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Бележки\",\n        \"dbSize\": \"Размер на базата данни\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Моля Изчакайте\"\n    },\n    \"npm\": {\n        \"enabled\": \"Активирано\",\n        \"disabled\": \"Деактивирано\",\n        \"total\": \"Общо\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Настрой за следене една или повече крипто валути\",\n        \"1hour\": \"1 Час\",\n        \"1day\": \"1 Ден\",\n        \"7days\": \"7 Дена\",\n        \"30days\": \"30 дни\"\n    },\n    \"gotify\": {\n        \"apps\": \"Приложения\",\n        \"clients\": \"Клиенти\",\n        \"messages\": \"Съобщения\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Индексъри\",\n        \"numberOfGrabs\": \"Grabs\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"Fail Grabs\",\n        \"numberOfFailQueries\": \"Fail Queries\"\n    },\n    \"jackett\": {\n        \"configured\": \"Настроен\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Сесии\",\n        \"numConnections\": \"Connections\",\n        \"dataRelayed\": \"\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Потребители\",\n        \"status_count\": \"Posts\",\n        \"domain_count\": \"Domains\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Играчи\",\n        \"version\": \"Версия\",\n        \"status\": \"Статус\",\n        \"up\": \"Онлайн\",\n        \"down\": \"Офлайн\"\n    },\n    \"miniflux\": {\n        \"read\": \"Read\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Потребители\",\n        \"loginsLast24H\": \"Logins (24h)\",\n        \"failedLoginsLast24H\": \"Failed Logins (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Температура\",\n        \"warn\": \"Предупреждение\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Crit\",\n        \"read\": \"Read\",\n        \"write\": \"Write\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Bookmark\",\n        \"service\": \"Service\",\n        \"search\": \"Търсене\",\n        \"custom\": \"По избор\",\n        \"visit\": \"Посети\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Предложение\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Слънчево\",\n        \"0-night\": \"Ясно\",\n        \"1-day\": \"Предимно Слънчево\",\n        \"1-night\": \"Предимно Ясно\",\n        \"2-day\": \"Частична Облачност\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"Облачно\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"Мъгливо\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"Слабо преваляване\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"Преваляване\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"Тежко преваляване\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"Слабо студено преваляване\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"Студено преваляване\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"Слаб дъжд\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"Дъжд\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Силен дъжд\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Леден дъжд\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Лек снеговалеж\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Снеговалеж\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Силен снеговалеж\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Snow Grains\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Лек дъжд\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Дъжд\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Силен дъжд\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Снеговалеж\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"Гръмотевична буря\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Гръмотевична буря с градушка\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Система\",\n        \"updates\": \"Актуализации\",\n        \"update_available\": \"Налична актуализация\",\n        \"up_to_date\": \"Актуално\",\n        \"child_bridges\": \"Child Bridges\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Нови\",\n        \"up\": \"Up\",\n        \"grace\": \"Гратисен период\",\n        \"down\": \"Down\",\n        \"paused\": \"На пауза\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Последен пинг\",\n        \"never\": \"Няма пинг все още\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Сканирани\",\n        \"containers_updated\": \"Обновени\",\n        \"containers_failed\": \"Провалени\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Отхвърлени\",\n        \"filters\": \"Филтри\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Видео\",\n        \"channels\": \"Канали\",\n        \"playlists\": \"Плейлист\"\n    },\n    \"truenas\": {\n        \"load\": \"Натоварване на системата\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Скорост\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Публично IP\",\n        \"region\": \"Регион\",\n        \"country\": \"Страна\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Тунери\",\n        \"channelNumber\": \"Канал\",\n        \"channelNetwork\": \"Мрежа\",\n        \"signalStrength\": \"Сила\",\n        \"signalQuality\": \"Качество\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Клиент\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Одобрени\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Входящи\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Заряд на батерията\",\n        \"ups_load\": \"Натоварване на UPS\",\n        \"ups_status\": \"Статус на UPS\",\n        \"online\": \"Online\",\n        \"on_battery\": \"На батерия\",\n        \"low_battery\": \"Слаба батерия\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"Не е получена дата от устройство\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"Натоварване на процесор\",\n        \"memoryUsed\": \"Изполвана памет\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Leases\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Всички потоци\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG канали\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Днес\",\n        \"absolutePower\": \"Захранване\",\n        \"relativePower\": \"Захранване %\",\n        \"limit\": \"Лимит\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Активна памет\",\n        \"wanUpload\": \"WAN качване\",\n        \"wanDownload\": \"WAN теглене\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Статус на принтер\",\n        \"print_status\": \"Статус на принта\",\n        \"print_progress\": \"Напредък\",\n        \"layers\": \"Слоеве\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Температур на уреда\",\n        \"temp_bed\": \"Температура на леглото\",\n        \"job_completion\": \"Завършване\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Origin IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Средно натоварване\",\n        \"memory\": \"Употреба памет\",\n        \"wanStatus\": \"WAN статус\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Употреба на диска\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"База данни\",\n        \"failed_tasks_24h\": \"Провалени задачи 24ч\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Памет\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Снимки\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Хранилище\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Инцидент\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Архиви\",\n        \"chapters\": \"Глави\",\n        \"categories\": \"Категории\"\n    },\n    \"komga\": {\n        \"libraries\": \"Библиотеки\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Издания\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"Хора\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Време\"\n    },\n    \"firefly\": {\n        \"networth\": \"Net Worth\",\n        \"budget\": \"Бюджет\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Dashboards\",\n        \"datasources\": \"Източници на данни\",\n        \"totalalerts\": \"Total Alerts\",\n        \"alertstriggered\": \"Alerts Triggered\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Натоварване на процесор\",\n        \"memoryusage\": \"Употреба на паметта\",\n        \"freespace\": \"Свободно пространство\",\n        \"activeusers\": \"Активни потребители\",\n        \"numfiles\": \"Файлове\",\n        \"numshares\": \"Споделени записи\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Размер\",\n        \"lastrun\": \"Последно стартиране\",\n        \"nextrun\": \"Следващо стартиране\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Активни работници\",\n        \"total_workers\": \"Общо работници\",\n        \"records_total\": \"Дължина на опашка\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Сървъри\",\n        \"nodes\": \"Възли\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Targets Up\",\n        \"targets_down\": \"Targets Down\",\n        \"targets_total\": \"Total Targets\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"Една година\",\n        \"gross_percent_max\": \"All time\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Подкасти\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Продължителност\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"People Home\",\n        \"lights_on\": \"Lights On\",\n        \"switches_on\": \"Switches On\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Наблюдение\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Автори\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Резултат\",\n        \"status\": \"Status\",\n        \"buildId\": \"Build ID\",\n        \"succeeded\": \"Успешни\",\n        \"notStarted\": \"Не стартирани\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Отказани\",\n        \"inProgress\": \"В процес\",\n        \"totalPrs\": \"Total PRs\",\n        \"myPrs\": \"My PRs\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Име\",\n        \"map\": \"Карта\",\n        \"currentPlayers\": \"Текущи играчи\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Максимален брой играчи\",\n        \"bots\": \"Ботове\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"ОК\",\n        \"errored\": \"Грешки\",\n        \"noRecent\": \"Out of Date\",\n        \"totalUsed\": \"Used Storage\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Рецепти\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Тагове\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Изтегляне\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"CPU Load Avg (5m)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Изпратено\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Последно изключване\",\n        \"downDuration\": \"Продължителност на изключване\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Непроверено\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Изглежда изключено\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"В кината\",\n        \"physicalRelease\": \"Physical release\",\n        \"digitalRelease\": \"Дигитално издания\",\n        \"noEventsToday\": \"Няма събития за днес!\",\n        \"noEventsFound\": \"Няма намерени събития\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Платформи\",\n        \"totalRoms\": \"Игри\",\n        \"saves\": \"Saves\",\n        \"states\": \"States\",\n        \"screenshots\": \"Снимки на екрана\",\n        \"totalfilesize\": \"Общ размер\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Пощенски кутии\",\n        \"mails\": \"Mails\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Предупреждения\",\n        \"criticals\": \"Критични\"\n    },\n    \"plantit\": {\n        \"events\": \"Събития\",\n        \"plants\": \"Растения\",\n        \"photos\": \"Photos\",\n        \"species\": \"Видове\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Известия\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Заявки за сливане\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Сцени\",\n        \"scenesPlayed\": \"Scenes Played\",\n        \"playCount\": \"Total Plays\",\n        \"playDuration\": \"Time Watched\",\n        \"sceneSize\": \"Размер на сцени\",\n        \"sceneDuration\": \"Продължителност на сцени\",\n        \"images\": \"Изображения\",\n        \"imageSize\": \"Размер на изображенията\",\n        \"galleries\": \"Галерии\",\n        \"performers\": \"Performers\",\n        \"studios\": \"Студиа\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O Count\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Ключови думи\"\n    },\n    \"homebox\": {\n        \"items\": \"Елементи\",\n        \"totalWithWarranty\": \"В гаранция\",\n        \"locations\": \"Места\",\n        \"labels\": \"Етикети\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Обща стойност\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Bans\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxied\",\n        \"auth\": \"With Auth\",\n        \"outdated\": \"Outdated\",\n        \"banned\": \"Banned\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Акции\",\n        \"loading\": \"Зареждане\",\n        \"open\": \"Отворен - пазар САЩ\",\n        \"closed\": \"Затворен - пазар САЩ\",\n        \"invalidConfiguration\": \"Невалидна конфигурация\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Cameras\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Links\",\n        \"collections\": \"Collections\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Not classified\",\n        \"information\": \"Information\",\n        \"warning\": \"Warning\",\n        \"average\": \"Average\",\n        \"high\": \"High\",\n        \"disaster\": \"Disaster\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vehicle\",\n        \"vehicles\": \"Vehicles\",\n        \"serviceRecords\": \"Service Records\",\n        \"reminders\": \"Reminders\",\n        \"nextReminder\": \"Следващо напомняне\",\n        \"none\": \"Нищо\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Активни проекти\",\n        \"tasks7d\": \"Задачи тази седмица\",\n        \"tasksOverdue\": \"Overdue Tasks\",\n        \"tasksInProgress\": \"Tasks In Progress\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Системи\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Диск\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Приложения\",\n        \"synced\": \"Synced\",\n        \"outOfSync\": \"\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Деградирани\",\n        \"progressing\": \"Progressing\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Suspended\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Groups\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Проекти\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bookmarks\",\n        \"favorites\": \"Favorites\",\n        \"archived\": \"Archived\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Lists\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Other\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/ca/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"mes\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Falta el tipus de widget: {{type}}\",\n        \"api_error\": \"Error d'API\",\n        \"information\": \"Informació\",\n        \"status\": \"Estat\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Error sense processar\",\n        \"response_data\": \"Dades de resposta\"\n    },\n    \"weather\": {\n        \"current\": \"Localització actual\",\n        \"allow\": \"Feu clic per permetre\",\n        \"updating\": \"Actualitzant\",\n        \"wait\": \"Si us plau, espereu\"\n    },\n    \"search\": {\n        \"placeholder\": \"Cerca…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"MEM\",\n        \"total\": \"Total\",\n        \"free\": \"Lliure\",\n        \"used\": \"Utilitzat\",\n        \"load\": \"Càrrega\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Màx.\",\n        \"uptime\": \"ACTIU\"\n    },\n    \"unifi\": {\n        \"users\": \"Usuaris\",\n        \"uptime\": \"Temps actiu\",\n        \"days\": \"Dies\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Dispositius\",\n        \"lan_devices\": \"Dispositius LAN\",\n        \"wlan_devices\": \"Dispositius WLAN\",\n        \"lan_users\": \"Usuaris LAN\",\n        \"wlan_users\": \"Usuaris WLAN\",\n        \"up\": \"ACTIU\",\n        \"down\": \"INACTIU\",\n        \"wait\": \"Si us plau espera\",\n        \"empty_data\": \"Estat del subsistema desconegut\"\n    },\n    \"docker\": {\n        \"rx\": \"Rebut\",\n        \"tx\": \"Transmès\",\n        \"mem\": \"Memòria\",\n        \"cpu\": \"CPU\",\n        \"running\": \"En execució\",\n        \"offline\": \"Fora de línia\",\n        \"error\": \"Error\",\n        \"unknown\": \"Desconegut\",\n        \"healthy\": \"Saludable\",\n        \"starting\": \"Iniciant\",\n        \"unhealthy\": \"No saludable\",\n        \"not_found\": \"No trobat\",\n        \"exited\": \"Tancat\",\n        \"partial\": \"Parcial\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"Latència\",\n        \"down\": \"Inactiu\",\n        \"up\": \"Actiu\",\n        \"not_available\": \"No Disponible\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"Estat HTTP\",\n        \"error\": \"Error\",\n        \"response\": \"Resposta\",\n        \"down\": \"Inactiu\",\n        \"up\": \"Actiu\",\n        \"not_available\": \"No disponible\"\n    },\n    \"emby\": {\n        \"playing\": \"Reproduint\",\n        \"transcoding\": \"Transcodificant\",\n        \"bitrate\": \"Taxa de bits\",\n        \"no_active\": \"Sense reproduccions actives\",\n        \"movies\": \"Pel·lícules\",\n        \"series\": \"Sèries\",\n        \"episodes\": \"Episodis\",\n        \"songs\": \"Cançons\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Reproduïnt\",\n        \"transcoding\": \"Transcodificant\",\n        \"bitrate\": \"Taxa de bits\",\n        \"no_active\": \"Sense reproduccions actives\",\n        \"movies\": \"Pel·lícules\",\n        \"series\": \"Sèries\",\n        \"episodes\": \"Episodis\",\n        \"songs\": \"Cançons\"\n    },\n    \"esphome\": {\n        \"offline\": \"Desconnectat\",\n        \"offline_alt\": \"Desconnectat\",\n        \"online\": \"En línia\",\n        \"total\": \"Total\",\n        \"unknown\": \"Desconegut\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Producció\",\n        \"battery_soc\": \"Bateria\",\n        \"grid_power\": \"Xarxa\",\n        \"home_power\": \"Consum\",\n        \"charge_power\": \"Carregador\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Descarregar\",\n        \"upload\": \"Pujada\",\n        \"leech\": \"Sangonera\",\n        \"seed\": \"Llavors\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Subcripcions\",\n        \"unread\": \"Sense llegir\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Estat\",\n        \"connectionStatusUnconfigured\": \"Sense configurar\",\n        \"connectionStatusConnecting\": \"Connectant\",\n        \"connectionStatusAuthenticating\": \"Autenticant\",\n        \"connectionStatusPendingDisconnect\": \"Desconnexió pendent\",\n        \"connectionStatusDisconnecting\": \"Desconnectant\",\n        \"connectionStatusDisconnected\": \"Desconnectat\",\n        \"connectionStatusConnected\": \"Connectat\",\n        \"uptime\": \"Temps en funcionament\",\n        \"maxDown\": \"Màx. Descàrrega\",\n        \"maxUp\": \"Màx. Càrrega\",\n        \"down\": \"Inactiu\",\n        \"up\": \"Actiu\",\n        \"received\": \"Rebuts\",\n        \"sent\": \"Enviats\",\n        \"externalIPAddress\": \"IP ext.\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Peticions actuals\",\n        \"requests_failed\": \"Peticions fallides\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Total d'observats\",\n        \"diffsDetected\": \"Diferències detectades\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Sèries\",\n        \"recordings\": \"Gravacions\",\n        \"scheduled\": \"Programat\",\n        \"passes\": \"Aprovat\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Reproduïnt\",\n        \"transcoding\": \"Transcodificant\",\n        \"bitrate\": \"Taxa de bits\",\n        \"no_active\": \"Sense reproduccions actives\",\n        \"plex_connection_error\": \"Comprova la connexió de Plex\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"Sense reproduccions actives\",\n        \"streams\": \"Transmissions\",\n        \"transcodes\": \"Transcodificacions\",\n        \"directplay\": \"Reproducció directa\",\n        \"bitrate\": \"Taxa de bits\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"AP connectats\",\n        \"activeUser\": \"Dispositius actius\",\n        \"alerts\": \"Alertes\",\n        \"connectedGateways\": \"Pasarel·les connectades\",\n        \"connectedSwitches\": \"Conmutadors connectats\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Taxa\",\n        \"remaining\": \"Restant\",\n        \"downloaded\": \"Descarregat\"\n    },\n    \"plex\": {\n        \"streams\": \"Transmissions actives\",\n        \"albums\": \"Àlbums\",\n        \"movies\": \"Pel·lícules\",\n        \"tv\": \"Sèries\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Taxa\",\n        \"queue\": \"Cua\",\n        \"timeleft\": \"Temps restant\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Actiu\",\n        \"upload\": \"Pujada\",\n        \"download\": \"Baixada\"\n    },\n    \"transmission\": {\n        \"download\": \"Baixada\",\n        \"upload\": \"Pujada\",\n        \"leech\": \"Sangonera\",\n        \"seed\": \"Sembrat\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"Ús de CPU\",\n        \"memUsage\": \"Ús de Memòria\",\n        \"systemTempC\": \"Temp. Sistema\",\n        \"poolUsage\": \"Ús de les Reserves\",\n        \"volumeUsage\": \"Ús dels Volums\",\n        \"invalid\": \"No vàlid\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Bytes trobats a la memòria cau\",\n        \"cachemissbytes\": \"Bytes no trobats a la memòria cau\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Volgut\",\n        \"queued\": \"En cua\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"Falten\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"Artistes\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"Llibres\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Episodis que falten\",\n        \"missingMovies\": \"Pel·lícules que falten\"\n    },\n    \"ombi\": {\n        \"pending\": \"Pendent\",\n        \"approved\": \"Aprovat\",\n        \"available\": \"Disponible\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"Nous dispositius\",\n        \"down_alerts\": \"Alertes de caigudes\"\n    },\n    \"pihole\": {\n        \"queries\": \"Consultes\",\n        \"blocked\": \"Bloquejat\",\n        \"blocked_percent\": \"Bloquejat %\",\n        \"gravity\": \"Gravetat\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"Filtrat\",\n        \"latency\": \"Latència\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Aturat\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Descarregat\",\n        \"nondownload\": \"No descarregat\",\n        \"read\": \"Llegits\",\n        \"unread\": \"No llegits\",\n        \"downloadedread\": \"Descarregat i llegit\",\n        \"downloadedunread\": \"Descarregat i per llegir\",\n        \"nondownloadedread\": \"No descarregat i llegit\",\n        \"nondownloadedunread\": \"No descarregat i per llegir\"\n    },\n    \"tailscale\": {\n        \"address\": \"Adreça\",\n        \"expires\": \"Caduca\",\n        \"never\": \"Mai\",\n        \"last_seen\": \"Vist per darrer cop\",\n        \"now\": \"Ara\",\n        \"years\": \"{{number}}a\",\n        \"weeks\": \"{{number}}set\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"Fa {{value}}\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Consultes\",\n        \"totalNoError\": \"Èxits\",\n        \"totalServerFailure\": \"Fallades\",\n        \"totalNxDomain\": \"Dominis NX\",\n        \"totalRefused\": \"Rebutjat\",\n        \"totalAuthoritative\": \"Autoritatiu\",\n        \"totalRecursive\": \"Recursiu\",\n        \"totalCached\": \"A la memòria cau\",\n        \"totalBlocked\": \"Bloquejats\",\n        \"totalDropped\": \"Abandonat\",\n        \"totalClients\": \"Clients\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Cua\",\n        \"processed\": \"Processat\",\n        \"errored\": \"Error\",\n        \"saved\": \"Estalviat\"\n    },\n    \"traefik\": {\n        \"routers\": \"Encaminadors\",\n        \"services\": \"Serveis\",\n        \"middleware\": \"Intermediari\"\n    },\n    \"trilium\": {\n        \"version\": \"Versió\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Tamany de la base de dades\",\n        \"unknown\": \"Desconegut\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"Sense reproduccions actives\",\n        \"please_wait\": \"Espereu si us plau\"\n    },\n    \"npm\": {\n        \"enabled\": \"Activat\",\n        \"disabled\": \"Desactivat\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Configura una o més criptomonedes per fer el seguiment\",\n        \"1hour\": \"1 Hora\",\n        \"1day\": \"1 Dia\",\n        \"7days\": \"7 Dies\",\n        \"30days\": \"30 Dies\"\n    },\n    \"gotify\": {\n        \"apps\": \"Aplicacions\",\n        \"clients\": \"Clients\",\n        \"messages\": \"Missatges\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indexadors\",\n        \"numberOfGrabs\": \"Captures\",\n        \"numberOfQueries\": \"Consultes\",\n        \"numberOfFailGrabs\": \"Captures fallides\",\n        \"numberOfFailQueries\": \"Consultes fallides\"\n    },\n    \"jackett\": {\n        \"configured\": \"Configurat\",\n        \"errored\": \"Errors\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sessions\",\n        \"numConnections\": \"Connexions\",\n        \"dataRelayed\": \"Transmès\",\n        \"transferRate\": \"Taxa\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Usuaris\",\n        \"status_count\": \"Publicacions\",\n        \"domain_count\": \"Dominis\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Volguts\",\n        \"queued\": \"Encuat\",\n        \"series\": \"Sèries\"\n    },\n    \"minecraft\": {\n        \"players\": \"Jugadors\",\n        \"version\": \"Versió\",\n        \"status\": \"Estat\",\n        \"up\": \"En línia\",\n        \"down\": \"Fora de línia\"\n    },\n    \"miniflux\": {\n        \"read\": \"Llegit\",\n        \"unread\": \"No llegits\"\n    },\n    \"authentik\": {\n        \"users\": \"Usuaris\",\n        \"loginsLast24H\": \"Inicis de sessió (24h)\",\n        \"failedLoginsLast24H\": \"Errors d'inici de sessió (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Càrrega\",\n        \"wait\": \"Si us plau espera\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temp\",\n        \"warn\": \"Avís\",\n        \"uptime\": \"ACTIU\",\n        \"total\": \"Total\",\n        \"free\": \"Lliure\",\n        \"used\": \"Utilitzat\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Crític\",\n        \"read\": \"Lectura\",\n        \"write\": \"Escriptura\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Intercanvi\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Marcador\",\n        \"service\": \"Servei\",\n        \"search\": \"Cerca\",\n        \"custom\": \"Personalitzat\",\n        \"visit\": \"Visita\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Suggeriment\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Assolellat\",\n        \"0-night\": \"Cel clar\",\n        \"1-day\": \"Majorment assolellat\",\n        \"1-night\": \"Majorment clar\",\n        \"2-day\": \"Parcialment ennuvolat\",\n        \"2-night\": \"Parcialment ennuvolat\",\n        \"3-day\": \"Ennuvolat\",\n        \"3-night\": \"Ennuvolat\",\n        \"45-day\": \"Boirós\",\n        \"45-night\": \"Emboirat\",\n        \"48-day\": \"Boirós\",\n        \"48-night\": \"Emboirat\",\n        \"51-day\": \"Ruixats lleugers\",\n        \"51-night\": \"Plugim lleuger\",\n        \"53-day\": \"Ruixat\",\n        \"53-night\": \"Plugim\",\n        \"55-day\": \"Ruixat intens\",\n        \"55-night\": \"Plovisqueig intens\",\n        \"56-day\": \"Lleuger ruixat gelat\",\n        \"56-night\": \"Lleuger ruixat gelat\",\n        \"57-day\": \"Ruixat gelat\",\n        \"57-night\": \"Plugim gelat\",\n        \"61-day\": \"Pluja lleugera\",\n        \"61-night\": \"Pluja lleugera\",\n        \"63-day\": \"Pluja\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Pluja intensa\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Pluja gelada\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Neu lleugera\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Neu\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Neu intensa\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Neu lleugera\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Plovisqueig\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Xàfecs\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Xàfecs intensos\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Xàfecs de neu\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"Tempesta\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Tempesta amb calamarsa\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Sistema\",\n        \"updates\": \"Actualitzacions\",\n        \"update_available\": \"Actualització disponible\",\n        \"up_to_date\": \"Actualitzat\",\n        \"child_bridges\": \"Ponts fills\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Nou\",\n        \"up\": \"Up\",\n        \"grace\": \"En Període de gràcia\",\n        \"down\": \"Down\",\n        \"paused\": \"En pausa\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Últim ping\",\n        \"never\": \"Sense pings\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Escanejat\",\n        \"containers_updated\": \"Actualitzat\",\n        \"containers_failed\": \"Error\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Rebutjat\",\n        \"filters\": \"Filtres\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Vídeos\",\n        \"channels\": \"Canals\",\n        \"playlists\": \"Llistes de reproducció\"\n    },\n    \"truenas\": {\n        \"load\": \"Càrrega del sistema\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Velocitat\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"IP Pública\",\n        \"region\": \"Regió\",\n        \"country\": \"País\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Sintonitzadors\",\n        \"channelNumber\": \"Canal\",\n        \"channelNetwork\": \"Xarxa\",\n        \"signalStrength\": \"Intensitat\",\n        \"signalQuality\": \"Qualitat\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Client\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Aprovat\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Safata d'entrada\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Càrrega de la bateria\",\n        \"ups_load\": \"Càrrega del SAI\",\n        \"ups_status\": \"Estat del SAI\",\n        \"online\": \"Online\",\n        \"on_battery\": \"En Bateria\",\n        \"low_battery\": \"Bateria Baixa\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"No s'han rebut dades del Dispositiu\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"Càrrega de CPU\",\n        \"memoryUsed\": \"Memoria en ús\",\n        \"uptime\": \"Temps en funcionament\",\n        \"numberOfLeases\": \"IPs assignades\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Tots els streams\",\n        \"streams_active\": \"Transmissions actives\",\n        \"streams_xepg\": \"Canals XEPG\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Avui\",\n        \"absolutePower\": \"Potència\",\n        \"relativePower\": \"Potència %\",\n        \"limit\": \"Límit\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"Càrrega de CPU\",\n        \"memory\": \"Memòria activa\",\n        \"wanUpload\": \"Pujada WAN\",\n        \"wanDownload\": \"Baixada WAN\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Estat de l'impressora\",\n        \"print_status\": \"Estat de l'impressió\",\n        \"print_progress\": \"Progrés\",\n        \"layers\": \"Capes\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Estat\",\n        \"temp_tool\": \"Temperatura capçal\",\n        \"temp_bed\": \"Temperatura llit\",\n        \"job_completion\": \"Finalització\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"IP Origen\",\n        \"status\": \"Estat\"\n    },\n    \"pfsense\": {\n        \"load\": \"Càrrega mitjana\",\n        \"memory\": \"Ús Memòria\",\n        \"wanStatus\": \"Estat WAN\",\n        \"up\": \"Actiu\",\n        \"down\": \"Inactiu\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Ús Disc\",\n        \"wanIP\": \"IP WAN\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datastore\",\n        \"failed_tasks_24h\": \"Tasques fallides (24h)\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memòria\"\n    },\n    \"immich\": {\n        \"users\": \"Usuaris\",\n        \"photos\": \"Fotos\",\n        \"videos\": \"Vídeos\",\n        \"storage\": \"Emmagatzematge\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Actius\",\n        \"down\": \"Caiguts\",\n        \"uptime\": \"Temps en funcionament\",\n        \"incident\": \"Incidència\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Sèries\",\n        \"archives\": \"Arxius\",\n        \"chapters\": \"Capítols\",\n        \"categories\": \"Categories\"\n    },\n    \"komga\": {\n        \"libraries\": \"Biblioteques\",\n        \"series\": \"Sèries\",\n        \"books\": \"Llibres\"\n    },\n    \"diskstation\": {\n        \"days\": \"Dies\",\n        \"uptime\": \"Temps en funcionament\",\n        \"volumeAvailable\": \"Disponible\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Canals\",\n        \"streams\": \"Transmissions\"\n    },\n    \"mylar\": {\n        \"series\": \"Sèries\",\n        \"issues\": \"Problemes\",\n        \"wanted\": \"Volguts\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Àlbums\",\n        \"photos\": \"Fotos\",\n        \"videos\": \"Vídeos\",\n        \"people\": \"Gent\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Cua\",\n        \"processing\": \"Processant\",\n        \"processed\": \"Processat\",\n        \"time\": \"Temps\"\n    },\n    \"firefly\": {\n        \"networth\": \"Valor Net\",\n        \"budget\": \"Pressupost\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Taulells\",\n        \"datasources\": \"Orígens de dades\",\n        \"totalalerts\": \"Alertes Totals\",\n        \"alertstriggered\": \"Alertes disparades\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Càrrega de CPU\",\n        \"memoryusage\": \"Ús Memòria\",\n        \"freespace\": \"Espai lliure\",\n        \"activeusers\": \"Usuaris actius\",\n        \"numfiles\": \"Fitxers\",\n        \"numshares\": \"Elements compartits\"\n    },\n    \"kopia\": {\n        \"status\": \"Estat\",\n        \"size\": \"Mida\",\n        \"lastrun\": \"Darrera execució\",\n        \"nextrun\": \"Següent execució\",\n        \"failed\": \"Error\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Treballadors actius\",\n        \"total_workers\": \"Treballadors Totals\",\n        \"records_total\": \"Llargada de la Cua\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servidors\",\n        \"nodes\": \"Nodes\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Objectius actius\",\n        \"targets_down\": \"Objectius caiguts\",\n        \"targets_total\": \"Objectius Totals\"\n    },\n    \"gatus\": {\n        \"up\": \"Actius\",\n        \"down\": \"Caiguts\",\n        \"uptime\": \"Temps en funcionament\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Avui\",\n        \"gross_percent_1y\": \"Un any\",\n        \"gross_percent_max\": \"Sempre\",\n        \"net_worth\": \"Valor Net\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Pòdcasts\",\n        \"books\": \"Llibres\",\n        \"podcastsDuration\": \"Durada\",\n        \"booksDuration\": \"Durada\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Gent a casa\",\n        \"lights_on\": \"Llums enceses\",\n        \"switches_on\": \"Endolls activats\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Supervisió\",\n        \"updates\": \"Actualitzacions\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Llibres\",\n        \"authors\": \"Autors\",\n        \"categories\": \"Categories\",\n        \"series\": \"Sèries\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Biblioteques\",\n        \"books\": \"Llibres\",\n        \"reading\": \"Llegint\",\n        \"finished\": \"Acabats\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Cua\",\n        \"downloadBytesRemaining\": \"Restant\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Resultat\",\n        \"status\": \"Status\",\n        \"buildId\": \"Id de compilació\",\n        \"succeeded\": \"Amb èxit\",\n        \"notStarted\": \"No Iniciat\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Cancel·lat\",\n        \"inProgress\": \"En curs\",\n        \"totalPrs\": \"PRs Totals\",\n        \"myPrs\": \"Les meves PRs\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Nom\",\n        \"map\": \"Mapa\",\n        \"currentPlayers\": \"Jugadors actuals\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Màxim de jugadors\",\n        \"bots\": \"Bots\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Errors\",\n        \"noRecent\": \"Obsolet\",\n        \"totalUsed\": \"Emmagatzematge utilitzat\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Receptes\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Etiquetes\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Descarregant\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"Càrrega mitjana de CPU (5min)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Enviat\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Darrera Inactivitat\",\n        \"downDuration\": \"Duració d'Inactivitat\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Sense verificar\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Sembla caigut\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"En cines\",\n        \"physicalRelease\": \"Estrena física\",\n        \"digitalRelease\": \"Estrena digital\",\n        \"noEventsToday\": \"Cap esdeveniment per avui!\",\n        \"noEventsFound\": \"No s'han trobat esdeveniments\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Plataformes\",\n        \"totalRoms\": \"Jocs\",\n        \"saves\": \"Partides desades\",\n        \"states\": \"Estats\",\n        \"screenshots\": \"Captures de pantalla\",\n        \"totalfilesize\": \"Tamany total\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Bústies\",\n        \"mails\": \"Correus\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Avisos\",\n        \"criticals\": \"Crítics\"\n    },\n    \"plantit\": {\n        \"events\": \"Esdeveniments\",\n        \"plants\": \"Plantes\",\n        \"photos\": \"Photos\",\n        \"species\": \"Espècies\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Notificacions\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Sol·licitud de Canvis\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Escenes\",\n        \"scenesPlayed\": \"Escenes reproduïdes\",\n        \"playCount\": \"Total reproduccions\",\n        \"playDuration\": \"Temps visionat\",\n        \"sceneSize\": \"Tamany d'escenes\",\n        \"sceneDuration\": \"Duració Escenes\",\n        \"images\": \"Imatges\",\n        \"imageSize\": \"Tamany d'imatges\",\n        \"galleries\": \"Biblioteques\",\n        \"performers\": \"Intèrprets\",\n        \"studios\": \"Estudis\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O Count\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Paraules claus\"\n    },\n    \"homebox\": {\n        \"items\": \"Elements\",\n        \"totalWithWarranty\": \"Amb Garantia\",\n        \"locations\": \"Ubicacions\",\n        \"labels\": \"Etiquetes\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Valor total\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Prohibicions\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Intermediat\",\n        \"auth\": \"Amb autentificació\",\n        \"outdated\": \"Obsolet\",\n        \"banned\": \"Bloquejat\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Accions\",\n        \"loading\": \"Carregant\",\n        \"open\": \"Obert - Mercat EEUU\",\n        \"closed\": \"Tancat - Mercat EEUU\",\n        \"invalidConfiguration\": \"Configuració no vàlida\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Càmeres\",\n        \"uptime\": \"Temps en funcionament\",\n        \"version\": \"Versió\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Enllaços\",\n        \"collections\": \"Col·leccions\",\n        \"tags\": \"Etiquetes\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"No classificat\",\n        \"information\": \"Informació\",\n        \"warning\": \"Avís\",\n        \"average\": \"Mitjana\",\n        \"high\": \"Alt\",\n        \"disaster\": \"Desastre\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vehicle\",\n        \"vehicles\": \"Vehicles\",\n        \"serviceRecords\": \"Constàncies de manteniment\",\n        \"reminders\": \"Recordatoris\",\n        \"nextReminder\": \"Proper recordatori\",\n        \"none\": \"Cap\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Projectes actius\",\n        \"tasks7d\": \"Tasques a completar aquesta setmana\",\n        \"tasksOverdue\": \"Tasques vençudes\",\n        \"tasksInProgress\": \"Tasques en marxa\"\n    },\n    \"headscale\": {\n        \"name\": \"Nom\",\n        \"address\": \"Adreça\",\n        \"last_seen\": \"Vist per darrera vegada\",\n        \"status\": \"Estat\",\n        \"online\": \"En línia\",\n        \"offline\": \"Desconnectat\"\n    },\n    \"beszel\": {\n        \"name\": \"Nom\",\n        \"systems\": \"Sistemes\",\n        \"up\": \"Actiu\",\n        \"down\": \"Inactiu\",\n        \"paused\": \"Pausat\",\n        \"pending\": \"Pendent\",\n        \"status\": \"Estat\",\n        \"updated\": \"Actualitzat\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Disc\",\n        \"network\": \"XARXA\"\n    },\n    \"argocd\": {\n        \"apps\": \"Apps\",\n        \"synced\": \"Sincronitzats\",\n        \"outOfSync\": \"Dessincronitzats\",\n        \"healthy\": \"Sa\",\n        \"degraded\": \"Degradats\",\n        \"progressing\": \"Progressant\",\n        \"missing\": \"Falten\",\n        \"suspended\": \"Suspesos\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Carregant\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Grups\",\n        \"issues\": \"Problemes\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Projectes\"\n    },\n    \"apcups\": {\n        \"status\": \"Estat\",\n        \"load\": \"Càrrega\",\n        \"bcharge\": \"Càrrega de la bateria\",\n        \"timeleft\": \"Temps restant\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Marcadors\",\n        \"favorites\": \"Preferits\",\n        \"archived\": \"Arxivats\",\n        \"highlights\": \"Destacats\",\n        \"lists\": \"Llistes\",\n        \"tags\": \"Etiquetes\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Other\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/cs/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"měs\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Chybí typ widgetu: {{type}}\",\n        \"api_error\": \"Chyba API\",\n        \"information\": \"Informace\",\n        \"status\": \"Stav\",\n        \"url\": \"Odkaz\",\n        \"raw_error\": \"Nevyřešená chyba\",\n        \"response_data\": \"Data odezvy\"\n    },\n    \"weather\": {\n        \"current\": \"Aktuální poloha\",\n        \"allow\": \"Klikni pro povolení\",\n        \"updating\": \"Probíhá aktualizace\",\n        \"wait\": \"Počkejte prosím\"\n    },\n    \"search\": {\n        \"placeholder\": \"Hledat…\"\n    },\n    \"resources\": {\n        \"cpu\": \"Zatížení procesoru\",\n        \"mem\": \"Využití paměti\",\n        \"total\": \"Celkem\",\n        \"free\": \"Volné\",\n        \"used\": \"Využité\",\n        \"load\": \"Zatížení\",\n        \"temp\": \"TEPLOTA\",\n        \"max\": \"Max.\",\n        \"uptime\": \"BĚŽÍ\"\n    },\n    \"unifi\": {\n        \"users\": \"Uživatelé\",\n        \"uptime\": \"Doba provozu\",\n        \"days\": \"dní\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Zařízení\",\n        \"lan_devices\": \"Zařízení LAN\",\n        \"wlan_devices\": \"Zařízení WLAN\",\n        \"lan_users\": \"Uživatelé LAN\",\n        \"wlan_users\": \"Uživatelé WLAN\",\n        \"up\": \"BĚŽÍ\",\n        \"down\": \"NEFUNKČNÍ\",\n        \"wait\": \"Čekejte prosím\",\n        \"empty_data\": \"Stav podsystému neznámý\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"Využití paměti\",\n        \"cpu\": \"Zatížení procesoru\",\n        \"running\": \"Běží\",\n        \"offline\": \"Offline\",\n        \"error\": \"Chyba\",\n        \"unknown\": \"Neznámý\",\n        \"healthy\": \"Zdravý\",\n        \"starting\": \"Spouští se\",\n        \"unhealthy\": \"Nezdravý\",\n        \"not_found\": \"Nenalezen\",\n        \"exited\": \"Ukončen\",\n        \"partial\": \"Částečný\"\n    },\n    \"ping\": {\n        \"error\": \"Chyba\",\n        \"ping\": \"Odezva\",\n        \"down\": \"Výpadek\",\n        \"up\": \"Běží\",\n        \"not_available\": \"Není k dispozici\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"Stav HTTP\",\n        \"error\": \"Chyba\",\n        \"response\": \"Odpověď\",\n        \"down\": \"Výpadek\",\n        \"up\": \"Běží\",\n        \"not_available\": \"Nedostupný\"\n    },\n    \"emby\": {\n        \"playing\": \"Přehrává\",\n        \"transcoding\": \"Překódovávání\",\n        \"bitrate\": \"Přenosová rychlost\",\n        \"no_active\": \"Žádný aktivní stream\",\n        \"movies\": \"Filmy\",\n        \"series\": \"Seriály\",\n        \"episodes\": \"Epizody\",\n        \"songs\": \"Skladby\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Přehrává se\",\n        \"transcoding\": \"Překódovávání\",\n        \"bitrate\": \"Přenosová rychlost\",\n        \"no_active\": \"Žádný aktivní stream\",\n        \"movies\": \"Filmy\",\n        \"series\": \"Seriály\",\n        \"episodes\": \"Epizody\",\n        \"songs\": \"Skladby\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Online\",\n        \"total\": \"Celkem\",\n        \"unknown\": \"Neznámý\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Produkce\",\n        \"battery_soc\": \"Baterie\",\n        \"grid_power\": \"Mřížka\",\n        \"home_power\": \"Spotřeba\",\n        \"charge_power\": \"Nabíječka\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Stahování\",\n        \"upload\": \"Nahrávání\",\n        \"leech\": \"Leechované\",\n        \"seed\": \"Seedované\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Odběry\",\n        \"unread\": \"Nepřečteno\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Stav\",\n        \"connectionStatusUnconfigured\": \"Nenastaveno\",\n        \"connectionStatusConnecting\": \"Připojuji\",\n        \"connectionStatusAuthenticating\": \"Ověřování\",\n        \"connectionStatusPendingDisconnect\": \"Čeká na odpojení\",\n        \"connectionStatusDisconnecting\": \"Odpojování\",\n        \"connectionStatusDisconnected\": \"Odpojeno\",\n        \"connectionStatusConnected\": \"\",\n        \"uptime\": \"Doba provozu\",\n        \"maxDown\": \"Max. Down\",\n        \"maxUp\": \"Max. Up\",\n        \"down\": \"Výpadek\",\n        \"up\": \"Běží\",\n        \"received\": \"Přijaté\",\n        \"sent\": \"Odeslané\",\n        \"externalIPAddress\": \"Ext. IP\",\n        \"externalIPv6Address\": \"Veřejná IPv6\",\n        \"externalIPv6Prefix\": \"Věřejná IPv6 prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Odesílání dat\",\n        \"requests\": \"Aktuální požadavky\",\n        \"requests_failed\": \"Selhavší požadavky\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Celkem zjištěno\",\n        \"diffsDetected\": \"Rozdíly detekovány\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Pořady\",\n        \"recordings\": \"Nahrávky\",\n        \"scheduled\": \"Naplánováno\",\n        \"passes\": \"Průchody\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Přehrává se\",\n        \"transcoding\": \"Překódovávání\",\n        \"bitrate\": \"Přenosová rychlost\",\n        \"no_active\": \"Žádný aktivní stream\",\n        \"plex_connection_error\": \"Zkontrolujte připojení Plexu\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"Žádný aktivní stream\",\n        \"streams\": \"Streamy\",\n        \"transcodes\": \"Překódování\",\n        \"directplay\": \"Přímé přehrávání\",\n        \"bitrate\": \"Přenosová rychlost\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Připojené APs\",\n        \"activeUser\": \"Aktivní zařízení\",\n        \"alerts\": \"Upozornění\",\n        \"connectedGateways\": \"Připojené brány\",\n        \"connectedSwitches\": \"Připojené přepínače\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Rychlost\",\n        \"remaining\": \"Zbývá\",\n        \"downloaded\": \"Staženo\"\n    },\n    \"plex\": {\n        \"streams\": \"Aktivní streamy\",\n        \"albums\": \"Alba\",\n        \"movies\": \"Filmy\",\n        \"tv\": \"Seriály\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rychlost\",\n        \"queue\": \"Fronta\",\n        \"timeleft\": \"Zbývající čas\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Aktivní\",\n        \"upload\": \"Nahrávání\",\n        \"download\": \"Stahování\"\n    },\n    \"transmission\": {\n        \"download\": \"Stahování\",\n        \"upload\": \"Nahrávání\",\n        \"leech\": \"Leechované\",\n        \"seed\": \"Seedované\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Stahování\",\n        \"upload\": \"Nahrávání\",\n        \"leech\": \"Leechované\",\n        \"seed\": \"Seedované\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"Zatížení procesoru\",\n        \"memUsage\": \"Využití paměti\",\n        \"systemTempC\": \"Teplota systému\",\n        \"poolUsage\": \"Využití fondu\",\n        \"volumeUsage\": \"Využití svazku\",\n        \"invalid\": \"Neplatné\"\n    },\n    \"deluge\": {\n        \"download\": \"Stahování\",\n        \"upload\": \"Nahrávání\",\n        \"leech\": \"Leechované\",\n        \"seed\": \"Seedování\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Byty nalezené v mezipaměti\",\n        \"cachemissbytes\": \"Byty nenalezené v mezipaměti\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Stahování\",\n        \"upload\": \"Nahravání\",\n        \"leech\": \"Leechované\",\n        \"seed\": \"Seedování\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Požadované\",\n        \"queued\": \"Ve frontě\",\n        \"series\": \"Seriály\",\n        \"queue\": \"Fronta\",\n        \"unknown\": \"Neznámý\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Požadované\",\n        \"missing\": \"Chybějící\",\n        \"queued\": \"Ve frontě\",\n        \"movies\": \"Filmy\",\n        \"queue\": \"Fronta\",\n        \"unknown\": \"Neznámý\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Požadované\",\n        \"queued\": \"Ve frontě\",\n        \"artists\": \"Interpreti\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Požadované\",\n        \"queued\": \"Ve frontě\",\n        \"books\": \"Knihy\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Chybějící epizody\",\n        \"missingMovies\": \"Chybějící filmy\"\n    },\n    \"ombi\": {\n        \"pending\": \"Čekající\",\n        \"approved\": \"Schváleno\",\n        \"available\": \"Dostupné\"\n    },\n    \"seerr\": {\n        \"pending\": \"Čekající\",\n        \"approved\": \"Schválené\",\n        \"available\": \"K dispozici\",\n        \"completed\": \"Dokončené\",\n        \"processing\": \"Zpracovává se\",\n        \"issues\": \"Aktuální problémy\"\n    },\n    \"netalertx\": {\n        \"total\": \"Celkem\",\n        \"connected\": \"Připojeno\",\n        \"new_devices\": \"\",\n        \"down_alerts\": \"Upozornění na výpadek\"\n    },\n    \"pihole\": {\n        \"queries\": \"Dotazy\",\n        \"blocked\": \"Blokováno\",\n        \"blocked_percent\": \"Blokováno\",\n        \"gravity\": \"Gravity\"\n    },\n    \"adguard\": {\n        \"queries\": \"Dotazy\",\n        \"blocked\": \"Blokováno\",\n        \"filtered\": \"Filtrováno\",\n        \"latency\": \"Odezva\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Nahrávání\",\n        \"download\": \"Stahování\",\n        \"ping\": \"Odezva\"\n    },\n    \"portainer\": {\n        \"running\": \"Běží\",\n        \"stopped\": \"Zastaveno\",\n        \"total\": \"Celkem\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Staženo\",\n        \"nondownload\": \"Nestaženo\",\n        \"read\": \"Přečtené\",\n        \"unread\": \"Nepřečtené\",\n        \"downloadedread\": \"Staženo a přečteno\",\n        \"downloadedunread\": \"Staženo a nepřečteno\",\n        \"nondownloadedread\": \"Nestaženo a přečteno\",\n        \"nondownloadedunread\": \"Nestaženo a nepřečteno\"\n    },\n    \"tailscale\": {\n        \"address\": \"Adresa\",\n        \"expires\": \"Vyprší\",\n        \"never\": \"Nikdy\",\n        \"last_seen\": \"Naposledy viděno\",\n        \"now\": \"Nyní\",\n        \"years\": \"{{number}}r\",\n        \"weeks\": \"{{number}}t\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"Před {{value}}\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Dotazy\",\n        \"totalNoError\": \"Úspěšně\",\n        \"totalServerFailure\": \"Chyby\",\n        \"totalNxDomain\": \"NX domény\",\n        \"totalRefused\": \"Odmítnuto\",\n        \"totalAuthoritative\": \"Autoritativní\",\n        \"totalRecursive\": \"Rekurzivní\",\n        \"totalCached\": \"V mezipaměti\",\n        \"totalBlocked\": \"Blokováno\",\n        \"totalDropped\": \"Vynecháno\",\n        \"totalClients\": \"Klienti\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Fronta\",\n        \"processed\": \"Zpracováno\",\n        \"errored\": \"Chybné\",\n        \"saved\": \"Uložené\"\n    },\n    \"traefik\": {\n        \"routers\": \"Routery\",\n        \"services\": \"Služby\",\n        \"middleware\": \"Prostředník\"\n    },\n    \"trilium\": {\n        \"version\": \"Verze\",\n        \"notesCount\": \"Poznámky\",\n        \"dbSize\": \"Velikost databáze\",\n        \"unknown\": \"Neznámý\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"Nic se nepřehrává\",\n        \"please_wait\": \"Čekejte prosím\"\n    },\n    \"npm\": {\n        \"enabled\": \"Povoleno\",\n        \"disabled\": \"Zakázáno\",\n        \"total\": \"Celkem\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Nakonfigurujte alespoň jednu crypto měnu ke sledování\",\n        \"1hour\": \"1 Hodina\",\n        \"1day\": \"1 Den\",\n        \"7days\": \"7 Dní\",\n        \"30days\": \"30 Dní\"\n    },\n    \"gotify\": {\n        \"apps\": \"Aplikace\",\n        \"clients\": \"Klienti\",\n        \"messages\": \"Zprávy\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indexery\",\n        \"numberOfGrabs\": \"Uchopení\",\n        \"numberOfQueries\": \"Dotazy\",\n        \"numberOfFailGrabs\": \"Neúspěšné uchopení\",\n        \"numberOfFailQueries\": \"Neúspěšné dotazy\"\n    },\n    \"jackett\": {\n        \"configured\": \"Konfigurováno\",\n        \"errored\": \"Chybné\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sezení\",\n        \"numConnections\": \"Připojení\",\n        \"dataRelayed\": \"Přenášení\",\n        \"transferRate\": \"Rychlost\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Uživatelé\",\n        \"status_count\": \"Příspěvky\",\n        \"domain_count\": \"Domény\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Požadované\",\n        \"queued\": \"Ve frontě\",\n        \"series\": \"Seriály\"\n    },\n    \"minecraft\": {\n        \"players\": \"Hráči\",\n        \"version\": \"Verze\",\n        \"status\": \"Stav\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Přečteno\",\n        \"unread\": \"Nepřečteno\"\n    },\n    \"authentik\": {\n        \"users\": \"Uživatelé\",\n        \"loginsLast24H\": \"Příhlášení (24h)\",\n        \"failedLoginsLast24H\": \"Neúspěšná přihlášení (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"Využití paměti\",\n        \"cpu\": \"Zatížení procesoru\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"Virtuální Stroje\"\n    },\n    \"glances\": {\n        \"cpu\": \"Zatížení procesoru\",\n        \"load\": \"Zatížení\",\n        \"wait\": \"Čekejte prosím\",\n        \"temp\": \"TEPLOTA\",\n        \"_temp\": \"Teplota\",\n        \"warn\": \"Varováni\",\n        \"uptime\": \"BĚŽÍ\",\n        \"total\": \"Celkem\",\n        \"free\": \"Volné\",\n        \"used\": \"Využité\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Kritické\",\n        \"read\": \"Přečteno\",\n        \"write\": \"Zápis\",\n        \"gpu\": \"Grafická karta\",\n        \"mem\": \"Využití paměti\",\n        \"swap\": \"Swap RAM\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Záložka\",\n        \"service\": \"Služba\",\n        \"search\": \"Hledat\",\n        \"custom\": \"Vlastní\",\n        \"visit\": \"Navštivte\",\n        \"url\": \"Odkaz\",\n        \"searchsuggestion\": \"Doporučení\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Slunečno\",\n        \"0-night\": \"Jasno\",\n        \"1-day\": \"Převážně slunečno\",\n        \"1-night\": \"Převážně jasno\",\n        \"2-day\": \"Polojasno\",\n        \"2-night\": \"Polojasno\",\n        \"3-day\": \"Oblačno\",\n        \"3-night\": \"Oblačno\",\n        \"45-day\": \"Mlha\",\n        \"45-night\": \"Mlha\",\n        \"48-day\": \"Mlha\",\n        \"48-night\": \"Mlha\",\n        \"51-day\": \"Lehké mrholení\",\n        \"51-night\": \"Lehké mrholení\",\n        \"53-day\": \"Mrholení\",\n        \"53-night\": \"Mrholení\",\n        \"55-day\": \"Silné mrholení\",\n        \"55-night\": \"Silné mrholení\",\n        \"56-day\": \"Mírné mrznoucí mrholení\",\n        \"56-night\": \"Mírné mrznoucí mrholení\",\n        \"57-day\": \"Mrznoucí mrholení\",\n        \"57-night\": \"Mrznoucí mrholení\",\n        \"61-day\": \"Slabý déšť\",\n        \"61-night\": \"Slabý déšť\",\n        \"63-day\": \"Déšť\",\n        \"63-night\": \"Déšť\",\n        \"65-day\": \"Silný déšť\",\n        \"65-night\": \"Silný déšť\",\n        \"66-day\": \"Mrznoucí déšť\",\n        \"66-night\": \"Mrznoucí déšť\",\n        \"67-day\": \"Mrznoucí déšť\",\n        \"67-night\": \"Mrznoucí déšť\",\n        \"71-day\": \"Slabé sněžení\",\n        \"71-night\": \"Slabé sněžení\",\n        \"73-day\": \"Sněžení\",\n        \"73-night\": \"Sněžení\",\n        \"75-day\": \"Silné sněžení\",\n        \"75-night\": \"Silné sněžení\",\n        \"77-day\": \"Sněhová zrna\",\n        \"77-night\": \"Sněhová zrna\",\n        \"80-day\": \"Lehké přeháňky\",\n        \"80-night\": \"Lehké přeháňky\",\n        \"81-day\": \"Přeháňky\",\n        \"81-night\": \"Přeháňky\",\n        \"82-day\": \"Silné přeháňky\",\n        \"82-night\": \"Silné přeháňky\",\n        \"85-day\": \"Déšť se sněhem\",\n        \"85-night\": \"Déšť se sněhem\",\n        \"86-day\": \"Déšť se sněhem\",\n        \"86-night\": \"Déšť se sněhem\",\n        \"95-day\": \"Bouřka\",\n        \"95-night\": \"Bouřka\",\n        \"96-day\": \"Bouřka s krupobitím\",\n        \"96-night\": \"Bouřka s krupobitím\",\n        \"99-day\": \"Bouřka s krupobitím\",\n        \"99-night\": \"Bouřka s krupobitím\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Systém\",\n        \"updates\": \"Aktualizace\",\n        \"update_available\": \"Dostupná\",\n        \"up_to_date\": \"Aktuální\",\n        \"child_bridges\": \"Podřízené můstky\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Běží\",\n        \"pending\": \"Čekající\",\n        \"down\": \"Výpadek\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Nové\",\n        \"up\": \"Běží\",\n        \"grace\": \"V období odkladu\",\n        \"down\": \"Výpadek\",\n        \"paused\": \"Pozastaveno\",\n        \"status\": \"Stav\",\n        \"last_ping\": \"Poslední ping\",\n        \"never\": \"Zatím žádné pingy\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Prohledáno\",\n        \"containers_updated\": \"Aktualizováno\",\n        \"containers_failed\": \"Selhalo\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Schváleno\",\n        \"rejectedPushes\": \"Zamítnuto\",\n        \"filters\": \"Filtry\",\n        \"indexers\": \"Indexery\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Fronta\",\n        \"videos\": \"Videa\",\n        \"channels\": \"Kanály\",\n        \"playlists\": \"Playlisty\"\n    },\n    \"truenas\": {\n        \"load\": \"Zatížení systému\",\n        \"uptime\": \"Doba provozu\",\n        \"alerts\": \"Upozornění\"\n    },\n    \"pyload\": {\n        \"speed\": \"Rychlost\",\n        \"active\": \"Aktivní\",\n        \"queue\": \"Fronta\",\n        \"total\": \"Celkem\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Veřejná IP\",\n        \"region\": \"Oblast\",\n        \"country\": \"Stát\",\n        \"port_forwarded\": \"Port přesměrován\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Kanály\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tuner\",\n        \"channelNumber\": \"Kanál\",\n        \"channelNetwork\": \"Síť\",\n        \"signalStrength\": \"Síla\",\n        \"signalQuality\": \"Kvalita\",\n        \"symbolQuality\": \"Kvalita\",\n        \"networkRate\": \"Přenosová rychlost\",\n        \"clientIP\": \"Klient\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Úspěšné\",\n        \"failed\": \"Selhalo\",\n        \"unknown\": \"Neznámý\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Doručená pošta\",\n        \"total\": \"Celkem\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Organizace\",\n        \"sites\": \"Stránky\",\n        \"resources\": \"Zdroje\",\n        \"targets\": \"Cíle\",\n        \"traffic\": \"Provoz\",\n        \"in\": \"Příchozí\",\n        \"out\": \"Odchozí\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Úroveň baterie\",\n        \"ups_load\": \"Zítěž UPS\",\n        \"ups_status\": \"Stav UPS\",\n        \"online\": \"Online\",\n        \"on_battery\": \"Na baterii\",\n        \"low_battery\": \"Nízký stav baterie\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Počkejte, prosím\",\n        \"no_devices\": \"Žádná přijatá data zařízení\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"Zatížení procesoru\",\n        \"memoryUsed\": \"Využití paměti\",\n        \"uptime\": \"Doba provozu\",\n        \"numberOfLeases\": \"Pronájmy\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Všechny streamy\",\n        \"streams_active\": \"Aktivní streamy\",\n        \"streams_xepg\": \"Kanály XEPG\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Dnes\",\n        \"absolutePower\": \"Výkon\",\n        \"relativePower\": \"Výkon %\",\n        \"limit\": \"Limit\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"Využití procesoru\",\n        \"memory\": \"Využití paměti\",\n        \"wanUpload\": \"Nahrávání WAN\",\n        \"wanDownload\": \"Stahování WAN\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Stav tiskárny\",\n        \"print_status\": \"Stav tisku\",\n        \"print_progress\": \"Průběh\",\n        \"layers\": \"Vrstvy\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Stav\",\n        \"temp_tool\": \"Teplota nástroje\",\n        \"temp_bed\": \"Teplota postele\",\n        \"job_completion\": \"Dokončení\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Původní IP\",\n        \"status\": \"Stav\"\n    },\n    \"pfsense\": {\n        \"load\": \"Prům. zatížení\",\n        \"memory\": \"Využití paměti\",\n        \"wanStatus\": \"Stav WAN\",\n        \"up\": \"Běží\",\n        \"down\": \"Výpadek\",\n        \"temp\": \"Teplota\",\n        \"disk\": \"Využití disku\",\n        \"wanIP\": \"IP WAN\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datové úložiště\",\n        \"failed_tasks_24h\": \"Neúspěšné úlohy 24h\",\n        \"cpu_usage\": \"Zatížení procesoru\",\n        \"memory_usage\": \"Využití paměti\"\n    },\n    \"immich\": {\n        \"users\": \"Uživatelé\",\n        \"photos\": \"Fotografie\",\n        \"videos\": \"Videa\",\n        \"storage\": \"Úložiště\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Weby běží\",\n        \"down\": \"Weby nefungují\",\n        \"uptime\": \"Doba provozu\",\n        \"incident\": \"Událost\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Seriály\",\n        \"archives\": \"Archivy\",\n        \"chapters\": \"Kapitoly\",\n        \"categories\": \"Kategorie\"\n    },\n    \"komga\": {\n        \"libraries\": \"Knihovny\",\n        \"series\": \"Série\",\n        \"books\": \"Knihy\"\n    },\n    \"diskstation\": {\n        \"days\": \"Dny\",\n        \"uptime\": \"Doba provozu\",\n        \"volumeAvailable\": \"Dostupné\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Kanály\",\n        \"streams\": \"Streamy\"\n    },\n    \"mylar\": {\n        \"series\": \"Série\",\n        \"issues\": \"Problémy\",\n        \"wanted\": \"Požadované\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Alba\",\n        \"photos\": \"Fotografie\",\n        \"videos\": \"Videa\",\n        \"people\": \"Lidé\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Fronta\",\n        \"processing\": \"Zpracovává se\",\n        \"processed\": \"Zpracováno\",\n        \"time\": \"Čas\"\n    },\n    \"firefly\": {\n        \"networth\": \"Čisté jmění\",\n        \"budget\": \"Rozpočet\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Nástěnky\",\n        \"datasources\": \"Zdroje dat\",\n        \"totalalerts\": \"Celkový počet upozornění2\",\n        \"alertstriggered\": \"Spuštěné výstrahy\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Zatížení procesoru\",\n        \"memoryusage\": \"Využití paměti\",\n        \"freespace\": \"Volný prostor\",\n        \"activeusers\": \"Aktivní uživatelé\",\n        \"numfiles\": \"Soubory\",\n        \"numshares\": \"Sdílené položky\"\n    },\n    \"kopia\": {\n        \"status\": \"Stav\",\n        \"size\": \"Velikost\",\n        \"lastrun\": \"Poslední spuštění\",\n        \"nextrun\": \"Další spuštění\",\n        \"failed\": \"Selhalo\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Aktivní workers\",\n        \"total_workers\": \"Workers celkem\",\n        \"records_total\": \"Délka fronty\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servery\",\n        \"nodes\": \"Uzly\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Cíle zapnuté\",\n        \"targets_down\": \"Cíle vypnuté\",\n        \"targets_total\": \"Cíle celkem\"\n    },\n    \"gatus\": {\n        \"up\": \"Weby běží\",\n        \"down\": \"Weby nefungují\",\n        \"uptime\": \"Doba provozu\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Dnes\",\n        \"gross_percent_1y\": \"Jeden rok\",\n        \"gross_percent_max\": \"Za celou dobu\",\n        \"net_worth\": \"Čisté jmění\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasty\",\n        \"books\": \"Knihy\",\n        \"podcastsDuration\": \"Trvání\",\n        \"booksDuration\": \"Délka\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Lidí doma\",\n        \"lights_on\": \"Rozsvícená světla\",\n        \"switches_on\": \"Zapnuté přepínače\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Sledování\",\n        \"updates\": \"Aktualizace\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Knihy\",\n        \"authors\": \"Autoři\",\n        \"categories\": \"Kategorie\",\n        \"series\": \"Série\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Knihovny\",\n        \"books\": \"Knihy\",\n        \"reading\": \"Čtené\",\n        \"finished\": \"Přečtené\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Fronta\",\n        \"downloadBytesRemaining\": \"Zbývá\",\n        \"downloadTotalBytes\": \"Velikost\",\n        \"downloadSpeed\": \"Rychlost\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Série\",\n        \"totalFiles\": \"Soubory\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Výsledek\",\n        \"status\": \"Stav\",\n        \"buildId\": \"ID sestavení\",\n        \"succeeded\": \"Úspěšně\",\n        \"notStarted\": \"Nezahájeno\",\n        \"failed\": \"Selhalo\",\n        \"canceled\": \"Zrušeno\",\n        \"inProgress\": \"Probíhá\",\n        \"totalPrs\": \"Celkem PR\",\n        \"myPrs\": \"Moje PR\",\n        \"approved\": \"Schváleno\"\n    },\n    \"gamedig\": {\n        \"status\": \"Stav\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Název\",\n        \"map\": \"Mapa\",\n        \"currentPlayers\": \"Počet hráčů\",\n        \"players\": \"Hráči\",\n        \"maxPlayers\": \"Maximální počet hráčů\",\n        \"bots\": \"Boti\",\n        \"ping\": \"Odezva\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Chyby\",\n        \"noRecent\": \"Zastaralý\",\n        \"totalUsed\": \"Využití úložiště\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Recepty\",\n        \"users\": \"Uživatelé\",\n        \"categories\": \"Kategorie\",\n        \"tags\": \"Štítky\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Stahování\",\n        \"total\": \"Celkem\",\n        \"running\": \"Běží\",\n        \"stopped\": \"Zastaveno\",\n        \"passed\": \"Úspěšně\",\n        \"failed\": \"Selhalo\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Doba provozu\",\n        \"cpuLoad\": \"Prům. zatížení procesoru (5m)\",\n        \"up\": \"Běží\",\n        \"down\": \"Výpadek\",\n        \"bytesTx\": \"Přeneseno\",\n        \"bytesRx\": \"Přijato\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Stav\",\n        \"uptime\": \"Doba provozu\",\n        \"lastDown\": \"Poslední výpadek\",\n        \"downDuration\": \"Trvání výpadku\",\n        \"sitesUp\": \"Weby běží\",\n        \"sitesDown\": \"Weby nefungují\",\n        \"paused\": \"Pozastaveno\",\n        \"notyetchecked\": \"Zatím nezkontrolováno\",\n        \"up\": \"Běží\",\n        \"seemsdown\": \"Zdá se nedostupný\",\n        \"down\": \"Výpadek\",\n        \"unknown\": \"Neznámý\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"V kinech\",\n        \"physicalRelease\": \"Fyzické vydání\",\n        \"digitalRelease\": \"Digitální vydání\",\n        \"noEventsToday\": \"Pro dnešek žádné události!\",\n        \"noEventsFound\": \"Nemáte žádné události\",\n        \"errorWhenLoadingData\": \"Chyba při načítání dat kalendáře\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platformy\",\n        \"totalRoms\": \"Hry\",\n        \"saves\": \"Uložené\",\n        \"states\": \"Stavy\",\n        \"screenshots\": \"Snímky obrazovky\",\n        \"totalfilesize\": \"Celková velikost\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domény\",\n        \"mailboxes\": \"E-mailové schránky\",\n        \"mails\": \"Maily\",\n        \"storage\": \"Úložiště\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Upozornění\",\n        \"criticals\": \"Kritické\"\n    },\n    \"plantit\": {\n        \"events\": \"Události\",\n        \"plants\": \"Rostliny\",\n        \"photos\": \"Fotografie\",\n        \"species\": \"Druhy\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Oznámení\",\n        \"issues\": \"Problémy\",\n        \"pulls\": \"Pull Requests\",\n        \"repositories\": \"Repozitáře\"\n    },\n    \"stash\": {\n        \"scenes\": \"Scény\",\n        \"scenesPlayed\": \"Přehrané scény\",\n        \"playCount\": \"Celkový počet přehrání\",\n        \"playDuration\": \"Čas sledování\",\n        \"sceneSize\": \"Velikost scén\",\n        \"sceneDuration\": \"Délka scény\",\n        \"images\": \"Obrázky\",\n        \"imageSize\": \"Velikost obrázků\",\n        \"galleries\": \"Galerie\",\n        \"performers\": \"Herci\",\n        \"studios\": \"Studia\",\n        \"movies\": \"Filmy\",\n        \"tags\": \"Štítky\",\n        \"oCount\": \"Počet O\"\n    },\n    \"tandoor\": {\n        \"users\": \"Uživatelé\",\n        \"recipes\": \"Recepty\",\n        \"keywords\": \"Klíčová slova\"\n    },\n    \"homebox\": {\n        \"items\": \"Položky\",\n        \"totalWithWarranty\": \"Se zárukou\",\n        \"locations\": \"Lokality\",\n        \"labels\": \"Štítky\",\n        \"users\": \"Uživatelé\",\n        \"totalValue\": \"Celková hodnota\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Upozornění\",\n        \"bans\": \"Bany\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Připojeno\",\n        \"enabled\": \"Povoleno\",\n        \"disabled\": \"Zakázáno\",\n        \"total\": \"Celkem\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Přes proxy\",\n        \"auth\": \"S ověřením\",\n        \"outdated\": \"Zastaralé\",\n        \"banned\": \"Zabanován\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Odezva\",\n        \"download\": \"Stahování\",\n        \"upload\": \"Nahrávání\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Akcie\",\n        \"loading\": \"Načítání\",\n        \"open\": \"Otevřeno - US trh\",\n        \"closed\": \"Uzavřeno - US trh\",\n        \"invalidConfiguration\": \"Neplatná konfigurace\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Kamery\",\n        \"uptime\": \"Doba provozu\",\n        \"version\": \"Verze\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Linky\",\n        \"collections\": \"Sbírky\",\n        \"tags\": \"Štítky\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Neklasifikováno\",\n        \"information\": \"Informace\",\n        \"warning\": \"Upozornění\",\n        \"average\": \"Průměr\",\n        \"high\": \"Vysoký\",\n        \"disaster\": \"Katastrofa\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vozidlo\",\n        \"vehicles\": \"Vozidla\",\n        \"serviceRecords\": \"Servisní záznamy\",\n        \"reminders\": \"Připomenutí\",\n        \"nextReminder\": \"Další připomenutí\",\n        \"none\": \"Žádné\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Aktivní projekty\",\n        \"tasks7d\": \"Úkoly k dokončení tento týden\",\n        \"tasksOverdue\": \"Zpožděné úkoly\",\n        \"tasksInProgress\": \"Probíhají úkoly\"\n    },\n    \"headscale\": {\n        \"name\": \"Název\",\n        \"address\": \"Adresa\",\n        \"last_seen\": \"Naposledy aktivní\",\n        \"status\": \"Stav\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Název\",\n        \"systems\": \"Systém\",\n        \"up\": \"Běží\",\n        \"down\": \"Výpadek\",\n        \"paused\": \"Pozastaveno\",\n        \"pending\": \"Čekající\",\n        \"status\": \"Stav\",\n        \"updated\": \"Aktualizováno\",\n        \"cpu\": \"Zatížení procesoru\",\n        \"memory\": \"Využití paměti\",\n        \"disk\": \"Disk\",\n        \"network\": \"Síť\"\n    },\n    \"argocd\": {\n        \"apps\": \"Aplikace\",\n        \"synced\": \"Synchronizováno\",\n        \"outOfSync\": \"Nesynchronizováno\",\n        \"healthy\": \"Zdravý\",\n        \"degraded\": \"Degradováno\",\n        \"progressing\": \"Probíhá\",\n        \"missing\": \"Chybějící\",\n        \"suspended\": \"Pozastaveno\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Načítání\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Skupiny\",\n        \"issues\": \"Problémy\",\n        \"merges\": \"Žádosti o sloučení\",\n        \"projects\": \"Projekty\"\n    },\n    \"apcups\": {\n        \"status\": \"Stav\",\n        \"load\": \"Zatížení\",\n        \"bcharge\": \"Úroveň baterie\",\n        \"timeleft\": \"Zbývající čas\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Záložky\",\n        \"favorites\": \"Oblíbené\",\n        \"archived\": \"Archivováno\",\n        \"highlights\": \"Zvýraznění\",\n        \"lists\": \"Seznamy\",\n        \"tags\": \"Štítky\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Síť\",\n        \"connected\": \"Připojeno\",\n        \"disconnected\": \"Odpojeno\",\n        \"updateStatus\": \"Aktualizace\",\n        \"update_yes\": \"Dostupné\",\n        \"update_no\": \"Aktuální\",\n        \"downloads\": \"Stažení\",\n        \"uploads\": \"Nahrávání\",\n        \"sharedFiles\": \"Soubory\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Skladby\",\n        \"movies\": \"Filmy\",\n        \"episodes\": \"Epizody\",\n        \"other\": \"Ostatní\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Problémy se službami\",\n        \"hostErrors\": \"Problémy zařízení\"\n    },\n    \"komodo\": {\n        \"total\": \"Celkem\",\n        \"running\": \"Běží\",\n        \"stopped\": \"Zastaveno\",\n        \"down\": \"Výpadek\",\n        \"unhealthy\": \"Nezdravý\",\n        \"unknown\": \"Neznámý\",\n        \"servers\": \"Servery\",\n        \"stacks\": \"Stacky\",\n        \"containers\": \"Kontejnery\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Dostupné\",\n        \"used\": \"Využito\",\n        \"total\": \"Celkem\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Předplatná\",\n        \"thisMonthlyCost\": \"Tento měsíc\",\n        \"nextMonthlyCost\": \"Příští měsíc\",\n        \"previousMonthlyCost\": \"Předchozí měsíc\",\n        \"nextRenewingSubscription\": \"Další platba\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Spuštěno\",\n        \"STOPPED\": \"Zastaveno\",\n        \"NEW_ARRAY\": \"Nové pole\",\n        \"RECON_DISK\": \"Rekonstrukce disku\",\n        \"DISABLE_DISK\": \"Disk deaktivován\",\n        \"SWAP_DSBL\": \"Swap vypnut\",\n        \"INVALID_EXPANSION\": \"Neplatné rozšíření\",\n        \"PARITY_NOT_BIGGEST\": \"Paritní disk není největší\",\n        \"TOO_MANY_MISSING_DISKS\": \"Příliš mnoho chybějících disků\",\n        \"NEW_DISK_TOO_SMALL\": \"Nový disk je příliš malý\",\n        \"NO_DATA_DISKS\": \"Žádné datové disky\",\n        \"notifications\": \"Upozornění\",\n        \"status\": \"Stav\",\n        \"cpu\": \"Zatížení procesoru\",\n        \"memoryUsed\": \"Využití paměti\",\n        \"memoryAvailable\": \"Volná paměť\",\n        \"arrayUsed\": \"Využito pole\",\n        \"arrayFree\": \"Volné místo\",\n        \"poolUsed\": \"Využito v {{pool}}\",\n        \"poolFree\": \"Volné v {{pool}}\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plány\",\n        \"num_success_30\": \"Úspěšně\",\n        \"num_failure_30\": \"Neúspěšně\",\n        \"num_success_latest\": \"Úspěšně\",\n        \"num_failure_latest\": \"Neúspěšně\",\n        \"bytes_added_30\": \"Přidané bajty\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Skladby\",\n        \"time\": \"Délka\",\n        \"artists\": \"Umělci\"\n    },\n    \"arcane\": {\n        \"containers\": \"Kontejnery\",\n        \"images\": \"Obrazy\",\n        \"image_updates\": \"Obrazy k aktualizaci\",\n        \"images_unused\": \"Nepoužívané obrazy\",\n        \"environment_required\": \"Požadováno ID prostředí\"\n    },\n    \"dockhand\": {\n        \"running\": \"Běží\",\n        \"stopped\": \"Zastaveno\",\n        \"cpu\": \"Zatížení procesoru\",\n        \"memory\": \"Využití paměti\",\n        \"images\": \"Obrazy\",\n        \"volumes\": \"Úložiště\",\n        \"events_today\": \"Dnešní události\",\n        \"pending_updates\": \"Čekající aktualizace\",\n        \"stacks\": \"Stacky\",\n        \"paused\": \"Pozastaveno\",\n        \"total\": \"Celkem\",\n        \"environment_not_found\": \"Prostředí nenalezeno\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Snězeno\",\n        \"burned\": \"Spáleno\",\n        \"remaining\": \"Zbývá\",\n        \"steps\": \"Kroky\"\n    }\n}\n"
  },
  {
    "path": "public/locales/da/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"mnd\",\n        \"days\": \"d\",\n        \"hours\": \"t\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Manglende Widget Type: {{type}}\",\n        \"api_error\": \"API fejl\",\n        \"information\": \"Information\",\n        \"status\": \"Status\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Rå Fejl\",\n        \"response_data\": \"Svardata\"\n    },\n    \"weather\": {\n        \"current\": \"Nuværende lokation\",\n        \"allow\": \"Klik for at tillade\",\n        \"updating\": \"Opdaterer\",\n        \"wait\": \"Vent venligst\"\n    },\n    \"search\": {\n        \"placeholder\": \"Søg…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"RAM\",\n        \"total\": \"Total\",\n        \"free\": \"Fri\",\n        \"used\": \"Brugt\",\n        \"load\": \"Belastning\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Maks\",\n        \"uptime\": \"OP\"\n    },\n    \"unifi\": {\n        \"users\": \"Brugere\",\n        \"uptime\": \"Oppetid\",\n        \"days\": \"Dage\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"Wifi\",\n        \"devices\": \"Enheder\",\n        \"lan_devices\": \"LAN Enheder\",\n        \"wlan_devices\": \"WLAN Enheder\",\n        \"lan_users\": \"LAN Brugere\",\n        \"wlan_users\": \"WLAN Brugere\",\n        \"up\": \"UP\",\n        \"down\": \"NED\",\n        \"wait\": \"Please wait\",\n        \"empty_data\": \"Subsystem status ukendt\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Kører\",\n        \"offline\": \"Offline\",\n        \"error\": \"Fejl\",\n        \"unknown\": \"Ukendt\",\n        \"healthy\": \"Sund\",\n        \"starting\": \"Starter\",\n        \"unhealthy\": \"Usund\",\n        \"not_found\": \"Ikke Fundet\",\n        \"exited\": \"Forladt\",\n        \"partial\": \"Delvis\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"Ping\",\n        \"down\": \"Ned\",\n        \"up\": \"Op\",\n        \"not_available\": \"Ikke tilgængelig\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP-status\",\n        \"error\": \"Error\",\n        \"response\": \"Response\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"emby\": {\n        \"playing\": \"Afspiller\",\n        \"transcoding\": \"Transcoder\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"Ingen Aktive Streams\",\n        \"movies\": \"Film\",\n        \"series\": \"Serier\",\n        \"episodes\": \"Episoder\",\n        \"songs\": \"Sange\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Online\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Produktion\",\n        \"battery_soc\": \"Batteri\",\n        \"grid_power\": \"Gitter\",\n        \"home_power\": \"Forbrug\",\n        \"charge_power\": \"Oplader\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Abonnementer\",\n        \"unread\": \"Ulæst\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Unconfigured\",\n        \"connectionStatusConnecting\": \"Connecting\",\n        \"connectionStatusAuthenticating\": \"Authenticating\",\n        \"connectionStatusPendingDisconnect\": \"Pending Disconnect\",\n        \"connectionStatusDisconnecting\": \"Disconnecting\",\n        \"connectionStatusDisconnected\": \"Disconnected\",\n        \"connectionStatusConnected\": \"Connected\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"Max. Down\",\n        \"maxUp\": \"Max. Up\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"Modtaget\",\n        \"sent\": \"Sendt\",\n        \"externalIPAddress\": \"Ekstern IP\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Aktuelle anmodninger\",\n        \"requests_failed\": \"Mislykkede anmodninger\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Total Observeret\",\n        \"diffsDetected\": \"Forskelle Detekteret\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Serier\",\n        \"recordings\": \"Optagelser\",\n        \"scheduled\": \"Planlagt\",\n        \"passes\": \"Bestået\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"Tjek Plex-forbindelse\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Forbundne APs\",\n        \"activeUser\": \"Aktive enheder\",\n        \"alerts\": \"Advarsler\",\n        \"connectedGateways\": \"Connected gateways\",\n        \"connectedSwitches\": \"Forbundne switches\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Sats\",\n        \"remaining\": \"Manglende\",\n        \"downloaded\": \"Hentet\"\n    },\n    \"plex\": {\n        \"streams\": \"Aktive Streams\",\n        \"albums\": \"Albums\",\n        \"movies\": \"Movies\",\n        \"tv\": \"TV-Shows\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"Kø\",\n        \"timeleft\": \"Resterende tid\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Aktive\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU Forbrug\",\n        \"memUsage\": \"MEM Forbrug\",\n        \"systemTempC\": \"System Temp\",\n        \"poolUsage\": \"Pool Forbrug\",\n        \"volumeUsage\": \"Volume Forbrug\",\n        \"invalid\": \"Ugyldig\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Ønsket\",\n        \"queued\": \"I Kø\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"Mangler\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"Artister\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"Bøger\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Manglende Afsnit\",\n        \"missingMovies\": \"Manglende Film\"\n    },\n    \"ombi\": {\n        \"pending\": \"Afventer\",\n        \"approved\": \"Godkendt\",\n        \"available\": \"Tilgængelig\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"New Devices\",\n        \"down_alerts\": \"Down Alerts\"\n    },\n    \"pihole\": {\n        \"queries\": \"Forespørgsler\",\n        \"blocked\": \"Blokerede\",\n        \"blocked_percent\": \"Blokeret %\",\n        \"gravity\": \"Tyngdekraft\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"Filtreret\",\n        \"latency\": \"Latenstid\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stoppede\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Non-Downloaded\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Downloaded & Read\",\n        \"downloadedunread\": \"Downloaded & Unread\",\n        \"nondownloadedread\": \"Non-Downloaded & Read\",\n        \"nondownloadedunread\": \"Non-Downloaded & Unread\"\n    },\n    \"tailscale\": {\n        \"address\": \"Adresse\",\n        \"expires\": \"Udløber\",\n        \"never\": \"Aldrig\",\n        \"last_seen\": \"Sidst Set\",\n        \"now\": \"Nu\",\n        \"years\": \"{{number}}å\",\n        \"weeks\": \"{{number}}u\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}t\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Siden\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"Success\",\n        \"totalServerFailure\": \"Failures\",\n        \"totalNxDomain\": \"NX Domains\",\n        \"totalRefused\": \"Refused\",\n        \"totalAuthoritative\": \"Authoritative\",\n        \"totalRecursive\": \"Recursive\",\n        \"totalCached\": \"Cached\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"Dropped\",\n        \"totalClients\": \"Klienter\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Behandlet\",\n        \"errored\": \"Fejlet\",\n        \"saved\": \"Gemt\"\n    },\n    \"traefik\": {\n        \"routers\": \"Routere\",\n        \"services\": \"Tjenester\",\n        \"middleware\": \"Middleware\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Vent venligst\"\n    },\n    \"npm\": {\n        \"enabled\": \"Aktiveret\",\n        \"disabled\": \"Deaktiveret\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Konfigurer en eller flere crypto valutaer til tracking\",\n        \"1hour\": \"1 time\",\n        \"1day\": \"1 Dag\",\n        \"7days\": \"7 Dage\",\n        \"30days\": \"30 Dage\"\n    },\n    \"gotify\": {\n        \"apps\": \"Applikationer\",\n        \"clients\": \"Clients\",\n        \"messages\": \"Beskeder\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indeksører\",\n        \"numberOfGrabs\": \"Grab\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"Fejl Grabs\",\n        \"numberOfFailQueries\": \"Fejl Forespørgsler\"\n    },\n    \"jackett\": {\n        \"configured\": \"Konfigureret\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sessioner\",\n        \"numConnections\": \"Forbindelser\",\n        \"dataRelayed\": \"Videresendt\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"Indlæg\",\n        \"domain_count\": \"Domæner\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Afspillere\",\n        \"version\": \"Version\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Læst\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"Login (24 timer)\",\n        \"failedLoginsLast24H\": \"Mislykkede logins (24 timer)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temp\",\n        \"warn\": \"Advar\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Crit\",\n        \"read\": \"Read\",\n        \"write\": \"Skriv\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Ram\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Bogmærker\",\n        \"service\": \"Tjeneste\",\n        \"search\": \"Søg\",\n        \"custom\": \"Brugerdefinerede\",\n        \"visit\": \"Besøg\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Suggestion\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Solrig\",\n        \"0-night\": \"Klart\",\n        \"1-day\": \"Overvejende Solrigt\",\n        \"1-night\": \"Overvejende Skyfrit\",\n        \"2-day\": \"Delvist Overskyet\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"Skyet\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"Tåget\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"Let Støvregn\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"Støvregn\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"Kraftig Støvregn\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"Let Frysende Støvregn\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"Frysende Støvregn\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"Let Regn\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"Regn\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Kraftig Regn\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Frysende Regn\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Let Sne\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Sne\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Kraftig Sne\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Snekorn\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Lette Byger\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Byger\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Kraftige Byger\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Snebyger\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"Tordenvejr\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Tordenvejr Med Hagl\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"System\",\n        \"updates\": \"Opdateringer\",\n        \"update_available\": \"Opdateringer Tilgængelige\",\n        \"up_to_date\": \"Opdateret\",\n        \"child_bridges\": \"Underbroer\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Ny\",\n        \"up\": \"Up\",\n        \"grace\": \"Henstandsperiode\",\n        \"down\": \"Down\",\n        \"paused\": \"Pause\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Sidste Ping\",\n        \"never\": \"Ingen Pings Endnu\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Scannet\",\n        \"containers_updated\": \"Opdateret\",\n        \"containers_failed\": \"Fejlet\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Afviste\",\n        \"filters\": \"Filtre\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Videoer\",\n        \"channels\": \"Kanaler\",\n        \"playlists\": \"Afspilningslister\"\n    },\n    \"truenas\": {\n        \"load\": \"Systembelastning\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Hastighed\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Offentlig IP\",\n        \"region\": \"Område\",\n        \"country\": \"Land\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tuners\",\n        \"channelNumber\": \"Channel\",\n        \"channelNetwork\": \"Network\",\n        \"signalStrength\": \"Strength\",\n        \"signalQuality\": \"Quality\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Client\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Bestået\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Indbakke\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Batteriniveau\",\n        \"ups_load\": \"UPS Load\",\n        \"ups_status\": \"UPS Status\",\n        \"online\": \"Online\",\n        \"on_battery\": \"På batteri\",\n        \"low_battery\": \"Lavt batteriniveau\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"Ingen Enhedsdata Modtaget\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"CPU Belastning\",\n        \"memoryUsed\": \"Hukommelse Brugt\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Leasinger\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Alle Streams\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG Kanaler\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"I dag\",\n        \"absolutePower\": \"Strøm\",\n        \"relativePower\": \"Strøm %\",\n        \"limit\": \"Begrænsning\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Aktiv Hukommelse\",\n        \"wanUpload\": \"WAN Upload\",\n        \"wanDownload\": \"WAN Download\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Printer Tilstand\",\n        \"print_status\": \"Print Status\",\n        \"print_progress\": \"Fremskridt\",\n        \"layers\": \"Lag\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Værktøj temp\",\n        \"temp_bed\": \"Seng temp\",\n        \"job_completion\": \"Færdiggørelse\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Oprindelses-IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Belastning Gns\",\n        \"memory\": \"Hukommelse Forbrug\",\n        \"wanStatus\": \"WAN Status\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Disk Forbrug\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datalager\",\n        \"failed_tasks_24h\": \"Mislykkede Opgaver 24t\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Hukommelse\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Billeder\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Lager\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Sider Oppe\",\n        \"down\": \"Sider Nede\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Hændelse\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Arkiver\",\n        \"chapters\": \"Kapitler\",\n        \"categories\": \"Kategorier\"\n    },\n    \"komga\": {\n        \"libraries\": \"Biblioteker\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Problemer\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"Mennesker\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Tid\"\n    },\n    \"firefly\": {\n        \"networth\": \"Net Worth\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Kontrolpanel\",\n        \"datasources\": \"Data Kilder\",\n        \"totalalerts\": \"Totale Advarsler\",\n        \"alertstriggered\": \"Advarsler Udløst\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Cpu Belastning\",\n        \"memoryusage\": \"Hukommelse Forbrug\",\n        \"freespace\": \"Ledig Plads\",\n        \"activeusers\": \"Aktive Brugere\",\n        \"numfiles\": \"Filer\",\n        \"numshares\": \"Delte Genstande\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Størrelse\",\n        \"lastrun\": \"Sidst Kørt\",\n        \"nextrun\": \"Næste Kørsel\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Aktive Arbejdere\",\n        \"total_workers\": \"Totale Arbejdere\",\n        \"records_total\": \"Kø Længde\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servere\",\n        \"nodes\": \"Noder\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Mål Oppe\",\n        \"targets_down\": \"Mål Nede\",\n        \"targets_total\": \"Totale Mål\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"Et År\",\n        \"gross_percent_max\": \"Altid\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasts\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Varighed\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Personer Hjemme\",\n        \"lights_on\": \"Lys Tændt\",\n        \"switches_on\": \"Kontakter Tændt\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Overvåger\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Forfattere\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Resultat\",\n        \"status\": \"Status\",\n        \"buildId\": \"Build ID\",\n        \"succeeded\": \"Lykkedes\",\n        \"notStarted\": \"Ikke Startet\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Annulleret\",\n        \"inProgress\": \"I Gang\",\n        \"totalPrs\": \"Total PRs\",\n        \"myPrs\": \"Mine PRs\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Navn\",\n        \"map\": \"Kort\",\n        \"currentPlayers\": \"Nuværende Spillere\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Maks spillere\",\n        \"bots\": \"Bots\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Fejl\",\n        \"noRecent\": \"Uddateret\",\n        \"totalUsed\": \"Brugt Lager\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Opskrifter\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Tags\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Downloader\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"CPU Load Avg (5m)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Transmitted\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Seneste Nedetid\",\n        \"downDuration\": \"Nedetid Varighed\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Endnu Ikke Kontrolleret\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Synes Ned\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"I biografen\",\n        \"physicalRelease\": \"Fysisk udgivelse\",\n        \"digitalRelease\": \"Digitale udgivelser\",\n        \"noEventsToday\": \"No events for today!\",\n        \"noEventsFound\": \"No events found\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platforme\",\n        \"totalRoms\": \"Games\",\n        \"saves\": \"Saves\",\n        \"states\": \"States\",\n        \"screenshots\": \"Screenshots\",\n        \"totalfilesize\": \"Total Size\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Mailboxes\",\n        \"mails\": \"Mails\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Advarsler\",\n        \"criticals\": \"Criticals\"\n    },\n    \"plantit\": {\n        \"events\": \"Events\",\n        \"plants\": \"Plants\",\n        \"photos\": \"Photos\",\n        \"species\": \"Species\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Notifications\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Pull Requests\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Scenes\",\n        \"scenesPlayed\": \"Scenes Played\",\n        \"playCount\": \"Total Plays\",\n        \"playDuration\": \"Time Watched\",\n        \"sceneSize\": \"Scenes Size\",\n        \"sceneDuration\": \"Scenes Duration\",\n        \"images\": \"Images\",\n        \"imageSize\": \"Images Size\",\n        \"galleries\": \"Galleries\",\n        \"performers\": \"Performers\",\n        \"studios\": \"Studios\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O Count\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Keywords\"\n    },\n    \"homebox\": {\n        \"items\": \"Items\",\n        \"totalWithWarranty\": \"With Warranty\",\n        \"locations\": \"Locations\",\n        \"labels\": \"Labels\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Total Value\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Bans\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxied\",\n        \"auth\": \"With Auth\",\n        \"outdated\": \"Outdated\",\n        \"banned\": \"Banned\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Stocks\",\n        \"loading\": \"Loading\",\n        \"open\": \"Open - US Market\",\n        \"closed\": \"Closed - US Market\",\n        \"invalidConfiguration\": \"Invalid Configuration\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Cameras\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Links\",\n        \"collections\": \"Collections\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Not classified\",\n        \"information\": \"Information\",\n        \"warning\": \"Warning\",\n        \"average\": \"Average\",\n        \"high\": \"High\",\n        \"disaster\": \"Disaster\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vehicle\",\n        \"vehicles\": \"Vehicles\",\n        \"serviceRecords\": \"Service Records\",\n        \"reminders\": \"Reminders\",\n        \"nextReminder\": \"Next Reminder\",\n        \"none\": \"None\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Active Projects\",\n        \"tasks7d\": \"Tasks Due This Week\",\n        \"tasksOverdue\": \"Overdue Tasks\",\n        \"tasksInProgress\": \"Tasks In Progress\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Systems\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Disk\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Apps\",\n        \"synced\": \"Synced\",\n        \"outOfSync\": \"Out Of Sync\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Degraded\",\n        \"progressing\": \"Progressing\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Suspended\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Groups\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Projects\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bookmarks\",\n        \"favorites\": \"Favorites\",\n        \"archived\": \"Archived\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Lists\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Other\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/de/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"Mo.\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"minutes\": \"min\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Fehlender Widget-Typ: {{type}}\",\n        \"api_error\": \"API-Fehler\",\n        \"information\": \"Informationen\",\n        \"status\": \"Status\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Rohfehler\",\n        \"response_data\": \"Antwortdaten\"\n    },\n    \"weather\": {\n        \"current\": \"Aktueller Standort\",\n        \"allow\": \"Zum Zulassen anklicken\",\n        \"updating\": \"Aktualisieren\",\n        \"wait\": \"Bitte warten\"\n    },\n    \"search\": {\n        \"placeholder\": \"Suche…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"RAM\",\n        \"total\": \"Gesamt\",\n        \"free\": \"Frei\",\n        \"used\": \"In Benutzung\",\n        \"load\": \"Last\",\n        \"temp\": \"Temp\",\n        \"max\": \"Max\",\n        \"uptime\": \"Betriebszeit\"\n    },\n    \"unifi\": {\n        \"users\": \"Benutzer\",\n        \"uptime\": \"Betriebszeit\",\n        \"days\": \"Tage\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Geräte\",\n        \"lan_devices\": \"LAN-Geräte\",\n        \"wlan_devices\": \"WLAN-Geräte\",\n        \"lan_users\": \"LAN-Benutzer\",\n        \"wlan_users\": \"WLAN-Benutzer\",\n        \"up\": \"Gesendet\",\n        \"down\": \"EMPFANGEN\",\n        \"wait\": \"Bitte warten\",\n        \"empty_data\": \"Subsystem-Status unbekannt\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"RAM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Wird ausgeführt\",\n        \"offline\": \"Offline\",\n        \"error\": \"Fehler\",\n        \"unknown\": \"Unbekannt\",\n        \"healthy\": \"Fehlerfrei\",\n        \"starting\": \"Startet\",\n        \"unhealthy\": \"Fehlerhaft\",\n        \"not_found\": \"Nicht gefunden\",\n        \"exited\": \"Beendet\",\n        \"partial\": \"Teilweise\"\n    },\n    \"ping\": {\n        \"error\": \"Fehler\",\n        \"ping\": \"Ping\",\n        \"down\": \"Offline\",\n        \"up\": \"Online\",\n        \"not_available\": \"Nicht verfügbar\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP-Status\",\n        \"error\": \"Fehler\",\n        \"response\": \"Antwort\",\n        \"down\": \"Offline\",\n        \"up\": \"Online\",\n        \"not_available\": \"Nicht verfügbar\"\n    },\n    \"emby\": {\n        \"playing\": \"Wiedergabe\",\n        \"transcoding\": \"Transcodiert\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"Keine aktiven Streams\",\n        \"movies\": \"Filme\",\n        \"series\": \"Serien\",\n        \"episodes\": \"Episoden\",\n        \"songs\": \"Songs\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Wiedergabe\",\n        \"transcoding\": \"Transkodierung\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"Keine aktiven Streams\",\n        \"movies\": \"Filme\",\n        \"series\": \"Serien\",\n        \"episodes\": \"Episoden\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Online\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unbekannt\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Erzeugung\",\n        \"battery_soc\": \"Batterie\",\n        \"grid_power\": \"Netz\",\n        \"home_power\": \"verbauch\",\n        \"charge_power\": \"Ladegerät\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Abonnements\",\n        \"unread\": \"Ungelesen\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Unkonfiguriert\",\n        \"connectionStatusConnecting\": \"Verbinde\",\n        \"connectionStatusAuthenticating\": \"Authentifiziere\",\n        \"connectionStatusPendingDisconnect\": \"Ausstehende Trennung\",\n        \"connectionStatusDisconnecting\": \"Trenne\",\n        \"connectionStatusDisconnected\": \"Getrennt\",\n        \"connectionStatusConnected\": \"Verbunden\",\n        \"uptime\": \"Betriebszeit\",\n        \"maxDown\": \"Max. Down\",\n        \"maxUp\": \"Max. Up\",\n        \"down\": \"Download\",\n        \"up\": \"Upload\",\n        \"received\": \"Empfangen\",\n        \"sent\": \"Gesendet\",\n        \"externalIPAddress\": \"Externe IP\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Präfix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Aktuelle Anfragen\",\n        \"requests_failed\": \"Fehlgeschlagene Anfragen\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Gesamt beobachtet\",\n        \"diffsDetected\": \"Erkannte Änderungen\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Serien\",\n        \"recordings\": \"Aufnahmen\",\n        \"scheduled\": \"Geplant\",\n        \"passes\": \"Durchläufe\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Spielt\",\n        \"transcoding\": \"Transcodiert\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"Keine aktiven Streams\",\n        \"plex_connection_error\": \"Prüfe Plex-Verbindung\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Verbundene APs\",\n        \"activeUser\": \"Aktive Geräte\",\n        \"alerts\": \"Warnungen\",\n        \"connectedGateways\": \"Verbundene Gateways\",\n        \"connectedSwitches\": \"Verbundene Switche\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Datenrate\",\n        \"remaining\": \"Verbleibend\",\n        \"downloaded\": \"Heruntergeladen\"\n    },\n    \"plex\": {\n        \"streams\": \"Aktive Streams\",\n        \"albums\": \"Alben\",\n        \"movies\": \"Filme\",\n        \"tv\": \"TV-Serien\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"Warteschlange\",\n        \"timeleft\": \"Verbleibende Zeit\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Aktiv\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU-Nutzung\",\n        \"memUsage\": \"Speichernutzung\",\n        \"systemTempC\": \"Systemtemperatur\",\n        \"poolUsage\": \"Pool-Nutzung\",\n        \"volumeUsage\": \"Speichernutzung\",\n        \"invalid\": \"Ungültig\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache-Trefferbytes\",\n        \"cachemissbytes\": \"Cache-Fehlbytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Gesucht\",\n        \"queued\": \"In Warteschlange\",\n        \"series\": \"Serien\",\n        \"queue\": \"Warteschlange\",\n        \"unknown\": \"Unbekannt\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Gesucht\",\n        \"missing\": \"Fehlend\",\n        \"queued\": \"In Warteschlange\",\n        \"movies\": \"Filme\",\n        \"queue\": \"Warteschlange\",\n        \"unknown\": \"Unbekannt\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Gesucht\",\n        \"queued\": \"In Warteschlange\",\n        \"artists\": \"Künstler\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Gesucht\",\n        \"queued\": \"In Warteschlange\",\n        \"books\": \"Bücher\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Fehlende Episoden\",\n        \"missingMovies\": \"Fehlende Filme\"\n    },\n    \"ombi\": {\n        \"pending\": \"Ausstehend\",\n        \"approved\": \"Genehmigt\",\n        \"available\": \"Verfügbar\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Verbunden\",\n        \"new_devices\": \"Neue Geräte\",\n        \"down_alerts\": \"Down-Warnungen\"\n    },\n    \"pihole\": {\n        \"queries\": \"Anfragen\",\n        \"blocked\": \"Blockiert\",\n        \"blocked_percent\": \"Blockiert %\",\n        \"gravity\": \"Gravity\"\n    },\n    \"adguard\": {\n        \"queries\": \"Anfragen\",\n        \"blocked\": \"Blockiert\",\n        \"filtered\": \"Gefiltert\",\n        \"latency\": \"Latenz\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Wird ausgeführt\",\n        \"stopped\": \"Gestoppt\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Heruntergeladen\",\n        \"nondownload\": \"Nicht heruntergeladen\",\n        \"read\": \"Gelesen\",\n        \"unread\": \"Ungelesen\",\n        \"downloadedread\": \"Heruntergeladen & gelesen\",\n        \"downloadedunread\": \"Heruntergeladen & ungelesen\",\n        \"nondownloadedread\": \"Nicht heruntergeladen & gelesen\",\n        \"nondownloadedunread\": \"Nicht heruntergeladen & ungelesen\"\n    },\n    \"tailscale\": {\n        \"address\": \"Adresse\",\n        \"expires\": \"Läuft ab\",\n        \"never\": \"Nie\",\n        \"last_seen\": \"Zuletzt gesehen\",\n        \"now\": \"Jetzt\",\n        \"years\": \"{{number}}a\",\n        \"weeks\": \"{{number}} Woche(n)\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}min\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"Vor {{value}}\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Anfragen\",\n        \"totalNoError\": \"Erfolgreich\",\n        \"totalServerFailure\": \"Fehler\",\n        \"totalNxDomain\": \"NX-Domänen\",\n        \"totalRefused\": \"Verweigert\",\n        \"totalAuthoritative\": \"Autoritativ\",\n        \"totalRecursive\": \"Rekursiv\",\n        \"totalCached\": \"Im Cache\",\n        \"totalBlocked\": \"Blockiert\",\n        \"totalDropped\": \"Verworfen\",\n        \"totalClients\": \"Benutzer\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Warteschlange\",\n        \"processed\": \"Verarbeitet\",\n        \"errored\": \"Fehlgeschlagen\",\n        \"saved\": \"Eingespart\"\n    },\n    \"traefik\": {\n        \"routers\": \"Router\",\n        \"services\": \"Dienste\",\n        \"middleware\": \"Middleware\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notizen\",\n        \"dbSize\": \"Datenbankgröße\",\n        \"unknown\": \"Unbekannt\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"Keine aktiven Streams\",\n        \"please_wait\": \"Bitte warten\"\n    },\n    \"npm\": {\n        \"enabled\": \"Aktiviert\",\n        \"disabled\": \"Deaktiviert\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Konfiguriere eine oder mehrere Kryptowährungen zur Beobachtung\",\n        \"1hour\": \"1 Stunde\",\n        \"1day\": \"1 Tag\",\n        \"7days\": \"7 Tage\",\n        \"30days\": \"30 Tage\"\n    },\n    \"gotify\": {\n        \"apps\": \"Programme\",\n        \"clients\": \"Endgeräte\",\n        \"messages\": \"Nachrichten\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indexer\",\n        \"numberOfGrabs\": \"Abrufungen\",\n        \"numberOfQueries\": \"Anfragen\",\n        \"numberOfFailGrabs\": \"Fehlgeschlagene Abrufungen\",\n        \"numberOfFailQueries\": \"Fehlgeschlagene Anfragen\"\n    },\n    \"jackett\": {\n        \"configured\": \"Konfiguriert\",\n        \"errored\": \"Fehlgeschlagen\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sitzungen\",\n        \"numConnections\": \"Verbindungen\",\n        \"dataRelayed\": \"Weitergeleitet\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Benutzer\",\n        \"status_count\": \"Beiträge\",\n        \"domain_count\": \"Domänen\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Gesucht\",\n        \"queued\": \"In Warteschlange\",\n        \"series\": \"Serien\"\n    },\n    \"minecraft\": {\n        \"players\": \"Spieler\",\n        \"version\": \"Version\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Gelesen\",\n        \"unread\": \"Ungelesen\"\n    },\n    \"authentik\": {\n        \"users\": \"Benutzer\",\n        \"loginsLast24H\": \"Anmeldungen (24 h)\",\n        \"failedLoginsLast24H\": \"Fehlversuche (24 h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"RAM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Last\",\n        \"wait\": \"Bitte warten\",\n        \"temp\": \"Temp\",\n        \"_temp\": \"Temperatur\",\n        \"warn\": \"Warnung\",\n        \"uptime\": \"Betriebszeit\",\n        \"total\": \"Total\",\n        \"free\": \"Frei\",\n        \"used\": \"Benutzt\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Krit\",\n        \"read\": \"Lesen\",\n        \"write\": \"Schreiben\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"RAM\",\n        \"swap\": \"Auslagerung\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Lesezeichen\",\n        \"service\": \"Dienst\",\n        \"search\": \"Suchen\",\n        \"custom\": \"Benutzerdefiniert\",\n        \"visit\": \"Besuchen\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Vorschlag\"\n    },\n    \"wmo\": {\n        \"0-day\": \"sonnig\",\n        \"0-night\": \"klar\",\n        \"1-day\": \"überwiegend sonnig\",\n        \"1-night\": \"Überwiegend klar\",\n        \"2-day\": \"Teilweise bewölkt\",\n        \"2-night\": \"Teilweise bewölkt\",\n        \"3-day\": \"Bewölkt\",\n        \"3-night\": \"Bewölkt\",\n        \"45-day\": \"neblig\",\n        \"45-night\": \"Nebel\",\n        \"48-day\": \"Nebel\",\n        \"48-night\": \"Nebel\",\n        \"51-day\": \"leichter Nieselregen\",\n        \"51-night\": \"Leichter Nieselregen\",\n        \"53-day\": \"Nieselregen\",\n        \"53-night\": \"Nieselregen\",\n        \"55-day\": \"starker Nieselregen\",\n        \"55-night\": \"Starker Nieselregen\",\n        \"56-day\": \"leichter gefrierender Nieselregen\",\n        \"56-night\": \"Leicht gefrierender Nieselregen\",\n        \"57-day\": \"gefrierender Nieselregen\",\n        \"57-night\": \"Gefrierender Nieselregen\",\n        \"61-day\": \"leichter Regen\",\n        \"61-night\": \"Leichter Regen\",\n        \"63-day\": \"Regen\",\n        \"63-night\": \"Regen\",\n        \"65-day\": \"starker Regen\",\n        \"65-night\": \"Starker Regen\",\n        \"66-day\": \"Gefrierender Regen\",\n        \"66-night\": \"Gefrierender Regen\",\n        \"67-day\": \"Gefrierender Regen\",\n        \"67-night\": \"Gefrierender Regen\",\n        \"71-day\": \"Leichter Schneefall\",\n        \"71-night\": \"Leichter Schnee\",\n        \"73-day\": \"Schnee\",\n        \"73-night\": \"Schnee\",\n        \"75-day\": \"Starker Schneefall\",\n        \"75-night\": \"Starker Schnee\",\n        \"77-day\": \"Schneegriesel\",\n        \"77-night\": \"Schneekörner\",\n        \"80-day\": \"Leichte Schauer\",\n        \"80-night\": \"Leichte Schauer\",\n        \"81-day\": \"Schauer\",\n        \"81-night\": \"Schauer\",\n        \"82-day\": \"Starke Schauer\",\n        \"82-night\": \"Starke Schauer\",\n        \"85-day\": \"Schneeschauer\",\n        \"85-night\": \"Schneeschauer\",\n        \"86-day\": \"Schneeschauer\",\n        \"86-night\": \"Schneeschauer\",\n        \"95-day\": \"Gewitter\",\n        \"95-night\": \"Sturm\",\n        \"96-day\": \"Gewitter mit Hagel\",\n        \"96-night\": \"Sturm mit Hagel\",\n        \"99-day\": \"Sturm mit Hagel\",\n        \"99-night\": \"Sturm mit Hagel\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"System\",\n        \"updates\": \"Aktualisierungen\",\n        \"update_available\": \"Aktualisierung verfügbar\",\n        \"up_to_date\": \"Aktuell\",\n        \"child_bridges\": \"Unter-Bridges\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Online\",\n        \"pending\": \"Wartend\",\n        \"down\": \"Offline\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Neu\",\n        \"up\": \"Online\",\n        \"grace\": \"In Karenzzeit\",\n        \"down\": \"Offline\",\n        \"paused\": \"Pausiert\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Letzter Ping\",\n        \"never\": \"Noch keine Pings\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Überprüft\",\n        \"containers_updated\": \"Aktualisiert\",\n        \"containers_failed\": \"Fehlgeschlagen\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Genehmigt\",\n        \"rejectedPushes\": \"Abgelehnt\",\n        \"filters\": \"Filter\",\n        \"indexers\": \"Indexierer\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Warteschlange\",\n        \"videos\": \"Videos\",\n        \"channels\": \"Kanäle\",\n        \"playlists\": \"Wiedergabelisten\"\n    },\n    \"truenas\": {\n        \"load\": \"Systemlast\",\n        \"uptime\": \"Betriebszeit\",\n        \"alerts\": \"Alarme\"\n    },\n    \"pyload\": {\n        \"speed\": \"Datenrate\",\n        \"active\": \"Aktiv\",\n        \"queue\": \"Warteschlange\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Öffentliche IP\",\n        \"region\": \"Region\",\n        \"country\": \"Land\",\n        \"port_forwarded\": \"Port weitergeleitet\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Kanäle\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Empfänger\",\n        \"channelNumber\": \"Kanal\",\n        \"channelNetwork\": \"Netzwerk\",\n        \"signalStrength\": \"Stärke\",\n        \"signalQuality\": \"Qualität\",\n        \"symbolQuality\": \"Qualität\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Client\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Bestanden\",\n        \"failed\": \"Fehlerhaft\",\n        \"unknown\": \"Unbekannt\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Posteingang\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Ressourcen\",\n        \"targets\": \"Ziele\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Akkuladung\",\n        \"ups_load\": \"USV-Auslastung\",\n        \"ups_status\": \"USV-Status\",\n        \"online\": \"Online\",\n        \"on_battery\": \"Im Akkubetrieb\",\n        \"low_battery\": \"Akkustand niedrig\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Bitte warten\",\n        \"no_devices\": \"Keine Daten empfangen\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"CPU-Auslastung\",\n        \"memoryUsed\": \"RAM Verbrauch\",\n        \"uptime\": \"Betriebszeit\",\n        \"numberOfLeases\": \"Leases\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Keine Übertragungen\",\n        \"streams_active\": \"Aktive Streams\",\n        \"streams_xepg\": \"XEPG-Kanäle\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Heute\",\n        \"absolutePower\": \"Leistung\",\n        \"relativePower\": \"Leistung %\",\n        \"limit\": \"Grenze\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU-Last\",\n        \"memory\": \"RAM aktiv\",\n        \"wanUpload\": \"WAN Up\",\n        \"wanDownload\": \"WAN Down\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Druckerstatus\",\n        \"print_status\": \"Druckstatus\",\n        \"print_progress\": \"Fortschritt\",\n        \"layers\": \"Schichten\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Düsentemperatur\",\n        \"temp_bed\": \"Betttemperatur\",\n        \"job_completion\": \"Fortschritt\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Ursprüngliche IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Durchschnittliche Last\",\n        \"memory\": \"Speichernutzung\",\n        \"wanStatus\": \"WAN-Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Datenträgernutzung\",\n        \"wanIP\": \"WAN-IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datenspeicher\",\n        \"failed_tasks_24h\": \"Fehlgeschlagene Prozesse (24 h)\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"RAM\"\n    },\n    \"immich\": {\n        \"users\": \"Benutzer\",\n        \"photos\": \"Fotos\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Speicher\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"uptime\": \"Betriebszeit\",\n        \"incident\": \"Vorfall\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Serien\",\n        \"archives\": \"Archive\",\n        \"chapters\": \"Kapitel\",\n        \"categories\": \"Kategorien\"\n    },\n    \"komga\": {\n        \"libraries\": \"Bibliotheken\",\n        \"series\": \"Serien\",\n        \"books\": \"Bücher\"\n    },\n    \"diskstation\": {\n        \"days\": \"Tage\",\n        \"uptime\": \"Betriebszeit\",\n        \"volumeAvailable\": \"Verfügbar\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Serien\",\n        \"issues\": \"Probleme\",\n        \"wanted\": \"Gesucht\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Alben\",\n        \"photos\": \"Fotos\",\n        \"videos\": \"Videos\",\n        \"people\": \"Personen\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Warteschlange\",\n        \"processing\": \"Wird verarbeitet\",\n        \"processed\": \"Wird verarbeitet\",\n        \"time\": \"Zeit\"\n    },\n    \"firefly\": {\n        \"networth\": \"Reinvermögen\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Dashboards\",\n        \"datasources\": \"Datenquellen\",\n        \"totalalerts\": \"Warnungen gesamt\",\n        \"alertstriggered\": \"Warnungen ausgelöst\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"CPU Last\",\n        \"memoryusage\": \"RAM Verbrauch\",\n        \"freespace\": \"Freier Speicher\",\n        \"activeusers\": \"Aktive Nutzer\",\n        \"numfiles\": \"Dateien\",\n        \"numshares\": \"Geteilte Elemente\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Größe\",\n        \"lastrun\": \"Letzter Durchlauf\",\n        \"nextrun\": \"Nächster Durchlauf\",\n        \"failed\": \"Fehlerhaft\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Aktive Worker\",\n        \"total_workers\": \"Alle Worker\",\n        \"records_total\": \"Länge der Warteschlange\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Server\",\n        \"nodes\": \"Knotenpunkte\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Ziele Up\",\n        \"targets_down\": \"Ziele Down\",\n        \"targets_total\": \"Alle Ziele\"\n    },\n    \"gatus\": {\n        \"up\": \"Seiten online\",\n        \"down\": \"Seiten offline\",\n        \"uptime\": \"Betriebszeit\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Heute\",\n        \"gross_percent_1y\": \"Ein Jahr\",\n        \"gross_percent_max\": \"Gesamt\",\n        \"net_worth\": \"\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasts\",\n        \"books\": \"Bücher\",\n        \"podcastsDuration\": \"Dauer\",\n        \"booksDuration\": \"Dauer\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Personen zuhause\",\n        \"lights_on\": \"Lichter an\",\n        \"switches_on\": \"Schalter an\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Überwacht\",\n        \"updates\": \"Aktualisierungen\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Bücher\",\n        \"authors\": \"Autoren\",\n        \"categories\": \"Kategorien\",\n        \"series\": \"Serien\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Bücher\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Warteschlange\",\n        \"downloadBytesRemaining\": \"Verbleibend\",\n        \"downloadTotalBytes\": \"Größe\",\n        \"downloadSpeed\": \"Datenrate\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Serien\",\n        \"totalFiles\": \"Dateien\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Ergebnis\",\n        \"status\": \"Status\",\n        \"buildId\": \"Build-ID\",\n        \"succeeded\": \"Erfolgreich\",\n        \"notStarted\": \"Nicht gestartet\",\n        \"failed\": \"Fehlerhaft\",\n        \"canceled\": \"Abgebrochen\",\n        \"inProgress\": \"In Bearbeitung\",\n        \"totalPrs\": \"PRs gesamt\",\n        \"myPrs\": \"Meine PRs\",\n        \"approved\": \"Genehmigt\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Name\",\n        \"map\": \"Karte\",\n        \"currentPlayers\": \"Aktuelle Spieler\",\n        \"players\": \"Spieler\",\n        \"maxPlayers\": \"Max. Spieler\",\n        \"bots\": \"Bots\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"OK\",\n        \"errored\": \"Fehler\",\n        \"noRecent\": \"Veraltet\",\n        \"totalUsed\": \"Belegter Speicherplatz\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Rezepte\",\n        \"users\": \"Benutzer\",\n        \"categories\": \"Kategorien\",\n        \"tags\": \"Schlagwörter\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Wird heruntergeladen\",\n        \"total\": \"Total\",\n        \"running\": \"Wird ausgeführt\",\n        \"stopped\": \"Gestoppt\",\n        \"passed\": \"Erfolgreich\",\n        \"failed\": \"Fehlerhaft\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Betriebszeit\",\n        \"cpuLoad\": \"CPU-Last (5 min-Durchschnitt)\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\",\n        \"bytesTx\": \"Übertragen\",\n        \"bytesRx\": \"Empfangen\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Betriebszeit\",\n        \"lastDown\": \"Letzter Ausfall\",\n        \"downDuration\": \"Ausfalldauer\",\n        \"sitesUp\": \"Seiten online\",\n        \"sitesDown\": \"Seiten offline\",\n        \"paused\": \"Pausiert\",\n        \"notyetchecked\": \"Noch nicht geprüft\",\n        \"up\": \"Online\",\n        \"seemsdown\": \"Scheint nicht verfügbar\",\n        \"down\": \"Unbekannt\",\n        \"unknown\": \"Unbekannt\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"Im Kino\",\n        \"physicalRelease\": \"Physische Version\",\n        \"digitalRelease\": \"Digitale Version\",\n        \"noEventsToday\": \"Heute keine Ereignisse!\",\n        \"noEventsFound\": \"Keine Termine gefunden\",\n        \"errorWhenLoadingData\": \"Fehler beim Laden der Kalenderdaten\"\n    },\n    \"romm\": {\n        \"platforms\": \"Plattformen\",\n        \"totalRoms\": \"Spiele\",\n        \"saves\": \"Spielstände\",\n        \"states\": \"Speicherstände\",\n        \"screenshots\": \"Bildschirmfotos\",\n        \"totalfilesize\": \"Gesamtgröße\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Postfächer\",\n        \"mails\": \"E-Mails\",\n        \"storage\": \"Speicher\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Warnungen\",\n        \"criticals\": \"Kritisch\"\n    },\n    \"plantit\": {\n        \"events\": \"Ereignisse\",\n        \"plants\": \"Pflanzen\",\n        \"photos\": \"Fotos\",\n        \"species\": \"Spezies\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Benachrichtigungen\",\n        \"issues\": \"Probleme\",\n        \"pulls\": \"Pull-Requests\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Szenen\",\n        \"scenesPlayed\": \"Gespielte Szenen\",\n        \"playCount\": \"Wiedergaben gesamt\",\n        \"playDuration\": \"Zeit angesehen\",\n        \"sceneSize\": \"Szenengröße\",\n        \"sceneDuration\": \"Szenendauer\",\n        \"images\": \"Bilder\",\n        \"imageSize\": \"Bildgröße\",\n        \"galleries\": \"Galerien\",\n        \"performers\": \"Darsteller\",\n        \"studios\": \"Studios\",\n        \"movies\": \"Filme\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O-Anzahl\"\n    },\n    \"tandoor\": {\n        \"users\": \"Benutzer\",\n        \"recipes\": \"Rezepte\",\n        \"keywords\": \"Schlagwörter\"\n    },\n    \"homebox\": {\n        \"items\": \"Objekte\",\n        \"totalWithWarranty\": \"Mit Garantie\",\n        \"locations\": \"Orte\",\n        \"labels\": \"Labels\",\n        \"users\": \"Benutzer\",\n        \"totalValue\": \"Gesamtwert\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alarme\",\n        \"bans\": \"Banns\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Verbunden\",\n        \"enabled\": \"Aktiviert\",\n        \"disabled\": \"Deaktiviert\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxied\",\n        \"auth\": \"Mit Auth\",\n        \"outdated\": \"Veraltet\",\n        \"banned\": \"Gebannt\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Aktien\",\n        \"loading\": \"Wird geladen\",\n        \"open\": \"Offen - US-Markt\",\n        \"closed\": \"Geschlossen - US-Markt\",\n        \"invalidConfiguration\": \"Ungültige Konfiguration\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Kameras\",\n        \"uptime\": \"Betriebszeit\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Links\",\n        \"collections\": \"Sammlungen\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Nicht klassifiziert\",\n        \"information\": \"Information\",\n        \"warning\": \"Warnung\",\n        \"average\": \"Durchschnitt\",\n        \"high\": \"Hoch\",\n        \"disaster\": \"Katastrophe\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Fahrzeug\",\n        \"vehicles\": \"Fahrzeuge\",\n        \"serviceRecords\": \"Wartungseinträge\",\n        \"reminders\": \"Erinnerungen\",\n        \"nextReminder\": \"Nächste Erinnerung\",\n        \"none\": \"Keine\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Aktive Projekte\",\n        \"tasks7d\": \"Diese Woche fällige Aufgaben\",\n        \"tasksOverdue\": \"Überfällige Aufgaben\",\n        \"tasksInProgress\": \"Aufgaben in Arbeit\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Adresse\",\n        \"last_seen\": \"Zuletzt gesehen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Systeme\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Pausiert\",\n        \"pending\": \"Wartend\",\n        \"status\": \"Status\",\n        \"updated\": \"Aktuell\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"RAM\",\n        \"disk\": \"Festplatte\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Anwendungen\",\n        \"synced\": \"Synchronisiert\",\n        \"outOfSync\": \"Nicht mehr synchronisiert\",\n        \"healthy\": \"Gesund\",\n        \"degraded\": \"Beeinträchtigt\",\n        \"progressing\": \"Fortschritt\",\n        \"missing\": \"Fehlend\",\n        \"suspended\": \"Unterbrochen\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Lädt\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Gruppen\",\n        \"issues\": \"Probleme\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Projekte\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Last\",\n        \"bcharge\": \"Batterieladung\",\n        \"timeleft\": \"Verbleibende Zeit\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Lesezeichen\",\n        \"favorites\": \"Favoriten\",\n        \"archived\": \"Archiviert\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Listen\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Netzwerk\",\n        \"connected\": \"Verbunden\",\n        \"disconnected\": \"Getrennt\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Verfügbar\",\n        \"update_no\": \"Aktuell\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Dateien\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Filme\",\n        \"episodes\": \"Episoden\",\n        \"other\": \"Andere\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Dienstprobleme\",\n        \"hostErrors\": \"Hostprobleme\"\n    },\n    \"komodo\": {\n        \"total\": \"Gesamt\",\n        \"running\": \"Aktiv\",\n        \"stopped\": \"Angehalten\",\n        \"down\": \"Inaktiv\",\n        \"unhealthy\": \"Fehlerhaft\",\n        \"unknown\": \"Unbekannt\",\n        \"servers\": \"Server\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Container\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Verfügbar\",\n        \"used\": \"Benutzt\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Abonnements\",\n        \"thisMonthlyCost\": \"Dieser Monat\",\n        \"nextMonthlyCost\": \"Nächster Monat\",\n        \"previousMonthlyCost\": \"Vorh. Monat\",\n        \"nextRenewingSubscription\": \"Nächste Zahlung\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Gestartet\",\n        \"STOPPED\": \"Angehalten\",\n        \"NEW_ARRAY\": \"Neues Array\",\n        \"RECON_DISK\": \"Festplatte wird neu aufgebaut\",\n        \"DISABLE_DISK\": \"Festplatte deaktiviert\",\n        \"SWAP_DSBL\": \"Swap deaktivieren\",\n        \"INVALID_EXPANSION\": \"Üngültige Erweiterung\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Zu viele fehlende Festplatten\",\n        \"NEW_DISK_TOO_SMALL\": \"Neue Festplatte zu klein\",\n        \"NO_DATA_DISKS\": \"Keine Datenträger\",\n        \"notifications\": \"Mitteilungen\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Speichernutzung\",\n        \"memoryAvailable\": \"Verfügbarer Speicher\",\n        \"arrayUsed\": \"Array verwendet\",\n        \"arrayFree\": \"Array frei\",\n        \"poolUsed\": \"{{pool}} verwendet\",\n        \"poolFree\": \"{{pool}} frei\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Pläne\",\n        \"num_success_30\": \"Erfolgreich\",\n        \"num_failure_30\": \"Fehlerhaft\",\n        \"num_success_latest\": \"Erfolgreich\",\n        \"num_failure_latest\": \"Fehlgeschlagen\",\n        \"bytes_added_30\": \"Bytes hinzugefügt\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Titel\",\n        \"time\": \"Zeit\",\n        \"artists\": \"Künstler\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Wird ausgeführt\",\n        \"stopped\": \"Gestoppt\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"RAM\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Heutige Ereignisse\",\n        \"pending_updates\": \"Ausstehende Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Pausiert\",\n        \"total\": \"Gesamt\",\n        \"environment_not_found\": \"Umgebung nicht gefunden\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/el/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"mo\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Λείπει ο τύπος widget: {{type}}\",\n        \"api_error\": \"Σφάλμα API\",\n        \"information\": \"Πληροφορία\",\n        \"status\": \"Κατάσταση\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Ανεπεξέργαστο σφάλμα\",\n        \"response_data\": \"Δεδομένα απόκρισης\"\n    },\n    \"weather\": {\n        \"current\": \"Τωρινή τοποθεσία\",\n        \"allow\": \"Κάντε κλικ για να επιτρέψετε\",\n        \"updating\": \"Ενημέρωση\",\n        \"wait\": \"Παρακαλώ περιμένετε\"\n    },\n    \"search\": {\n        \"placeholder\": \"Αναζήτηση…\"\n    },\n    \"resources\": {\n        \"cpu\": \"Επεξεργαστής\",\n        \"mem\": \"Μνήμη\",\n        \"total\": \"Σύνολο\",\n        \"free\": \"Δωρεάν\",\n        \"used\": \"χρησιμοποιημένο\",\n        \"load\": \"Φόρτωση\",\n        \"temp\": \"Θερμοκρασία\",\n        \"max\": \"Μέγιστο\",\n        \"uptime\": \"Χρόνος Λειτουργίας\"\n    },\n    \"unifi\": {\n        \"users\": \"Χρήστες\",\n        \"uptime\": \"Χρόνος Λειτουργίας\",\n        \"days\": \"Ημέρες\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Συσκευές\",\n        \"lan_devices\": \"LAN Συσκευές\",\n        \"wlan_devices\": \"WLAN Συσκευές\",\n        \"lan_users\": \"LAN Χρήστες\",\n        \"wlan_users\": \"WLAN Χρήστες\",\n        \"up\": \"UP\",\n        \"down\": \"ΚΑΤΩ\",\n        \"wait\": \"Please wait\",\n        \"empty_data\": \"Άγνωστη κατάσταση υποσυστήματος\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Τρέχων\",\n        \"offline\": \"Εκτός σύνδεσης\",\n        \"error\": \"Σφάλμα\",\n        \"unknown\": \"Άγνωστο\",\n        \"healthy\": \"Υγειές\",\n        \"starting\": \"Ξεκινάει\",\n        \"unhealthy\": \"Άρρωστο\",\n        \"not_found\": \"Δεν βρέθηκε\",\n        \"exited\": \"Έκλεισε\",\n        \"partial\": \"Μερικό\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"Ping\",\n        \"down\": \"Ping down\",\n        \"up\": \"Ping up\",\n        \"not_available\": \"Μη διαθέσιμο\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"Κατάσταση HTTP\",\n        \"error\": \"Error\",\n        \"response\": \"Απόκριση\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"emby\": {\n        \"playing\": \"Παίζει\",\n        \"transcoding\": \"Διακωδικοποίηση\",\n        \"bitrate\": \"Ρυθμός bit\",\n        \"no_active\": \"Δεν υπάρχουν ενεργές ροές\",\n        \"movies\": \"Ταινίες\",\n        \"series\": \"Σειρές\",\n        \"episodes\": \"Επεισόδια\",\n        \"songs\": \"Τραγούδια\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Συνδεδεμένοι\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Παραγωγή\",\n        \"battery_soc\": \"Μπαταρία\",\n        \"grid_power\": \"Πλέγμα\",\n        \"home_power\": \"Κατανάλωση\",\n        \"charge_power\": \"Φορτιστής\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Λήξη\",\n        \"upload\": \"Μεταφόρτωση\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Συνδρομές\",\n        \"unread\": \"Μη Διαβασμένο\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Μη Ρυθμισμένο\",\n        \"connectionStatusConnecting\": \"Κατάσταση Σύνδεσης\",\n        \"connectionStatusAuthenticating\": \"Ταυτοποίηση\",\n        \"connectionStatusPendingDisconnect\": \"Εκκρεμεί Αποσύνδεση\",\n        \"connectionStatusDisconnecting\": \"Αποσύνδεση\",\n        \"connectionStatusDisconnected\": \"Αποσυνδέθηκε\",\n        \"connectionStatusConnected\": \"Συνδέθηκε\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"Μέγιστο Download\",\n        \"maxUp\": \"Μέγιστο Upload\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"Ληφθέντα\",\n        \"sent\": \"Απεσταλμένα\",\n        \"externalIPAddress\": \"Εξωτερική IP\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Τρέχοντα αιτήματα\",\n        \"requests_failed\": \"Αποτυχημένα αιτήματα\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Συνολικά παρατηρηθείσα\",\n        \"diffsDetected\": \"Εντοπίστηκαν διαφορές\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Εκπομπές\",\n        \"recordings\": \"Εγγραφές\",\n        \"scheduled\": \"Προγραμματισμένα\",\n        \"passes\": \"Περάσματα\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"Έλεγχος Σύνδεσης με Plex\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Συνδεδεμένα APs\",\n        \"activeUser\": \"Ενεργές συσκευές\",\n        \"alerts\": \"Ειδοποιήσεις\",\n        \"connectedGateways\": \"Connected gateways\",\n        \"connectedSwitches\": \"Συνδεδεμένα switches\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Ρυθμός\",\n        \"remaining\": \"Υπόλοιπο\",\n        \"downloaded\": \"Κατεβασμένο\"\n    },\n    \"plex\": {\n        \"streams\": \"Ενεργές Ροές\",\n        \"albums\": \"Άλμπουμ\",\n        \"movies\": \"Movies\",\n        \"tv\": \"Τηλεοπτικές εκπομπές\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"Ουρά\",\n        \"timeleft\": \"Χρόνος που απομένει\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Ενεργό\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"Χρήση επεξεργαστή\",\n        \"memUsage\": \"Χρήση μνήμης\",\n        \"systemTempC\": \"Θερμοκρασία συστήματος\",\n        \"poolUsage\": \"Χρήση πισίνας\",\n        \"volumeUsage\": \"Χρήση Όγκου\",\n        \"invalid\": \"Μη έγκυρο\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Επιθυμούντε\",\n        \"queued\": \"Σε σειρά\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"Απουσιάζει\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"Καλλιτέχνες\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"Βιβλία\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Επεισόδια που λείπουν\",\n        \"missingMovies\": \"Ταινίες που Λείπουν\"\n    },\n    \"ombi\": {\n        \"pending\": \"Σε εκκρεμότητα\",\n        \"approved\": \"Εγκρίθηκε\",\n        \"available\": \"Διαθέσιμο\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"Νέες συσκευές\",\n        \"down_alerts\": \"Ειδοποιήσεις offline\"\n    },\n    \"pihole\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Αποκλεισμένο\",\n        \"blocked_percent\": \"Αποκλεισμένο %\",\n        \"gravity\": \"Gravity\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"Φιλτραρισμένα\",\n        \"latency\": \"Καθυστέρηση\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Σταματημένο\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Non-Downloaded\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Downloaded & Read\",\n        \"downloadedunread\": \"Downloaded & Unread\",\n        \"nondownloadedread\": \"Non-Downloaded & Read\",\n        \"nondownloadedunread\": \"Non-Downloaded & Unread\"\n    },\n    \"tailscale\": {\n        \"address\": \"Διεύθυνση\",\n        \"expires\": \"Λήγει\",\n        \"never\": \"Ποτέ\",\n        \"last_seen\": \"Τελευταία Σύνδεση\",\n        \"now\": \"Τώρα\",\n        \"years\": \"{{number}}χρόνια\",\n        \"weeks\": \"{{number}}εβδομάδες\",\n        \"days\": \"{{number}}μέρες\",\n        \"hours\": \"{{number}}ώρες\",\n        \"minutes\": \"{{number}}λεπτά\",\n        \"seconds\": \"{{number}}δευτερόλεπτα\",\n        \"ago\": \"{{value}} πρίν\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"Success\",\n        \"totalServerFailure\": \"Failures\",\n        \"totalNxDomain\": \"NX Domains\",\n        \"totalRefused\": \"Refused\",\n        \"totalAuthoritative\": \"Authoritative\",\n        \"totalRecursive\": \"Recursive\",\n        \"totalCached\": \"Cached\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"Dropped\",\n        \"totalClients\": \"Πελάτες\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Σε επεξεργασία\",\n        \"errored\": \"Σφάλματα\",\n        \"saved\": \"Αποθηκεύτηκε\"\n    },\n    \"traefik\": {\n        \"routers\": \"Δρομολογητές\",\n        \"services\": \"Υπηρεσίες\",\n        \"middleware\": \"Ενδιάμεσο λογισμικό\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Παρακαλώ περιμένετε\"\n    },\n    \"npm\": {\n        \"enabled\": \"Ενεργοποιημένο\",\n        \"disabled\": \"Απενεργοποιημένο\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Ρυθμίστε ένα ή περισσότερα κρυπτονομίσματα για παρακολούθηση\",\n        \"1hour\": \"1 Ώρα\",\n        \"1day\": \"1 ημέρα\",\n        \"7days\": \"7 Ημέρες\",\n        \"30days\": \"30 Ημέρες\"\n    },\n    \"gotify\": {\n        \"apps\": \"Εφαρμογές\",\n        \"clients\": \"Clients\",\n        \"messages\": \"Μηνύματα\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Ευρετήρια\",\n        \"numberOfGrabs\": \"Αρπαγές\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"Αποτυχημένες Αρπαγές\",\n        \"numberOfFailQueries\": \"Fail Queries\"\n    },\n    \"jackett\": {\n        \"configured\": \"Ρυθμισμένο\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Συνεδρίες\",\n        \"numConnections\": \"Συνδέσεις\",\n        \"dataRelayed\": \"Relayed\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"Δημοσιεύσεις\",\n        \"domain_count\": \"Τομείς\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Παίκτες\",\n        \"version\": \"Έκδοση\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Διαβάστηκε\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"Συνδέσεις (24h)\",\n        \"failedLoginsLast24H\": \"Αποτυχημένες Συνδέσεις (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temp\",\n        \"warn\": \"Warn\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Crit\",\n        \"read\": \"Read\",\n        \"write\": \"Write\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Μνήμη\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Bookmark\",\n        \"service\": \"Service\",\n        \"search\": \"Search\",\n        \"custom\": \"Custom\",\n        \"visit\": \"Visit\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Suggestion\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Λιακάδα\",\n        \"0-night\": \"Καθαρή\",\n        \"1-day\": \"Κυρίως Ηλιόλουστη\",\n        \"1-night\": \"Κυρίως καθαρή\",\n        \"2-day\": \"Αραιές Νεφώσεις\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"Συννεφιές\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"Ομίχλη\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"Ψιλόβροχο\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"Ψιλόβροχο\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"Heavy Drizzle\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"Light Freezing Drizzle\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"Παγωμένο ψιχάλισμα\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"Ψιλόβροχο\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"Βροχή\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Δυνατή βροχή\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Παγωμένη βροχή\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Ελαφριά Χιονόπτωση\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Χιόνι\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Ισχυρή χιονόπτωση\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Κόκκοι Χιονιού\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Ασθενείς βροχές\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Βροχοπτώσεις\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Ισχυρές βροχοπτώσεις\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Χιονοπτώσεις\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"Καταιγίδα\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Καταιγίδα Με Χαλάζι\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Σύστημα\",\n        \"updates\": \"Ενημερώσεις\",\n        \"update_available\": \"Διαθέσιμη ενημέρωση\",\n        \"up_to_date\": \"Ενημερωμένο\",\n        \"child_bridges\": \"Child Bridges\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"New\",\n        \"up\": \"Up\",\n        \"grace\": \"In Grace Period\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Τελευταίο Ping\",\n        \"never\": \"Δεν υπάρχουν ping ακόμα\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Σκαναρισμένο\",\n        \"containers_updated\": \"Ενημερώθηκε\",\n        \"containers_failed\": \"Απέτυχε\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Απορρίφθηκε\",\n        \"filters\": \"Φίλτρα\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Βίντεο\",\n        \"channels\": \"Κανάλια\",\n        \"playlists\": \"Λίστες αναπαραγωγής\"\n    },\n    \"truenas\": {\n        \"load\": \"Φόρτος Συστήματος\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Ταχύτητα\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Δημόσια ΙΡ\",\n        \"region\": \"Περιοχή\",\n        \"country\": \"Χώρα\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Δέκτες\",\n        \"channelNumber\": \"Κανάλι\",\n        \"channelNetwork\": \"Δίκτυο\",\n        \"signalStrength\": \"Ισχύς σήματος\",\n        \"signalQuality\": \"Ποιότητα\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Πελάτης\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Εισερχόμενα\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Battery Charge\",\n        \"ups_load\": \"UPS Load\",\n        \"ups_status\": \"UPS Status\",\n        \"online\": \"Online\",\n        \"on_battery\": \"Σε μπαταρία\",\n        \"low_battery\": \"Χαμηλή μπαταρία\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"No Device Data Received\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"Φόρτος CPU\",\n        \"memoryUsed\": \"Χρήση μνήμης\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Μισθώσεις\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"All Streams\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG Channels\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Σήμερα\",\n        \"absolutePower\": \"Ισχύς\",\n        \"relativePower\": \"Ισχύς %\",\n        \"limit\": \"Limit\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Active Memory\",\n        \"wanUpload\": \"WAN Upload\",\n        \"wanDownload\": \"WAN Download\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Κατάσταση Εκτυπωτή\",\n        \"print_status\": \"Κατάσταση Εκτύπωσης\",\n        \"print_progress\": \"Progress\",\n        \"layers\": \"Layers\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Tool temp\",\n        \"temp_bed\": \"Bed temp\",\n        \"job_completion\": \"Ολοκλήρωση\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Origin IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Load Avg\",\n        \"memory\": \"Mem Usage\",\n        \"wanStatus\": \"WAN Status\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Χρήση δίσκου\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datastore\",\n        \"failed_tasks_24h\": \"Failed Tasks 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memory\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Φωτογραφίες\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Αποθηκευτικός χώρος\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Online τοποθεσίες\",\n        \"down\": \"Offline τοποθεσίες\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Περιστατικό\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Archives\",\n        \"chapters\": \"Chapters\",\n        \"categories\": \"Κατηγορίες\"\n    },\n    \"komga\": {\n        \"libraries\": \"Libraries\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Issues\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"Άνθρωποι\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Ώρα\"\n    },\n    \"firefly\": {\n        \"networth\": \"Net Worth\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Πίνακας Ελέγχου\",\n        \"datasources\": \"Πηγές Δεδομένων\",\n        \"totalalerts\": \"Σύνολο Ειδοποιήσεων\",\n        \"alertstriggered\": \"Ενεργοποιημένες Ειδοποιήσεις\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Φόρτος CPU\",\n        \"memoryusage\": \"Χρήση Mνήμης\",\n        \"freespace\": \"Ελεύθερος χώρος\",\n        \"activeusers\": \"Ενεργοί χρήστες\",\n        \"numfiles\": \"Αρχεία\",\n        \"numshares\": \"Κοινόχρηστα στοιχεία\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Μέγεθος\",\n        \"lastrun\": \"Τελευταία εκτέλεση\",\n        \"nextrun\": \"Επόμενη εκτέλεση\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Ενεργοί χρήστες\",\n        \"total_workers\": \"Total Workers\",\n        \"records_total\": \"Μήκος Ουράς\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Διακομιστές\",\n        \"nodes\": \"Κόμβοι [Nodes]\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Στόχοι Πάνω\",\n        \"targets_down\": \"Στόχοι Κάτω\",\n        \"targets_total\": \"Συνολικοί Στόχοι\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"Ένας χρόνος\",\n        \"gross_percent_max\": \"Διαχρονικά\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasts\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Διάρκεια\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Σύνολο ανθρώπων στο σπίτι\",\n        \"lights_on\": \"Αναμμένα φώτα\",\n        \"switches_on\": \"Ανοιχτοί διακόπτες\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Παρακολούθηση\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Συντάκτες\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Αποτέλεσμα\",\n        \"status\": \"Status\",\n        \"buildId\": \"Build ID\",\n        \"succeeded\": \"Πέτυχε\",\n        \"notStarted\": \"Δεν ξεκίνησε\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Ακυρώθηκε\",\n        \"inProgress\": \"Σε εξέλιξη\",\n        \"totalPrs\": \"Total PRs\",\n        \"myPrs\": \"My PRs\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Όνομα\",\n        \"map\": \"Χάρτης\",\n        \"currentPlayers\": \"Current players\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Max players\",\n        \"bots\": \"Bots\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Οκ\",\n        \"errored\": \"Σφάλματα\",\n        \"noRecent\": \"Απαρχαιωμένη έκδοση\",\n        \"totalUsed\": \"Χώρος αποθήκευσης σε χρήση\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Συνταγές\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Ετικέτες\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Γίνεται λήψη\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"CPU Load Avg (5m)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Transmitted\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Last Downtime\",\n        \"downDuration\": \"Downtime Duration\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Not Yet Checked\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Seems Down\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"In cinemas\",\n        \"physicalRelease\": \"Physical release\",\n        \"digitalRelease\": \"Digital release\",\n        \"noEventsToday\": \"No events for today!\",\n        \"noEventsFound\": \"No events found\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platforms\",\n        \"totalRoms\": \"Games\",\n        \"saves\": \"Saves\",\n        \"states\": \"States\",\n        \"screenshots\": \"Screenshots\",\n        \"totalfilesize\": \"Total Size\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Mailboxes\",\n        \"mails\": \"Mails\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Warnings\",\n        \"criticals\": \"Criticals\"\n    },\n    \"plantit\": {\n        \"events\": \"Events\",\n        \"plants\": \"Plants\",\n        \"photos\": \"Photos\",\n        \"species\": \"Species\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Notifications\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Pull Requests\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Scenes\",\n        \"scenesPlayed\": \"Scenes Played\",\n        \"playCount\": \"Total Plays\",\n        \"playDuration\": \"Time Watched\",\n        \"sceneSize\": \"Scenes Size\",\n        \"sceneDuration\": \"Scenes Duration\",\n        \"images\": \"Images\",\n        \"imageSize\": \"Images Size\",\n        \"galleries\": \"Galleries\",\n        \"performers\": \"Performers\",\n        \"studios\": \"Studios\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O Count\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Keywords\"\n    },\n    \"homebox\": {\n        \"items\": \"Items\",\n        \"totalWithWarranty\": \"With Warranty\",\n        \"locations\": \"Locations\",\n        \"labels\": \"Labels\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Total Value\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Bans\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxied\",\n        \"auth\": \"With Auth\",\n        \"outdated\": \"Outdated\",\n        \"banned\": \"Banned\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Stocks\",\n        \"loading\": \"Loading\",\n        \"open\": \"Open - US Market\",\n        \"closed\": \"Closed - US Market\",\n        \"invalidConfiguration\": \"Invalid Configuration\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Cameras\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Links\",\n        \"collections\": \"Collections\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Not classified\",\n        \"information\": \"Information\",\n        \"warning\": \"Warning\",\n        \"average\": \"Average\",\n        \"high\": \"High\",\n        \"disaster\": \"Disaster\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vehicle\",\n        \"vehicles\": \"Vehicles\",\n        \"serviceRecords\": \"Service Records\",\n        \"reminders\": \"Reminders\",\n        \"nextReminder\": \"Next Reminder\",\n        \"none\": \"None\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Active Projects\",\n        \"tasks7d\": \"Tasks Due This Week\",\n        \"tasksOverdue\": \"Overdue Tasks\",\n        \"tasksInProgress\": \"Tasks In Progress\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Systems\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Disk\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Apps\",\n        \"synced\": \"Synced\",\n        \"outOfSync\": \"Out Of Sync\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Degraded\",\n        \"progressing\": \"Progressing\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Suspended\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Groups\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Projects\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bookmarks\",\n        \"favorites\": \"Favorites\",\n        \"archived\": \"Archived\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Lists\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Other\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/en/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"mo\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Missing Widget Type: {{type}}\",\n        \"api_error\": \"API Error\",\n        \"information\": \"Information\",\n        \"status\": \"Status\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Raw Error\",\n        \"response_data\": \"Response Data\"\n    },\n    \"weather\": {\n        \"current\": \"Current Location\",\n        \"allow\": \"Click to allow\",\n        \"updating\": \"Updating\",\n        \"wait\": \"Please wait\"\n    },\n    \"search\": {\n        \"placeholder\": \"Search…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"MEM\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"load\": \"Load\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Max\",\n        \"uptime\": \"UP\"\n    },\n    \"unifi\": {\n        \"users\": \"Users\",\n        \"uptime\": \"Uptime\",\n        \"days\": \"Days\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Devices\",\n        \"lan_devices\": \"LAN Devices\",\n        \"wlan_devices\": \"WLAN Devices\",\n        \"lan_users\": \"LAN Users\",\n        \"wlan_users\": \"WLAN Users\",\n        \"up\": \"UP\",\n        \"down\": \"DOWN\",\n        \"wait\": \"Please wait\",\n        \"empty_data\": \"Subsystem status unknown\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Running\",\n        \"offline\": \"Offline\",\n        \"error\": \"Error\",\n        \"unknown\": \"Unknown\",\n        \"healthy\": \"Healthy\",\n        \"starting\": \"Starting\",\n        \"unhealthy\": \"Unhealthy\",\n        \"not_found\": \"Not Found\",\n        \"exited\": \"Exited\",\n        \"partial\": \"Partial\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"Ping\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP status\",\n        \"error\": \"Error\",\n        \"response\": \"Response\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"emby\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Online\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Production\",\n        \"battery_soc\": \"Battery\",\n        \"grid_power\": \"Grid\",\n        \"home_power\": \"Consumption\",\n        \"charge_power\": \"Charger\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Subscriptions\",\n        \"unread\": \"Unread\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Unconfigured\",\n        \"connectionStatusConnecting\": \"Connecting\",\n        \"connectionStatusAuthenticating\": \"Authenticating\",\n        \"connectionStatusPendingDisconnect\": \"Pending Disconnect\",\n        \"connectionStatusDisconnecting\": \"Disconnecting\",\n        \"connectionStatusDisconnected\": \"Disconnected\",\n        \"connectionStatusConnected\": \"Connected\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"Max. Down\",\n        \"maxUp\": \"Max. Up\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"Received\",\n        \"sent\": \"Sent\",\n        \"externalIPAddress\": \"Ext. IP\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Current requests\",\n        \"requests_failed\": \"Failed requests\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Total Observed\",\n        \"diffsDetected\": \"Diffs Detected\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Shows\",\n        \"recordings\": \"Recordings\",\n        \"scheduled\": \"Scheduled\",\n        \"passes\": \"Passes\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"Check Plex Connection\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Connected APs\",\n        \"activeUser\": \"Active devices\",\n        \"alerts\": \"Alerts\",\n        \"connectedGateways\": \"Connected gateways\",\n        \"connectedSwitches\": \"Connected switches\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Rate\",\n        \"remaining\": \"Remaining\",\n        \"downloaded\": \"Downloaded\"\n    },\n    \"plex\": {\n        \"streams\": \"Active Streams\",\n        \"albums\": \"Albums\",\n        \"movies\": \"Movies\",\n        \"tv\": \"TV Shows\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"Queue\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Active\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU Usage\",\n        \"memUsage\": \"MEM Usage\",\n        \"systemTempC\": \"System Temp\",\n        \"poolUsage\": \"Pool Usage\",\n        \"volumeUsage\": \"Volume Usage\",\n        \"invalid\": \"Invalid\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"Missing\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"Artists\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"Books\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Missing Episodes\",\n        \"missingMovies\": \"Missing Movies\"\n    },\n    \"ombi\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"New Devices\",\n        \"down_alerts\": \"Down Alerts\"\n    },\n    \"pihole\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"blocked_percent\": \"Blocked %\",\n        \"gravity\": \"Gravity\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"Filtered\",\n        \"latency\": \"Latency\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n      \"download\": \"Downloaded\",\n      \"nondownload\": \"Non-Downloaded\",\n      \"read\": \"Read\",\n      \"unread\": \"Unread\",\n      \"downloadedread\": \"Downloaded & Read\",\n      \"downloadedunread\": \"Downloaded & Unread\",\n      \"nondownloadedread\": \"Non-Downloaded & Read\",\n      \"nondownloadedunread\": \"Non-Downloaded & Unread\"\n    },\n    \"tailscale\": {\n        \"address\": \"Address\",\n        \"expires\": \"Expires\",\n        \"never\": \"Never\",\n        \"last_seen\": \"Last Seen\",\n        \"now\": \"Now\",\n        \"years\": \"{{number}}y\",\n        \"weeks\": \"{{number}}w\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Ago\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"Success\",\n        \"totalServerFailure\": \"Failures\",\n        \"totalNxDomain\": \"NX Domains\",\n        \"totalRefused\": \"Refused\",\n        \"totalAuthoritative\": \"Authoritative\",\n        \"totalRecursive\": \"Recursive\",\n        \"totalCached\": \"Cached\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"Dropped\",\n        \"totalClients\": \"Clients\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Processed\",\n        \"errored\": \"Errored\",\n        \"saved\": \"Saved\"\n    },\n    \"traefik\": {\n        \"routers\": \"Routers\",\n        \"services\": \"Services\",\n        \"middleware\": \"Middleware\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Please Wait\"\n    },\n    \"npm\": {\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Configure one or more crypto currencies to track\",\n        \"1hour\": \"1 Hour\",\n        \"1day\": \"1 Day\",\n        \"7days\": \"7 Days\",\n        \"30days\": \"30 Days\"\n    },\n    \"gotify\": {\n        \"apps\": \"Applications\",\n        \"clients\": \"Clients\",\n        \"messages\": \"Messages\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indexers\",\n        \"numberOfGrabs\": \"Grabs\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"Fail Grabs\",\n        \"numberOfFailQueries\": \"Fail Queries\"\n    },\n    \"jackett\": {\n        \"configured\": \"Configured\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sessions\",\n        \"numConnections\": \"Connections\",\n        \"dataRelayed\": \"Relayed\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"Posts\",\n        \"domain_count\": \"Domains\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Players\",\n        \"version\": \"Version\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Read\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"Logins (24h)\",\n        \"failedLoginsLast24H\": \"Failed Logins (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temp\",\n        \"warn\": \"Warn\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Crit\",\n        \"read\": \"Read\",\n        \"write\": \"Write\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Bookmark\",\n        \"service\": \"Service\",\n        \"search\": \"Search\",\n        \"custom\": \"Custom\",\n        \"visit\": \"Visit\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Suggestion\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Sunny\",\n        \"0-night\": \"Clear\",\n        \"1-day\": \"Mainly Sunny\",\n        \"1-night\": \"Mainly Clear\",\n        \"2-day\": \"Partly Cloudy\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"Cloudy\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"Foggy\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"Light Drizzle\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"Drizzle\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"Heavy Drizzle\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"Light Freezing Drizzle\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"Freezing Drizzle\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"Light Rain\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"Rain\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Heavy Rain\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Freezing Rain\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Light Snow\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Snow\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Heavy Snow\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Snow Grains\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Light Showers\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Showers\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Heavy Showers\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Snow Showers\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"Thunderstorm\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Thunderstorm With Hail\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"System\",\n        \"updates\": \"Updates\",\n        \"update_available\": \"Update Available\",\n        \"up_to_date\": \"Up to Date\",\n        \"child_bridges\": \"Child Bridges\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"New\",\n        \"up\": \"Up\",\n        \"grace\": \"In Grace Period\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Last Ping\",\n        \"never\": \"No pings yet\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Scanned\",\n        \"containers_updated\": \"Updated\",\n        \"containers_failed\": \"Failed\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Rejected\",\n        \"filters\": \"Filters\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Videos\",\n        \"channels\": \"Channels\",\n        \"playlists\": \"Playlists\"\n    },\n    \"truenas\": {\n        \"load\": \"System Load\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Speed\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Public IP\",\n        \"region\": \"Region\",\n        \"country\": \"Country\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tuners\",\n        \"channelNumber\": \"Channel\",\n        \"channelNetwork\": \"Network\",\n        \"signalStrength\": \"Strength\",\n        \"signalQuality\": \"Quality\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Client\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Inbox\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Battery Charge\",\n        \"ups_load\": \"UPS Load\",\n        \"ups_status\": \"UPS Status\",\n        \"online\": \"Online\",\n        \"on_battery\": \"On Battery\",\n        \"low_battery\": \"Low Battery\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"No Device Data Received\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"CPU Load\",\n        \"memoryUsed\": \"Memory Used\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Leases\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"All Streams\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG Channels\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Today\",\n        \"absolutePower\": \"Power\",\n        \"relativePower\": \"Power %\",\n        \"limit\": \"Limit\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Active Memory\",\n        \"wanUpload\": \"WAN Upload\",\n        \"wanDownload\": \"WAN Download\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Printer State\",\n        \"print_status\": \"Print Status\",\n        \"print_progress\": \"Progress\",\n        \"layers\": \"Layers\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Tool temp\",\n        \"temp_bed\": \"Bed temp\",\n        \"job_completion\": \"Completion\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Origin IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Load Avg\",\n        \"memory\": \"Mem Usage\",\n        \"wanStatus\": \"WAN Status\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Disk Usage\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datastore\",\n        \"failed_tasks_24h\": \"Failed Tasks 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memory\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Storage\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Incident\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Archives\",\n        \"chapters\": \"Chapters\",\n        \"categories\": \"Categories\"\n    },\n    \"komga\": {\n        \"libraries\": \"Libraries\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Issues\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"People\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Time\"\n    },\n    \"firefly\": {\n        \"networth\": \"Net Worth\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Dashboards\",\n        \"datasources\": \"Data Sources\",\n        \"totalalerts\": \"Total Alerts\",\n        \"alertstriggered\": \"Alerts Triggered\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Cpu Load\",\n        \"memoryusage\": \"Memory Usage\",\n        \"freespace\": \"Free Space\",\n        \"activeusers\": \"Active Users\",\n        \"numfiles\": \"Files\",\n        \"numshares\": \"Shared Items\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Size\",\n        \"lastrun\": \"Last Run\",\n        \"nextrun\": \"Next Run\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Active Workers\",\n        \"total_workers\": \"Total Workers\",\n        \"records_total\": \"Queue Length\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servers\",\n        \"nodes\": \"Nodes\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Targets Up\",\n        \"targets_down\": \"Targets Down\",\n        \"targets_total\": \"Total Targets\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"One year\",\n        \"gross_percent_max\": \"All time\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasts\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Duration\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"People Home\",\n        \"lights_on\": \"Lights On\",\n        \"switches_on\": \"Switches On\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Monitoring\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Authors\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Result\",\n        \"status\": \"Status\",\n        \"buildId\": \"Build ID\",\n        \"succeeded\": \"Succeeded\",\n        \"notStarted\": \"Not Started\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Canceled\",\n        \"inProgress\": \"In Progress\",\n        \"totalPrs\": \"Total PRs\",\n        \"myPrs\": \"My PRs\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Name\",\n        \"map\": \"Map\",\n        \"currentPlayers\": \"Current players\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Max players\",\n        \"bots\": \"Bots\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\" : \"Ok\",\n        \"errored\": \"Errors\",\n        \"noRecent\": \"Out of Date\",\n        \"totalUsed\": \"Used Storage\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Recipes\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Tags\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Downloading\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"CPU Load Avg (5m)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Transmitted\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Last Downtime\",\n        \"downDuration\": \"Downtime Duration\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Not Yet Checked\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Seems Down\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"In cinemas\",\n        \"physicalRelease\": \"Physical release\",\n        \"digitalRelease\": \"Digital release\",\n        \"noEventsToday\": \"No events for today!\",\n        \"noEventsFound\": \"No events found\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platforms\",\n        \"totalRoms\": \"Games\",\n        \"saves\": \"Saves\",\n        \"states\": \"States\",\n        \"screenshots\": \"Screenshots\",\n        \"totalfilesize\": \"Total Size\"\n    },\n    \"mailcow\": {\n      \"domains\": \"Domains\",\n      \"mailboxes\": \"Mailboxes\",\n      \"mails\": \"Mails\",\n      \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n      \"warnings\": \"Warnings\",\n      \"criticals\": \"Criticals\"\n    },\n    \"plantit\": {\n        \"events\": \"Events\",\n        \"plants\": \"Plants\",\n        \"photos\": \"Photos\",\n        \"species\": \"Species\"\n    },\n    \"gitea\": {\n      \"notifications\": \"Notifications\",\n      \"issues\": \"Issues\",\n      \"pulls\": \"Pull Requests\",\n      \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Scenes\",\n        \"scenesPlayed\": \"Scenes Played\",\n        \"playCount\": \"Total Plays\",\n        \"playDuration\": \"Time Watched\",\n        \"sceneSize\": \"Scenes Size\",\n        \"sceneDuration\": \"Scenes Duration\",\n        \"images\": \"Images\",\n        \"imageSize\": \"Images Size\",\n        \"galleries\": \"Galleries\",\n        \"performers\": \"Performers\",\n        \"studios\": \"Studios\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O Count\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Keywords\"\n    },\n    \"homebox\": {\n        \"items\": \"Items\",\n        \"totalWithWarranty\": \"With Warranty\",\n        \"locations\": \"Locations\",\n        \"labels\": \"Labels\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Total Value\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Bans\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxied\",\n        \"auth\": \"With Auth\",\n        \"outdated\": \"Outdated\",\n        \"banned\": \"Banned\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Stocks\",\n        \"loading\": \"Loading\",\n        \"open\": \"Open - US Market\",\n        \"closed\": \"Closed - US Market\",\n        \"invalidConfiguration\": \"Invalid Configuration\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Cameras\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Links\",\n        \"collections\": \"Collections\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n      \"unclassified\": \"Not classified\",\n      \"information\": \"Information\",\n      \"warning\": \"Warning\",\n      \"average\": \"Average\",\n      \"high\": \"High\",\n      \"disaster\": \"Disaster\"\n    },\n    \"lubelogger\": {\n      \"vehicle\": \"Vehicle\",\n      \"vehicles\": \"Vehicles\",\n      \"serviceRecords\": \"Service Records\",\n      \"reminders\": \"Reminders\",\n      \"nextReminder\": \"Next Reminder\",\n      \"none\": \"None\"\n    },\n    \"vikunja\": {\n      \"projects\": \"Active Projects\",\n      \"tasks7d\": \"Tasks Due This Week\",\n      \"tasksOverdue\": \"Overdue Tasks\",\n      \"tasksInProgress\": \"Tasks In Progress\"\n    },\n    \"headscale\": {\n      \"name\": \"Name\",\n      \"address\": \"Address\",\n      \"last_seen\": \"Last Seen\",\n      \"status\": \"Status\",\n      \"online\": \"Online\",\n      \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n      \"name\": \"Name\",\n      \"systems\": \"Systems\",\n      \"up\": \"Up\",\n      \"down\": \"Down\",\n      \"paused\": \"Paused\",\n      \"pending\": \"Pending\",\n      \"status\": \"Status\",\n      \"updated\": \"Updated\",\n      \"cpu\": \"CPU\",\n      \"memory\": \"MEM\",\n      \"disk\": \"Disk\",\n      \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Apps\",\n        \"synced\": \"Synced\",\n        \"outOfSync\": \"Out Of Sync\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Degraded\",\n        \"progressing\": \"Progressing\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Suspended\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Groups\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Projects\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\":\"Battery Charge\",\n        \"timeleft\":\"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bookmarks\",\n        \"favorites\": \"Favorites\",\n        \"archived\": \"Archived\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Lists\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Other\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n      \"songs\": \"Songs\",\n      \"time\":  \"Time\",\n      \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/eo/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"mo\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Manka Tipo de Fenestraĵo: {{type}}\",\n        \"api_error\": \"Eraro de API\",\n        \"information\": \"Informo\",\n        \"status\": \"Stato\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Kruda Eraro\",\n        \"response_data\": \"Respondoj de Demandoj\"\n    },\n    \"weather\": {\n        \"current\": \"Aktuala loko\",\n        \"allow\": \"Klaku por permesi\",\n        \"updating\": \"Ĝisdatiganta\",\n        \"wait\": \"Bonvolu atendi\"\n    },\n    \"search\": {\n        \"placeholder\": \"Serĉi…\"\n    },\n    \"resources\": {\n        \"cpu\": \"Ĉefprocesoro\",\n        \"mem\": \"MEM\",\n        \"total\": \"Totalo\",\n        \"free\": \"Libera\",\n        \"used\": \"Uzata\",\n        \"load\": \"Ŝarĝo\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Maks\",\n        \"uptime\": \"UP\"\n    },\n    \"unifi\": {\n        \"users\": \"Uzantoj\",\n        \"uptime\": \"Uptime\",\n        \"days\": \"Tagoj\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Aparatoj\",\n        \"lan_devices\": \"LAN-Aparatoj\",\n        \"wlan_devices\": \"WLAN-Aparatoj\",\n        \"lan_users\": \"LAN-Uzantoj\",\n        \"wlan_users\": \"WLAN-Uzantoj\",\n        \"up\": \"UP\",\n        \"down\": \"DOWN\",\n        \"wait\": \"Please wait\",\n        \"empty_data\": \"Subsistemostatuso nekonata\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Rulata\",\n        \"offline\": \"Malkonekta\",\n        \"error\": \"Eraro\",\n        \"unknown\": \"Nekonata\",\n        \"healthy\": \"Sana\",\n        \"starting\": \"Lanĉante\",\n        \"unhealthy\": \"Malsana\",\n        \"not_found\": \"Ne trovita\",\n        \"exited\": \"Eliris\",\n        \"partial\": \"Parta\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"Sondaĵo\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP status\",\n        \"error\": \"Error\",\n        \"response\": \"Response\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"emby\": {\n        \"playing\": \"Ludante\",\n        \"transcoding\": \"Transkodigo\",\n        \"bitrate\": \"Bitrapido\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Filmoj\",\n        \"series\": \"Serioj\",\n        \"episodes\": \"Epizodoj\",\n        \"songs\": \"Kantoj\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Online\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Production\",\n        \"battery_soc\": \"Battery\",\n        \"grid_power\": \"Grid\",\n        \"home_power\": \"Consumption\",\n        \"charge_power\": \"Charger\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Elŝuti\",\n        \"upload\": \"Alŝuti\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Subscriptions\",\n        \"unread\": \"Unread\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Unconfigured\",\n        \"connectionStatusConnecting\": \"Connecting\",\n        \"connectionStatusAuthenticating\": \"Authenticating\",\n        \"connectionStatusPendingDisconnect\": \"Pending Disconnect\",\n        \"connectionStatusDisconnecting\": \"Disconnecting\",\n        \"connectionStatusDisconnected\": \"Disconnected\",\n        \"connectionStatusConnected\": \"Connected\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"Max. Down\",\n        \"maxUp\": \"Max. Up\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"Received\",\n        \"sent\": \"Sent\",\n        \"externalIPAddress\": \"Ext. IP\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Current requests\",\n        \"requests_failed\": \"Failed requests\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Total Observed\",\n        \"diffsDetected\": \"Diffs Detected\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Shows\",\n        \"recordings\": \"Recordings\",\n        \"scheduled\": \"Scheduled\",\n        \"passes\": \"Passes\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"Check Plex Connection\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Connected APs\",\n        \"activeUser\": \"Active devices\",\n        \"alerts\": \"Alerts\",\n        \"connectedGateways\": \"Connected gateways\",\n        \"connectedSwitches\": \"Connected switches\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Rate\",\n        \"remaining\": \"Remaining\",\n        \"downloaded\": \"Downloaded\"\n    },\n    \"plex\": {\n        \"streams\": \"Active Streams\",\n        \"albums\": \"Albums\",\n        \"movies\": \"Movies\",\n        \"tv\": \"Televidprogramoj\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"Queue\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Active\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU Usage\",\n        \"memUsage\": \"MEM Usage\",\n        \"systemTempC\": \"System Temp\",\n        \"poolUsage\": \"Pool Usage\",\n        \"volumeUsage\": \"Volume Usage\",\n        \"invalid\": \"Invalid\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"Missing\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"Artists\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"Libroj\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Missing Episodes\",\n        \"missingMovies\": \"Missing Movies\"\n    },\n    \"ombi\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Aprobita\",\n        \"available\": \"Havebla\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"New Devices\",\n        \"down_alerts\": \"Down Alerts\"\n    },\n    \"pihole\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"blocked_percent\": \"Blocked %\",\n        \"gravity\": \"Gravity\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"Filtritaj\",\n        \"latency\": \"Latency\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Non-Downloaded\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Downloaded & Read\",\n        \"downloadedunread\": \"Downloaded & Unread\",\n        \"nondownloadedread\": \"Non-Downloaded & Read\",\n        \"nondownloadedunread\": \"Non-Downloaded & Unread\"\n    },\n    \"tailscale\": {\n        \"address\": \"Address\",\n        \"expires\": \"Expires\",\n        \"never\": \"Never\",\n        \"last_seen\": \"Last Seen\",\n        \"now\": \"Now\",\n        \"years\": \"{{number}}y\",\n        \"weeks\": \"{{number}}w\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Ago\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"Success\",\n        \"totalServerFailure\": \"Failures\",\n        \"totalNxDomain\": \"NX Domains\",\n        \"totalRefused\": \"Refused\",\n        \"totalAuthoritative\": \"Authoritative\",\n        \"totalRecursive\": \"Recursive\",\n        \"totalCached\": \"Cached\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"Dropped\",\n        \"totalClients\": \"Klientoj\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Processed\",\n        \"errored\": \"Errored\",\n        \"saved\": \"Saved\"\n    },\n    \"traefik\": {\n        \"routers\": \"Routers\",\n        \"services\": \"Servoj\",\n        \"middleware\": \"Middleware\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Please Wait\"\n    },\n    \"npm\": {\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Configure one or more crypto currencies to track\",\n        \"1hour\": \"1 horo\",\n        \"1day\": \"1 tago\",\n        \"7days\": \"7 tagoj\",\n        \"30days\": \"30 tagoj\"\n    },\n    \"gotify\": {\n        \"apps\": \"Applications\",\n        \"clients\": \"Clients\",\n        \"messages\": \"Mesaĝoj\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indexers\",\n        \"numberOfGrabs\": \"Grabs\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"Fail Grabs\",\n        \"numberOfFailQueries\": \"Fail Queries\"\n    },\n    \"jackett\": {\n        \"configured\": \"Configured\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Seancoj\",\n        \"numConnections\": \"Konektoj\",\n        \"dataRelayed\": \"Relayed\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"Afiŝoj\",\n        \"domain_count\": \"Domains\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Players\",\n        \"version\": \"Version\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Read\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"Logins (24h)\",\n        \"failedLoginsLast24H\": \"Failed Logins (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temp\",\n        \"warn\": \"Warn\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Crit\",\n        \"read\": \"Read\",\n        \"write\": \"Write\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Bookmark\",\n        \"service\": \"Servo\",\n        \"search\": \"Search\",\n        \"custom\": \"Custom\",\n        \"visit\": \"Visit\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Suggestion\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Suna\",\n        \"0-night\": \"Sennuba\",\n        \"1-day\": \"Mainly Sunny\",\n        \"1-night\": \"Mainly Clear\",\n        \"2-day\": \"Nubeta\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"Nuba\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"Nebula\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"Light Drizzle\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"Drizzle\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"Heavy Drizzle\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"Light Freezing Drizzle\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"Freezing Drizzle\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"Light Rain\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"Pluvo\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Pluvego\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Frosta pluvo\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Light Snow\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Neĝo\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Neĝego\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Snow Grains\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Light Showers\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Showers\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Heavy Showers\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Snow Showers\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"Fulmotondro\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Fulmotondro kun hajlo\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Sistemo\",\n        \"updates\": \"Updates\",\n        \"update_available\": \"Update Available\",\n        \"up_to_date\": \"Up to Date\",\n        \"child_bridges\": \"Child Bridges\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"New\",\n        \"up\": \"Up\",\n        \"grace\": \"In Grace Period\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Last Ping\",\n        \"never\": \"No pings yet\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Scanned\",\n        \"containers_updated\": \"Updated\",\n        \"containers_failed\": \"Failed\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Rejected\",\n        \"filters\": \"Filtriloj\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Videos\",\n        \"channels\": \"Kanaloj\",\n        \"playlists\": \"Playlists\"\n    },\n    \"truenas\": {\n        \"load\": \"System Load\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Speed\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Public IP\",\n        \"region\": \"Regiono\",\n        \"country\": \"Lando\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tuners\",\n        \"channelNumber\": \"Channel\",\n        \"channelNetwork\": \"Network\",\n        \"signalStrength\": \"Strength\",\n        \"signalQuality\": \"Quality\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Client\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Inbox\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Battery Charge\",\n        \"ups_load\": \"UPS Load\",\n        \"ups_status\": \"UPS Status\",\n        \"online\": \"Online\",\n        \"on_battery\": \"On Battery\",\n        \"low_battery\": \"Low Battery\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"No Device Data Received\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"CPU Load\",\n        \"memoryUsed\": \"Memory Used\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Leases\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"All Streams\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG Channels\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Today\",\n        \"absolutePower\": \"Power\",\n        \"relativePower\": \"Power %\",\n        \"limit\": \"Limit\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Active Memory\",\n        \"wanUpload\": \"WAN Upload\",\n        \"wanDownload\": \"WAN Download\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Printer State\",\n        \"print_status\": \"Print Status\",\n        \"print_progress\": \"Progress\",\n        \"layers\": \"Layers\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Tool temp\",\n        \"temp_bed\": \"Bed temp\",\n        \"job_completion\": \"Completion\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Origin IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Load Avg\",\n        \"memory\": \"Mem Usage\",\n        \"wanStatus\": \"WAN Status\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Disk Usage\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datastore\",\n        \"failed_tasks_24h\": \"Failed Tasks 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memory\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Storage\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Incident\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Archives\",\n        \"chapters\": \"Chapters\",\n        \"categories\": \"Categories\"\n    },\n    \"komga\": {\n        \"libraries\": \"Libraries\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Issues\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"People\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Time\"\n    },\n    \"firefly\": {\n        \"networth\": \"Net Worth\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Dashboards\",\n        \"datasources\": \"Data Sources\",\n        \"totalalerts\": \"Total Alerts\",\n        \"alertstriggered\": \"Alerts Triggered\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Cpu Load\",\n        \"memoryusage\": \"Memory Usage\",\n        \"freespace\": \"Free Space\",\n        \"activeusers\": \"Active Users\",\n        \"numfiles\": \"Files\",\n        \"numshares\": \"Shared Items\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Size\",\n        \"lastrun\": \"Last Run\",\n        \"nextrun\": \"Next Run\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Active Workers\",\n        \"total_workers\": \"Total Workers\",\n        \"records_total\": \"Queue Length\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servers\",\n        \"nodes\": \"Nodes\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Targets Up\",\n        \"targets_down\": \"Targets Down\",\n        \"targets_total\": \"Total Targets\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"One year\",\n        \"gross_percent_max\": \"All time\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasts\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Duration\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"People Home\",\n        \"lights_on\": \"Lights On\",\n        \"switches_on\": \"Switches On\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Monitoring\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Authors\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Result\",\n        \"status\": \"Status\",\n        \"buildId\": \"Build ID\",\n        \"succeeded\": \"Succeeded\",\n        \"notStarted\": \"Not Started\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Canceled\",\n        \"inProgress\": \"In Progress\",\n        \"totalPrs\": \"Total PRs\",\n        \"myPrs\": \"My PRs\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Name\",\n        \"map\": \"Map\",\n        \"currentPlayers\": \"Current players\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Max players\",\n        \"bots\": \"Bots\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Errors\",\n        \"noRecent\": \"Out of Date\",\n        \"totalUsed\": \"Used Storage\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Recipes\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Tags\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Downloading\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"CPU Load Avg (5m)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Transmitted\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Last Downtime\",\n        \"downDuration\": \"Downtime Duration\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Not Yet Checked\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Seems Down\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"In cinemas\",\n        \"physicalRelease\": \"Physical release\",\n        \"digitalRelease\": \"Digital release\",\n        \"noEventsToday\": \"No events for today!\",\n        \"noEventsFound\": \"No events found\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platforms\",\n        \"totalRoms\": \"Games\",\n        \"saves\": \"Saves\",\n        \"states\": \"States\",\n        \"screenshots\": \"Screenshots\",\n        \"totalfilesize\": \"Total Size\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Mailboxes\",\n        \"mails\": \"Mails\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Warnings\",\n        \"criticals\": \"Criticals\"\n    },\n    \"plantit\": {\n        \"events\": \"Events\",\n        \"plants\": \"Plants\",\n        \"photos\": \"Photos\",\n        \"species\": \"Species\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Notifications\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Pull Requests\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Scenes\",\n        \"scenesPlayed\": \"Scenes Played\",\n        \"playCount\": \"Total Plays\",\n        \"playDuration\": \"Time Watched\",\n        \"sceneSize\": \"Scenes Size\",\n        \"sceneDuration\": \"Scenes Duration\",\n        \"images\": \"Images\",\n        \"imageSize\": \"Images Size\",\n        \"galleries\": \"Galleries\",\n        \"performers\": \"Performers\",\n        \"studios\": \"Studios\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O Count\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Keywords\"\n    },\n    \"homebox\": {\n        \"items\": \"Items\",\n        \"totalWithWarranty\": \"With Warranty\",\n        \"locations\": \"Locations\",\n        \"labels\": \"Labels\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Total Value\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Bans\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxied\",\n        \"auth\": \"With Auth\",\n        \"outdated\": \"Outdated\",\n        \"banned\": \"Banned\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Stocks\",\n        \"loading\": \"Loading\",\n        \"open\": \"Open - US Market\",\n        \"closed\": \"Closed - US Market\",\n        \"invalidConfiguration\": \"Invalid Configuration\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Cameras\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Links\",\n        \"collections\": \"Collections\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Not classified\",\n        \"information\": \"Information\",\n        \"warning\": \"Warning\",\n        \"average\": \"Average\",\n        \"high\": \"High\",\n        \"disaster\": \"Disaster\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vehicle\",\n        \"vehicles\": \"Vehicles\",\n        \"serviceRecords\": \"Service Records\",\n        \"reminders\": \"Reminders\",\n        \"nextReminder\": \"Next Reminder\",\n        \"none\": \"None\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Active Projects\",\n        \"tasks7d\": \"Tasks Due This Week\",\n        \"tasksOverdue\": \"Overdue Tasks\",\n        \"tasksInProgress\": \"Tasks In Progress\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Systems\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Disk\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Apps\",\n        \"synced\": \"Synced\",\n        \"outOfSync\": \"Out Of Sync\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Degraded\",\n        \"progressing\": \"Progressing\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Suspended\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Groups\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Projects\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bookmarks\",\n        \"favorites\": \"Favorites\",\n        \"archived\": \"Archived\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Lists\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Other\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/es/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"me\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Falta el tipo de widget: {{type}}\",\n        \"api_error\": \"Error de API\",\n        \"information\": \"Información\",\n        \"status\": \"Estado\",\n        \"url\": \"Enlace\",\n        \"raw_error\": \"Error sin procesar\",\n        \"response_data\": \"Datos de respuesta\"\n    },\n    \"weather\": {\n        \"current\": \"Ubicación actual\",\n        \"allow\": \"Pulsa para permitir\",\n        \"updating\": \"Actualizando\",\n        \"wait\": \"Espera, por favor\"\n    },\n    \"search\": {\n        \"placeholder\": \"Buscar…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"MEM\",\n        \"total\": \"Total\",\n        \"free\": \"Libre\",\n        \"used\": \"Utilizado\",\n        \"load\": \"Carga\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Máx.\",\n        \"uptime\": \"ACTIVO\"\n    },\n    \"unifi\": {\n        \"users\": \"Usuarios\",\n        \"uptime\": \"Tiempo activo\",\n        \"days\": \"Días\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Dispositivos\",\n        \"lan_devices\": \"Dispositivos LAN\",\n        \"wlan_devices\": \"Dispositivos WLAN\",\n        \"lan_users\": \"Usuarios LAN\",\n        \"wlan_users\": \"Usuarios WLAN\",\n        \"up\": \"ACTIVO\",\n        \"down\": \"CAÍDO\",\n        \"wait\": \"Espere, por favor\",\n        \"empty_data\": \"Se desconoce el estado del subsistema\"\n    },\n    \"docker\": {\n        \"rx\": \"Recibido\",\n        \"tx\": \"Transmitido\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Ejecutando\",\n        \"offline\": \"Desconectado\",\n        \"error\": \"Fallo\",\n        \"unknown\": \"Desconocido\",\n        \"healthy\": \"Saludable\",\n        \"starting\": \"Comenzando\",\n        \"unhealthy\": \"No saludable\",\n        \"not_found\": \"No encontrado\",\n        \"exited\": \"Terminado\",\n        \"partial\": \"Parcial\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"Ping\",\n        \"down\": \"Inactivo\",\n        \"up\": \"Activo\",\n        \"not_available\": \"No disponible\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"Estado HTTP\",\n        \"error\": \"Error\",\n        \"response\": \"Respuesta\",\n        \"down\": \"Inactivo\",\n        \"up\": \"Activos\",\n        \"not_available\": \"No disponible\"\n    },\n    \"emby\": {\n        \"playing\": \"Reproduciendo\",\n        \"transcoding\": \"Transcodificando\",\n        \"bitrate\": \"Tasa de bits\",\n        \"no_active\": \"Sin transmisiones activas\",\n        \"movies\": \"Películas\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodios\",\n        \"songs\": \"Canciones\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Reproduciendo\",\n        \"transcoding\": \"Convirtiendo\",\n        \"bitrate\": \"Tasa de Bits\",\n        \"no_active\": \"No hay Streams activos\",\n        \"movies\": \"Películas\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodios\",\n        \"songs\": \"Canciones\"\n    },\n    \"esphome\": {\n        \"offline\": \"Fuera de línea\",\n        \"offline_alt\": \"Fuera de línea\",\n        \"online\": \"En línea\",\n        \"total\": \"Total\",\n        \"unknown\": \"Desconocido\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Producción\",\n        \"battery_soc\": \"Batería\",\n        \"grid_power\": \"Red\",\n        \"home_power\": \"Consumo\",\n        \"charge_power\": \"Cargador\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Descarga\",\n        \"upload\": \"Subida\",\n        \"leech\": \"Descargas\",\n        \"seed\": \"Semillas\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Suscripciones\",\n        \"unread\": \"Sin leer\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Estado\",\n        \"connectionStatusUnconfigured\": \"Sin configurar\",\n        \"connectionStatusConnecting\": \"Conectando\",\n        \"connectionStatusAuthenticating\": \"Autenticando\",\n        \"connectionStatusPendingDisconnect\": \"Desconexión pendiente\",\n        \"connectionStatusDisconnecting\": \"Desconectando\",\n        \"connectionStatusDisconnected\": \"Desconectado\",\n        \"connectionStatusConnected\": \"Conectado\",\n        \"uptime\": \"Tiempo activo\",\n        \"maxDown\": \"Descarga máxima\",\n        \"maxUp\": \"Subida máxima\",\n        \"down\": \"Inactivo\",\n        \"up\": \"Activos\",\n        \"received\": \"Recibido\",\n        \"sent\": \"Enviado\",\n        \"externalIPAddress\": \"IP ext.\",\n        \"externalIPv6Address\": \"IPv6 ext.\",\n        \"externalIPv6Prefix\": \"Prefijo IPv6 ext.\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstream (desarrollo de software)\",\n        \"requests\": \"Peticiones actuales\",\n        \"requests_failed\": \"Peticiones fallidas\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Total Observados\",\n        \"diffsDetected\": \"Diferencias detectadas\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Series\",\n        \"recordings\": \"Grabaciones\",\n        \"scheduled\": \"Programado\",\n        \"passes\": \"Pases\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Reproduciendo\",\n        \"transcoding\": \"Transcodificando\",\n        \"bitrate\": \"Tasa de bits\",\n        \"no_active\": \"Sin transmisiones activas\",\n        \"plex_connection_error\": \"Comprueba la conexión a Plex\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"AP conectados\",\n        \"activeUser\": \"Dispositivos activos\",\n        \"alerts\": \"Alertas\",\n        \"connectedGateways\": \"Puertas de enlace conectadas\",\n        \"connectedSwitches\": \"Conmutadores conectados\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Tasa\",\n        \"remaining\": \"Restante\",\n        \"downloaded\": \"Descargado\"\n    },\n    \"plex\": {\n        \"streams\": \"Transmisiones activas\",\n        \"albums\": \"Álbumes\",\n        \"movies\": \"Películas\",\n        \"tv\": \"Series\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Tasa\",\n        \"queue\": \"En cola\",\n        \"timeleft\": \"Tiempo restante\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Activo\",\n        \"upload\": \"Subida\",\n        \"download\": \"Descarga\"\n    },\n    \"transmission\": {\n        \"download\": \"Descarga\",\n        \"upload\": \"Subida\",\n        \"leech\": \"Descargando\",\n        \"seed\": \"Semillas\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Descarga\",\n        \"upload\": \"Subida\",\n        \"leech\": \"Descargando\",\n        \"seed\": \"Semillas\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"Uso de la CPU\",\n        \"memUsage\": \"Uso de la memoria\",\n        \"systemTempC\": \"Temperatura del sistema\",\n        \"poolUsage\": \"Uso del pool\",\n        \"volumeUsage\": \"Uso de volúmenes\",\n        \"invalid\": \"No válido\"\n    },\n    \"deluge\": {\n        \"download\": \"Descarga\",\n        \"upload\": \"Subida\",\n        \"leech\": \"Descargando\",\n        \"seed\": \"Semillas\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Bytes encontrados en caché\",\n        \"cachemissbytes\": \"Bytes faltantes en caché\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Descarga\",\n        \"upload\": \"Subida\",\n        \"leech\": \"Descargando\",\n        \"seed\": \"Semillas\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Buscando\",\n        \"queued\": \"En cola\",\n        \"series\": \"Series\",\n        \"queue\": \"Cola\",\n        \"unknown\": \"Desconocido\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Buscando\",\n        \"missing\": \"Faltantes\",\n        \"queued\": \"En cola\",\n        \"movies\": \"Películas\",\n        \"queue\": \"Cola\",\n        \"unknown\": \"Desconocido\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Buscando\",\n        \"queued\": \"En cola\",\n        \"artists\": \"Artistas\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Buscando\",\n        \"queued\": \"En cola\",\n        \"books\": \"Libros\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Episodios faltantes\",\n        \"missingMovies\": \"Películas faltantes\"\n    },\n    \"ombi\": {\n        \"pending\": \"Pendiente\",\n        \"approved\": \"Aprobado\",\n        \"available\": \"Disponible\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Conectado\",\n        \"new_devices\": \"Nuevos dispositivos\",\n        \"down_alerts\": \"Alertas de caída\"\n    },\n    \"pihole\": {\n        \"queries\": \"Consultas\",\n        \"blocked\": \"Bloqueado\",\n        \"blocked_percent\": \"% bloqueado\",\n        \"gravity\": \"Gravity\"\n    },\n    \"adguard\": {\n        \"queries\": \"Consultas\",\n        \"blocked\": \"Bloqueado\",\n        \"filtered\": \"Filtrado\",\n        \"latency\": \"Latencia\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Subida\",\n        \"download\": \"Descarga\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"En ejecución\",\n        \"stopped\": \"Detenido\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Descargado\",\n        \"nondownload\": \"No descargado\",\n        \"read\": \"Leído\",\n        \"unread\": \"No leídos\",\n        \"downloadedread\": \"Descargado y leído\",\n        \"downloadedunread\": \"Descargado y no leído\",\n        \"nondownloadedread\": \"No descargado y leído\",\n        \"nondownloadedunread\": \"No descargado y no leído\"\n    },\n    \"tailscale\": {\n        \"address\": \"Dirección\",\n        \"expires\": \"Caduca en\",\n        \"never\": \"Nunca\",\n        \"last_seen\": \"Visto por última vez\",\n        \"now\": \"Ahora\",\n        \"years\": \"{{number}}a\",\n        \"weeks\": \"{{number}}sem\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"Hace {{value}}\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Consultas\",\n        \"totalNoError\": \"Éxitos\",\n        \"totalServerFailure\": \"Fallas\",\n        \"totalNxDomain\": \"Dominios NX\",\n        \"totalRefused\": \"Rechazados\",\n        \"totalAuthoritative\": \"Autoritarios\",\n        \"totalRecursive\": \"Recursivos\",\n        \"totalCached\": \"En caché\",\n        \"totalBlocked\": \"Bloqueado\",\n        \"totalDropped\": \"Descartados\",\n        \"totalClients\": \"Clientes\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Cola\",\n        \"processed\": \"Procesado\",\n        \"errored\": \"Error\",\n        \"saved\": \"Guardado\"\n    },\n    \"traefik\": {\n        \"routers\": \"Enrutadores\",\n        \"services\": \"Servicios\",\n        \"middleware\": \"Software intermedio\"\n    },\n    \"trilium\": {\n        \"version\": \"Versión\",\n        \"notesCount\": \"Notas\",\n        \"dbSize\": \"Tamaño de la base de datos\",\n        \"unknown\": \"Desconocido\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"Sin transmisiones activas\",\n        \"please_wait\": \"Por favor, espera\"\n    },\n    \"npm\": {\n        \"enabled\": \"Activos\",\n        \"disabled\": \"Inactivos\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Configurar una o más criptomonedas para rastrear\",\n        \"1hour\": \"1 Hora\",\n        \"1day\": \"1 Día\",\n        \"7days\": \"7 Días\",\n        \"30days\": \"30 Días\"\n    },\n    \"gotify\": {\n        \"apps\": \"Aplicaciones\",\n        \"clients\": \"Clientes\",\n        \"messages\": \"Mensajes\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indexadores\",\n        \"numberOfGrabs\": \"Capturas\",\n        \"numberOfQueries\": \"Consultas\",\n        \"numberOfFailGrabs\": \"Capturas fallidas\",\n        \"numberOfFailQueries\": \"Consultas fallidas\"\n    },\n    \"jackett\": {\n        \"configured\": \"Configurado\",\n        \"errored\": \"Con fallo\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sesiones\",\n        \"numConnections\": \"Conexiones\",\n        \"dataRelayed\": \"Retransmitido\",\n        \"transferRate\": \"Tasa\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Usuarios\",\n        \"status_count\": \"Publicaciones\",\n        \"domain_count\": \"Dominios\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Buscando\",\n        \"queued\": \"En cola\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Jugadores\",\n        \"version\": \"Versión\",\n        \"status\": \"Estado\",\n        \"up\": \"En línea\",\n        \"down\": \"Fuera de línea\"\n    },\n    \"miniflux\": {\n        \"read\": \"Leer\",\n        \"unread\": \"Sin leer\"\n    },\n    \"authentik\": {\n        \"users\": \"Usuarios\",\n        \"loginsLast24H\": \"Inicios de sesión (24h)\",\n        \"failedLoginsLast24H\": \"Inicios de sesión fallidos (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"Máquinas Virtuales\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Carga\",\n        \"wait\": \"Por favor, espera\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temperatura\",\n        \"warn\": \"Advertir\",\n        \"uptime\": \"ACTIVO\",\n        \"total\": \"Total\",\n        \"free\": \"Libre\",\n        \"used\": \"Usado\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Crít.\",\n        \"read\": \"Leído\",\n        \"write\": \"Escribir\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Memoria\",\n        \"swap\": \"Intercambiar\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Marcadores\",\n        \"service\": \"Servicio\",\n        \"search\": \"Buscar\",\n        \"custom\": \"Personalizado\",\n        \"visit\": \"Visitar\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Sugerencia\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Soleado\",\n        \"0-night\": \"Despejado\",\n        \"1-day\": \"Mayormente soleado\",\n        \"1-night\": \"Mayormente despejado\",\n        \"2-day\": \"Parcialmente nuboso\",\n        \"2-night\": \"Parcialmente nublado\",\n        \"3-day\": \"Nublado\",\n        \"3-night\": \"Nublado\",\n        \"45-day\": \"Niebla\",\n        \"45-night\": \"Neblinoso\",\n        \"48-day\": \"Neblinoso\",\n        \"48-night\": \"Neblinoso\",\n        \"51-day\": \"Llovizna ligera\",\n        \"51-night\": \"Llovizna ligera\",\n        \"53-day\": \"Llovizna\",\n        \"53-night\": \"Llovizna\",\n        \"55-day\": \"Llovizna intensa\",\n        \"55-night\": \"Llovizna intensa\",\n        \"56-day\": \"Llovizna helada ligera\",\n        \"56-night\": \"Llovizna helada ligera\",\n        \"57-day\": \"Llovizna helada\",\n        \"57-night\": \"Llovizna helada\",\n        \"61-day\": \"Lluvia ligera\",\n        \"61-night\": \"Lluvia ligera\",\n        \"63-day\": \"Lluvia\",\n        \"63-night\": \"Lluvia\",\n        \"65-day\": \"Lluvia torrencial\",\n        \"65-night\": \"Lluvia fuerte\",\n        \"66-day\": \"Granizo\",\n        \"66-night\": \"Lluvia helada\",\n        \"67-day\": \"Lluvia helada\",\n        \"67-night\": \"Lluvia helada\",\n        \"71-day\": \"Nevada leve\",\n        \"71-night\": \"Nieve ligera\",\n        \"73-day\": \"Nevada\",\n        \"73-night\": \"Nieve\",\n        \"75-day\": \"Nevada intensa\",\n        \"75-night\": \"Nieve intensa\",\n        \"77-day\": \"Granizada\",\n        \"77-night\": \"Granizada\",\n        \"80-day\": \"Llovizna\",\n        \"80-night\": \"Chubascos ligeros\",\n        \"81-day\": \"Lluvia\",\n        \"81-night\": \"Chubascos\",\n        \"82-day\": \"Lluvias torrenciales\",\n        \"82-night\": \"Chubascos fuertes\",\n        \"85-day\": \"Lluvia de nieve\",\n        \"85-night\": \"Chubascos de nieve\",\n        \"86-day\": \"Chubascos de nieve\",\n        \"86-night\": \"Chubascos de nieve\",\n        \"95-day\": \"Tormenta\",\n        \"95-night\": \"Tormenta\",\n        \"96-day\": \"Tormenta con granizo\",\n        \"96-night\": \"Tormenta con granizo\",\n        \"99-day\": \"Tormenta con granizo\",\n        \"99-night\": \"Tormenta con granizo\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Sistema\",\n        \"updates\": \"Actualizaciones\",\n        \"update_available\": \"Actualización disponible\",\n        \"up_to_date\": \"Actualizado\",\n        \"child_bridges\": \"Bridges secundarios\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Activo\",\n        \"pending\": \"Pendiente\",\n        \"down\": \"Inactivo\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Nuevo\",\n        \"up\": \"Activo\",\n        \"grace\": \"En Periodo de Gracia\",\n        \"down\": \"Inactivo\",\n        \"paused\": \"Pausado\",\n        \"status\": \"Estado\",\n        \"last_ping\": \"Último ping\",\n        \"never\": \"Aún no hay pings\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Escaneado\",\n        \"containers_updated\": \"Actualizado\",\n        \"containers_failed\": \"Fallido\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Aprobado\",\n        \"rejectedPushes\": \"Rechazado\",\n        \"filters\": \"Filtros\",\n        \"indexers\": \"Indexadores\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Cola\",\n        \"videos\": \"Videos\",\n        \"channels\": \"Canales\",\n        \"playlists\": \"Listas de reproducción\"\n    },\n    \"truenas\": {\n        \"load\": \"Carga del sistema\",\n        \"uptime\": \"Tiempo activo\",\n        \"alerts\": \"Alertas\"\n    },\n    \"pyload\": {\n        \"speed\": \"Velocidad\",\n        \"active\": \"Activo\",\n        \"queue\": \"Cola\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"IP pública\",\n        \"region\": \"Región\",\n        \"country\": \"País\",\n        \"port_forwarded\": \"Puerto redireccionado\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Canales\",\n        \"hd\": \"Alta definición\",\n        \"tunerCount\": \"Sintonizadores\",\n        \"channelNumber\": \"Canal\",\n        \"channelNetwork\": \"Red\",\n        \"signalStrength\": \"Intensidad\",\n        \"signalQuality\": \"Calidad\",\n        \"symbolQuality\": \"Calidad\",\n        \"networkRate\": \"Tasa de bits\",\n        \"clientIP\": \"Cliente\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Aprobado\",\n        \"failed\": \"Fallido\",\n        \"unknown\": \"Desconocido\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Bandeja de entrada\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sitios\",\n        \"resources\": \"Recursos\",\n        \"targets\": \"Destinos\",\n        \"traffic\": \"Tráfico\",\n        \"in\": \"Entrante\",\n        \"out\": \"Saliente\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Carga de la batería\",\n        \"ups_load\": \"Carga del UPS\",\n        \"ups_status\": \"Estado del UPS\",\n        \"online\": \"En línea\",\n        \"on_battery\": \"Con batería\",\n        \"low_battery\": \"Batería baja\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Por favor, espera\",\n        \"no_devices\": \"No se recibieron datos del dispositivo\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"Carga de la CPU\",\n        \"memoryUsed\": \"Memoria utilizada\",\n        \"uptime\": \"Tiempo activo\",\n        \"numberOfLeases\": \"Alquileres\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Todas las transmisiones\",\n        \"streams_active\": \"Transmisiones activas\",\n        \"streams_xepg\": \"Canales XEPG\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Hoy\",\n        \"absolutePower\": \"Potencia\",\n        \"relativePower\": \"Potencia %\",\n        \"limit\": \"Límite\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"Carga de la CPU\",\n        \"memory\": \"Memoria activa\",\n        \"wanUpload\": \"Subida WAN\",\n        \"wanDownload\": \"Descarga WAN\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Estado de la impresora\",\n        \"print_status\": \"Estado de la impresión\",\n        \"print_progress\": \"Progreso\",\n        \"layers\": \"Capas\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Estado\",\n        \"temp_tool\": \"Temperatura de la herramienta\",\n        \"temp_bed\": \"Temperatura de la plataforma\",\n        \"job_completion\": \"Finalización\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"IP de origen\",\n        \"status\": \"Estado\"\n    },\n    \"pfsense\": {\n        \"load\": \"Promedio de carga\",\n        \"memory\": \"Uso de memoria\",\n        \"wanStatus\": \"Estado de la WAN\",\n        \"up\": \"Activo\",\n        \"down\": \"Inactivo\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Uso del disco\",\n        \"wanIP\": \"IP de la WAN\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Almacén de datos\",\n        \"failed_tasks_24h\": \"Tareas fallidas en 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memoria\"\n    },\n    \"immich\": {\n        \"users\": \"Usuarios\",\n        \"photos\": \"Fotos\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Almacenamiento\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Sitios activos\",\n        \"down\": \"Sitios inactivos\",\n        \"uptime\": \"Tiempo activo\",\n        \"incident\": \"Incidencia\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Archivos\",\n        \"chapters\": \"Capítulos\",\n        \"categories\": \"Categorías\"\n    },\n    \"komga\": {\n        \"libraries\": \"Librerías\",\n        \"series\": \"Series\",\n        \"books\": \"Libros\"\n    },\n    \"diskstation\": {\n        \"days\": \"Días\",\n        \"uptime\": \"Tiempo activo\",\n        \"volumeAvailable\": \"Disponible\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Canales\",\n        \"streams\": \"Transmisiones\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Números\",\n        \"wanted\": \"Buscando\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Álbumes\",\n        \"photos\": \"Fotos\",\n        \"videos\": \"Videos\",\n        \"people\": \"Personas\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Cola\",\n        \"processing\": \"Procesando\",\n        \"processed\": \"Procesado\",\n        \"time\": \"Tiempo\"\n    },\n    \"firefly\": {\n        \"networth\": \"Patrimonio neto\",\n        \"budget\": \"Presupuesto\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Tableros\",\n        \"datasources\": \"Fuentes de datos\",\n        \"totalalerts\": \"Alertas totales\",\n        \"alertstriggered\": \"Alertas activadas\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Carga de la CPU\",\n        \"memoryusage\": \"Uso de la memoria\",\n        \"freespace\": \"Espacio libre\",\n        \"activeusers\": \"Usuarios activos\",\n        \"numfiles\": \"Archivos\",\n        \"numshares\": \"Elementos compartidos\"\n    },\n    \"kopia\": {\n        \"status\": \"Estado\",\n        \"size\": \"Tamaño\",\n        \"lastrun\": \"Última ejecución\",\n        \"nextrun\": \"Siguiente ejecución\",\n        \"failed\": \"Fallido\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Trabajadores activos\",\n        \"total_workers\": \"Total de trabajadores\",\n        \"records_total\": \"Longitud de la cola\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servidores\",\n        \"nodes\": \"Nodos\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Objetivos activos\",\n        \"targets_down\": \"Objetivos inactivos\",\n        \"targets_total\": \"Objetivos totales\"\n    },\n    \"gatus\": {\n        \"up\": \"Sitios activos\",\n        \"down\": \"Sitios inactivos\",\n        \"uptime\": \"Tiempo activo\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Hoy\",\n        \"gross_percent_1y\": \"Un año\",\n        \"gross_percent_max\": \"Todo el tiempo\",\n        \"net_worth\": \"Patrimonio neto\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasts\",\n        \"books\": \"Libros\",\n        \"podcastsDuration\": \"Duración\",\n        \"booksDuration\": \"Duración\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Personas en casa\",\n        \"lights_on\": \"Luces encendidas\",\n        \"switches_on\": \"Interruptores activados\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Monitorizando\",\n        \"updates\": \"Actualizaciones\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Libros\",\n        \"authors\": \"Autores\",\n        \"categories\": \"Categorías\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Librerías\",\n        \"books\": \"Libros\",\n        \"reading\": \"Lectura\",\n        \"finished\": \"Finalizado\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"En cola\",\n        \"downloadBytesRemaining\": \"Restante\",\n        \"downloadTotalBytes\": \"Tamaño\",\n        \"downloadSpeed\": \"Velocidad\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Archivos\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Resultado\",\n        \"status\": \"Estado\",\n        \"buildId\": \"ID de compilación\",\n        \"succeeded\": \"Exitoso\",\n        \"notStarted\": \"No iniciado\",\n        \"failed\": \"Fallido\",\n        \"canceled\": \"Cancelado\",\n        \"inProgress\": \"En curso\",\n        \"totalPrs\": \"PRs totales\",\n        \"myPrs\": \"Mis PRs\",\n        \"approved\": \"Aprobado\"\n    },\n    \"gamedig\": {\n        \"status\": \"Estado\",\n        \"online\": \"En línea\",\n        \"offline\": \"Fuera de línea\",\n        \"name\": \"Nombre\",\n        \"map\": \"Mapa\",\n        \"currentPlayers\": \"Jugadores actuales\",\n        \"players\": \"Jugadores\",\n        \"maxPlayers\": \"Jugadores máximos\",\n        \"bots\": \"Bots\",\n        \"ping\": \"Latencia\"\n    },\n    \"urbackup\": {\n        \"ok\": \"OK\",\n        \"errored\": \"Errores\",\n        \"noRecent\": \"Caducado\",\n        \"totalUsed\": \"Almacenamiento usado\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Recetas\",\n        \"users\": \"Usuarios\",\n        \"categories\": \"Categorías\",\n        \"tags\": \"Etiquetas\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Descargando\",\n        \"total\": \"Total\",\n        \"running\": \"Ejecutando\",\n        \"stopped\": \"Detenido\",\n        \"passed\": \"Aprobado\",\n        \"failed\": \"Fallido\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Tiempo activo\",\n        \"cpuLoad\": \"Carga promedio del CPU (5m)\",\n        \"up\": \"Activo\",\n        \"down\": \"Inactivo\",\n        \"bytesTx\": \"Transmitido\",\n        \"bytesRx\": \"Recibido\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Estado\",\n        \"uptime\": \"Tiempo activo\",\n        \"lastDown\": \"Último periodo de inactividad\",\n        \"downDuration\": \"Duración de inactividad\",\n        \"sitesUp\": \"Sitios activos\",\n        \"sitesDown\": \"Sitios inactivos\",\n        \"paused\": \"Pausado\",\n        \"notyetchecked\": \"Aún no comprobado\",\n        \"up\": \"Activo\",\n        \"seemsdown\": \"Parece caído\",\n        \"down\": \"Inactivo\",\n        \"unknown\": \"Desconocido\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"En cines\",\n        \"physicalRelease\": \"Lanzamiento en físico\",\n        \"digitalRelease\": \"Lanzamiento en digital\",\n        \"noEventsToday\": \"¡Sin eventos para hoy!\",\n        \"noEventsFound\": \"No se encontraron eventos\",\n        \"errorWhenLoadingData\": \"Error al cargar los datos del calendario\"\n    },\n    \"romm\": {\n        \"platforms\": \"Plataformas\",\n        \"totalRoms\": \"Juegos\",\n        \"saves\": \"Guardados\",\n        \"states\": \"Estados\",\n        \"screenshots\": \"Capturas de pantalla\",\n        \"totalfilesize\": \"Tamaño total\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Dominios\",\n        \"mailboxes\": \"Buzones de correo\",\n        \"mails\": \"Correos\",\n        \"storage\": \"Almacenamiento\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Advertencias\",\n        \"criticals\": \"Críticos\"\n    },\n    \"plantit\": {\n        \"events\": \"Eventos\",\n        \"plants\": \"Plantas\",\n        \"photos\": \"Fotos\",\n        \"species\": \"Especies\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Notificaciones\",\n        \"issues\": \"Incidencias\",\n        \"pulls\": \"Solicitudes de cambios\",\n        \"repositories\": \"Repositorios\"\n    },\n    \"stash\": {\n        \"scenes\": \"Escenas\",\n        \"scenesPlayed\": \"Escenas reproducidas\",\n        \"playCount\": \"Reproducciones totales\",\n        \"playDuration\": \"Tiempo visto\",\n        \"sceneSize\": \"Tamaño de las escenas\",\n        \"sceneDuration\": \"Duración de las escenas\",\n        \"images\": \"Imágenes\",\n        \"imageSize\": \"Tamaño de las imágenes\",\n        \"galleries\": \"Galerías\",\n        \"performers\": \"Intérpretes\",\n        \"studios\": \"Estudios\",\n        \"movies\": \"Películas\",\n        \"tags\": \"Etiquetas\",\n        \"oCount\": \"Cantidad de O\"\n    },\n    \"tandoor\": {\n        \"users\": \"Usuarios\",\n        \"recipes\": \"Recetas\",\n        \"keywords\": \"Palabras clave\"\n    },\n    \"homebox\": {\n        \"items\": \"Objetos\",\n        \"totalWithWarranty\": \"Con garantía\",\n        \"locations\": \"Ubicaciones\",\n        \"labels\": \"Etiquetas\",\n        \"users\": \"Usuarios\",\n        \"totalValue\": \"Valor total\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alertas\",\n        \"bans\": \"Baneos\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Conectados\",\n        \"enabled\": \"Activo\",\n        \"disabled\": \"Inactivos\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxy activado\",\n        \"auth\": \"Con autenticación\",\n        \"outdated\": \"Desactualizado\",\n        \"banned\": \"Baneado\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Latencia\",\n        \"download\": \"Descarga\",\n        \"upload\": \"Subida\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Acciones\",\n        \"loading\": \"Cargando\",\n        \"open\": \"Abierto - Mercado EE. UU.\",\n        \"closed\": \"Cerrado - Mercado EE. UU.\",\n        \"invalidConfiguration\": \"Configuración inválida\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Cámaras\",\n        \"uptime\": \"Tiempo activo\",\n        \"version\": \"Versión\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Enlaces\",\n        \"collections\": \"Colecciones\",\n        \"tags\": \"Etiquetas\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"No clasificado\",\n        \"information\": \"Información\",\n        \"warning\": \"Advertencia\",\n        \"average\": \"Promedio\",\n        \"high\": \"Alto\",\n        \"disaster\": \"Desastre\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vehículo\",\n        \"vehicles\": \"Vehículos\",\n        \"serviceRecords\": \"Registros de servicio\",\n        \"reminders\": \"Recordatorios\",\n        \"nextReminder\": \"Siguiente recordatorio\",\n        \"none\": \"Nada\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Proyectos activos\",\n        \"tasks7d\": \"Tareas que vencen esta semana\",\n        \"tasksOverdue\": \"Tareas vencidas\",\n        \"tasksInProgress\": \"Tareas en progreso\"\n    },\n    \"headscale\": {\n        \"name\": \"Nombre\",\n        \"address\": \"Dirección\",\n        \"last_seen\": \"Visto por última vez\",\n        \"status\": \"Estado\",\n        \"online\": \"En línea\",\n        \"offline\": \"Fuera de línea\"\n    },\n    \"beszel\": {\n        \"name\": \"Nombre\",\n        \"systems\": \"Sistemas\",\n        \"up\": \"Activo\",\n        \"down\": \"Inactivo\",\n        \"paused\": \"Pausado\",\n        \"pending\": \"Pendiente\",\n        \"status\": \"Estado\",\n        \"updated\": \"Actualizado\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Disco\",\n        \"network\": \"RED\"\n    },\n    \"argocd\": {\n        \"apps\": \"Apps\",\n        \"synced\": \"Sincronizado\",\n        \"outOfSync\": \"Desincronizado\",\n        \"healthy\": \"Saludable\",\n        \"degraded\": \"Degradado\",\n        \"progressing\": \"Progresando\",\n        \"missing\": \"Faltantes\",\n        \"suspended\": \"Suspendido\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Cargando\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Grupos\",\n        \"issues\": \"Incidencias\",\n        \"merges\": \"Solicitudes de fusión\",\n        \"projects\": \"Proyectos\"\n    },\n    \"apcups\": {\n        \"status\": \"Estado\",\n        \"load\": \"Carga\",\n        \"bcharge\": \"Carga de la batería\",\n        \"timeleft\": \"Tiempo restante\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Marcadores\",\n        \"favorites\": \"Favoritos\",\n        \"archived\": \"Archivado\",\n        \"highlights\": \"Destacados\",\n        \"lists\": \"Listas\",\n        \"tags\": \"Etiquetas\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Red\",\n        \"connected\": \"Conectado\",\n        \"disconnected\": \"Desconectado\",\n        \"updateStatus\": \"Actualización\",\n        \"update_yes\": \"Disponible\",\n        \"update_no\": \"Actualizado\",\n        \"downloads\": \"Descargas\",\n        \"uploads\": \"Subidas\",\n        \"sharedFiles\": \"Archivos\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Canciones\",\n        \"movies\": \"Películas\",\n        \"episodes\": \"Episodios\",\n        \"other\": \"Otros\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Problemas de servicio\",\n        \"hostErrors\": \"Problemas de host\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"En ejecución\",\n        \"stopped\": \"Detenido\",\n        \"down\": \"Inactivo\",\n        \"unhealthy\": \"En mal estado\",\n        \"unknown\": \"Desconocido\",\n        \"servers\": \"Servidores\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Contenedores\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Disponible\",\n        \"used\": \"Usado\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Suscripciones\",\n        \"thisMonthlyCost\": \"Este mes\",\n        \"nextMonthlyCost\": \"Próximo mes\",\n        \"previousMonthlyCost\": \"Mes anterior\",\n        \"nextRenewingSubscription\": \"Próximo pago\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Iniciado\",\n        \"STOPPED\": \"Detenido\",\n        \"NEW_ARRAY\": \"Nueva matriz\",\n        \"RECON_DISK\": \"Reconstruyendo disco\",\n        \"DISABLE_DISK\": \"Disco deshabilitado\",\n        \"SWAP_DSBL\": \"Swap deshabilitado\",\n        \"INVALID_EXPANSION\": \"Expansión inválida\",\n        \"PARITY_NOT_BIGGEST\": \"Paridad no es el más grande\",\n        \"TOO_MANY_MISSING_DISKS\": \"Demasiados discos faltantes\",\n        \"NEW_DISK_TOO_SMALL\": \"Nuevo disco demasiado pequeño\",\n        \"NO_DATA_DISKS\": \"Sin discos de datos\",\n        \"notifications\": \"Notificaciones\",\n        \"status\": \"Estado\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memoria usada\",\n        \"memoryAvailable\": \"Memoria disponible\",\n        \"arrayUsed\": \"Matriz usada\",\n        \"arrayFree\": \"Matriz libre\",\n        \"poolUsed\": \"{{pool}} Usado\",\n        \"poolFree\": \"{{pool}} Libre\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Planes\",\n        \"num_success_30\": \"Éxitos\",\n        \"num_failure_30\": \"Fallos\",\n        \"num_success_latest\": \"Exitosa\",\n        \"num_failure_latest\": \"Fallida\",\n        \"bytes_added_30\": \"Bytes Añadidos\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Canciones\",\n        \"time\": \"Tiempo\",\n        \"artists\": \"Artistas\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Activo\",\n        \"stopped\": \"Detenido\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memoria\",\n        \"images\": \"Imágenes\",\n        \"volumes\": \"Volumen\",\n        \"events_today\": \"Eventos de hoy\",\n        \"pending_updates\": \"Actualizaciones pendientes\",\n        \"stacks\": \"Entornos\",\n        \"paused\": \"En Pausa\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Entorno no encontrado\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/eu/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"mo\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Missing Widget Type: {{type}}\",\n        \"api_error\": \"API Error\",\n        \"information\": \"Informazioa\",\n        \"status\": \"Status\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Raw Error\",\n        \"response_data\": \"Response Data\"\n    },\n    \"weather\": {\n        \"current\": \"Current Location\",\n        \"allow\": \"Click to allow\",\n        \"updating\": \"Eguneratzen\",\n        \"wait\": \"Itxaron mesedez\"\n    },\n    \"search\": {\n        \"placeholder\": \"Bilatu…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"MEM\",\n        \"total\": \"Guztira\",\n        \"free\": \"Free\",\n        \"used\": \"Erabilita\",\n        \"load\": \"Load\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Max\",\n        \"uptime\": \"UP\"\n    },\n    \"unifi\": {\n        \"users\": \"Users\",\n        \"uptime\": \"Uptime\",\n        \"days\": \"Egun\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Gailuak\",\n        \"lan_devices\": \"LAN Gailuak\",\n        \"wlan_devices\": \"WLAN Gailuak\",\n        \"lan_users\": \"LAN Erabiltzaileak\",\n        \"wlan_users\": \"WLAN Erabiltzaileak\",\n        \"up\": \"UP\",\n        \"down\": \"DOWN\",\n        \"wait\": \"Please wait\",\n        \"empty_data\": \"Subsystem status unknown\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Running\",\n        \"offline\": \"Offline\",\n        \"error\": \"Error\",\n        \"unknown\": \"Ezezaguna\",\n        \"healthy\": \"Osasuntsu\",\n        \"starting\": \"Abiarazten\",\n        \"unhealthy\": \"Unhealthy\",\n        \"not_found\": \"Not Found\",\n        \"exited\": \"Exited\",\n        \"partial\": \"Partial\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"Ping\",\n        \"down\": \"Behera\",\n        \"up\": \"Gora\",\n        \"not_available\": \"Not Available\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP status\",\n        \"error\": \"Error\",\n        \"response\": \"Erantzuna\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"emby\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bit-tasa\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Filmak\",\n        \"series\": \"Serieak\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Abestiak\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Online\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Produkzioak\",\n        \"battery_soc\": \"Bateria\",\n        \"grid_power\": \"Sarea\",\n        \"home_power\": \"Kontsumoa\",\n        \"charge_power\": \"Kargagailua\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Jeitsierak\",\n        \"upload\": \"Kargatu\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Harpidetzak\",\n        \"unread\": \"Irakurri gabe\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Unconfigured\",\n        \"connectionStatusConnecting\": \"Konektatzen\",\n        \"connectionStatusAuthenticating\": \"Authenticating\",\n        \"connectionStatusPendingDisconnect\": \"Pending Disconnect\",\n        \"connectionStatusDisconnecting\": \"Disconnecting\",\n        \"connectionStatusDisconnected\": \"Deskonektatuta\",\n        \"connectionStatusConnected\": \"Konektatuta\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"Max. Down\",\n        \"maxUp\": \"Max. Up\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"Received\",\n        \"sent\": \"Bidalita\",\n        \"externalIPAddress\": \"Ext. IP\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Current requests\",\n        \"requests_failed\": \"Failed requests\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Total Observed\",\n        \"diffsDetected\": \"Diffs Detected\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Shows\",\n        \"recordings\": \"Recordings\",\n        \"scheduled\": \"Scheduled\",\n        \"passes\": \"Passes\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"Check Plex Connection\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Connected APs\",\n        \"activeUser\": \"Active devices\",\n        \"alerts\": \"Alerts\",\n        \"connectedGateways\": \"Connected gateways\",\n        \"connectedSwitches\": \"Connected switches\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Rate\",\n        \"remaining\": \"Remaining\",\n        \"downloaded\": \"Downloaded\"\n    },\n    \"plex\": {\n        \"streams\": \"Active Streams\",\n        \"albums\": \"Albums\",\n        \"movies\": \"Movies\",\n        \"tv\": \"TV Shows\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"Queue\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Active\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU Usage\",\n        \"memUsage\": \"MEM Usage\",\n        \"systemTempC\": \"System Temp\",\n        \"poolUsage\": \"Pool Usage\",\n        \"volumeUsage\": \"Volume Usage\",\n        \"invalid\": \"Invalid\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"Missing\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"Artists\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"Books\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Missing Episodes\",\n        \"missingMovies\": \"Missing Movies\"\n    },\n    \"ombi\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"New Devices\",\n        \"down_alerts\": \"Down Alerts\"\n    },\n    \"pihole\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"blocked_percent\": \"Blocked %\",\n        \"gravity\": \"Gravity\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"Filtered\",\n        \"latency\": \"Latency\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Non-Downloaded\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Downloaded & Read\",\n        \"downloadedunread\": \"Downloaded & Unread\",\n        \"nondownloadedread\": \"Non-Downloaded & Read\",\n        \"nondownloadedunread\": \"Non-Downloaded & Unread\"\n    },\n    \"tailscale\": {\n        \"address\": \"Address\",\n        \"expires\": \"Expires\",\n        \"never\": \"Never\",\n        \"last_seen\": \"Last Seen\",\n        \"now\": \"Now\",\n        \"years\": \"{{number}}y\",\n        \"weeks\": \"{{number}}w\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Ago\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"Success\",\n        \"totalServerFailure\": \"Failures\",\n        \"totalNxDomain\": \"NX Domains\",\n        \"totalRefused\": \"Refused\",\n        \"totalAuthoritative\": \"Authoritative\",\n        \"totalRecursive\": \"Recursive\",\n        \"totalCached\": \"Cached\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"Dropped\",\n        \"totalClients\": \"Clients\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Processed\",\n        \"errored\": \"Errored\",\n        \"saved\": \"Saved\"\n    },\n    \"traefik\": {\n        \"routers\": \"Routers\",\n        \"services\": \"Services\",\n        \"middleware\": \"Middleware\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Please Wait\"\n    },\n    \"npm\": {\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Configure one or more crypto currencies to track\",\n        \"1hour\": \"1 Hour\",\n        \"1day\": \"1 Day\",\n        \"7days\": \"7 Days\",\n        \"30days\": \"30 Days\"\n    },\n    \"gotify\": {\n        \"apps\": \"Applications\",\n        \"clients\": \"Clients\",\n        \"messages\": \"Messages\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indexers\",\n        \"numberOfGrabs\": \"Grabs\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"Fail Grabs\",\n        \"numberOfFailQueries\": \"Fail Queries\"\n    },\n    \"jackett\": {\n        \"configured\": \"Configured\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sessions\",\n        \"numConnections\": \"Connections\",\n        \"dataRelayed\": \"Relayed\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"Posts\",\n        \"domain_count\": \"Domains\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Jokalariak\",\n        \"version\": \"Version\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Read\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"Logins (24h)\",\n        \"failedLoginsLast24H\": \"Failed Logins (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temp\",\n        \"warn\": \"Warn\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Crit\",\n        \"read\": \"Read\",\n        \"write\": \"Write\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Bookmark\",\n        \"service\": \"Service\",\n        \"search\": \"Search\",\n        \"custom\": \"Custom\",\n        \"visit\": \"Visit\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Suggestion\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Sunny\",\n        \"0-night\": \"Clear\",\n        \"1-day\": \"Mainly Sunny\",\n        \"1-night\": \"Mainly Clear\",\n        \"2-day\": \"Partly Cloudy\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"Cloudy\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"Foggy\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"Light Drizzle\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"Drizzle\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"Heavy Drizzle\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"Light Freezing Drizzle\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"Freezing Drizzle\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"Light Rain\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"Rain\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Heavy Rain\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Freezing Rain\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Light Snow\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Snow\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Heavy Snow\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Snow Grains\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Light Showers\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Showers\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Heavy Showers\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Snow Showers\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"Thunderstorm\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Thunderstorm With Hail\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"System\",\n        \"updates\": \"Updates\",\n        \"update_available\": \"Update Available\",\n        \"up_to_date\": \"Up to Date\",\n        \"child_bridges\": \"Child Bridges\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"New\",\n        \"up\": \"Up\",\n        \"grace\": \"In Grace Period\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Last Ping\",\n        \"never\": \"No pings yet\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Scanned\",\n        \"containers_updated\": \"Updated\",\n        \"containers_failed\": \"Failed\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Rejected\",\n        \"filters\": \"Filters\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Videos\",\n        \"channels\": \"Channels\",\n        \"playlists\": \"Playlists\"\n    },\n    \"truenas\": {\n        \"load\": \"System Load\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Speed\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Public IP\",\n        \"region\": \"Region\",\n        \"country\": \"Country\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tuners\",\n        \"channelNumber\": \"Channel\",\n        \"channelNetwork\": \"Network\",\n        \"signalStrength\": \"Strength\",\n        \"signalQuality\": \"Quality\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Client\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Inbox\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Battery Charge\",\n        \"ups_load\": \"UPS Load\",\n        \"ups_status\": \"UPS Status\",\n        \"online\": \"Online\",\n        \"on_battery\": \"On Battery\",\n        \"low_battery\": \"Low Battery\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"No Device Data Received\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"CPU Load\",\n        \"memoryUsed\": \"Memory Used\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Leases\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"All Streams\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG Channels\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Today\",\n        \"absolutePower\": \"Power\",\n        \"relativePower\": \"Power %\",\n        \"limit\": \"Limit\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Active Memory\",\n        \"wanUpload\": \"WAN Upload\",\n        \"wanDownload\": \"WAN Download\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Printer State\",\n        \"print_status\": \"Print Status\",\n        \"print_progress\": \"Progress\",\n        \"layers\": \"Layers\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Tool temp\",\n        \"temp_bed\": \"Bed temp\",\n        \"job_completion\": \"Completion\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Origin IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Load Avg\",\n        \"memory\": \"Mem Usage\",\n        \"wanStatus\": \"WAN Status\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Disk Usage\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datastore\",\n        \"failed_tasks_24h\": \"Failed Tasks 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memory\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Argazkiak\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Storage\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Incident\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Archives\",\n        \"chapters\": \"Chapters\",\n        \"categories\": \"Categories\"\n    },\n    \"komga\": {\n        \"libraries\": \"Libraries\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Arazoak\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"People\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Time\"\n    },\n    \"firefly\": {\n        \"networth\": \"Net Worth\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Dashboards\",\n        \"datasources\": \"Data Sources\",\n        \"totalalerts\": \"Total Alerts\",\n        \"alertstriggered\": \"Alerts Triggered\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Cpu Load\",\n        \"memoryusage\": \"Memory Usage\",\n        \"freespace\": \"Free Space\",\n        \"activeusers\": \"Active Users\",\n        \"numfiles\": \"Files\",\n        \"numshares\": \"Shared Items\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Size\",\n        \"lastrun\": \"Last Run\",\n        \"nextrun\": \"Next Run\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Active Workers\",\n        \"total_workers\": \"Total Workers\",\n        \"records_total\": \"Queue Length\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servers\",\n        \"nodes\": \"Nodes\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Targets Up\",\n        \"targets_down\": \"Targets Down\",\n        \"targets_total\": \"Total Targets\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"One year\",\n        \"gross_percent_max\": \"All time\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasts\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Duration\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"People Home\",\n        \"lights_on\": \"Lights On\",\n        \"switches_on\": \"Switches On\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Monitoring\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Authors\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Result\",\n        \"status\": \"Status\",\n        \"buildId\": \"Build ID\",\n        \"succeeded\": \"Succeeded\",\n        \"notStarted\": \"Not Started\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Canceled\",\n        \"inProgress\": \"In Progress\",\n        \"totalPrs\": \"Total PRs\",\n        \"myPrs\": \"My PRs\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Izena\",\n        \"map\": \"Mapa\",\n        \"currentPlayers\": \"Current players\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Max players\",\n        \"bots\": \"Bots\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Errors\",\n        \"noRecent\": \"Out of Date\",\n        \"totalUsed\": \"Used Storage\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Recipes\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Etiketak\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Deskargatzen\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"CPU Load Avg (5m)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Transmitted\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Last Downtime\",\n        \"downDuration\": \"Downtime Duration\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Not Yet Checked\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Seems Down\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"In cinemas\",\n        \"physicalRelease\": \"Physical release\",\n        \"digitalRelease\": \"Digital release\",\n        \"noEventsToday\": \"No events for today!\",\n        \"noEventsFound\": \"Ez da gertaerarik aurkitu.\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platforms\",\n        \"totalRoms\": \"Games\",\n        \"saves\": \"Saves\",\n        \"states\": \"States\",\n        \"screenshots\": \"Screenshots\",\n        \"totalfilesize\": \"Total Size\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Gutunontziak\",\n        \"mails\": \"Mails\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Warnings\",\n        \"criticals\": \"Criticals\"\n    },\n    \"plantit\": {\n        \"events\": \"Ekitaldiak\",\n        \"plants\": \"Landareak\",\n        \"photos\": \"Photos\",\n        \"species\": \"Species\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Jakinarazpenak\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Pull Requests\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Scenes\",\n        \"scenesPlayed\": \"Scenes Played\",\n        \"playCount\": \"Total Plays\",\n        \"playDuration\": \"Time Watched\",\n        \"sceneSize\": \"Scenes Size\",\n        \"sceneDuration\": \"Scenes Duration\",\n        \"images\": \"Irudia\",\n        \"imageSize\": \"Irudiaren tamaina\",\n        \"galleries\": \"Galleries\",\n        \"performers\": \"Performers\",\n        \"studios\": \"Studios\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O Count\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Hitz gakoak\"\n    },\n    \"homebox\": {\n        \"items\": \"Elementuak\",\n        \"totalWithWarranty\": \"With Warranty\",\n        \"locations\": \"Locations\",\n        \"labels\": \"Etiketak\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Guztira\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Bans\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxied\",\n        \"auth\": \"With Auth\",\n        \"outdated\": \"Outdated\",\n        \"banned\": \"Banned\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Stocks\",\n        \"loading\": \"Loading\",\n        \"open\": \"Open - US Market\",\n        \"closed\": \"Closed - US Market\",\n        \"invalidConfiguration\": \"Invalid Configuration\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Cameras\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Links\",\n        \"collections\": \"Bildumak\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Not classified\",\n        \"information\": \"Information\",\n        \"warning\": \"Abisua\",\n        \"average\": \"Batez besteko\",\n        \"high\": \"Altua\",\n        \"disaster\": \"Disaster\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vehicle\",\n        \"vehicles\": \"Ibilgailuak\",\n        \"serviceRecords\": \"Service Records\",\n        \"reminders\": \"Oroigarriak\",\n        \"nextReminder\": \"Hurrengo abisua\",\n        \"none\": \"None\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Active Projects\",\n        \"tasks7d\": \"Tasks Due This Week\",\n        \"tasksOverdue\": \"Overdue Tasks\",\n        \"tasksInProgress\": \"Tasks In Progress\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Systems\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Disk\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Aplikazioak\",\n        \"synced\": \"Sinkronizatuta\",\n        \"outOfSync\": \"Out Of Sync\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Degraded\",\n        \"progressing\": \"Progressing\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Etenda\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Taldeak\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Proiektuak\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bookmarks\",\n        \"favorites\": \"Favorites\",\n        \"archived\": \"Archived\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Lists\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Other\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/fi/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"mo\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Puuttuva härpäkkeen tyyppi: {{type}}\",\n        \"api_error\": \"API-virhe\",\n        \"information\": \"Information\",\n        \"status\": \"Tila\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Raw Error\",\n        \"response_data\": \"Response Data\"\n    },\n    \"weather\": {\n        \"current\": \"Nykyinen sijainti\",\n        \"allow\": \"Klikkaa salliaksesi\",\n        \"updating\": \"Päivitetään\",\n        \"wait\": \"Odota, ole hyvä\"\n    },\n    \"search\": {\n        \"placeholder\": \"Hae…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"MEM\",\n        \"total\": \"Yhteensä\",\n        \"free\": \"Vapaana\",\n        \"used\": \"Käytetty\",\n        \"load\": \"Kuorma\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Max\",\n        \"uptime\": \"UP\"\n    },\n    \"unifi\": {\n        \"users\": \"Users\",\n        \"uptime\": \"Uptime\",\n        \"days\": \"Days\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Devices\",\n        \"lan_devices\": \"LAN Devices\",\n        \"wlan_devices\": \"WLAN Devices\",\n        \"lan_users\": \"LAN Users\",\n        \"wlan_users\": \"WLAN Users\",\n        \"up\": \"UP\",\n        \"down\": \"DOWN\",\n        \"wait\": \"Please wait\",\n        \"empty_data\": \"Subsystem status unknown\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Running\",\n        \"offline\": \"Offline\",\n        \"error\": \"Error\",\n        \"unknown\": \"Unknown\",\n        \"healthy\": \"Healthy\",\n        \"starting\": \"Starting\",\n        \"unhealthy\": \"Unhealthy\",\n        \"not_found\": \"Not Found\",\n        \"exited\": \"Exited\",\n        \"partial\": \"Partial\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"Ping\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP status\",\n        \"error\": \"Error\",\n        \"response\": \"Response\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"emby\": {\n        \"playing\": \"Toistaa\",\n        \"transcoding\": \"Transkoodaa\",\n        \"bitrate\": \"Bittinopeus\",\n        \"no_active\": \"Ei aktiivisia striimejä\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Online\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Production\",\n        \"battery_soc\": \"Battery\",\n        \"grid_power\": \"Grid\",\n        \"home_power\": \"Consumption\",\n        \"charge_power\": \"Charger\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Subscriptions\",\n        \"unread\": \"Unread\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Unconfigured\",\n        \"connectionStatusConnecting\": \"Connecting\",\n        \"connectionStatusAuthenticating\": \"Authenticating\",\n        \"connectionStatusPendingDisconnect\": \"Pending Disconnect\",\n        \"connectionStatusDisconnecting\": \"Disconnecting\",\n        \"connectionStatusDisconnected\": \"Disconnected\",\n        \"connectionStatusConnected\": \"Connected\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"Max. Down\",\n        \"maxUp\": \"Max. Up\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"Received\",\n        \"sent\": \"Sent\",\n        \"externalIPAddress\": \"Ext. IP\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Current requests\",\n        \"requests_failed\": \"Failed requests\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Total Observed\",\n        \"diffsDetected\": \"Diffs Detected\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Shows\",\n        \"recordings\": \"Recordings\",\n        \"scheduled\": \"Scheduled\",\n        \"passes\": \"Passes\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"Check Plex Connection\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Connected APs\",\n        \"activeUser\": \"Active devices\",\n        \"alerts\": \"Alerts\",\n        \"connectedGateways\": \"Connected gateways\",\n        \"connectedSwitches\": \"Connected switches\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Nopeus\",\n        \"remaining\": \"Jäljellä\",\n        \"downloaded\": \"Ladattu\"\n    },\n    \"plex\": {\n        \"streams\": \"Active Streams\",\n        \"albums\": \"Albums\",\n        \"movies\": \"Movies\",\n        \"tv\": \"TV Shows\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"Jono\",\n        \"timeleft\": \"Aikaa jäljellä\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Aktiivinen\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU Usage\",\n        \"memUsage\": \"MEM Usage\",\n        \"systemTempC\": \"System Temp\",\n        \"poolUsage\": \"Pool Usage\",\n        \"volumeUsage\": \"Volume Usage\",\n        \"invalid\": \"Invalid\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Haluttu\",\n        \"queued\": \"Jonossa\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"Missing\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"Artists\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"Kirjoja\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Puuttuvia jaksoja\",\n        \"missingMovies\": \"Puuttuvia elokuvia\"\n    },\n    \"ombi\": {\n        \"pending\": \"Vireillä\",\n        \"approved\": \"Hyväksytty\",\n        \"available\": \"Saatavilla\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"New Devices\",\n        \"down_alerts\": \"Down Alerts\"\n    },\n    \"pihole\": {\n        \"queries\": \"Kyselyjä\",\n        \"blocked\": \"Estetty\",\n        \"blocked_percent\": \"Blocked %\",\n        \"gravity\": \"Vakavuus\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"Suodatettu\",\n        \"latency\": \"Viive\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Pysäytetty\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Non-Downloaded\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Downloaded & Read\",\n        \"downloadedunread\": \"Downloaded & Unread\",\n        \"nondownloadedread\": \"Non-Downloaded & Read\",\n        \"nondownloadedunread\": \"Non-Downloaded & Unread\"\n    },\n    \"tailscale\": {\n        \"address\": \"Address\",\n        \"expires\": \"Expires\",\n        \"never\": \"Never\",\n        \"last_seen\": \"Last Seen\",\n        \"now\": \"Now\",\n        \"years\": \"{{number}}y\",\n        \"weeks\": \"{{number}}w\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Ago\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"Success\",\n        \"totalServerFailure\": \"Failures\",\n        \"totalNxDomain\": \"NX Domains\",\n        \"totalRefused\": \"Refused\",\n        \"totalAuthoritative\": \"Authoritative\",\n        \"totalRecursive\": \"Recursive\",\n        \"totalCached\": \"Cached\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"Dropped\",\n        \"totalClients\": \"Asiakasohjelmia\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Processed\",\n        \"errored\": \"Errored\",\n        \"saved\": \"Saved\"\n    },\n    \"traefik\": {\n        \"routers\": \"Reitittimiä\",\n        \"services\": \"Palveluja\",\n        \"middleware\": \"Middlewareja\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Odota, ole hyvä\"\n    },\n    \"npm\": {\n        \"enabled\": \"Käytössä\",\n        \"disabled\": \"Poissa käytöstä\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Määritä yksi tai useampi kryptovaluutta seurattavaksi\",\n        \"1hour\": \"1 tunti\",\n        \"1day\": \"1 päivä\",\n        \"7days\": \"7 päivää\",\n        \"30days\": \"30 päivää\"\n    },\n    \"gotify\": {\n        \"apps\": \"Sovelluksia\",\n        \"clients\": \"Clients\",\n        \"messages\": \"Viestejä\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indeksoijia\",\n        \"numberOfGrabs\": \"Nappauksia\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"Epäonnistuneita nappauksia\",\n        \"numberOfFailQueries\": \"Epäonnistuneita kyselyjä\"\n    },\n    \"jackett\": {\n        \"configured\": \"Määritettyjä\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Istuntoja\",\n        \"numConnections\": \"Yhteyksiä\",\n        \"dataRelayed\": \"Välitetty\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"Kirjoituksia\",\n        \"domain_count\": \"Verkkotunnuksia\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Players\",\n        \"version\": \"Version\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Read\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"Kirjautumisia (24h)\",\n        \"failedLoginsLast24H\": \"Epäonnistuneita kirjautumisia (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VKt\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temp\",\n        \"warn\": \"Warn\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Crit\",\n        \"read\": \"Read\",\n        \"write\": \"Write\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Bookmark\",\n        \"service\": \"Service\",\n        \"search\": \"Search\",\n        \"custom\": \"Custom\",\n        \"visit\": \"Visit\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Suggestion\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Sunny\",\n        \"0-night\": \"Clear\",\n        \"1-day\": \"Mainly Sunny\",\n        \"1-night\": \"Mainly Clear\",\n        \"2-day\": \"Partly Cloudy\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"Cloudy\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"Foggy\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"Light Drizzle\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"Drizzle\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"Heavy Drizzle\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"Light Freezing Drizzle\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"Freezing Drizzle\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"Light Rain\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"Rain\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Heavy Rain\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Freezing Rain\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Light Snow\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Snow\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Heavy Snow\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Snow Grains\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Light Showers\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Showers\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Heavy Showers\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Snow Showers\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"Thunderstorm\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Thunderstorm With Hail\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"System\",\n        \"updates\": \"Updates\",\n        \"update_available\": \"Update Available\",\n        \"up_to_date\": \"Up to Date\",\n        \"child_bridges\": \"Child Bridges\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"New\",\n        \"up\": \"Up\",\n        \"grace\": \"In Grace Period\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Last Ping\",\n        \"never\": \"No pings yet\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Scanned\",\n        \"containers_updated\": \"Updated\",\n        \"containers_failed\": \"Failed\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Rejected\",\n        \"filters\": \"Filters\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Videos\",\n        \"channels\": \"Channels\",\n        \"playlists\": \"Playlists\"\n    },\n    \"truenas\": {\n        \"load\": \"System Load\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Speed\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Public IP\",\n        \"region\": \"Region\",\n        \"country\": \"Country\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tuners\",\n        \"channelNumber\": \"Channel\",\n        \"channelNetwork\": \"Network\",\n        \"signalStrength\": \"Strength\",\n        \"signalQuality\": \"Quality\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Client\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Inbox\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Battery Charge\",\n        \"ups_load\": \"UPS Load\",\n        \"ups_status\": \"UPS Status\",\n        \"online\": \"Online\",\n        \"on_battery\": \"On Battery\",\n        \"low_battery\": \"Low Battery\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"No Device Data Received\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"CPU Load\",\n        \"memoryUsed\": \"Memory Used\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Leases\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"All Streams\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG Channels\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Today\",\n        \"absolutePower\": \"Power\",\n        \"relativePower\": \"Power %\",\n        \"limit\": \"Limit\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Active Memory\",\n        \"wanUpload\": \"WAN Upload\",\n        \"wanDownload\": \"WAN Download\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Printer State\",\n        \"print_status\": \"Print Status\",\n        \"print_progress\": \"Progress\",\n        \"layers\": \"Layers\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Tool temp\",\n        \"temp_bed\": \"Bed temp\",\n        \"job_completion\": \"Completion\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Origin IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Load Avg\",\n        \"memory\": \"Mem Usage\",\n        \"wanStatus\": \"WAN Status\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Disk Usage\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datastore\",\n        \"failed_tasks_24h\": \"Failed Tasks 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memory\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Storage\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Incident\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Archives\",\n        \"chapters\": \"Chapters\",\n        \"categories\": \"Categories\"\n    },\n    \"komga\": {\n        \"libraries\": \"Libraries\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Issues\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"People\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Time\"\n    },\n    \"firefly\": {\n        \"networth\": \"Net Worth\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Dashboards\",\n        \"datasources\": \"Data Sources\",\n        \"totalalerts\": \"Total Alerts\",\n        \"alertstriggered\": \"Alerts Triggered\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Cpu Load\",\n        \"memoryusage\": \"Memory Usage\",\n        \"freespace\": \"Free Space\",\n        \"activeusers\": \"Active Users\",\n        \"numfiles\": \"Files\",\n        \"numshares\": \"Shared Items\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Size\",\n        \"lastrun\": \"Last Run\",\n        \"nextrun\": \"Next Run\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Active Workers\",\n        \"total_workers\": \"Total Workers\",\n        \"records_total\": \"Queue Length\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servers\",\n        \"nodes\": \"Nodes\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Targets Up\",\n        \"targets_down\": \"Targets Down\",\n        \"targets_total\": \"Total Targets\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"One year\",\n        \"gross_percent_max\": \"All time\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasts\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Duration\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"People Home\",\n        \"lights_on\": \"Lights On\",\n        \"switches_on\": \"Switches On\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Monitoring\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Authors\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Result\",\n        \"status\": \"Status\",\n        \"buildId\": \"Build ID\",\n        \"succeeded\": \"Succeeded\",\n        \"notStarted\": \"Not Started\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Canceled\",\n        \"inProgress\": \"In Progress\",\n        \"totalPrs\": \"Total PRs\",\n        \"myPrs\": \"My PRs\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Name\",\n        \"map\": \"Map\",\n        \"currentPlayers\": \"Current players\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Max players\",\n        \"bots\": \"Bots\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Errors\",\n        \"noRecent\": \"Out of Date\",\n        \"totalUsed\": \"Used Storage\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Recipes\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Tags\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Downloading\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"CPU Load Avg (5m)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Transmitted\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Last Downtime\",\n        \"downDuration\": \"Downtime Duration\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Not Yet Checked\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Seems Down\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"In cinemas\",\n        \"physicalRelease\": \"Physical release\",\n        \"digitalRelease\": \"Digital release\",\n        \"noEventsToday\": \"No events for today!\",\n        \"noEventsFound\": \"No events found\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platforms\",\n        \"totalRoms\": \"Games\",\n        \"saves\": \"Saves\",\n        \"states\": \"States\",\n        \"screenshots\": \"Screenshots\",\n        \"totalfilesize\": \"Total Size\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Mailboxes\",\n        \"mails\": \"Mails\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Warnings\",\n        \"criticals\": \"Criticals\"\n    },\n    \"plantit\": {\n        \"events\": \"Events\",\n        \"plants\": \"Plants\",\n        \"photos\": \"Photos\",\n        \"species\": \"Species\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Notifications\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Pull Requests\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Scenes\",\n        \"scenesPlayed\": \"Scenes Played\",\n        \"playCount\": \"Total Plays\",\n        \"playDuration\": \"Time Watched\",\n        \"sceneSize\": \"Scenes Size\",\n        \"sceneDuration\": \"Scenes Duration\",\n        \"images\": \"Images\",\n        \"imageSize\": \"Images Size\",\n        \"galleries\": \"Galleries\",\n        \"performers\": \"Performers\",\n        \"studios\": \"Studios\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O Count\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Keywords\"\n    },\n    \"homebox\": {\n        \"items\": \"Items\",\n        \"totalWithWarranty\": \"With Warranty\",\n        \"locations\": \"Locations\",\n        \"labels\": \"Labels\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Total Value\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Bans\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxied\",\n        \"auth\": \"With Auth\",\n        \"outdated\": \"Outdated\",\n        \"banned\": \"Banned\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Stocks\",\n        \"loading\": \"Loading\",\n        \"open\": \"Open - US Market\",\n        \"closed\": \"Closed - US Market\",\n        \"invalidConfiguration\": \"Invalid Configuration\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Cameras\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Links\",\n        \"collections\": \"Collections\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Not classified\",\n        \"information\": \"Information\",\n        \"warning\": \"Warning\",\n        \"average\": \"Average\",\n        \"high\": \"High\",\n        \"disaster\": \"Disaster\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vehicle\",\n        \"vehicles\": \"Vehicles\",\n        \"serviceRecords\": \"Service Records\",\n        \"reminders\": \"Reminders\",\n        \"nextReminder\": \"Next Reminder\",\n        \"none\": \"None\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Active Projects\",\n        \"tasks7d\": \"Tasks Due This Week\",\n        \"tasksOverdue\": \"Overdue Tasks\",\n        \"tasksInProgress\": \"Tasks In Progress\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Systems\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Disk\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Apps\",\n        \"synced\": \"Synced\",\n        \"outOfSync\": \"Out Of Sync\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Degraded\",\n        \"progressing\": \"Progressing\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Suspended\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Groups\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Projects\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bookmarks\",\n        \"favorites\": \"Favorites\",\n        \"archived\": \"Archived\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Lists\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Other\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/fr/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"mo\",\n        \"days\": \"j\",\n        \"hours\": \"h\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Type de widget manquant : {{type}}\",\n        \"api_error\": \"Erreur API\",\n        \"information\": \"Informations\",\n        \"status\": \"État\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Erreur brute\",\n        \"response_data\": \"Données de réponse\"\n    },\n    \"weather\": {\n        \"current\": \"Emplacement actuel\",\n        \"allow\": \"Cliquez pour autoriser\",\n        \"updating\": \"Mise à jour\",\n        \"wait\": \"Veuillez patienter\"\n    },\n    \"search\": {\n        \"placeholder\": \"Recherche…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"RAM\",\n        \"total\": \"Total\",\n        \"free\": \"Libre\",\n        \"used\": \"Utilisé\",\n        \"load\": \"Charge\",\n        \"temp\": \"Température\",\n        \"max\": \"Max\",\n        \"uptime\": \"Actif\"\n    },\n    \"unifi\": {\n        \"users\": \"Utilisateurs\",\n        \"uptime\": \"Démarré depuis\",\n        \"days\": \"Jours\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Équipt.\",\n        \"lan_devices\": \"Périphériques LAN\",\n        \"wlan_devices\": \"Périphériques WLAN\",\n        \"lan_users\": \"Utilisateurs LAN\",\n        \"wlan_users\": \"Utilisateurs WLAN\",\n        \"up\": \"ACTIF\",\n        \"down\": \"INACTIF\",\n        \"wait\": \"Veuillez patienter\",\n        \"empty_data\": \"Statut du sous-système inconnu\"\n    },\n    \"docker\": {\n        \"rx\": \"Rx\",\n        \"tx\": \"Tx\",\n        \"mem\": \"RAM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Démarré\",\n        \"offline\": \"Stoppé\",\n        \"error\": \"Erreur\",\n        \"unknown\": \"Inconnu\",\n        \"healthy\": \"Fonctionnel\",\n        \"starting\": \"Démarrage\",\n        \"unhealthy\": \"Mauvaise santé\",\n        \"not_found\": \"Inconnu\",\n        \"exited\": \"Arrêté\",\n        \"partial\": \"Partiel\"\n    },\n    \"ping\": {\n        \"error\": \"Erreur\",\n        \"ping\": \"Latence\",\n        \"down\": \"Hors ligne\",\n        \"up\": \"En ligne\",\n        \"not_available\": \"Non disponible\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"Statut HTTP\",\n        \"error\": \"Erreur\",\n        \"response\": \"Réponse\",\n        \"down\": \"Hors ligne\",\n        \"up\": \"En ligne\",\n        \"not_available\": \"Non disponible\"\n    },\n    \"emby\": {\n        \"playing\": \"En lecture\",\n        \"transcoding\": \"Transcodage\",\n        \"bitrate\": \"Débit\",\n        \"no_active\": \"Aucune lecture en cours\",\n        \"movies\": \"Films\",\n        \"series\": \"Séries\",\n        \"episodes\": \"Épisodes\",\n        \"songs\": \"Morceaux\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"En cours\",\n        \"transcoding\": \"En cours d'encodage\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Hors ligne\",\n        \"offline_alt\": \"Hors ligne\",\n        \"online\": \"En ligne\",\n        \"total\": \"Total\",\n        \"unknown\": \"Inconnu\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Production\",\n        \"battery_soc\": \"Batterie\",\n        \"grid_power\": \"Réseau\",\n        \"home_power\": \"Consommation\",\n        \"charge_power\": \"Charge\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Récep.\",\n        \"upload\": \"Envoi\",\n        \"leech\": \"En téléchargement\",\n        \"seed\": \"En partage\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Abonnements\",\n        \"unread\": \"Non lu\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"État\",\n        \"connectionStatusUnconfigured\": \"Non configuré\",\n        \"connectionStatusConnecting\": \"Connexion en cours\",\n        \"connectionStatusAuthenticating\": \"En cours d'authentification\",\n        \"connectionStatusPendingDisconnect\": \"Déconnexion en attente\",\n        \"connectionStatusDisconnecting\": \"Déconnexion en cours\",\n        \"connectionStatusDisconnected\": \"Déconnecté\",\n        \"connectionStatusConnected\": \"Connecté\",\n        \"uptime\": \"Démarré depuis\",\n        \"maxDown\": \"Réception max\",\n        \"maxUp\": \"Envoi max\",\n        \"down\": \"Réception\",\n        \"up\": \"Envoi\",\n        \"received\": \"Reçu\",\n        \"sent\": \"Envoyé\",\n        \"externalIPAddress\": \"IP externe\",\n        \"externalIPv6Address\": \"IPv6 externe\",\n        \"externalIPv6Prefix\": \"Préfixe IPv6 externe\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Requêtes en cours\",\n        \"requests_failed\": \"Requêtes échouées\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Total observé\",\n        \"diffsDetected\": \"Différences détectées\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Séries\",\n        \"recordings\": \"Enregistrements\",\n        \"scheduled\": \"Planifié\",\n        \"passes\": \"Passes\"\n    },\n    \"tautulli\": {\n        \"playing\": \"En cours de lecture\",\n        \"transcoding\": \"Transcodage\",\n        \"bitrate\": \"Débit\",\n        \"no_active\": \"Aucune lecture en cours\",\n        \"plex_connection_error\": \"Vérifier la connexion à Plex\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"APs connectées\",\n        \"activeUser\": \"Périphériques actifs\",\n        \"alerts\": \"Alertes\",\n        \"connectedGateways\": \"Passerelles connectées\",\n        \"connectedSwitches\": \"Switchs connectés\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Débit\",\n        \"remaining\": \"Restant\",\n        \"downloaded\": \"Téléchargé\"\n    },\n    \"plex\": {\n        \"streams\": \"Lectures en cours\",\n        \"albums\": \"Albums\",\n        \"movies\": \"Films\",\n        \"tv\": \"Séries\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Débit\",\n        \"queue\": \"File d'attente\",\n        \"timeleft\": \"Temps restant\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Actif\",\n        \"upload\": \"Envoi\",\n        \"download\": \"Réception\"\n    },\n    \"transmission\": {\n        \"download\": \"Réception\",\n        \"upload\": \"Envoi\",\n        \"leech\": \"En téléchargement\",\n        \"seed\": \"En partage\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Réception\",\n        \"upload\": \"Envoi\",\n        \"leech\": \"En téléchargement\",\n        \"seed\": \"En partage\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"Utilisation CPU\",\n        \"memUsage\": \"RAM utilisée\",\n        \"systemTempC\": \"Température système\",\n        \"poolUsage\": \"Utilisation de la pool\",\n        \"volumeUsage\": \"Utilisation du volume\",\n        \"invalid\": \"Invalide\"\n    },\n    \"deluge\": {\n        \"download\": \"Réception\",\n        \"upload\": \"Envoi\",\n        \"leech\": \"En téléchargement\",\n        \"seed\": \"En partage\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Octets acquis du cache\",\n        \"cachemissbytes\": \"Cache Miss (B)\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Réception\",\n        \"upload\": \"Envoi\",\n        \"leech\": \"En téléchargement\",\n        \"seed\": \"En partage\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Recherché\",\n        \"queued\": \"En attente\",\n        \"series\": \"Séries\",\n        \"queue\": \"File d'attente\",\n        \"unknown\": \"Inconnu\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Recherché\",\n        \"missing\": \"Manquant\",\n        \"queued\": \"En attente\",\n        \"movies\": \"Films\",\n        \"queue\": \"File d'attente\",\n        \"unknown\": \"Inconnu\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Recherché\",\n        \"queued\": \"En attente\",\n        \"artists\": \"Artistes\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Recherché\",\n        \"queued\": \"En attente\",\n        \"books\": \"Livres\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Épisodes manquants\",\n        \"missingMovies\": \"Films manquants\"\n    },\n    \"ombi\": {\n        \"pending\": \"En attente\",\n        \"approved\": \"Approuvé\",\n        \"available\": \"Disponible\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connecté\",\n        \"new_devices\": \"Nouveaux appareils\",\n        \"down_alerts\": \"Alertes d'arrêt\"\n    },\n    \"pihole\": {\n        \"queries\": \"Requêtes\",\n        \"blocked\": \"Bloqué\",\n        \"blocked_percent\": \"% bloqué\",\n        \"gravity\": \"Listes dom. Bloqués\"\n    },\n    \"adguard\": {\n        \"queries\": \"Requêtes\",\n        \"blocked\": \"Bloqué\",\n        \"filtered\": \"Filtrées\",\n        \"latency\": \"Latence\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Envoi\",\n        \"download\": \"Réception\",\n        \"ping\": \"Latence\"\n    },\n    \"portainer\": {\n        \"running\": \"En cours d'exécution\",\n        \"stopped\": \"Arrêté\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Téléchargé\",\n        \"nondownload\": \"Non téléchargé\",\n        \"read\": \"Lu\",\n        \"unread\": \"Non llu\",\n        \"downloadedread\": \"Téléchargé et lu\",\n        \"downloadedunread\": \"Téléchargé et non lu\",\n        \"nondownloadedread\": \"Non téléchargé et lu\",\n        \"nondownloadedunread\": \"Non téléchargé et non lu\"\n    },\n    \"tailscale\": {\n        \"address\": \"Adresse\",\n        \"expires\": \"Expire\",\n        \"never\": \"Jamais\",\n        \"last_seen\": \"Vu pour la dernière fois\",\n        \"now\": \"Maintenant\",\n        \"years\": \"{{number}}a\",\n        \"weeks\": \"{{number}}s\",\n        \"days\": \"{{number}}j\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"Il y a {{value}}\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Requêtes\",\n        \"totalNoError\": \"Succès\",\n        \"totalServerFailure\": \"Échecs\",\n        \"totalNxDomain\": \"Domaines NX\",\n        \"totalRefused\": \"Refusés\",\n        \"totalAuthoritative\": \"Autoritaire\",\n        \"totalRecursive\": \"Récursif\",\n        \"totalCached\": \"Mis en cache\",\n        \"totalBlocked\": \"Bloqué\",\n        \"totalDropped\": \"Abandonné\",\n        \"totalClients\": \"Clients\"\n    },\n    \"tdarr\": {\n        \"queue\": \"File d'attente\",\n        \"processed\": \"Traité\",\n        \"errored\": \"Erroné\",\n        \"saved\": \"Enregistré\"\n    },\n    \"traefik\": {\n        \"routers\": \"Routeurs\",\n        \"services\": \"Services\",\n        \"middleware\": \"Middleware\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Taille de la base de données\",\n        \"unknown\": \"Inconnu\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"Aucune lecture en cours\",\n        \"please_wait\": \"Merci de patienter\"\n    },\n    \"npm\": {\n        \"enabled\": \"Activé\",\n        \"disabled\": \"Désactivé\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Configurer une ou plusieurs crypto-monnaies à suivre\",\n        \"1hour\": \"1 Heure\",\n        \"1day\": \"1 Jour\",\n        \"7days\": \"7 Jours\",\n        \"30days\": \"30 Jours\"\n    },\n    \"gotify\": {\n        \"apps\": \"Applications\",\n        \"clients\": \"Clients\",\n        \"messages\": \"Messages\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indexeur\",\n        \"numberOfGrabs\": \"Captures\",\n        \"numberOfQueries\": \"Requêtes\",\n        \"numberOfFailGrabs\": \"Captures échouées\",\n        \"numberOfFailQueries\": \"Demandes échouées\"\n    },\n    \"jackett\": {\n        \"configured\": \"Configuré\",\n        \"errored\": \"Échoué\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sessions\",\n        \"numConnections\": \"Connexions\",\n        \"dataRelayed\": \"Relayé\",\n        \"transferRate\": \"Taux de transfert\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Utilisateurs\",\n        \"status_count\": \"Articles\",\n        \"domain_count\": \"Domaines\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Recherché\",\n        \"queued\": \"En attente\",\n        \"series\": \"Séries\"\n    },\n    \"minecraft\": {\n        \"players\": \"Joueurs\",\n        \"version\": \"Version\",\n        \"status\": \"Statut\",\n        \"up\": \"En ligne\",\n        \"down\": \"Hors ligne\"\n    },\n    \"miniflux\": {\n        \"read\": \"Lu\",\n        \"unread\": \"Non lu\"\n    },\n    \"authentik\": {\n        \"users\": \"Utilisateurs\",\n        \"loginsLast24H\": \"Connexions (24 h)\",\n        \"failedLoginsLast24H\": \"Connexions échouées (24 h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"RAM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Charge\",\n        \"wait\": \"Veuillez patienter\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temp\",\n        \"warn\": \"Alerte\",\n        \"uptime\": \"Démarré depuis\",\n        \"total\": \"Total\",\n        \"free\": \"Libre\",\n        \"used\": \"Utilisé\",\n        \"days\": \"j\",\n        \"hours\": \"h\",\n        \"crit\": \"Crit.\",\n        \"read\": \"Lu\",\n        \"write\": \"Écrit.\",\n        \"gpu\": \"Carte Graphique\",\n        \"mem\": \"Mém.\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Marque-Page\",\n        \"service\": \"Service\",\n        \"search\": \"Recherche\",\n        \"custom\": \"Personnalisé\",\n        \"visit\": \"Aller vers\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Suggestions\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Ensoleillé\",\n        \"0-night\": \"Clair\",\n        \"1-day\": \"Principalement ensoleillé\",\n        \"1-night\": \"Principalement clair\",\n        \"2-day\": \"Partiellement nuageux\",\n        \"2-night\": \"Partiellement nuageux\",\n        \"3-day\": \"Nuageux\",\n        \"3-night\": \"Nuageux\",\n        \"45-day\": \"Brumeux\",\n        \"45-night\": \"Brouillard\",\n        \"48-day\": \"Brouillard\",\n        \"48-night\": \"Brouillard\",\n        \"51-day\": \"Bruine légère\",\n        \"51-night\": \"Faible bruine\",\n        \"53-day\": \"Bruine\",\n        \"53-night\": \"Bruine\",\n        \"55-day\": \"Bruine épaisse\",\n        \"55-night\": \"Bruine épaisse\",\n        \"56-day\": \"Légère bruine verglaçante\",\n        \"56-night\": \"Faible bruine verglaçante\",\n        \"57-day\": \"Bruine verglaçante\",\n        \"57-night\": \"Bruine verglaçante\",\n        \"61-day\": \"Pluie légère\",\n        \"61-night\": \"Pluie légère\",\n        \"63-day\": \"Pluie\",\n        \"63-night\": \"Pluie\",\n        \"65-day\": \"Forte pluie\",\n        \"65-night\": \"Forte pluie\",\n        \"66-day\": \"Pluie verglaçante\",\n        \"66-night\": \"Pluie verglaçante\",\n        \"67-day\": \"Pluie verglaçante\",\n        \"67-night\": \"Pluie verglaçante\",\n        \"71-day\": \"Légères chutes de neige\",\n        \"71-night\": \"Légères chutes de neige\",\n        \"73-day\": \"Neige\",\n        \"73-night\": \"Neige\",\n        \"75-day\": \"Neige abondante\",\n        \"75-night\": \"Fortes chutes de neige\",\n        \"77-day\": \"Grains de neige\",\n        \"77-night\": \"Neige en grains\",\n        \"80-day\": \"Averses légères\",\n        \"80-night\": \"Averses légères\",\n        \"81-day\": \"Averses\",\n        \"81-night\": \"Averses\",\n        \"82-day\": \"Averses fortes\",\n        \"82-night\": \"Fortes averses\",\n        \"85-day\": \"Averses de neige\",\n        \"85-night\": \"Averses de neige\",\n        \"86-day\": \"Averses de neige\",\n        \"86-night\": \"Averses de neige\",\n        \"95-day\": \"Orage\",\n        \"95-night\": \"Orage\",\n        \"96-day\": \"Orage avec grêle\",\n        \"96-night\": \"Orage avec grêle\",\n        \"99-day\": \"Orage avec grêle\",\n        \"99-night\": \"Orage avec grêle\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Système\",\n        \"updates\": \"Mises à jour\",\n        \"update_available\": \"Mise à jour disponible\",\n        \"up_to_date\": \"À jour\",\n        \"child_bridges\": \"Child Bridges\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"En ligne\",\n        \"pending\": \"En attente\",\n        \"down\": \"Hors ligne\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Nouveau\",\n        \"up\": \"En ligne\",\n        \"grace\": \"En Période de Grâce\",\n        \"down\": \"Hors ligne\",\n        \"paused\": \"En Pause\",\n        \"status\": \"Statut\",\n        \"last_ping\": \"Dernier Ping\",\n        \"never\": \"Pas de Ping\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Scanné\",\n        \"containers_updated\": \"Mis à jour\",\n        \"containers_failed\": \"Échoué\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approuvé\",\n        \"rejectedPushes\": \"Rejeté\",\n        \"filters\": \"Filtres\",\n        \"indexers\": \"Indexeurs\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"File d'attente\",\n        \"videos\": \"Vidéos\",\n        \"channels\": \"Chaînes\",\n        \"playlists\": \"Listes de lecture\"\n    },\n    \"truenas\": {\n        \"load\": \"Charge Système\",\n        \"uptime\": \"Démarré depuis\",\n        \"alerts\": \"Alertes\"\n    },\n    \"pyload\": {\n        \"speed\": \"Débit\",\n        \"active\": \"Actif\",\n        \"queue\": \"File d'attente\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"IP publique\",\n        \"region\": \"Région\",\n        \"country\": \"Pays\",\n        \"port_forwarded\": \"Port Transféré\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Chaînes\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tuners\",\n        \"channelNumber\": \"Canal\",\n        \"channelNetwork\": \"Réseau\",\n        \"signalStrength\": \"Force\",\n        \"signalQuality\": \"Qualité\",\n        \"symbolQuality\": \"Qualité\",\n        \"networkRate\": \"Débit\",\n        \"clientIP\": \"Client\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Réussi\",\n        \"failed\": \"Échoué\",\n        \"unknown\": \"Inconnu\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Boîte de réception\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Ressources\",\n        \"targets\": \"Cibles\",\n        \"traffic\": \"Trafique\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Charge de la batterie\",\n        \"ups_load\": \"Charge de l’ASI\",\n        \"ups_status\": \"État de l’ASI\",\n        \"online\": \"En ligne\",\n        \"on_battery\": \"Sur Batterie\",\n        \"low_battery\": \"Batterie Faible\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Veuillez patienter\",\n        \"no_devices\": \"Aucune donnée d'appareil reçue\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"Charge du processeur\",\n        \"memoryUsed\": \"Mémoire utilisée\",\n        \"uptime\": \"Démarré depuis\",\n        \"numberOfLeases\": \"Baux\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Tous les flux\",\n        \"streams_active\": \"Lectures en cours\",\n        \"streams_xepg\": \"Canal XEPG\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Aujourd'hui\",\n        \"absolutePower\": \"Puissance\",\n        \"relativePower\": \"% de puissance\",\n        \"limit\": \"Limite\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"Charge CPU\",\n        \"memory\": \"Mémoire utilisée\",\n        \"wanUpload\": \"Envoi WAN\",\n        \"wanDownload\": \"WAN Récep.\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"État de l'imprimante\",\n        \"print_status\": \"Statut de l'imprimante\",\n        \"print_progress\": \"Progression\",\n        \"layers\": \"Couches\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Statut\",\n        \"temp_tool\": \"Temp. de l'outil\",\n        \"temp_bed\": \"Temp. du lit\",\n        \"job_completion\": \"Achèvement\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"IP Publique\",\n        \"status\": \"Statut\"\n    },\n    \"pfsense\": {\n        \"load\": \"Charge moy.\",\n        \"memory\": \"Util. Mém.\",\n        \"wanStatus\": \"Statut WAN\",\n        \"up\": \"Haut\",\n        \"down\": \"Bas\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Util. Disque\",\n        \"wanIP\": \"IP WAN\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datastore\",\n        \"failed_tasks_24h\": \"Tâches échouées 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Mémoire\"\n    },\n    \"immich\": {\n        \"users\": \"Utilisateurs\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Vidéos\",\n        \"storage\": \"Stockage\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Sites actifs\",\n        \"down\": \"Sites inactifs\",\n        \"uptime\": \"Démarré depuis\",\n        \"incident\": \"Incident\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Séries\",\n        \"archives\": \"Archives\",\n        \"chapters\": \"Chapitres\",\n        \"categories\": \"Catégories\"\n    },\n    \"komga\": {\n        \"libraries\": \"Bibliothèques\",\n        \"series\": \"Séries\",\n        \"books\": \"Livres\"\n    },\n    \"diskstation\": {\n        \"days\": \"Jours\",\n        \"uptime\": \"Démarré depuis\",\n        \"volumeAvailable\": \"Disponible\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Séries\",\n        \"issues\": \"Anomalies\",\n        \"wanted\": \"Recherché\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Vidéos\",\n        \"people\": \"Personnes\"\n    },\n    \"fileflows\": {\n        \"queue\": \"File d'attente\",\n        \"processing\": \"En traitement\",\n        \"processed\": \"Traité\",\n        \"time\": \"Temps\"\n    },\n    \"firefly\": {\n        \"networth\": \"Valeur Nette\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Tableau de bord\",\n        \"datasources\": \"Sources données\",\n        \"totalalerts\": \"Alertes totales\",\n        \"alertstriggered\": \"Alertes déclenchées\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Charge CPU\",\n        \"memoryusage\": \"Utilisation Mémoire\",\n        \"freespace\": \"Libre\",\n        \"activeusers\": \"Utilisateurs Actifs\",\n        \"numfiles\": \"Fichiers\",\n        \"numshares\": \"Partages\"\n    },\n    \"kopia\": {\n        \"status\": \"Statut\",\n        \"size\": \"Taille\",\n        \"lastrun\": \"Dernière exécution\",\n        \"nextrun\": \"Prochaine exécution\",\n        \"failed\": \"Échoué\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"En cours\",\n        \"total_workers\": \"Total\",\n        \"records_total\": \"En attente\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Serveurs\",\n        \"nodes\": \"Nœuds\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Up\",\n        \"targets_down\": \"Down\",\n        \"targets_total\": \"Total\"\n    },\n    \"gatus\": {\n        \"up\": \"En ligne\",\n        \"down\": \"Hors ligne\",\n        \"uptime\": \"Disponibilité\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Aujourd'hui\",\n        \"gross_percent_1y\": \"Un an\",\n        \"gross_percent_max\": \"Depuis le début\",\n        \"net_worth\": \"Patrimoine net\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasts\",\n        \"books\": \"Livres\",\n        \"podcastsDuration\": \"Durée\",\n        \"booksDuration\": \"Durée\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Personne à la maison\",\n        \"lights_on\": \"Lumières allumées\",\n        \"switches_on\": \"Interrupteurs allumés\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Conteneurs\",\n        \"updates\": \"Mises à jour\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Livres\",\n        \"authors\": \"Auteurs\",\n        \"categories\": \"Catégories\",\n        \"series\": \"Séries\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"File d'attente\",\n        \"downloadBytesRemaining\": \"Restant\",\n        \"downloadTotalBytes\": \"Taille\",\n        \"downloadSpeed\": \"Vitesse\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Séries\",\n        \"totalFiles\": \"Fichiers\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Résultat\",\n        \"status\": \"Statut\",\n        \"buildId\": \"Build ID\",\n        \"succeeded\": \"Réussi\",\n        \"notStarted\": \"Arrêté\",\n        \"failed\": \"Échoué\",\n        \"canceled\": \"Annulé\",\n        \"inProgress\": \"En cours\",\n        \"totalPrs\": \"PRs Total\",\n        \"myPrs\": \"Mes PRs\",\n        \"approved\": \"Approuvé\"\n    },\n    \"gamedig\": {\n        \"status\": \"Statut\",\n        \"online\": \"En ligne\",\n        \"offline\": \"Hors ligne\",\n        \"name\": \"Nom\",\n        \"map\": \"Carte\",\n        \"currentPlayers\": \"Joueurs actuels\",\n        \"players\": \"Joueurs\",\n        \"maxPlayers\": \"Joueurs max\",\n        \"bots\": \"Robots\",\n        \"ping\": \"Latence\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Erreur\",\n        \"noRecent\": \"Obsolète\",\n        \"totalUsed\": \"Esp. Utilisé\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Recettes\",\n        \"users\": \"Utilisateurs\",\n        \"categories\": \"Catégories\",\n        \"tags\": \"Étiquettes\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Téléchargement\",\n        \"total\": \"Total\",\n        \"running\": \"Actif\",\n        \"stopped\": \"Arrêté\",\n        \"passed\": \"Réussi\",\n        \"failed\": \"Échoué\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Disponibilité\",\n        \"cpuLoad\": \"Charge moyenne CPU (5 min)\",\n        \"up\": \"Actif\",\n        \"down\": \"Inactif\",\n        \"bytesTx\": \"Transmis\",\n        \"bytesRx\": \"Reçu\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Statut\",\n        \"uptime\": \"Disponibilité\",\n        \"lastDown\": \"Dernière interruption\",\n        \"downDuration\": \"Durée d'interruption\",\n        \"sitesUp\": \"Sites actifs\",\n        \"sitesDown\": \"Sites inactifs\",\n        \"paused\": \"En pause\",\n        \"notyetchecked\": \"Non vérifié\",\n        \"up\": \"En ligne\",\n        \"seemsdown\": \"Semble hors ligne\",\n        \"down\": \"Hors ligne\",\n        \"unknown\": \"Inconnu\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"En salle\",\n        \"physicalRelease\": \"Sortie physique\",\n        \"digitalRelease\": \"Sortie numérique\",\n        \"noEventsToday\": \"Rien pour aujourd'hui !\",\n        \"noEventsFound\": \"Aucun événement trouvé\",\n        \"errorWhenLoadingData\": \"Erreur lors du chargement du calendrier\"\n    },\n    \"romm\": {\n        \"platforms\": \"Plateformes\",\n        \"totalRoms\": \"Jeux\",\n        \"saves\": \"Sauvegardes\",\n        \"states\": \"États\",\n        \"screenshots\": \"Captures d'écran\",\n        \"totalfilesize\": \"Taille totale\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domaines\",\n        \"mailboxes\": \"Boites mail\",\n        \"mails\": \"Courriels\",\n        \"storage\": \"Stockage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Avertissements\",\n        \"criticals\": \"Urgent\"\n    },\n    \"plantit\": {\n        \"events\": \"Événements\",\n        \"plants\": \"Plantes\",\n        \"photos\": \"Photos\",\n        \"species\": \"Espèces\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Notifications\",\n        \"issues\": \"Tickets\",\n        \"pulls\": \"Demandes de tirage\",\n        \"repositories\": \"Dépôts\"\n    },\n    \"stash\": {\n        \"scenes\": \"Scènes\",\n        \"scenesPlayed\": \"Scènes jouées\",\n        \"playCount\": \"Lectures Totales\",\n        \"playDuration\": \"Temps regardé\",\n        \"sceneSize\": \"Taille des scènes\",\n        \"sceneDuration\": \"Durée des scènes\",\n        \"images\": \"Images\",\n        \"imageSize\": \"Taille des images\",\n        \"galleries\": \"Galeries\",\n        \"performers\": \"Acteurs\",\n        \"studios\": \"Studios\",\n        \"movies\": \"Films\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O-mètre\"\n    },\n    \"tandoor\": {\n        \"users\": \"Utilisateurs\",\n        \"recipes\": \"Recettes\",\n        \"keywords\": \"Mots-clés\"\n    },\n    \"homebox\": {\n        \"items\": \"Objets\",\n        \"totalWithWarranty\": \"Avec garantie\",\n        \"locations\": \"Emplacements\",\n        \"labels\": \"Étiquettes\",\n        \"users\": \"Utilisateurs\",\n        \"totalValue\": \"Valeur Totale\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alertes\",\n        \"bans\": \"Bannissements\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connecté\",\n        \"enabled\": \"Activé\",\n        \"disabled\": \"Désactivé\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Par proxy\",\n        \"auth\": \"Avec authentification\",\n        \"outdated\": \"Obsolète\",\n        \"banned\": \"Banni\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Latence\",\n        \"download\": \"Réception\",\n        \"upload\": \"Envoi\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Stocks\",\n        \"loading\": \"Chargement\",\n        \"open\": \"Ouvert - Marché américain\",\n        \"closed\": \"Fermé - marché américain\",\n        \"invalidConfiguration\": \"Configuration invalide\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Caméras\",\n        \"uptime\": \"Disponibilité\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Liens\",\n        \"collections\": \"Collections\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Non classé\",\n        \"information\": \"Information\",\n        \"warning\": \"Avertissement\",\n        \"average\": \"Moyen\",\n        \"high\": \"Haut\",\n        \"disaster\": \"Désastre\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Véhicule\",\n        \"vehicles\": \"Véhicules\",\n        \"serviceRecords\": \"Service d'enregistrements\",\n        \"reminders\": \"Rappels\",\n        \"nextReminder\": \"Prochain rappel\",\n        \"none\": \"Aucun\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Projets actifs\",\n        \"tasks7d\": \"Tâches à faire cette semaine\",\n        \"tasksOverdue\": \"Tâches en retard\",\n        \"tasksInProgress\": \"Tâche en cours\"\n    },\n    \"headscale\": {\n        \"name\": \"Nom\",\n        \"address\": \"Adresse\",\n        \"last_seen\": \"Vu pour la dernière fois\",\n        \"status\": \"Statut\",\n        \"online\": \"En ligne\",\n        \"offline\": \"Hors ligne\"\n    },\n    \"beszel\": {\n        \"name\": \"Nom\",\n        \"systems\": \"Systèmes\",\n        \"up\": \"En ligne\",\n        \"down\": \"Hors ligne\",\n        \"paused\": \"En pause\",\n        \"pending\": \"En attente\",\n        \"status\": \"Statut\",\n        \"updated\": \"Mis à jour\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"RAM\",\n        \"disk\": \"Disque\",\n        \"network\": \"Réseau\"\n    },\n    \"argocd\": {\n        \"apps\": \"Applications\",\n        \"synced\": \"Synchronisé\",\n        \"outOfSync\": \"Désynchronisé\",\n        \"healthy\": \"Fonctionnel\",\n        \"degraded\": \"Dégradé\",\n        \"progressing\": \"En cours\",\n        \"missing\": \"Manquant\",\n        \"suspended\": \"Suspendu\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Chargement\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Groupes\",\n        \"issues\": \"Tickets\",\n        \"merges\": \"Demandes de fusion de branches\",\n        \"projects\": \"Projets\"\n    },\n    \"apcups\": {\n        \"status\": \"Statut\",\n        \"load\": \"Charge\",\n        \"bcharge\": \"Charge de la batterie\",\n        \"timeleft\": \"Temps restant\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Marque-pages\",\n        \"favorites\": \"Favoris\",\n        \"archived\": \"Archivé\",\n        \"highlights\": \"À la une\",\n        \"lists\": \"Listes\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Réseau\",\n        \"connected\": \"Connecté\",\n        \"disconnected\": \"Déconnecté\",\n        \"updateStatus\": \"Mise à jour\",\n        \"update_yes\": \"Disponible\",\n        \"update_no\": \"À jour\",\n        \"downloads\": \"Téléchargements\",\n        \"uploads\": \"Envois\",\n        \"sharedFiles\": \"Fichiers\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Morceaux\",\n        \"movies\": \"Films\",\n        \"episodes\": \"Épisodes\",\n        \"other\": \"Autres\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Problèmes de service\",\n        \"hostErrors\": \"Problèmes d'hôte\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Démarré\",\n        \"stopped\": \"Arrêté\",\n        \"down\": \"Hors ligne\",\n        \"unhealthy\": \"Non fonctionnel\",\n        \"unknown\": \"Inconnu\",\n        \"servers\": \"Serveurs\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Conteneurs\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Disponible\",\n        \"used\": \"Utilisé\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Abonnements\",\n        \"thisMonthlyCost\": \"Ce mois\",\n        \"nextMonthlyCost\": \"Mois prochain\",\n        \"previousMonthlyCost\": \"Mois précédent\",\n        \"nextRenewingSubscription\": \"Prochain paiement\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Commencé\",\n        \"STOPPED\": \"Arrêté\",\n        \"NEW_ARRAY\": \"Nouveau tableau\",\n        \"RECON_DISK\": \"Reconstruction du disque\",\n        \"DISABLE_DISK\": \"Disque désactivé\",\n        \"SWAP_DSBL\": \"Désactiver le swap\",\n        \"INVALID_EXPANSION\": \"Extension invalide\",\n        \"PARITY_NOT_BIGGEST\": \"La parité n'est pas la plus grande\",\n        \"TOO_MANY_MISSING_DISKS\": \"Trop de disques manquants\",\n        \"NEW_DISK_TOO_SMALL\": \"Nouveau disque trop petit\",\n        \"NO_DATA_DISKS\": \"Aucun disque de données\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"État\",\n        \"cpu\": \"UCT\",\n        \"memoryUsed\": \"Mémoire Utilisé\",\n        \"memoryAvailable\": \"Mémoire Disponible\",\n        \"arrayUsed\": \"RAID utilisé\",\n        \"arrayFree\": \"RAID libre\",\n        \"poolUsed\": \"{{pool}} Utilisé\",\n        \"poolFree\": \"{{pool}} Libre\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Abonnements\",\n        \"num_success_30\": \"Succès\",\n        \"num_failure_30\": \"Échecs\",\n        \"num_success_latest\": \"Réussi\",\n        \"num_failure_latest\": \"Échoué\",\n        \"bytes_added_30\": \"Octets ajoutés\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Musiques\",\n        \"time\": \"Durée\",\n        \"artists\": \"Artistes\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/he/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"חודשים\",\n        \"days\": \"ימים\",\n        \"hours\": \"שעות\",\n        \"minutes\": \"דקות\",\n        \"seconds\": \"שניות\"\n    },\n    \"widget\": {\n        \"missing_type\": \"סוג ווידג'ט חסר: {{type}}\",\n        \"api_error\": \"שגיאת API\",\n        \"information\": \"מידע\",\n        \"status\": \"סטטוס\",\n        \"url\": \"קישור\",\n        \"raw_error\": \"שגיאה מקורית\",\n        \"response_data\": \"נתוני תשובה\"\n    },\n    \"weather\": {\n        \"current\": \"מיקום נוכחי\",\n        \"allow\": \"לחץ לאישור\",\n        \"updating\": \"מעדכן\",\n        \"wait\": \"המתן בבקשה\"\n    },\n    \"search\": {\n        \"placeholder\": \"חיפוש…\"\n    },\n    \"resources\": {\n        \"cpu\": \"מעבד\",\n        \"mem\": \"זיכרון\",\n        \"total\": \"סה\\\"כ\",\n        \"free\": \"פנוי\",\n        \"used\": \"בשימוש\",\n        \"load\": \"עומס\",\n        \"temp\": \"טמפ׳\",\n        \"max\": \"מקסימום\",\n        \"uptime\": \"זמן פעילות\"\n    },\n    \"unifi\": {\n        \"users\": \"משתמשים\",\n        \"uptime\": \"זמן פעילות\",\n        \"days\": \"ימים\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"מכשירים\",\n        \"lan_devices\": \"מכשירים ב-LAN\",\n        \"wlan_devices\": \"מכשירים ב-WAN\",\n        \"lan_users\": \"משתמשים ב-LAN\",\n        \"wlan_users\": \"משתמשים ב-WLAN\",\n        \"up\": \"למעלה\",\n        \"down\": \"כבוי\",\n        \"wait\": \"נא להמתין\",\n        \"empty_data\": \"מצב תת-מערכת לא ידוע\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"זיכרון\",\n        \"cpu\": \"ניצול מעבד\",\n        \"running\": \"רץ\",\n        \"offline\": \"לא מקוון\",\n        \"error\": \"שגיאה\",\n        \"unknown\": \"לא ידוע\",\n        \"healthy\": \"בריא\",\n        \"starting\": \"בעלייה\",\n        \"unhealthy\": \"לא בריא\",\n        \"not_found\": \"לא נמצא\",\n        \"exited\": \"יצא\",\n        \"partial\": \"חלקי\"\n    },\n    \"ping\": {\n        \"error\": \"שגיאה\",\n        \"ping\": \"פינג\",\n        \"down\": \"למטה\",\n        \"up\": \"למעלה\",\n        \"not_available\": \"לא זמין\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"סטטוס HTTP\",\n        \"error\": \"שגיאה\",\n        \"response\": \"תגובה\",\n        \"down\": \"למטה\",\n        \"up\": \"למעלה\",\n        \"not_available\": \"לא זמין\"\n    },\n    \"emby\": {\n        \"playing\": \"מנגן\",\n        \"transcoding\": \"מקודד\",\n        \"bitrate\": \"סיביות\",\n        \"no_active\": \"אין הזרמות פעילות\",\n        \"movies\": \"סרטים\",\n        \"series\": \"סדרות\",\n        \"episodes\": \"פרקים\",\n        \"songs\": \"שירים\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"מכובה\",\n        \"offline_alt\": \"מכובה\",\n        \"online\": \"מקוון\",\n        \"total\": \"סה\\\"כ\",\n        \"unknown\": \"לא ידוע\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"הפקה\",\n        \"battery_soc\": \"סוללה\",\n        \"grid_power\": \"רשת\",\n        \"home_power\": \"צריכה\",\n        \"charge_power\": \"מטען\",\n        \"kilowatt\": \"קילוואט\"\n    },\n    \"flood\": {\n        \"download\": \"הורדה\",\n        \"upload\": \"העלאה\",\n        \"leech\": \"עלוקה\",\n        \"seed\": \"זרע\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"מנויים\",\n        \"unread\": \"לא נקרא\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"סטטוס\",\n        \"connectionStatusUnconfigured\": \"לא מוגדר\",\n        \"connectionStatusConnecting\": \"מתחבר\",\n        \"connectionStatusAuthenticating\": \"מאמת\",\n        \"connectionStatusPendingDisconnect\": \"ממתין לניתוק\",\n        \"connectionStatusDisconnecting\": \"מתנתק\",\n        \"connectionStatusDisconnected\": \"מנותק\",\n        \"connectionStatusConnected\": \"מחובר\",\n        \"uptime\": \"זמן פעילות\",\n        \"maxDown\": \"מקס׳ הורדה\",\n        \"maxUp\": \"מקס׳ העלאה\",\n        \"down\": \"למטה\",\n        \"up\": \"למעלה\",\n        \"received\": \"התקבל\",\n        \"sent\": \"נשלח\",\n        \"externalIPAddress\": \"כתובת IP חיצונית\",\n        \"externalIPv6Address\": \"כתובת IPv6 חיצונית\",\n        \"externalIPv6Prefix\": \"קידומת IPv6 חיצונית\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"ערוצי העלאה\",\n        \"requests\": \"בקשות נוכחיות\",\n        \"requests_failed\": \"בקשות שנכשלו\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"סה״כ נראו\",\n        \"diffsDetected\": \"הבדלים שזוהו\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"תוכניות\",\n        \"recordings\": \"הקלטות\",\n        \"scheduled\": \"מתוכנן\",\n        \"passes\": \"Passes\"\n    },\n    \"tautulli\": {\n        \"playing\": \"מנגן\",\n        \"transcoding\": \"המרת קידוד\",\n        \"bitrate\": \"קצב נתונים\",\n        \"no_active\": \"אין הזרמות פעילות\",\n        \"plex_connection_error\": \"בדוק חיבור ל-Plex\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"נקודות גישה מחוברות\",\n        \"activeUser\": \"מכשירים פעילים\",\n        \"alerts\": \"התראות\",\n        \"connectedGateways\": \"נתבים מחוברים\",\n        \"connectedSwitches\": \"נתבים מחוברים\"\n    },\n    \"nzbget\": {\n        \"rate\": \"יחס\",\n        \"remaining\": \"נותר\",\n        \"downloaded\": \"הורד\"\n    },\n    \"plex\": {\n        \"streams\": \"הזרמות פעילות\",\n        \"albums\": \"אלבומים\",\n        \"movies\": \"סרטים\",\n        \"tv\": \"תוכניות טלוויזיה\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"קצב\",\n        \"queue\": \"תור\",\n        \"timeleft\": \"זמן שנותר\"\n    },\n    \"rutorrent\": {\n        \"active\": \"פעיל\",\n        \"upload\": \"העלאה\",\n        \"download\": \"הורדה\"\n    },\n    \"transmission\": {\n        \"download\": \"הורדה\",\n        \"upload\": \"העלאה\",\n        \"leech\": \"עלוקה\",\n        \"seed\": \"זורע\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"הורדה\",\n        \"upload\": \"העלאה\",\n        \"leech\": \"עלוקה\",\n        \"seed\": \"זורע\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"שימוש במעבד\",\n        \"memUsage\": \"שימוש בזיכרון\",\n        \"systemTempC\": \"טמפ׳ מערכת\",\n        \"poolUsage\": \"שימוש Pool\",\n        \"volumeUsage\": \"שימוש בדיסק\",\n        \"invalid\": \"לא תקין\"\n    },\n    \"deluge\": {\n        \"download\": \"הורדה\",\n        \"upload\": \"העלאה\",\n        \"leech\": \"עלוקה\",\n        \"seed\": \"זורע\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"הורדה\",\n        \"upload\": \"העלאה\",\n        \"leech\": \"עלוקה\",\n        \"seed\": \"זורע\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"מבוקש\",\n        \"queued\": \"בתור\",\n        \"series\": \"סדרות\",\n        \"queue\": \"תור\",\n        \"unknown\": \"לא ידוע\"\n    },\n    \"radarr\": {\n        \"wanted\": \"מבוקש\",\n        \"missing\": \"חסרים\",\n        \"queued\": \"בתור\",\n        \"movies\": \"סרטים\",\n        \"queue\": \"תור\",\n        \"unknown\": \"לא ידוע\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"מבוקש\",\n        \"queued\": \"בתור\",\n        \"artists\": \"אמנים\"\n    },\n    \"readarr\": {\n        \"wanted\": \"מבוקש\",\n        \"queued\": \"בתור\",\n        \"books\": \"ספרים\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"פרקים חסרים\",\n        \"missingMovies\": \"סרטים חסרים\"\n    },\n    \"ombi\": {\n        \"pending\": \"ממתין\",\n        \"approved\": \"מאושר\",\n        \"available\": \"זמין\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"סה\\\"כ\",\n        \"connected\": \"מחובר\",\n        \"new_devices\": \"מכשירים חדשים\",\n        \"down_alerts\": \"התראות חוסר פעילות\"\n    },\n    \"pihole\": {\n        \"queries\": \"שאילתות\",\n        \"blocked\": \"נחסם\",\n        \"blocked_percent\": \"% נחסם\",\n        \"gravity\": \"Gravity\"\n    },\n    \"adguard\": {\n        \"queries\": \"שאילתות\",\n        \"blocked\": \"נחסם\",\n        \"filtered\": \"מסונן\",\n        \"latency\": \"השהיה\"\n    },\n    \"speedtest\": {\n        \"upload\": \"העלאה\",\n        \"download\": \"הורדה\",\n        \"ping\": \"זמן תגובה\"\n    },\n    \"portainer\": {\n        \"running\": \"רץ\",\n        \"stopped\": \"נעצר\",\n        \"total\": \"סה\\\"כ\"\n    },\n    \"suwayomi\": {\n        \"download\": \"ירד\",\n        \"nondownload\": \"לא הורד\",\n        \"read\": \"נקראו\",\n        \"unread\": \"לא נקראו\",\n        \"downloadedread\": \"ירד ונקרא\",\n        \"downloadedunread\": \"ירד ולא נקרא\",\n        \"nondownloadedread\": \"לא ירד ונקרא\",\n        \"nondownloadedunread\": \"לא ירד ולא נקרא\"\n    },\n    \"tailscale\": {\n        \"address\": \"כתובת\",\n        \"expires\": \"תאריך תפוגה\",\n        \"never\": \"אף פעם\",\n        \"last_seen\": \"נראה לאחרונה\",\n        \"now\": \"כעת\",\n        \"years\": \"{{number}}y\",\n        \"weeks\": \"{{number}}w\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Ago\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"שאילתות\",\n        \"totalNoError\": \"הצלחה\",\n        \"totalServerFailure\": \"כשלונות\",\n        \"totalNxDomain\": \"NX Domains\",\n        \"totalRefused\": \"סורב\",\n        \"totalAuthoritative\": \"Authoritative\",\n        \"totalRecursive\": \"רקורסיבי\",\n        \"totalCached\": \"נשמר במטמון\",\n        \"totalBlocked\": \"חסומות\",\n        \"totalDropped\": \"נפל\",\n        \"totalClients\": \"לקוחות\"\n    },\n    \"tdarr\": {\n        \"queue\": \"תור\",\n        \"processed\": \"עובד\",\n        \"errored\": \"נכשל\",\n        \"saved\": \"נשמר\"\n    },\n    \"traefik\": {\n        \"routers\": \"נתבים\",\n        \"services\": \"שירותים\",\n        \"middleware\": \"מתווך\"\n    },\n    \"trilium\": {\n        \"version\": \"גרסה\",\n        \"notesCount\": \"הערות\",\n        \"dbSize\": \"גודל מסד הנתונים\",\n        \"unknown\": \"לא ידוע\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"אין הזרמות פעילות\",\n        \"please_wait\": \"המתן בבקשה\"\n    },\n    \"npm\": {\n        \"enabled\": \"מופעל\",\n        \"disabled\": \"מבוטל\",\n        \"total\": \"סה\\\"כ\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"קבע את התצורה של מטבע קריפטו אחד או יותר למעקב\",\n        \"1hour\": \"שעה אחת\",\n        \"1day\": \"יום 1\",\n        \"7days\": \"7 יום\",\n        \"30days\": \"30 יום\"\n    },\n    \"gotify\": {\n        \"apps\": \"אפליקציות\",\n        \"clients\": \"לקוחות\",\n        \"messages\": \"הודעות\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"אינדקסים\",\n        \"numberOfGrabs\": \"תפיסות\",\n        \"numberOfQueries\": \"שאילתות\",\n        \"numberOfFailGrabs\": \"תפיסות שנכשלו\",\n        \"numberOfFailQueries\": \"שאילתות שנכשלו\"\n    },\n    \"jackett\": {\n        \"configured\": \"מוגדר\",\n        \"errored\": \"שגיאות\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"סשנים פעילים\",\n        \"numConnections\": \"חיבורים\",\n        \"dataRelayed\": \"דאטה שהועבר\",\n        \"transferRate\": \"קצב\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"משתמשים\",\n        \"status_count\": \"פוסטים\",\n        \"domain_count\": \"דומיינים\"\n    },\n    \"medusa\": {\n        \"wanted\": \"רצוי\",\n        \"queued\": \"בתור\",\n        \"series\": \"סדרות\"\n    },\n    \"minecraft\": {\n        \"players\": \"שחקנים\",\n        \"version\": \"גרסה\",\n        \"status\": \"סטטוס\",\n        \"up\": \"מקוון\",\n        \"down\": \"לא מקוון\"\n    },\n    \"miniflux\": {\n        \"read\": \"נקרא\",\n        \"unread\": \"לא נקראו\"\n    },\n    \"authentik\": {\n        \"users\": \"משתמשים\",\n        \"loginsLast24H\": \"כניסות (24h)\",\n        \"failedLoginsLast24H\": \"כניסות שנכשלו (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"זיכרון\",\n        \"cpu\": \"ניצול מעבד\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"glances\": {\n        \"cpu\": \"ניצול מעבד\",\n        \"load\": \"עומס\",\n        \"wait\": \"נא להמתין\",\n        \"temp\": \"טמפ׳\",\n        \"_temp\": \"טמפ׳\",\n        \"warn\": \"אזהרה\",\n        \"uptime\": \"זמן פעילות\",\n        \"total\": \"סה\\\"כ\",\n        \"free\": \"פנוי\",\n        \"used\": \"בשימוש\",\n        \"days\": \"ימים\",\n        \"hours\": \"שעות\",\n        \"crit\": \"Crit\",\n        \"read\": \"נקרא\",\n        \"write\": \"כתיבה\",\n        \"gpu\": \"כרטיס מסך\",\n        \"mem\": \"זיכרון\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"מועדף\",\n        \"service\": \"שירות\",\n        \"search\": \"חיפוש\",\n        \"custom\": \"מותאם אישית\",\n        \"visit\": \"ביקור\",\n        \"url\": \"קישור\",\n        \"searchsuggestion\": \"הצעה\"\n    },\n    \"wmo\": {\n        \"0-day\": \"שמשי\",\n        \"0-night\": \"בהיר\",\n        \"1-day\": \"בעיקר שמשי\",\n        \"1-night\": \"בעיקר בהיר\",\n        \"2-day\": \"מעונן חלקית\",\n        \"2-night\": \"מעונן חלקית\",\n        \"3-day\": \"מעונן\",\n        \"3-night\": \"מעונן\",\n        \"45-day\": \"ערפילי\",\n        \"45-night\": \"ערפילי\",\n        \"48-day\": \"ערפילי\",\n        \"48-night\": \"ערפילי\",\n        \"51-day\": \"טפטוף קל\",\n        \"51-night\": \"טפטוף קל\",\n        \"53-day\": \"טפטוף\",\n        \"53-night\": \"טפטוף\",\n        \"55-day\": \"טפטוף כבד\",\n        \"55-night\": \"טפטוף כבד\",\n        \"56-day\": \"טפטוף קפוא קל\",\n        \"56-night\": \"טפטוף קפוא קל\",\n        \"57-day\": \"טפטוף קפוא\",\n        \"57-night\": \"טפטוף קפוא\",\n        \"61-day\": \"גשם קל\",\n        \"61-night\": \"גשם קל\",\n        \"63-day\": \"גשם\",\n        \"63-night\": \"גשם\",\n        \"65-day\": \"גשם כבד\",\n        \"65-night\": \"גשם כבד\",\n        \"66-day\": \"גשם קפוא\",\n        \"66-night\": \"גשם קפוא\",\n        \"67-day\": \"גשם קפוא\",\n        \"67-night\": \"גשם קפוא\",\n        \"71-day\": \"שלג קל\",\n        \"71-night\": \"שלג קל\",\n        \"73-day\": \"שלג\",\n        \"73-night\": \"שלג\",\n        \"75-day\": \"שלג כבד\",\n        \"75-night\": \"שלג כבד\",\n        \"77-day\": \"גרגרי שלג\",\n        \"77-night\": \"פתיתי שלג\",\n        \"80-day\": \"ממטרים קלים\",\n        \"80-night\": \"גשם קל\",\n        \"81-day\": \"ממטרים\",\n        \"81-night\": \"גשם\",\n        \"82-day\": \"ממטרים כבדים\",\n        \"82-night\": \"גשם כבד\",\n        \"85-day\": \"ממטרי שלג\",\n        \"85-night\": \"גשם מושלג\",\n        \"86-day\": \"גשם מושלג\",\n        \"86-night\": \"גשם מושלג\",\n        \"95-day\": \"סופת רעמים\",\n        \"95-night\": \"סופת ברקים\",\n        \"96-day\": \"סופת רעמים עם ברד\",\n        \"96-night\": \"סופת ברקים וברד\",\n        \"99-day\": \"סופת ברקים וברד\",\n        \"99-night\": \"סופת ברקים וברד\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"מערכת\",\n        \"updates\": \"עדכונים\",\n        \"update_available\": \"עדכונים זמינים\",\n        \"up_to_date\": \"עדכני\",\n        \"child_bridges\": \"Child Bridges\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"למעלה\",\n        \"pending\": \"ממתין\",\n        \"down\": \"למטה\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"חדש\",\n        \"up\": \"למעלה\",\n        \"grace\": \"בתקופת חסד\",\n        \"down\": \"למטה\",\n        \"paused\": \"מושהה\",\n        \"status\": \"סטטוס\",\n        \"last_ping\": \"פינג אחרון\",\n        \"never\": \"אין פינגים עדיין\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"נסרק\",\n        \"containers_updated\": \"עודכן\",\n        \"containers_failed\": \"נכשל\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"אושרו\",\n        \"rejectedPushes\": \"נדחה\",\n        \"filters\": \"פילטרים\",\n        \"indexers\": \"אינדקסים\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"תור\",\n        \"videos\": \"סרטונים\",\n        \"channels\": \"ערוצים\",\n        \"playlists\": \"רשימות השמעה\"\n    },\n    \"truenas\": {\n        \"load\": \"עומס מערכת\",\n        \"uptime\": \"זמן פעילות\",\n        \"alerts\": \"התראות\"\n    },\n    \"pyload\": {\n        \"speed\": \"מהירות\",\n        \"active\": \"פעילות\",\n        \"queue\": \"תור\",\n        \"total\": \"סה\\\"כ\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"כתובת IP ציבורית\",\n        \"region\": \"אזור\",\n        \"country\": \"ארץ\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"ערוצים\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tuners\",\n        \"channelNumber\": \"ערוץ\",\n        \"channelNetwork\": \"רשת\",\n        \"signalStrength\": \"עוצמה\",\n        \"signalQuality\": \"איכות\",\n        \"symbolQuality\": \"איכות\",\n        \"networkRate\": \"קצב נתונים\",\n        \"clientIP\": \"לקוח\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"עבר\",\n        \"failed\": \"נכשלו\",\n        \"unknown\": \"לא ידוע\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"תיבת דואר נכנס\",\n        \"total\": \"סה\\\"כ\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"טעינת סוללה\",\n        \"ups_load\": \"עומס UPS\",\n        \"ups_status\": \"סטטוס UPS\",\n        \"online\": \"מחובר\",\n        \"on_battery\": \"על סוללה\",\n        \"low_battery\": \"סוללה חלשה\"\n    },\n    \"nextdns\": {\n        \"wait\": \"נא להמתין\",\n        \"no_devices\": \"אין מכשירים\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"עומס מעבד\",\n        \"memoryUsed\": \"זיכרון בשימוש\",\n        \"uptime\": \"זמן פעילות\",\n        \"numberOfLeases\": \"Leases\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"כל ההזרמות\",\n        \"streams_active\": \"הזרמות פעילות\",\n        \"streams_xepg\": \"ערוצי XEPG\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"היום\",\n        \"absolutePower\": \"Power\",\n        \"relativePower\": \"Power %\",\n        \"limit\": \"Limit\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"עומס מעבד\",\n        \"memory\": \"שימוש בזיכרון\",\n        \"wanUpload\": \"העלאה ל-WAN\",\n        \"wanDownload\": \"הורדה מ-WAN\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"מצב מדפסת\",\n        \"print_status\": \"מצב הדפסה\",\n        \"print_progress\": \"התקדמות הדפסה\",\n        \"layers\": \"שכבות\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"סטטוס\",\n        \"temp_tool\": \"טמפ׳ כלי\",\n        \"temp_bed\": \"טמפ׳ מיטה\",\n        \"job_completion\": \"השלמת עבודה\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"כתובת IP מקורית\",\n        \"status\": \"סטטוס\"\n    },\n    \"pfsense\": {\n        \"load\": \"עומס מערכת ממוצע\",\n        \"memory\": \"שימוש בזיכרון\",\n        \"wanStatus\": \"סטטוס WAN\",\n        \"up\": \"למעלה\",\n        \"down\": \"למטה\",\n        \"temp\": \"טמפ׳\",\n        \"disk\": \"שימוש בדיסק\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datastore\",\n        \"failed_tasks_24h\": \"משימות שנכשלו 24h\",\n        \"cpu_usage\": \"ניצול מעבד\",\n        \"memory_usage\": \"זיכרון\"\n    },\n    \"immich\": {\n        \"users\": \"משתמשים\",\n        \"photos\": \"תמונות\",\n        \"videos\": \"סרטונים\",\n        \"storage\": \"אחסון\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"אתרים פעילים\",\n        \"down\": \"אתרים לא פעילים\",\n        \"uptime\": \"זמן פעילות\",\n        \"incident\": \"תקריות\",\n        \"m\": \"דקות\"\n    },\n    \"atsumeru\": {\n        \"series\": \"סדרות\",\n        \"archives\": \"ארכיונים\",\n        \"chapters\": \"פרקים\",\n        \"categories\": \"קטגוריות\"\n    },\n    \"komga\": {\n        \"libraries\": \"ספריות\",\n        \"series\": \"סדרות\",\n        \"books\": \"ספרים\"\n    },\n    \"diskstation\": {\n        \"days\": \"ימים\",\n        \"uptime\": \"זמן פעילות\",\n        \"volumeAvailable\": \"זמין\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"סדרות\",\n        \"issues\": \"גיליונות\",\n        \"wanted\": \"מבוקש\"\n    },\n    \"photoprism\": {\n        \"albums\": \"אלבומים\",\n        \"photos\": \"תמונות\",\n        \"videos\": \"סרטונים\",\n        \"people\": \"אנשים\"\n    },\n    \"fileflows\": {\n        \"queue\": \"תור\",\n        \"processing\": \"בעיבוד\",\n        \"processed\": \"עובדו\",\n        \"time\": \"זמן\"\n    },\n    \"firefly\": {\n        \"networth\": \"שווי נקי\",\n        \"budget\": \"תקציב\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"דשבורדים\",\n        \"datasources\": \"מקורות נתונים\",\n        \"totalalerts\": \"סה\\\"כ התראות\",\n        \"alertstriggered\": \"התראות מופעלות\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"עומס מעבד\",\n        \"memoryusage\": \"שימוש בזיכרון\",\n        \"freespace\": \"דיסק פנוי\",\n        \"activeusers\": \"משתמשים\",\n        \"numfiles\": \"קבצים\",\n        \"numshares\": \"שיתופים\"\n    },\n    \"kopia\": {\n        \"status\": \"סטטוס\",\n        \"size\": \"גודל\",\n        \"lastrun\": \"הרצה אחרונה\",\n        \"nextrun\": \"הרצה הבאה\",\n        \"failed\": \"נכשלו\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"עובדים פעילים\",\n        \"total_workers\": \"סה\\\"כ עובדים\",\n        \"records_total\": \"סה\\\"כ רשומות\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"שרתים\",\n        \"nodes\": \"Nodes\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"מטרות פעילות\",\n        \"targets_down\": \"מטרות לא פעילות\",\n        \"targets_total\": \"סה\\\"כ מטרות\"\n    },\n    \"gatus\": {\n        \"up\": \"אתרים למעלה\",\n        \"down\": \"אתרים למטה\",\n        \"uptime\": \"זמן פעילות\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"היום\",\n        \"gross_percent_1y\": \"שנה\",\n        \"gross_percent_max\": \"כל הזמן\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"פודקאסטים\",\n        \"books\": \"ספרים\",\n        \"podcastsDuration\": \"משך פודקאסטים\",\n        \"booksDuration\": \"משך זמן\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"אנשים בבית\",\n        \"lights_on\": \"אורות דולקים\",\n        \"switches_on\": \"מתגים דולקים\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"מנטר\",\n        \"updates\": \"עדכונים\"\n    },\n    \"calibreweb\": {\n        \"books\": \"ספרים\",\n        \"authors\": \"מחברים\",\n        \"categories\": \"קטגוריות\",\n        \"series\": \"סדרות\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"תור\",\n        \"downloadBytesRemaining\": \"נותר\",\n        \"downloadTotalBytes\": \"גודל\",\n        \"downloadSpeed\": \"מהירות\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"סדרות\",\n        \"totalFiles\": \"קבצים\"\n    },\n    \"azuredevops\": {\n        \"result\": \"תוצאה\",\n        \"status\": \"סטטוס\",\n        \"buildId\": \"Build ID\",\n        \"succeeded\": \"הצליח\",\n        \"notStarted\": \"לא התחיל\",\n        \"failed\": \"נכשלו\",\n        \"canceled\": \"בוטל\",\n        \"inProgress\": \"בתהליך\",\n        \"totalPrs\": \"סה\\\"כ PRs\",\n        \"myPrs\": \"PRs שלי\",\n        \"approved\": \"אושרו\"\n    },\n    \"gamedig\": {\n        \"status\": \"סטטוס\",\n        \"online\": \"מחובר\",\n        \"offline\": \"מנותק\",\n        \"name\": \"שם\",\n        \"map\": \"מפה\",\n        \"currentPlayers\": \"שחקנים נוכחיים\",\n        \"players\": \"שחקנים\",\n        \"maxPlayers\": \"שחקנים מקסימליים\",\n        \"bots\": \"בוטים\",\n        \"ping\": \"פינג\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"שגיאות\",\n        \"noRecent\": \"לא עדכני\",\n        \"totalUsed\": \"אחסון בשימוש\"\n    },\n    \"mealie\": {\n        \"recipes\": \"מתכונים\",\n        \"users\": \"משתמשים\",\n        \"categories\": \"קטגוריות\",\n        \"tags\": \"טגיות\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"מוריד\",\n        \"total\": \"סה\\\"כ\",\n        \"running\": \"רצים\",\n        \"stopped\": \"נעצרו\",\n        \"passed\": \"עברו\",\n        \"failed\": \"נכשלו\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"זמן פעילות\",\n        \"cpuLoad\": \"עומס מעבד ממוצע (5ד)\",\n        \"up\": \"למעלה\",\n        \"down\": \"למטה\",\n        \"bytesTx\": \"נשלח\",\n        \"bytesRx\": \"התקבל\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"זמן פעילות\",\n        \"uptime\": \"זמן פעילות\",\n        \"lastDown\": \"זמן השבתה אחרון\",\n        \"downDuration\": \"משך השבתה\",\n        \"sitesUp\": \"אתרים למעלה\",\n        \"sitesDown\": \"אתרים למטה\",\n        \"paused\": \"מושהה\",\n        \"notyetchecked\": \"לא נבדק עדיין\",\n        \"up\": \"למעלה\",\n        \"seemsdown\": \"נראה לא פעיל\",\n        \"down\": \"למטה\",\n        \"unknown\": \"לא ידוע\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"בקולנוע\",\n        \"physicalRelease\": \"הפצה פיזית\",\n        \"digitalRelease\": \"הפצה דיגיטלית\",\n        \"noEventsToday\": \"אין אירועים להיום!\",\n        \"noEventsFound\": \"לא נמצאו אירועים\",\n        \"errorWhenLoadingData\": \"שגיאה בטעינת נתוני לוח שנה\"\n    },\n    \"romm\": {\n        \"platforms\": \"פלטפורמות\",\n        \"totalRoms\": \"משחקים\",\n        \"saves\": \"שמירות\",\n        \"states\": \"מצבים\",\n        \"screenshots\": \"תמונות מסך\",\n        \"totalfilesize\": \"גודל סה\\\"כ\"\n    },\n    \"mailcow\": {\n        \"domains\": \"דומיינים\",\n        \"mailboxes\": \"תיבות דואר\",\n        \"mails\": \"מיילים\",\n        \"storage\": \"אחסון\"\n    },\n    \"netdata\": {\n        \"warnings\": \"התראות\",\n        \"criticals\": \"קריטיים\"\n    },\n    \"plantit\": {\n        \"events\": \"אירועים\",\n        \"plants\": \"צמחים\",\n        \"photos\": \"תמונות\",\n        \"species\": \"מינים\"\n    },\n    \"gitea\": {\n        \"notifications\": \"התראות\",\n        \"issues\": \"נושאים\",\n        \"pulls\": \"בקשות משיכה\",\n        \"repositories\": \"מאגרי מידע\"\n    },\n    \"stash\": {\n        \"scenes\": \"סצנות\",\n        \"scenesPlayed\": \"סצנות שנראו\",\n        \"playCount\": \"סה\\\"כ השמעות\",\n        \"playDuration\": \"משך צפייה\",\n        \"sceneSize\": \"גודל סצנות\",\n        \"sceneDuration\": \"משך סצנות\",\n        \"images\": \"תמונות\",\n        \"imageSize\": \"גודל תמונות\",\n        \"galleries\": \"גלריות\",\n        \"performers\": \"מבצעים\",\n        \"studios\": \"אולפנים\",\n        \"movies\": \"סרטים\",\n        \"tags\": \"תגיות\",\n        \"oCount\": \"O Count\"\n    },\n    \"tandoor\": {\n        \"users\": \"משתמשים\",\n        \"recipes\": \"מתכונים\",\n        \"keywords\": \"מילות מפתח\"\n    },\n    \"homebox\": {\n        \"items\": \"פריטים\",\n        \"totalWithWarranty\": \"עם אחריות\",\n        \"locations\": \"מיקומים\",\n        \"labels\": \"תוויות\",\n        \"users\": \"משתמשים\",\n        \"totalValue\": \"ערך כולל\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"התראות\",\n        \"bans\": \"חסימות\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"מחובר\",\n        \"enabled\": \"מופעל\",\n        \"disabled\": \"מושבת\",\n        \"total\": \"סה\\\"כ\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxied\",\n        \"auth\": \"With Auth\",\n        \"outdated\": \"Outdated\",\n        \"banned\": \"Banned\"\n    },\n    \"myspeed\": {\n        \"ping\": \"פינג\",\n        \"download\": \"הורדה\",\n        \"upload\": \"העלאה\"\n    },\n    \"stocks\": {\n        \"stocks\": \"מניות\",\n        \"loading\": \"טוען\",\n        \"open\": \"פתוח - שוק ארה\\\"ב\",\n        \"closed\": \"סגור - שוק ארה\\\"ב\",\n        \"invalidConfiguration\": \"תצורה לא תקינה\"\n    },\n    \"frigate\": {\n        \"cameras\": \"מצלמות\",\n        \"uptime\": \"זמן פעילות\",\n        \"version\": \"גרסה\"\n    },\n    \"linkwarden\": {\n        \"links\": \"קישורים\",\n        \"collections\": \"אוספים\",\n        \"tags\": \"תגיות\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"לא ממויין\",\n        \"information\": \"מידע\",\n        \"warning\": \"אזהרה\",\n        \"average\": \"ממוצע\",\n        \"high\": \"גבוה\",\n        \"disaster\": \"אסון\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"רכב\",\n        \"vehicles\": \"רכבים\",\n        \"serviceRecords\": \"רשומות שירות\",\n        \"reminders\": \"תזכורות\",\n        \"nextReminder\": \"תזכורת הבאה\",\n        \"none\": \"אין\"\n    },\n    \"vikunja\": {\n        \"projects\": \"פרויקטים פעילים\",\n        \"tasks7d\": \"משימות לסיום השבוע\",\n        \"tasksOverdue\": \"משימות באיחור\",\n        \"tasksInProgress\": \"משימות בעבודה\"\n    },\n    \"headscale\": {\n        \"name\": \"שם\",\n        \"address\": \"כתובת\",\n        \"last_seen\": \"נראה לאחרונה\",\n        \"status\": \"סטטוס\",\n        \"online\": \"מחובר\",\n        \"offline\": \"מנותק\"\n    },\n    \"beszel\": {\n        \"name\": \"שם\",\n        \"systems\": \"מערכות\",\n        \"up\": \"למעלה\",\n        \"down\": \"למטה\",\n        \"paused\": \"מושהה\",\n        \"pending\": \"ממתין\",\n        \"status\": \"סטטוס\",\n        \"updated\": \"מעודכן\",\n        \"cpu\": \"מעבד\",\n        \"memory\": \"זיכרון\",\n        \"disk\": \"דיסק\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"אפליקציות\",\n        \"synced\": \"מסונכרן\",\n        \"outOfSync\": \"לא מסונכרן\",\n        \"healthy\": \"בריא\",\n        \"degraded\": \"פגום\",\n        \"progressing\": \"מתקדם\",\n        \"missing\": \"חסר\",\n        \"suspended\": \"מושהה\"\n    },\n    \"spoolman\": {\n        \"loading\": \"טוען\"\n    },\n    \"gitlab\": {\n        \"groups\": \"קבוצות\",\n        \"issues\": \"נושאים\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"פרוייקטים\"\n    },\n    \"apcups\": {\n        \"status\": \"סטטוס\",\n        \"load\": \"עומס\",\n        \"bcharge\": \"טעינת סוללה\",\n        \"timeleft\": \"זמן שנותר\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"סימניות\",\n        \"favorites\": \"מועדפים\",\n        \"archived\": \"ארכיון\",\n        \"highlights\": \"הדגשות\",\n        \"lists\": \"רשימות\",\n        \"tags\": \"תגיות\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"רשת\",\n        \"connected\": \"מחובר\",\n        \"disconnected\": \"מנותק\",\n        \"updateStatus\": \"עדכן\",\n        \"update_yes\": \"זמין\",\n        \"update_no\": \"מעודכן\",\n        \"downloads\": \"הורדות\",\n        \"uploads\": \"העלאות\",\n        \"sharedFiles\": \"קבצים\"\n    },\n    \"jellystat\": {\n        \"songs\": \"שירים\",\n        \"movies\": \"סרטים\",\n        \"episodes\": \"פרקים\",\n        \"other\": \"אחר\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"שגיאות שירות\",\n        \"hostErrors\": \"שגיאות מארח\"\n    },\n    \"komodo\": {\n        \"total\": \"סה\\\"כ\",\n        \"running\": \"רץ\",\n        \"stopped\": \"עצר\",\n        \"down\": \"למטה\",\n        \"unhealthy\": \"לא בריא\",\n        \"unknown\": \"לא ידוע\",\n        \"servers\": \"שרתים\",\n        \"stacks\": \"ערימות\",\n        \"containers\": \"קונטיינרים\"\n    },\n    \"filebrowser\": {\n        \"available\": \"פנוי\",\n        \"used\": \"בשימוש\",\n        \"total\": \"סה\\\"כ\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"מנויים\",\n        \"thisMonthlyCost\": \"החודש\",\n        \"nextMonthlyCost\": \"חודש הבא\",\n        \"previousMonthlyCost\": \"חודש קודם\",\n        \"nextRenewingSubscription\": \"תשלום הבא\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/hi/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{value, date}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"माह\",\n        \"days\": \"d\",\n        \"hours\": \"घं.\",\n        \"minutes\": \"m\",\n        \"seconds\": \"पल\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Missing Widget Type: {{type}}\",\n        \"api_error\": \"API Error\",\n        \"information\": \"Information\",\n        \"status\": \"Status\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Raw Error\",\n        \"response_data\": \"Response Data\"\n    },\n    \"weather\": {\n        \"current\": \"Current Location\",\n        \"allow\": \"Click to allow\",\n        \"updating\": \"Updating\",\n        \"wait\": \"Please wait\"\n    },\n    \"search\": {\n        \"placeholder\": \"Search…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"MEM\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"load\": \"Load\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Max\",\n        \"uptime\": \"UP\"\n    },\n    \"unifi\": {\n        \"users\": \"Users\",\n        \"uptime\": \"Uptime\",\n        \"days\": \"Days\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Devices\",\n        \"lan_devices\": \"LAN Devices\",\n        \"wlan_devices\": \"WLAN Devices\",\n        \"lan_users\": \"LAN Users\",\n        \"wlan_users\": \"WLAN Users\",\n        \"up\": \"UP\",\n        \"down\": \"DOWN\",\n        \"wait\": \"Please wait\",\n        \"empty_data\": \"Subsystem status unknown\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Running\",\n        \"offline\": \"Offline\",\n        \"error\": \"Error\",\n        \"unknown\": \"Unknown\",\n        \"healthy\": \"Healthy\",\n        \"starting\": \"Starting\",\n        \"unhealthy\": \"Unhealthy\",\n        \"not_found\": \"Not Found\",\n        \"exited\": \"Exited\",\n        \"partial\": \"Partial\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"Ping\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP status\",\n        \"error\": \"Error\",\n        \"response\": \"Response\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"emby\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Online\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Production\",\n        \"battery_soc\": \"Battery\",\n        \"grid_power\": \"Grid\",\n        \"home_power\": \"Consumption\",\n        \"charge_power\": \"Charger\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Subscriptions\",\n        \"unread\": \"Unread\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Unconfigured\",\n        \"connectionStatusConnecting\": \"Connecting\",\n        \"connectionStatusAuthenticating\": \"Authenticating\",\n        \"connectionStatusPendingDisconnect\": \"Pending Disconnect\",\n        \"connectionStatusDisconnecting\": \"Disconnecting\",\n        \"connectionStatusDisconnected\": \"Disconnected\",\n        \"connectionStatusConnected\": \"Connected\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"Max. Down\",\n        \"maxUp\": \"Max. Up\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"Received\",\n        \"sent\": \"Sent\",\n        \"externalIPAddress\": \"Ext. IP\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Current requests\",\n        \"requests_failed\": \"Failed requests\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Total Observed\",\n        \"diffsDetected\": \"Diffs Detected\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Shows\",\n        \"recordings\": \"Recordings\",\n        \"scheduled\": \"Scheduled\",\n        \"passes\": \"Passes\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"Check Plex Connection\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Connected APs\",\n        \"activeUser\": \"Active devices\",\n        \"alerts\": \"Alerts\",\n        \"connectedGateways\": \"Connected gateways\",\n        \"connectedSwitches\": \"Connected switches\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Rate\",\n        \"remaining\": \"Remaining\",\n        \"downloaded\": \"Downloaded\"\n    },\n    \"plex\": {\n        \"streams\": \"Active Streams\",\n        \"albums\": \"Albums\",\n        \"movies\": \"Movies\",\n        \"tv\": \"TV Shows\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"Queue\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Active\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU Usage\",\n        \"memUsage\": \"MEM Usage\",\n        \"systemTempC\": \"System Temp\",\n        \"poolUsage\": \"Pool Usage\",\n        \"volumeUsage\": \"Volume Usage\",\n        \"invalid\": \"Invalid\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"Missing\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"Artists\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"Books\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Missing Episodes\",\n        \"missingMovies\": \"Missing Movies\"\n    },\n    \"ombi\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"New Devices\",\n        \"down_alerts\": \"Down Alerts\"\n    },\n    \"pihole\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"blocked_percent\": \"Blocked %\",\n        \"gravity\": \"Gravity\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"Filtered\",\n        \"latency\": \"Latency\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Non-Downloaded\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Downloaded & Read\",\n        \"downloadedunread\": \"Downloaded & Unread\",\n        \"nondownloadedread\": \"Non-Downloaded & Read\",\n        \"nondownloadedunread\": \"Non-Downloaded & Unread\"\n    },\n    \"tailscale\": {\n        \"address\": \"Address\",\n        \"expires\": \"Expires\",\n        \"never\": \"Never\",\n        \"last_seen\": \"Last Seen\",\n        \"now\": \"Now\",\n        \"years\": \"{{number}}y\",\n        \"weeks\": \"{{number}}w\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Ago\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"Success\",\n        \"totalServerFailure\": \"Failures\",\n        \"totalNxDomain\": \"NX Domains\",\n        \"totalRefused\": \"Refused\",\n        \"totalAuthoritative\": \"Authoritative\",\n        \"totalRecursive\": \"Recursive\",\n        \"totalCached\": \"Cached\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"Dropped\",\n        \"totalClients\": \"Clients\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Processed\",\n        \"errored\": \"Errored\",\n        \"saved\": \"Saved\"\n    },\n    \"traefik\": {\n        \"routers\": \"Routers\",\n        \"services\": \"Services\",\n        \"middleware\": \"Middleware\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Please Wait\"\n    },\n    \"npm\": {\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Configure one or more crypto currencies to track\",\n        \"1hour\": \"1 Hour\",\n        \"1day\": \"1 Day\",\n        \"7days\": \"7 Days\",\n        \"30days\": \"30 Days\"\n    },\n    \"gotify\": {\n        \"apps\": \"Applications\",\n        \"clients\": \"Clients\",\n        \"messages\": \"Messages\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indexers\",\n        \"numberOfGrabs\": \"Grabs\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"Fail Grabs\",\n        \"numberOfFailQueries\": \"Fail Queries\"\n    },\n    \"jackett\": {\n        \"configured\": \"Configured\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sessions\",\n        \"numConnections\": \"Connections\",\n        \"dataRelayed\": \"Relayed\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"Posts\",\n        \"domain_count\": \"Domains\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Players\",\n        \"version\": \"Version\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Read\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"Logins (24h)\",\n        \"failedLoginsLast24H\": \"Failed Logins (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temp\",\n        \"warn\": \"Warn\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Crit\",\n        \"read\": \"Read\",\n        \"write\": \"Write\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Bookmark\",\n        \"service\": \"Service\",\n        \"search\": \"Search\",\n        \"custom\": \"Custom\",\n        \"visit\": \"Visit\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Suggestion\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Sunny\",\n        \"0-night\": \"Clear\",\n        \"1-day\": \"Mainly Sunny\",\n        \"1-night\": \"Mainly Clear\",\n        \"2-day\": \"Partly Cloudy\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"Cloudy\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"Foggy\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"Light Drizzle\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"Drizzle\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"Heavy Drizzle\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"Light Freezing Drizzle\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"Freezing Drizzle\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"Light Rain\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"Rain\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Heavy Rain\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Freezing Rain\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Light Snow\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Snow\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Heavy Snow\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Snow Grains\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Light Showers\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Showers\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Heavy Showers\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Snow Showers\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"Thunderstorm\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Thunderstorm With Hail\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"System\",\n        \"updates\": \"Updates\",\n        \"update_available\": \"Update Available\",\n        \"up_to_date\": \"Up to Date\",\n        \"child_bridges\": \"Child Bridges\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"New\",\n        \"up\": \"Up\",\n        \"grace\": \"In Grace Period\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Last Ping\",\n        \"never\": \"No pings yet\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Scanned\",\n        \"containers_updated\": \"Updated\",\n        \"containers_failed\": \"Failed\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Rejected\",\n        \"filters\": \"Filters\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Videos\",\n        \"channels\": \"Channels\",\n        \"playlists\": \"Playlists\"\n    },\n    \"truenas\": {\n        \"load\": \"System Load\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Speed\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Public IP\",\n        \"region\": \"Region\",\n        \"country\": \"Country\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tuners\",\n        \"channelNumber\": \"Channel\",\n        \"channelNetwork\": \"Network\",\n        \"signalStrength\": \"Strength\",\n        \"signalQuality\": \"Quality\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Client\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Inbox\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Battery Charge\",\n        \"ups_load\": \"UPS Load\",\n        \"ups_status\": \"UPS Status\",\n        \"online\": \"Online\",\n        \"on_battery\": \"On Battery\",\n        \"low_battery\": \"Low Battery\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"No Device Data Received\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"CPU Load\",\n        \"memoryUsed\": \"Memory Used\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Leases\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"All Streams\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG Channels\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Today\",\n        \"absolutePower\": \"Power\",\n        \"relativePower\": \"Power %\",\n        \"limit\": \"Limit\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Active Memory\",\n        \"wanUpload\": \"WAN Upload\",\n        \"wanDownload\": \"WAN Download\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Printer State\",\n        \"print_status\": \"Print Status\",\n        \"print_progress\": \"Progress\",\n        \"layers\": \"Layers\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Tool temp\",\n        \"temp_bed\": \"Bed temp\",\n        \"job_completion\": \"Completion\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Origin IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Load Avg\",\n        \"memory\": \"Mem Usage\",\n        \"wanStatus\": \"WAN Status\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Disk Usage\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datastore\",\n        \"failed_tasks_24h\": \"Failed Tasks 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memory\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Storage\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Incident\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Archives\",\n        \"chapters\": \"Chapters\",\n        \"categories\": \"Categories\"\n    },\n    \"komga\": {\n        \"libraries\": \"Libraries\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Issues\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"People\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Time\"\n    },\n    \"firefly\": {\n        \"networth\": \"Net Worth\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Dashboards\",\n        \"datasources\": \"Data Sources\",\n        \"totalalerts\": \"Total Alerts\",\n        \"alertstriggered\": \"Alerts Triggered\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Cpu Load\",\n        \"memoryusage\": \"Memory Usage\",\n        \"freespace\": \"Free Space\",\n        \"activeusers\": \"Active Users\",\n        \"numfiles\": \"Files\",\n        \"numshares\": \"Shared Items\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Size\",\n        \"lastrun\": \"Last Run\",\n        \"nextrun\": \"Next Run\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Active Workers\",\n        \"total_workers\": \"Total Workers\",\n        \"records_total\": \"Queue Length\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servers\",\n        \"nodes\": \"Nodes\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Targets Up\",\n        \"targets_down\": \"Targets Down\",\n        \"targets_total\": \"Total Targets\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"One year\",\n        \"gross_percent_max\": \"All time\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasts\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Duration\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"People Home\",\n        \"lights_on\": \"Lights On\",\n        \"switches_on\": \"Switches On\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Monitoring\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Authors\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Result\",\n        \"status\": \"Status\",\n        \"buildId\": \"Build ID\",\n        \"succeeded\": \"Succeeded\",\n        \"notStarted\": \"Not Started\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Canceled\",\n        \"inProgress\": \"In Progress\",\n        \"totalPrs\": \"Total PRs\",\n        \"myPrs\": \"My PRs\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Name\",\n        \"map\": \"Map\",\n        \"currentPlayers\": \"Current players\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Max players\",\n        \"bots\": \"Bots\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Errors\",\n        \"noRecent\": \"Out of Date\",\n        \"totalUsed\": \"Used Storage\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Recipes\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Tags\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Downloading\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"CPU Load Avg (5m)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Transmitted\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Last Downtime\",\n        \"downDuration\": \"Downtime Duration\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Not Yet Checked\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Seems Down\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"In cinemas\",\n        \"physicalRelease\": \"Physical release\",\n        \"digitalRelease\": \"Digital release\",\n        \"noEventsToday\": \"No events for today!\",\n        \"noEventsFound\": \"No events found\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platforms\",\n        \"totalRoms\": \"Games\",\n        \"saves\": \"Saves\",\n        \"states\": \"States\",\n        \"screenshots\": \"Screenshots\",\n        \"totalfilesize\": \"Total Size\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Mailboxes\",\n        \"mails\": \"Mails\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Warnings\",\n        \"criticals\": \"Criticals\"\n    },\n    \"plantit\": {\n        \"events\": \"Events\",\n        \"plants\": \"Plants\",\n        \"photos\": \"Photos\",\n        \"species\": \"Species\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Notifications\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Pull Requests\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Scenes\",\n        \"scenesPlayed\": \"Scenes Played\",\n        \"playCount\": \"Total Plays\",\n        \"playDuration\": \"Time Watched\",\n        \"sceneSize\": \"Scenes Size\",\n        \"sceneDuration\": \"Scenes Duration\",\n        \"images\": \"Images\",\n        \"imageSize\": \"Images Size\",\n        \"galleries\": \"Galleries\",\n        \"performers\": \"Performers\",\n        \"studios\": \"Studios\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O Count\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Keywords\"\n    },\n    \"homebox\": {\n        \"items\": \"Items\",\n        \"totalWithWarranty\": \"With Warranty\",\n        \"locations\": \"Locations\",\n        \"labels\": \"Labels\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Total Value\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Bans\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxied\",\n        \"auth\": \"With Auth\",\n        \"outdated\": \"Outdated\",\n        \"banned\": \"Banned\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Stocks\",\n        \"loading\": \"Loading\",\n        \"open\": \"Open - US Market\",\n        \"closed\": \"Closed - US Market\",\n        \"invalidConfiguration\": \"Invalid Configuration\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Cameras\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Links\",\n        \"collections\": \"Collections\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Not classified\",\n        \"information\": \"Information\",\n        \"warning\": \"Warning\",\n        \"average\": \"Average\",\n        \"high\": \"High\",\n        \"disaster\": \"Disaster\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vehicle\",\n        \"vehicles\": \"Vehicles\",\n        \"serviceRecords\": \"Service Records\",\n        \"reminders\": \"Reminders\",\n        \"nextReminder\": \"Next Reminder\",\n        \"none\": \"None\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Active Projects\",\n        \"tasks7d\": \"Tasks Due This Week\",\n        \"tasksOverdue\": \"Overdue Tasks\",\n        \"tasksInProgress\": \"Tasks In Progress\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Systems\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Disk\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Apps\",\n        \"synced\": \"Synced\",\n        \"outOfSync\": \"Out Of Sync\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Degraded\",\n        \"progressing\": \"Progressing\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Suspended\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Groups\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Projects\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bookmarks\",\n        \"favorites\": \"Favorites\",\n        \"archived\": \"Archived\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Lists\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Other\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/hr/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"mj\",\n        \"days\": \"dan(a)\",\n        \"hours\": \"h\",\n        \"minutes\": \"min\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Nedostajuća vrsta widgeta: {{type}}\",\n        \"api_error\": \"API greška\",\n        \"information\": \"Informacije\",\n        \"status\": \"Stanje\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Raw greška\",\n        \"response_data\": \"Podaci odgovora\"\n    },\n    \"weather\": {\n        \"current\": \"Trenutačna lokacija\",\n        \"allow\": \"Pritisni za dozvoljavanje\",\n        \"updating\": \"Aktualiziranje\",\n        \"wait\": \"Pričekaj\"\n    },\n    \"search\": {\n        \"placeholder\": \"Traži …\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"MEM\",\n        \"total\": \"Ukupno\",\n        \"free\": \"Slobodno\",\n        \"used\": \"Korišteno\",\n        \"load\": \"Opterećenje\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Maks.\",\n        \"uptime\": \"Vrijeme rada\"\n    },\n    \"unifi\": {\n        \"users\": \"Korisnici\",\n        \"uptime\": \"Vrijeme rada\",\n        \"days\": \"Dani\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Uređaji\",\n        \"lan_devices\": \"LAN uređaji\",\n        \"wlan_devices\": \"WLAN uređaji\",\n        \"lan_users\": \"LAN korisnici\",\n        \"wlan_users\": \"WLAN korisnici\",\n        \"up\": \"AKTIVNO\",\n        \"down\": \"NEDOSTUPNO\",\n        \"wait\": \"Pričekaj\",\n        \"empty_data\": \"Stanje podsustava nepoznato\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Pokrenuto\",\n        \"offline\": \"Offline\",\n        \"error\": \"Greška\",\n        \"unknown\": \"Nepoznato\",\n        \"healthy\": \"Funkcionalno\",\n        \"starting\": \"Pokretanje\",\n        \"unhealthy\": \"Nefunkcionalno\",\n        \"not_found\": \"Nepronađeno\",\n        \"exited\": \"Zatoreno\",\n        \"partial\": \"Djelomično\"\n    },\n    \"ping\": {\n        \"error\": \"Greška\",\n        \"ping\": \"Ping\",\n        \"down\": \"Nedostupno\",\n        \"up\": \"Dostupno\",\n        \"not_available\": \"Nije dostupno\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"Stanje HTTP-a\",\n        \"error\": \"Greška\",\n        \"response\": \"Odgovor\",\n        \"down\": \"Neaktivno\",\n        \"up\": \"Aktivno\",\n        \"not_available\": \"Nije dostupno\"\n    },\n    \"emby\": {\n        \"playing\": \"Reprodukcija\",\n        \"transcoding\": \"Prekodiranje\",\n        \"bitrate\": \"Stopa bitova\",\n        \"no_active\": \"Nema aktivnih prijenosa\",\n        \"movies\": \"Filmovi\",\n        \"series\": \"Serije\",\n        \"episodes\": \"Epizode\",\n        \"songs\": \"Pjesme\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Reprodukcija u tijeku\",\n        \"transcoding\": \"Prekodiranje\",\n        \"bitrate\": \"Stopa bitova\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Filmovi\",\n        \"series\": \"Serije\",\n        \"episodes\": \"Epizode\",\n        \"songs\": \"Pjesme\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Online\",\n        \"total\": \"Ukupno\",\n        \"unknown\": \"Nepoznato\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Proizvodnja\",\n        \"battery_soc\": \"Baterija\",\n        \"grid_power\": \"Raspored\",\n        \"home_power\": \"Potrošnja\",\n        \"charge_power\": \"Punjač\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Preuzimanje\",\n        \"upload\": \"Prijenos\",\n        \"leech\": \"Korištenje tuđeg sadržaja\",\n        \"seed\": \"Prenošenje preuzetog sadržaja\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Pretplate\",\n        \"unread\": \"Nepročitano\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Stanje\",\n        \"connectionStatusUnconfigured\": \"Nekonfigurirano\",\n        \"connectionStatusConnecting\": \"Povezivanje\",\n        \"connectionStatusAuthenticating\": \"Autentificiranje\",\n        \"connectionStatusPendingDisconnect\": \"Odspajanje u tijeku\",\n        \"connectionStatusDisconnecting\": \"Odspajanje\",\n        \"connectionStatusDisconnected\": \"Odspojeno\",\n        \"connectionStatusConnected\": \"Povezano\",\n        \"uptime\": \"Vrijeme rada\",\n        \"maxDown\": \"Maksimum preuzimanja\",\n        \"maxUp\": \"Maksimum prijenosa\",\n        \"down\": \"Neaktivno\",\n        \"up\": \"Aktivno\",\n        \"received\": \"Primljeno\",\n        \"sent\": \"Poslano\",\n        \"externalIPAddress\": \"Eksterna IP adresa\",\n        \"externalIPv6Address\": \"Vanjs. IPv6\",\n        \"externalIPv6Prefix\": \"Vanjs. IPv6 prefiks\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Glavne grane\",\n        \"requests\": \"Aktualni zahtjevi\",\n        \"requests_failed\": \"Neuspjeli zahtjevi\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Ukupno promatrano\",\n        \"diffsDetected\": \"Otkrivene razlike\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Emisije\",\n        \"recordings\": \"Snimanja\",\n        \"scheduled\": \"Planirano\",\n        \"passes\": \"Prolazi\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Reprodukcija\",\n        \"transcoding\": \"Prekodiranje\",\n        \"bitrate\": \"Stopa bitova\",\n        \"no_active\": \"Nema aktivnih prijenosa\",\n        \"plex_connection_error\": \"Provjeri Plex vezu\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Prekodiranja\",\n        \"directplay\": \"Izravna reprodukcija\",\n        \"bitrate\": \"Stopa bitova\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Povezani AP-ovi\",\n        \"activeUser\": \"Aktivni uređaji\",\n        \"alerts\": \"Upozorenja\",\n        \"connectedGateways\": \"Povezani pristupnici\",\n        \"connectedSwitches\": \"Povezani prekidači\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Stopa\",\n        \"remaining\": \"Preostalo\",\n        \"downloaded\": \"Preuzeto\"\n    },\n    \"plex\": {\n        \"streams\": \"Aktivni prijenosi\",\n        \"albums\": \"Albumi\",\n        \"movies\": \"Filmovi\",\n        \"tv\": \"TV emisije\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Stopa\",\n        \"queue\": \"Red čekanja\",\n        \"timeleft\": \"Preostalo vrijeme\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Aktivno\",\n        \"upload\": \"Prenesi\",\n        \"download\": \"Preuzmi\"\n    },\n    \"transmission\": {\n        \"download\": \"Preuzimanje\",\n        \"upload\": \"Prijenos\",\n        \"leech\": \"Korištenje tuđeg sadržaja\",\n        \"seed\": \"Prenošenje preuzetog sadržaja\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Preuzmi\",\n        \"upload\": \"Prenesi\",\n        \"leech\": \"Korištenje tuđeg sadržaja\",\n        \"seed\": \"Prenošenje preuzetog sadržaja\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"Korištenje procesora\",\n        \"memUsage\": \"Korištenje memorije\",\n        \"systemTempC\": \"Temperatura sustava\",\n        \"poolUsage\": \"Korištenje memorijskog skupa\",\n        \"volumeUsage\": \"Korištenje jedinice memorije\",\n        \"invalid\": \"Neispravno\"\n    },\n    \"deluge\": {\n        \"download\": \"Preuzmi\",\n        \"upload\": \"Prenesi\",\n        \"leech\": \"Korištenje tuđeg sadržaja\",\n        \"seed\": \"Prenošenje preuzetog sadržaja\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Bajtovi pogodaka predmemorije\",\n        \"cachemissbytes\": \"Bajtovi promašaja predmemorije\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Preuzmi\",\n        \"upload\": \"Prenesi\",\n        \"leech\": \"Korištenje tuđeg sadržaja\",\n        \"seed\": \"Prenošenje preuzetog sadržaja\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Zatraženo\",\n        \"queued\": \"U redu čekanja\",\n        \"series\": \"Serije\",\n        \"queue\": \"Red čekanja\",\n        \"unknown\": \"Nepoznato\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Željeno\",\n        \"missing\": \"Nedostaje\",\n        \"queued\": \"U redu čekanja\",\n        \"movies\": \"Filmovi\",\n        \"queue\": \"Red čekanja\",\n        \"unknown\": \"Nepoznato\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Željeno\",\n        \"queued\": \"U redu čekanja\",\n        \"artists\": \"Umjetnici\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Željeno\",\n        \"queued\": \"U redu čekanja\",\n        \"books\": \"Knjige\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Nedostajuće epizode\",\n        \"missingMovies\": \"Nedostajući filmovi\"\n    },\n    \"ombi\": {\n        \"pending\": \"U tijeku\",\n        \"approved\": \"Odobreno\",\n        \"available\": \"Dostupno\"\n    },\n    \"seerr\": {\n        \"pending\": \"Na čekanju\",\n        \"approved\": \"Odobreno\",\n        \"available\": \"Dostupno\",\n        \"completed\": \"Dovršeno\",\n        \"processing\": \"Obrada\",\n        \"issues\": \"Otvoreni problemi\"\n    },\n    \"netalertx\": {\n        \"total\": \"Ukupno\",\n        \"connected\": \"Spojeno\",\n        \"new_devices\": \"Novi uređaji\",\n        \"down_alerts\": \"Obavijesti o nedostupnosti\"\n    },\n    \"pihole\": {\n        \"queries\": \"Upiti\",\n        \"blocked\": \"Blokirano\",\n        \"blocked_percent\": \"Blokirano %\",\n        \"gravity\": \"Gravitacija\"\n    },\n    \"adguard\": {\n        \"queries\": \"Upiti\",\n        \"blocked\": \"Blokirano\",\n        \"filtered\": \"Filtrirano\",\n        \"latency\": \"Kašnjenje\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Prijenos\",\n        \"download\": \"Preuzimanje\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Pokrenuto\",\n        \"stopped\": \"Prekinuto\",\n        \"total\": \"Ukupno\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Preuzeto\",\n        \"nondownload\": \"Nepreuzeto\",\n        \"read\": \"Pročitano\",\n        \"unread\": \"Nepročitano\",\n        \"downloadedread\": \"Preuzeto i pročitano\",\n        \"downloadedunread\": \"Preuzeto i nepročitano\",\n        \"nondownloadedread\": \"Nepreuzeto i pročitano\",\n        \"nondownloadedunread\": \"Nepreuzeto i nepročitano\"\n    },\n    \"tailscale\": {\n        \"address\": \"Adresa\",\n        \"expires\": \"Isteče\",\n        \"never\": \"Nikada\",\n        \"last_seen\": \"Zadnje viđeno\",\n        \"now\": \"Sada\",\n        \"years\": \"{{number}} god\",\n        \"weeks\": \"{{number}} tj\",\n        \"days\": \"{{number}} dan(a)\",\n        \"hours\": \"{{number}} h\",\n        \"minutes\": \"{{number}} min\",\n        \"seconds\": \"{{number}} s\",\n        \"ago\": \"Prije {{value}}\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Upiti\",\n        \"totalNoError\": \"Uspješno\",\n        \"totalServerFailure\": \"Neuspješno\",\n        \"totalNxDomain\": \"NX domene\",\n        \"totalRefused\": \"Odbijeno\",\n        \"totalAuthoritative\": \"Autoritativan\",\n        \"totalRecursive\": \"Rekurzivno\",\n        \"totalCached\": \"Predmemorirano\",\n        \"totalBlocked\": \"Blokirano\",\n        \"totalDropped\": \"Odbačeno\",\n        \"totalClients\": \"Klijenti\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Red čekanja\",\n        \"processed\": \"Obrađeno\",\n        \"errored\": \"S greškom\",\n        \"saved\": \"Spremljeno\"\n    },\n    \"traefik\": {\n        \"routers\": \"Ruteri\",\n        \"services\": \"Usluge\",\n        \"middleware\": \"Posrednički softver\"\n    },\n    \"trilium\": {\n        \"version\": \"Verzija\",\n        \"notesCount\": \"Bilješke\",\n        \"dbSize\": \"Veličina baze podataka\",\n        \"unknown\": \"Nepoznato\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"Nema aktivnih prijenosa\",\n        \"please_wait\": \"Pričekaj\"\n    },\n    \"npm\": {\n        \"enabled\": \"Aktivirano\",\n        \"disabled\": \"Deaktivirano\",\n        \"total\": \"Ukupno\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Konfiguriraj jednu ili više kripto valuta za praćenje\",\n        \"1hour\": \"1 sat\",\n        \"1day\": \"1 dan\",\n        \"7days\": \"7 dana\",\n        \"30days\": \"30 dana\"\n    },\n    \"gotify\": {\n        \"apps\": \"Programi\",\n        \"clients\": \"Klijenti\",\n        \"messages\": \"Poruke\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indeksatori\",\n        \"numberOfGrabs\": \"Dohvaćanja\",\n        \"numberOfQueries\": \"Upiti\",\n        \"numberOfFailGrabs\": \"Neuspjela dohvaćanja\",\n        \"numberOfFailQueries\": \"Neuspjeli upiti\"\n    },\n    \"jackett\": {\n        \"configured\": \"Konfigurirano\",\n        \"errored\": \"S greškom\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sesije\",\n        \"numConnections\": \"Veze\",\n        \"dataRelayed\": \"Proslijeđeno\",\n        \"transferRate\": \"Stopa\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Korisnici\",\n        \"status_count\": \"Objave\",\n        \"domain_count\": \"Domene\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Željeno\",\n        \"queued\": \"U redu čekanja\",\n        \"series\": \"Serije\"\n    },\n    \"minecraft\": {\n        \"players\": \"Igrači\",\n        \"version\": \"Verzija\",\n        \"status\": \"Stanje\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Pročitano\",\n        \"unread\": \"Nepročitano\"\n    },\n    \"authentik\": {\n        \"users\": \"Korisnici\",\n        \"loginsLast24H\": \"Prijave (24 h)\",\n        \"failedLoginsLast24H\": \"Neuspjele prijave (24 h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"Linux kontejner\",\n        \"vms\": \"Virtualni uređaji\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Opterećenje\",\n        \"wait\": \"Pričekaj\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temperatura\",\n        \"warn\": \"Upozori\",\n        \"uptime\": \"AKTIVNO\",\n        \"total\": \"Ukupno\",\n        \"free\": \"Slobodno\",\n        \"used\": \"Korišteno\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Krritično\",\n        \"read\": \"Pročitano\",\n        \"write\": \"Piši\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Memorija\",\n        \"swap\": \"Virtualna memorija\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Straničnik\",\n        \"service\": \"Usluga\",\n        \"search\": \"Traži\",\n        \"custom\": \"Prilagođeno\",\n        \"visit\": \"Posjeti\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Prijedlog\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Sunčano\",\n        \"0-night\": \"Vedro\",\n        \"1-day\": \"Pretežno sunčano\",\n        \"1-night\": \"Pretežno verdo\",\n        \"2-day\": \"Djelimično oblačno\",\n        \"2-night\": \"Djelimično oblačno\",\n        \"3-day\": \"Oblačno\",\n        \"3-night\": \"Oblačno\",\n        \"45-day\": \"Maglovito\",\n        \"45-night\": \"Maglovito\",\n        \"48-day\": \"Maglovito\",\n        \"48-night\": \"Maglovito\",\n        \"51-day\": \"Laka rosulja\",\n        \"51-night\": \"Laka rosulja\",\n        \"53-day\": \"Rosulja\",\n        \"53-night\": \"Rosulja\",\n        \"55-day\": \"Jaka rosulja\",\n        \"55-night\": \"Jaka rosulja\",\n        \"56-day\": \"Laka ledena rosulja\",\n        \"56-night\": \"Laka ledena rosulja\",\n        \"57-day\": \"Ledena rosulja\",\n        \"57-night\": \"Ledena rosulja\",\n        \"61-day\": \"Laka kiša\",\n        \"61-night\": \"Slaba kiša\",\n        \"63-day\": \"Kiša\",\n        \"63-night\": \"Kiša\",\n        \"65-day\": \"Jaka kiša\",\n        \"65-night\": \"Jaka kiša\",\n        \"66-day\": \"Ledena kiša\",\n        \"66-night\": \"Ledena kiša\",\n        \"67-day\": \"Ledena kiša\",\n        \"67-night\": \"Ledena kiša\",\n        \"71-day\": \"Laki snijeg\",\n        \"71-night\": \"Susnježica\",\n        \"73-day\": \"Snijeg\",\n        \"73-night\": \"Snijeg\",\n        \"75-day\": \"Jaki snijeg\",\n        \"75-night\": \"Jaki snijeg\",\n        \"77-day\": \"Zrnati snijeg\",\n        \"77-night\": \"Zrnati snijeg\",\n        \"80-day\": \"Laki pljuskovi\",\n        \"80-night\": \"Laki pljuskovi\",\n        \"81-day\": \"Pljuskovi\",\n        \"81-night\": \"Pljuskovi\",\n        \"82-day\": \"Jaki pljuskovi\",\n        \"82-night\": \"Jaki pljuskovi\",\n        \"85-day\": \"Snježni pljuskovi\",\n        \"85-night\": \"Snježni pljuskovi\",\n        \"86-day\": \"Snježni pljuskovi\",\n        \"86-night\": \"Snježni pljuskovi\",\n        \"95-day\": \"Oluja\",\n        \"95-night\": \"Oluja\",\n        \"96-day\": \"Oluja s tučom\",\n        \"96-night\": \"Oluja s tučom\",\n        \"99-day\": \"Oluja s tučom\",\n        \"99-night\": \"Oluja s tučom\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Sustav\",\n        \"updates\": \"Aktualiziranja\",\n        \"update_available\": \"Dostupna je nova verzija\",\n        \"up_to_date\": \"Aktualno\",\n        \"child_bridges\": \"Podređeni mosotvi\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Aktivno\",\n        \"pending\": \"U tijeku\",\n        \"down\": \"Neaktivno\",\n        \"ok\": \"U redu\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Novo\",\n        \"up\": \"Aktivno\",\n        \"grace\": \"U razdoblju odgode\",\n        \"down\": \"Neaktivno\",\n        \"paused\": \"Zaustavljeno\",\n        \"status\": \"Stanje\",\n        \"last_ping\": \"Zadnji ping\",\n        \"never\": \"Još nema pingova\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Skenirano\",\n        \"containers_updated\": \"Aktualizirano\",\n        \"containers_failed\": \"Neuspjelo\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Odobreno\",\n        \"rejectedPushes\": \"Odbijeno\",\n        \"filters\": \"Filtri\",\n        \"indexers\": \"Indeksatori\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Red čekanja\",\n        \"videos\": \"Videa\",\n        \"channels\": \"Kanali\",\n        \"playlists\": \"Playliste\"\n    },\n    \"truenas\": {\n        \"load\": \"Opterećenje sustava\",\n        \"uptime\": \"Vrijeme rada\",\n        \"alerts\": \"Upozorenja\"\n    },\n    \"pyload\": {\n        \"speed\": \"Brzina\",\n        \"active\": \"Aktivno\",\n        \"queue\": \"Red čekanja\",\n        \"total\": \"Ukupno\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Javni IP\",\n        \"region\": \"Regija\",\n        \"country\": \"Zemlja\",\n        \"port_forwarded\": \"Port proslijeđen\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Kanali\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tuneri\",\n        \"channelNumber\": \"Kanal\",\n        \"channelNetwork\": \"Mreža\",\n        \"signalStrength\": \"Jačina\",\n        \"signalQuality\": \"Kvaliteta\",\n        \"symbolQuality\": \"Kvaliteta\",\n        \"networkRate\": \"Stopa bitova\",\n        \"clientIP\": \"Klijent\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Uspjelo\",\n        \"failed\": \"Neuspjelo\",\n        \"unknown\": \"Nepoznato\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Ulazni sandučić\",\n        \"total\": \"Ukupno\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Organizacije\",\n        \"sites\": \"Web-stranice\",\n        \"resources\": \"Resursi\",\n        \"targets\": \"Ciljevi\",\n        \"traffic\": \"Promet\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Napunjenost baterije\",\n        \"ups_load\": \"UPS opterećenje\",\n        \"ups_status\": \"UPS stanje\",\n        \"online\": \"Online\",\n        \"on_battery\": \"Koristi bateriju\",\n        \"low_battery\": \"Slaba baterija\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Pričekaj\",\n        \"no_devices\": \"Podaci uređaja nisu primljeni\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"CPU opterećenje\",\n        \"memoryUsed\": \"Korištena memorija\",\n        \"uptime\": \"Vrijeme rada\",\n        \"numberOfLeases\": \"Unajmljivanja\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Svi prijenosi\",\n        \"streams_active\": \"Aktivni prijenosi\",\n        \"streams_xepg\": \"XEPG kanali\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Danas\",\n        \"absolutePower\": \"Snaga\",\n        \"relativePower\": \"Postotak snage\",\n        \"limit\": \"Ograničenje\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU opterećenje\",\n        \"memory\": \"Aktivna memorija\",\n        \"wanUpload\": \"WAN prijenos\",\n        \"wanDownload\": \"WAN preuzimanje\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Stanje pisača\",\n        \"print_status\": \"Stanje ispisa\",\n        \"print_progress\": \"Napredak\",\n        \"layers\": \"Slojevi\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Stanje\",\n        \"temp_tool\": \"Temperatura alata\",\n        \"temp_bed\": \"Temperatura platforme\",\n        \"job_completion\": \"Dovršenost\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"IP izvora\",\n        \"status\": \"Stanje\"\n    },\n    \"pfsense\": {\n        \"load\": \"Prosječno opterećenje\",\n        \"memory\": \"Korištenje memorije\",\n        \"wanStatus\": \"Stanje WAN-a\",\n        \"up\": \"Aktivno\",\n        \"down\": \"Neaktivno\",\n        \"temp\": \"Temperatura\",\n        \"disk\": \"Korištenje diska\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Spremište podataka\",\n        \"failed_tasks_24h\": \"Neuspjeli zadaci 24 h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memorija\"\n    },\n    \"immich\": {\n        \"users\": \"Korisnici\",\n        \"photos\": \"Fotografije\",\n        \"videos\": \"Videa\",\n        \"storage\": \"Spremište\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Dostupne stranice\",\n        \"down\": \"Nedostupne stranice\",\n        \"uptime\": \"Vrijeme rada\",\n        \"incident\": \"Slučaj\",\n        \"m\": \"min\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Serije\",\n        \"archives\": \"Arhive\",\n        \"chapters\": \"Poglavlja\",\n        \"categories\": \"Kategorije\"\n    },\n    \"komga\": {\n        \"libraries\": \"Biblioteke\",\n        \"series\": \"Serije\",\n        \"books\": \"Knjige\"\n    },\n    \"diskstation\": {\n        \"days\": \"Dani\",\n        \"uptime\": \"Vrijeme rada\",\n        \"volumeAvailable\": \"Dostupno\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Kanali\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Serije\",\n        \"issues\": \"Problemi\",\n        \"wanted\": \"Željeno\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albumi\",\n        \"photos\": \"Fotografije\",\n        \"videos\": \"Videa\",\n        \"people\": \"Osobe\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Red čekanja\",\n        \"processing\": \"Obrada\",\n        \"processed\": \"Obrađeno\",\n        \"time\": \"Vrijeme\"\n    },\n    \"firefly\": {\n        \"networth\": \"Neto vrijednost\",\n        \"budget\": \"Budžet\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Pregledne ploče\",\n        \"datasources\": \"Izvori podataka\",\n        \"totalalerts\": \"Ukupni broj upozorenja\",\n        \"alertstriggered\": \"Aktivirana upozorenja\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Cpu opterećenje\",\n        \"memoryusage\": \"Korištenje memorije\",\n        \"freespace\": \"Slobodna memorija\",\n        \"activeusers\": \"Aktivni korisnici\",\n        \"numfiles\": \"Datoteke\",\n        \"numshares\": \"Dijeljene stavke\"\n    },\n    \"kopia\": {\n        \"status\": \"Stanje\",\n        \"size\": \"Veličina\",\n        \"lastrun\": \"Zadnje pokretanje\",\n        \"nextrun\": \"Sljedeće pokretanje\",\n        \"failed\": \"Neuspjelo\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Aktivni radnici\",\n        \"total_workers\": \"Ukupni broj radnika\",\n        \"records_total\": \"Količina zapisa u redu čekanja\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Serveri\",\n        \"nodes\": \"Čvorovi\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Aktivni ciljevi\",\n        \"targets_down\": \"Neaktivni ciljevi\",\n        \"targets_total\": \"Ukupni broj ciljeva\"\n    },\n    \"gatus\": {\n        \"up\": \"Aktivne stranice\",\n        \"down\": \"Neaktivne stranice\",\n        \"uptime\": \"Vrijeme rada\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Danas\",\n        \"gross_percent_1y\": \"Jedna godina\",\n        \"gross_percent_max\": \"Svo vrijeme\",\n        \"net_worth\": \"Neto vrijednost\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasti\",\n        \"books\": \"Knjige\",\n        \"podcastsDuration\": \"Trajanje\",\n        \"booksDuration\": \"Trajanje\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Ljudi doma\",\n        \"lights_on\": \"Upaljena svjetla\",\n        \"switches_on\": \"Prekidači uključeni\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Praćenje\",\n        \"updates\": \"Aktualiziranja\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Knjige\",\n        \"authors\": \"Autori\",\n        \"categories\": \"Kategorije\",\n        \"series\": \"Serije\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Knjižnice\",\n        \"books\": \"Knjige\",\n        \"reading\": \"Čitanje\",\n        \"finished\": \"Završeno\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Red čekanja\",\n        \"downloadBytesRemaining\": \"Preostalo\",\n        \"downloadTotalBytes\": \"Veličina\",\n        \"downloadSpeed\": \"Brzina\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Serije\",\n        \"totalFiles\": \"Datoteke\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Rezultat\",\n        \"status\": \"Stanje\",\n        \"buildId\": \"ID izgradnje\",\n        \"succeeded\": \"Uspjelo\",\n        \"notStarted\": \"Nije započeto\",\n        \"failed\": \"Neuspjelo\",\n        \"canceled\": \"Prekinuto\",\n        \"inProgress\": \"U tijeku\",\n        \"totalPrs\": \"Ukupni broj PR-ova\",\n        \"myPrs\": \"Moji zahtjevi za preuzimanje (PR-ovi)\",\n        \"approved\": \"Odobreno\"\n    },\n    \"gamedig\": {\n        \"status\": \"Stanje\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Ime\",\n        \"map\": \"Karta\",\n        \"currentPlayers\": \"Trenutačni igrači\",\n        \"players\": \"Igrači\",\n        \"maxPlayers\": \"Maks. broj igrača\",\n        \"bots\": \"Botovi\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"U redu\",\n        \"errored\": \"Greške\",\n        \"noRecent\": \"Zastarjelo\",\n        \"totalUsed\": \"Korištena memorija\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Recepti\",\n        \"users\": \"Korisnici\",\n        \"categories\": \"Kategorije\",\n        \"tags\": \"Oznake\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Preuzimanje\",\n        \"total\": \"Ukupno\",\n        \"running\": \"Pokrenuto\",\n        \"stopped\": \"Prekinuto\",\n        \"passed\": \"Uspjelo\",\n        \"failed\": \"Neuspjelo\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Vrijeme rada\",\n        \"cpuLoad\": \"Prosjećno CPU opterećenje (5m)\",\n        \"up\": \"Aktivno\",\n        \"down\": \"Neaktivno\",\n        \"bytesTx\": \"Preneseno\",\n        \"bytesRx\": \"Primljeno\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Stanje\",\n        \"uptime\": \"Vrijeme rada\",\n        \"lastDown\": \"Zadnja nedostupnost\",\n        \"downDuration\": \"Trajanje nedostupnosti\",\n        \"sitesUp\": \"Aktivne stranice\",\n        \"sitesDown\": \"Neaktivne stranice\",\n        \"paused\": \"Pauzirano\",\n        \"notyetchecked\": \"Još nije provjereno\",\n        \"up\": \"Aktivno\",\n        \"seemsdown\": \"Čini se da je nedostupno\",\n        \"down\": \"Neaktivno\",\n        \"unknown\": \"Nepoznato\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"U kinima\",\n        \"physicalRelease\": \"Fizičko izdanje\",\n        \"digitalRelease\": \"Digitalno izdanje\",\n        \"noEventsToday\": \"Danas nema događaja!\",\n        \"noEventsFound\": \"Nema događaja\",\n        \"errorWhenLoadingData\": \"Pogreška prilikom učitavanja podataka kalendara\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platforme\",\n        \"totalRoms\": \"Igre\",\n        \"saves\": \"Spremljeno\",\n        \"states\": \"Stanja\",\n        \"screenshots\": \"Snimke ekrana\",\n        \"totalfilesize\": \"Ukupna veličina\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domene\",\n        \"mailboxes\": \"Poštanski sandučići\",\n        \"mails\": \"Pošta\",\n        \"storage\": \"Spremište\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Upozorenja\",\n        \"criticals\": \"Kritično\"\n    },\n    \"plantit\": {\n        \"events\": \"Događaji\",\n        \"plants\": \"Biljke\",\n        \"photos\": \"Fotografije\",\n        \"species\": \"Vrste\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Obavijesti\",\n        \"issues\": \"Problemi\",\n        \"pulls\": \"Zahtjevi za povlačenje\",\n        \"repositories\": \"Repozitoriji\"\n    },\n    \"stash\": {\n        \"scenes\": \"Scene\",\n        \"scenesPlayed\": \"Reproducirane scene\",\n        \"playCount\": \"Ukupni broj reprodukcija\",\n        \"playDuration\": \"Vrijeme gledanja\",\n        \"sceneSize\": \"Veličina scene\",\n        \"sceneDuration\": \"Trajanje scene\",\n        \"images\": \"Slike\",\n        \"imageSize\": \"Veličina slike\",\n        \"galleries\": \"Galerije\",\n        \"performers\": \"Glumci\",\n        \"studios\": \"Studiji\",\n        \"movies\": \"Filmovi\",\n        \"tags\": \"Oznake\",\n        \"oCount\": \"O zbroj\"\n    },\n    \"tandoor\": {\n        \"users\": \"Korisnici\",\n        \"recipes\": \"Recepti\",\n        \"keywords\": \"Ključne riječi\"\n    },\n    \"homebox\": {\n        \"items\": \"Stavke\",\n        \"totalWithWarranty\": \"S garancijom\",\n        \"locations\": \"Lokacije\",\n        \"labels\": \"Oznake\",\n        \"users\": \"Korisnici\",\n        \"totalValue\": \"Svukupno\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Upozorenja\",\n        \"bans\": \"Zabrane\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Spojeno\",\n        \"enabled\": \"Aktivirano\",\n        \"disabled\": \"Deaktivirano\",\n        \"total\": \"Ukupno\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Posredovano\",\n        \"auth\": \"S autentifikacijom\",\n        \"outdated\": \"Zastarjelo\",\n        \"banned\": \"Zabranjen pristup\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Preuzimanje\",\n        \"upload\": \"Prijenos\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Dionice\",\n        \"loading\": \"Učitavanje\",\n        \"open\": \"Otvoreno - američko tržište\",\n        \"closed\": \"Zatvoreno - američko tržište\",\n        \"invalidConfiguration\": \"Nepravilna konfiguracija\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Kamere\",\n        \"uptime\": \"Vrijeme rada\",\n        \"version\": \"Verzija\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Poveznice\",\n        \"collections\": \"Zbirke\",\n        \"tags\": \"Oznake\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Nije klasificirano\",\n        \"information\": \"Informacije\",\n        \"warning\": \"Upozorenje\",\n        \"average\": \"Prosjek\",\n        \"high\": \"Visoko\",\n        \"disaster\": \"Katastrofa\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vozilo\",\n        \"vehicles\": \"Vozila\",\n        \"serviceRecords\": \"Servisni zapisi\",\n        \"reminders\": \"Podsjetnici\",\n        \"nextReminder\": \"Sljedeći podsjetnik\",\n        \"none\": \"Ništa\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Aktivni projekti\",\n        \"tasks7d\": \"Zadaci dospijeća ovog tjedna\",\n        \"tasksOverdue\": \"Zakašnjeli zadaci\",\n        \"tasksInProgress\": \"Zadaci u tijeku\"\n    },\n    \"headscale\": {\n        \"name\": \"Ime\",\n        \"address\": \"Adresa\",\n        \"last_seen\": \"Zadnje viđeno\",\n        \"status\": \"Stanje\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Ime\",\n        \"systems\": \"Sustavi\",\n        \"up\": \"Aktivno\",\n        \"down\": \"Neaktivno\",\n        \"paused\": \"Pauzirano\",\n        \"pending\": \"U tijeku\",\n        \"status\": \"Stanje\",\n        \"updated\": \"Aktualizirano\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Disk\",\n        \"network\": \"Mreža\"\n    },\n    \"argocd\": {\n        \"apps\": \"Aplikacije\",\n        \"synced\": \"Sinkronizirano\",\n        \"outOfSync\": \"Izvan sinkronizacije\",\n        \"healthy\": \"Zdravo\",\n        \"degraded\": \"Degradirano\",\n        \"progressing\": \"Napredovanje\",\n        \"missing\": \"Nedostaje\",\n        \"suspended\": \"Suspendiran\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Učitavanje\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Grupe\",\n        \"issues\": \"Problemi\",\n        \"merges\": \"Zahtjevi za sjedinjenjem\",\n        \"projects\": \"Projekti\"\n    },\n    \"apcups\": {\n        \"status\": \"Stanje\",\n        \"load\": \"Opterećenje\",\n        \"bcharge\": \"Napunjenost baterije\",\n        \"timeleft\": \"Preostalo vrijeme\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Oznake\",\n        \"favorites\": \"Favoriti\",\n        \"archived\": \"Arhivirano\",\n        \"highlights\": \"Izdvajamo\",\n        \"lists\": \"Liste\",\n        \"tags\": \"Oznake\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Mreža\",\n        \"connected\": \"Spojeno\",\n        \"disconnected\": \"Odspojeno\",\n        \"updateStatus\": \"Ažuriraj\",\n        \"update_yes\": \"Dostupno\",\n        \"update_no\": \"Aktualno\",\n        \"downloads\": \"Preuzimanje\",\n        \"uploads\": \"Prijenos\",\n        \"sharedFiles\": \"Datoteke\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Pjesme\",\n        \"movies\": \"Filmovi\",\n        \"episodes\": \"Epizode\",\n        \"other\": \"Ostalo\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Problemi s uslugom\",\n        \"hostErrors\": \"Problemi s host računalom\"\n    },\n    \"komodo\": {\n        \"total\": \"Ukupno\",\n        \"running\": \"Pokrenuto\",\n        \"stopped\": \"Prekinuto\",\n        \"down\": \"Neaktivno\",\n        \"unhealthy\": \"Nezdravo\",\n        \"unknown\": \"Nepoznato\",\n        \"servers\": \"Serveri\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Kontejneri\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Dostupno\",\n        \"used\": \"Korišteno\",\n        \"total\": \"Ukupno\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Pretplate\",\n        \"thisMonthlyCost\": \"Ovaj mjesec\",\n        \"nextMonthlyCost\": \"Sljedeći mjesec\",\n        \"previousMonthlyCost\": \"Prethodni mjesec\",\n        \"nextRenewingSubscription\": \"Sljedeće plaćanje\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Pokrenuto\",\n        \"STOPPED\": \"Prekinuto\",\n        \"NEW_ARRAY\": \"Novi znakovni niz\",\n        \"RECON_DISK\": \"Rekonstrukcija diska\",\n        \"DISABLE_DISK\": \"Disk deaktiviran\",\n        \"SWAP_DSBL\": \"Swap deaktiviran\",\n        \"INVALID_EXPANSION\": \"Nevažeće proširenje\",\n        \"PARITY_NOT_BIGGEST\": \"Paritet nije najveći\",\n        \"TOO_MANY_MISSING_DISKS\": \"Previše nedostajućih diskova\",\n        \"NEW_DISK_TOO_SMALL\": \"Novi disk je premalen\",\n        \"NO_DATA_DISKS\": \"Nema podatkovnih diskova\",\n        \"notifications\": \"Obavijesti\",\n        \"status\": \"Stanje\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Korištena memorija\",\n        \"memoryAvailable\": \"Dostupna memorija\",\n        \"arrayUsed\": \"Korišteni znakovni niz\",\n        \"arrayFree\": \"Slobodni znakovni niz\",\n        \"poolUsed\": \"{{pool}} korišteno\",\n        \"poolFree\": \"{{pool}} slobodno\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Planovi\",\n        \"num_success_30\": \"Uspjesi\",\n        \"num_failure_30\": \"Neuspjesi\",\n        \"num_success_latest\": \"Uspjeh\",\n        \"num_failure_latest\": \"Neuspjeh\",\n        \"bytes_added_30\": \"Dodani bajtovi\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Pjesme\",\n        \"time\": \"Vrijeme\",\n        \"artists\": \"Izvođači\"\n    },\n    \"arcane\": {\n        \"containers\": \"Kontejneri\",\n        \"images\": \"Slike\",\n        \"image_updates\": \"Aktualizirane slike\",\n        \"images_unused\": \"Nekorišteno\",\n        \"environment_required\": \"ID okruženja se mora navesti\"\n    },\n    \"dockhand\": {\n        \"running\": \"Pokreće se\",\n        \"stopped\": \"Zaustavljeno\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memorija\",\n        \"images\": \"Slike\",\n        \"volumes\": \"Jedinice memorije\",\n        \"events_today\": \"Događanja danas\",\n        \"pending_updates\": \"Aktualiziranja na čekanju\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Pauzirano\",\n        \"total\": \"Ukupno\",\n        \"environment_not_found\": \"Okruženje nije pronađeno\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Pojedeno\",\n        \"burned\": \"Potrošeno\",\n        \"remaining\": \"Preostalo\",\n        \"steps\": \"Koraci\"\n    }\n}\n"
  },
  {
    "path": "public/locales/hu/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"hó\",\n        \"days\": \"n\",\n        \"hours\": \"ó\",\n        \"minutes\": \"p\",\n        \"seconds\": \"mp\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Hiányzó Widget Típus: {{type}}\",\n        \"api_error\": \"API Hiba\",\n        \"information\": \"Információ\",\n        \"status\": \"Státusz\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Nyers hiba\",\n        \"response_data\": \"Válaszadatok\"\n    },\n    \"weather\": {\n        \"current\": \"Aktuális hely\",\n        \"allow\": \"Kattints az engedélyezéshez\",\n        \"updating\": \"Frissítés\",\n        \"wait\": \"Kérjük várjon\"\n    },\n    \"search\": {\n        \"placeholder\": \"Keresés…\"\n    },\n    \"resources\": {\n        \"cpu\": \"Processzor\",\n        \"mem\": \"RAM\",\n        \"total\": \"Összes\",\n        \"free\": \"Szabad\",\n        \"used\": \"Használt\",\n        \"load\": \"Terhelés\",\n        \"temp\": \"HŐM\",\n        \"max\": \"Max\",\n        \"uptime\": \"FUT\"\n    },\n    \"unifi\": {\n        \"users\": \"Felhasználók\",\n        \"uptime\": \"Üzemidő\",\n        \"days\": \"Napok\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Eszközök\",\n        \"lan_devices\": \"LAN Eszközök\",\n        \"wlan_devices\": \"WLAN Eszközök\",\n        \"lan_users\": \"LAN Felhasználók\",\n        \"wlan_users\": \"WLAN Felhasználók\",\n        \"up\": \"FUT\",\n        \"down\": \"ÁLL\",\n        \"wait\": \"Kérjük várjon\",\n        \"empty_data\": \"Az alrendszer állapota ismeretlen\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"Processzor\",\n        \"running\": \"Futó\",\n        \"offline\": \"Nem elérhető\",\n        \"error\": \"Hiba\",\n        \"unknown\": \"Ismeretlen\",\n        \"healthy\": \"Egészséges\",\n        \"starting\": \"Indul\",\n        \"unhealthy\": \"Egészségtelen\",\n        \"not_found\": \"Nem található\",\n        \"exited\": \"Kilépett\",\n        \"partial\": \"Részleges\"\n    },\n    \"ping\": {\n        \"error\": \"Hiba\",\n        \"ping\": \"Ping\",\n        \"down\": \"Le\",\n        \"up\": \"Fel\",\n        \"not_available\": \"Nem elérhető\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP állapot\",\n        \"error\": \"Hiba\",\n        \"response\": \"Válasz\",\n        \"down\": \"Leállt\",\n        \"up\": \"Fut\",\n        \"not_available\": \"Nem elérhető\"\n    },\n    \"emby\": {\n        \"playing\": \"Lejátszás\",\n        \"transcoding\": \"Átkódolás\",\n        \"bitrate\": \"Bitráta\",\n        \"no_active\": \"Nincs aktív lejátszás\",\n        \"movies\": \"Film\",\n        \"series\": \"Sorozat\",\n        \"episodes\": \"Epizód\",\n        \"songs\": \"Zeneszám\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Nem elérhető\",\n        \"offline_alt\": \"Nem elérhető\",\n        \"online\": \"Csatlakozva\",\n        \"total\": \"Összes\",\n        \"unknown\": \"Ismeretlen\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Termelés\",\n        \"battery_soc\": \"Akkumulátor\",\n        \"grid_power\": \"Rács\",\n        \"home_power\": \"Fogyasztás\",\n        \"charge_power\": \"Töltő\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Letöltés\",\n        \"upload\": \"Feltöltés\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Előfizetések\",\n        \"unread\": \"Olvasatlan\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Státusz\",\n        \"connectionStatusUnconfigured\": \"Nem beállított\",\n        \"connectionStatusConnecting\": \"Kapcsolódás\",\n        \"connectionStatusAuthenticating\": \"Hitelesítés\",\n        \"connectionStatusPendingDisconnect\": \"Szétkapcsolás függőben\",\n        \"connectionStatusDisconnecting\": \"Kapcsolat bontása\",\n        \"connectionStatusDisconnected\": \"Kapcsolat bontva\",\n        \"connectionStatusConnected\": \"Csatlakozva\",\n        \"uptime\": \"Működési idő\",\n        \"maxDown\": \"Max let.\",\n        \"maxUp\": \"Max felt.\",\n        \"down\": \"Leállt\",\n        \"up\": \"Fut\",\n        \"received\": \"Fogadott\",\n        \"sent\": \"Küldött\",\n        \"externalIPAddress\": \"Külső IP cím\",\n        \"externalIPv6Address\": \"Küls. IPv6\",\n        \"externalIPv6Prefix\": \"Küls. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreamek\",\n        \"requests\": \"Jelenlegi kérelmek\",\n        \"requests_failed\": \"Sikertelen kérelmek\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Összes Megfigyelt\",\n        \"diffsDetected\": \"Észlelt különbségek\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Műsorok\",\n        \"recordings\": \"Felvételek\",\n        \"scheduled\": \"Ütemezett\",\n        \"passes\": \"Engedélyek\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Lejátszás\",\n        \"transcoding\": \"Transzkódolás\",\n        \"bitrate\": \"Bitráta\",\n        \"no_active\": \"Nincs aktív lejátszás\",\n        \"plex_connection_error\": \"Plex kapcsolat ellenőrzése\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Csatlakoztatott AP-k\",\n        \"activeUser\": \"Aktív eszközök\",\n        \"alerts\": \"Riasztások\",\n        \"connectedGateways\": \"Csatlakoztatott gateway-ek\",\n        \"connectedSwitches\": \"Csatlakoztatott switch-ek\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Ráta\",\n        \"remaining\": \"Hátralévő\",\n        \"downloaded\": \"Letöltött\"\n    },\n    \"plex\": {\n        \"streams\": \"Aktív Stream-ek\",\n        \"albums\": \"Albumok\",\n        \"movies\": \"Filmek\",\n        \"tv\": \"TV műsorok\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Ráta\",\n        \"queue\": \"Sor\",\n        \"timeleft\": \"Hátralévő idő\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Aktív\",\n        \"upload\": \"Feltöltés\",\n        \"download\": \"Letöltés\"\n    },\n    \"transmission\": {\n        \"download\": \"Letöltés\",\n        \"upload\": \"Feltöltés\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Letöltés\",\n        \"upload\": \"Feltöltés\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"Processzor Használat\",\n        \"memUsage\": \"Memória Használat\",\n        \"systemTempC\": \"Rendszerhőmérséklet\",\n        \"poolUsage\": \"Pool Használat\",\n        \"volumeUsage\": \"Kötet Használat\",\n        \"invalid\": \"Érvénytelen\"\n    },\n    \"deluge\": {\n        \"download\": \"Letöltés\",\n        \"upload\": \"Feltöltés\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Gyorsítótárban Sikeres Bitek\",\n        \"cachemissbytes\": \"Gyorsítótárban Hibás Bitek\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Letöltés\",\n        \"upload\": \"Feltöltés\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Keresett\",\n        \"queued\": \"Sorban áll\",\n        \"series\": \"Sorozatok\",\n        \"queue\": \"Várólista\",\n        \"unknown\": \"Ismeretlen\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Keresett\",\n        \"missing\": \"Hiányzik\",\n        \"queued\": \"Sorban áll\",\n        \"movies\": \"Filmek\",\n        \"queue\": \"Várólista\",\n        \"unknown\": \"Ismeretlen\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Keresett\",\n        \"queued\": \"Sorban áll\",\n        \"artists\": \"Előadók\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Keresett\",\n        \"queued\": \"Sorban áll\",\n        \"books\": \"Könyvek\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Hiányzó epizódok\",\n        \"missingMovies\": \"Hiányzó filmek\"\n    },\n    \"ombi\": {\n        \"pending\": \"Függőben\",\n        \"approved\": \"Engedélyezett\",\n        \"available\": \"Elérhető\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Összes\",\n        \"connected\": \"Csatlakoztatott\",\n        \"new_devices\": \"Új eszközök\",\n        \"down_alerts\": \"Leállási riasztások\"\n    },\n    \"pihole\": {\n        \"queries\": \"Lekérdezések\",\n        \"blocked\": \"Blokkolt\",\n        \"blocked_percent\": \"Blokkolt %\",\n        \"gravity\": \"Gravitáció\"\n    },\n    \"adguard\": {\n        \"queries\": \"Lekérdezések\",\n        \"blocked\": \"Blokkolt\",\n        \"filtered\": \"Szűrt\",\n        \"latency\": \"Késleltetés\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Feltöltés\",\n        \"download\": \"Letöltés\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Folyamatban\",\n        \"stopped\": \"Megállított\",\n        \"total\": \"Összes\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Letöltött\",\n        \"nondownload\": \"Nem Letöltött\",\n        \"read\": \"Olvasott\",\n        \"unread\": \"Olvasatlan\",\n        \"downloadedread\": \"Letöltött & Olvasott\",\n        \"downloadedunread\": \"Letöltött & Olvasatlan\",\n        \"nondownloadedread\": \"Nem Letöltött & Olvasatlan\",\n        \"nondownloadedunread\": \"Nem Letöltött & Olvasatlan\"\n    },\n    \"tailscale\": {\n        \"address\": \"Cím\",\n        \"expires\": \"Lejár\",\n        \"never\": \"Soha\",\n        \"last_seen\": \"Utoljára látott\",\n        \"now\": \"Most\",\n        \"years\": \"{{number}}év\",\n        \"weeks\": \"{{number}}h\",\n        \"days\": \"{{number}}n\",\n        \"hours\": \"{{number}}ó\",\n        \"minutes\": \"{{number}}p\",\n        \"seconds\": \"{{number}}mp\",\n        \"ago\": \"{{value}} Ezelőtt\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Lekérdezések\",\n        \"totalNoError\": \"Sikerek\",\n        \"totalServerFailure\": \"Hibák\",\n        \"totalNxDomain\": \"NX Domainek\",\n        \"totalRefused\": \"Elutasított\",\n        \"totalAuthoritative\": \"Irányadó\",\n        \"totalRecursive\": \"Rekurzív\",\n        \"totalCached\": \"Gyorsítótárazott\",\n        \"totalBlocked\": \"Blokkolt\",\n        \"totalDropped\": \"Eldobott\",\n        \"totalClients\": \"Kliensek\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Várólista\",\n        \"processed\": \"Feldolgozott\",\n        \"errored\": \"Hibás\",\n        \"saved\": \"Mentett\"\n    },\n    \"traefik\": {\n        \"routers\": \"Routerek\",\n        \"services\": \"Folyamatok\",\n        \"middleware\": \"Közvetítő\"\n    },\n    \"trilium\": {\n        \"version\": \"Verzió\",\n        \"notesCount\": \"Jegyzetek\",\n        \"dbSize\": \"Adatbázis mérete\",\n        \"unknown\": \"Ismeretlen\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"Nincs aktív lejátszás\",\n        \"please_wait\": \"Kérjük Várjon\"\n    },\n    \"npm\": {\n        \"enabled\": \"Bekapcsolva\",\n        \"disabled\": \"Kikapcsolva\",\n        \"total\": \"Összes\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Állíts be egy vagy több Cryptovalutát a követéshez\",\n        \"1hour\": \"1 Óra\",\n        \"1day\": \"1 Nap\",\n        \"7days\": \"7 Nap\",\n        \"30days\": \"30 Nap\"\n    },\n    \"gotify\": {\n        \"apps\": \"Applikációk\",\n        \"clients\": \"Kliensek\",\n        \"messages\": \"Üzenetek\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indexerek\",\n        \"numberOfGrabs\": \"Fogott\",\n        \"numberOfQueries\": \"Lekérdezések\",\n        \"numberOfFailGrabs\": \"Hibás fogások\",\n        \"numberOfFailQueries\": \"Hibás lekérdezések\"\n    },\n    \"jackett\": {\n        \"configured\": \"Beállított\",\n        \"errored\": \"Hibák\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Munkamenetek\",\n        \"numConnections\": \"Csatlakozások\",\n        \"dataRelayed\": \"Átirányított\",\n        \"transferRate\": \"Ráta\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Felhasználók\",\n        \"status_count\": \"Posztok\",\n        \"domain_count\": \"Domainek\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Keresett\",\n        \"queued\": \"Sorban áll\",\n        \"series\": \"Sorozatok\"\n    },\n    \"minecraft\": {\n        \"players\": \"Lejátszók\",\n        \"version\": \"Verzió\",\n        \"status\": \"Státusz\",\n        \"up\": \"Kapcsolódva\",\n        \"down\": \"Nem elérhető\"\n    },\n    \"miniflux\": {\n        \"read\": \"Olvasott\",\n        \"unread\": \"Olvasatlan\"\n    },\n    \"authentik\": {\n        \"users\": \"Felhasználók\",\n        \"loginsLast24H\": \"Bejelentkezések (24 óra)\",\n        \"failedLoginsLast24H\": \"Sikertelen bejelentkezések (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"Processzor\",\n        \"lxc\": \"LXC-k\",\n        \"vms\": \"VM-ek\"\n    },\n    \"glances\": {\n        \"cpu\": \"Processzor\",\n        \"load\": \"Terhelés\",\n        \"wait\": \"Kérem várjon\",\n        \"temp\": \"HŐM\",\n        \"_temp\": \"Hőmérséklet\",\n        \"warn\": \"Figyelmeztet\",\n        \"uptime\": \"FUT\",\n        \"total\": \"Összes\",\n        \"free\": \"Szabad\",\n        \"used\": \"Felhasznált\",\n        \"days\": \"n\",\n        \"hours\": \"ó\",\n        \"crit\": \"Kritikus\",\n        \"read\": \"Olvasott\",\n        \"write\": \"Írás\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Memória\",\n        \"swap\": \"Csere\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Könyvjelző\",\n        \"service\": \"Szolgáltatás\",\n        \"search\": \"Keresés\",\n        \"custom\": \"Egyedi\",\n        \"visit\": \"Megnéz\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Javaslat\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Napos\",\n        \"0-night\": \"Derült\",\n        \"1-day\": \"Többnyire napos\",\n        \"1-night\": \"Többnyire derült\",\n        \"2-day\": \"Részben felhős\",\n        \"2-night\": \"Részben felhős\",\n        \"3-day\": \"Felhős\",\n        \"3-night\": \"Felhős\",\n        \"45-day\": \"Ködös\",\n        \"45-night\": \"Ködös\",\n        \"48-day\": \"Ködös\",\n        \"48-night\": \"Ködös\",\n        \"51-day\": \"Enyhe szitálás\",\n        \"51-night\": \"Enyhe szitálás\",\n        \"53-day\": \"Szitálás\",\n        \"53-night\": \"Szitálás\",\n        \"55-day\": \"Erős szitálás\",\n        \"55-night\": \"Erős szitálás\",\n        \"56-day\": \"Enyhe fagyos szitálás\",\n        \"56-night\": \"Enyhe fagyos szitálás\",\n        \"57-day\": \"Fagyos szitálás\",\n        \"57-night\": \"Fagyos szitálás\",\n        \"61-day\": \"Enyhe eső\",\n        \"61-night\": \"Enyhe eső\",\n        \"63-day\": \"Eső\",\n        \"63-night\": \"Eső\",\n        \"65-day\": \"Heves eső\",\n        \"65-night\": \"Heves eső\",\n        \"66-day\": \"Ónos eső\",\n        \"66-night\": \"Ónos eső\",\n        \"67-day\": \"Ónos eső\",\n        \"67-night\": \"Ónos eső\",\n        \"71-day\": \"Enyhe havazás\",\n        \"71-night\": \"Enyhe havazás\",\n        \"73-day\": \"Hó\",\n        \"73-night\": \"Havazás\",\n        \"75-day\": \"Erős havazás\",\n        \"75-night\": \"Erős havazás\",\n        \"77-day\": \"Hódara\",\n        \"77-night\": \"Hódara\",\n        \"80-day\": \"Enyhe záporok\",\n        \"80-night\": \"Enyhe záporok\",\n        \"81-day\": \"Záporok\",\n        \"81-night\": \"Záporok\",\n        \"82-day\": \"Heves záporok\",\n        \"82-night\": \"Heves záporok\",\n        \"85-day\": \"Hózáporok\",\n        \"85-night\": \"Hózáporok\",\n        \"86-day\": \"Hózáporok\",\n        \"86-night\": \"Hózáporok\",\n        \"95-day\": \"Zivatar\",\n        \"95-night\": \"Vihar\",\n        \"96-day\": \"Zivatar jégesővel\",\n        \"96-night\": \"Zivatar jégesővel\",\n        \"99-day\": \"Zivatar jégesővel\",\n        \"99-night\": \"Zivatar jégesővel\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Rendszer\",\n        \"updates\": \"Frissítések\",\n        \"update_available\": \"Elérhető Frissítés\",\n        \"up_to_date\": \"Naprakész\",\n        \"child_bridges\": \"Gyerek Hidak\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Fut\",\n        \"pending\": \"Függőben lévő\",\n        \"down\": \"Leállt\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Új\",\n        \"up\": \"Fut\",\n        \"grace\": \"Türelmi idő alatt\",\n        \"down\": \"Leállt\",\n        \"paused\": \"Szünetel\",\n        \"status\": \"Státusz\",\n        \"last_ping\": \"Legutóbbi Ping\",\n        \"never\": \"Még nincsenek ping-ek\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Beolvasott\",\n        \"containers_updated\": \"Frissített\",\n        \"containers_failed\": \"Sikertelen\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Jóváhagyott\",\n        \"rejectedPushes\": \"Elutasított\",\n        \"filters\": \"Szűrők\",\n        \"indexers\": \"Indexerek\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Várólista\",\n        \"videos\": \"Videók\",\n        \"channels\": \"Csatornák\",\n        \"playlists\": \"Lejátszási listák\"\n    },\n    \"truenas\": {\n        \"load\": \"Rendszerterhelés\",\n        \"uptime\": \"Működési idő\",\n        \"alerts\": \"Figyelmeztetések\"\n    },\n    \"pyload\": {\n        \"speed\": \"Sebesség\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Nyilvános IP-cím\",\n        \"region\": \"Régió\",\n        \"country\": \"Ország\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tuner-ek\",\n        \"channelNumber\": \"Csatorna\",\n        \"channelNetwork\": \"Hálózat\",\n        \"signalStrength\": \"Erősség\",\n        \"signalQuality\": \"Minőség\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Kliens\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Megfelelt\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Beérkezett\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Akku töltöttsége\",\n        \"ups_load\": \"UPS terheltsége\",\n        \"ups_status\": \"UPS állapot\",\n        \"online\": \"Online\",\n        \"on_battery\": \"Akkuról\",\n        \"low_battery\": \"Alacsony töltöttség\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"Nincs fogadott eszközadat\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"Processzor Terhelés\",\n        \"memoryUsed\": \"Felhasznált Memória\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Bérletek\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Minden Stream\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG Csatornák\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Ma\",\n        \"absolutePower\": \"Energia\",\n        \"relativePower\": \"Energia %\",\n        \"limit\": \"Korlát\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Aktív Memória\",\n        \"wanUpload\": \"WAN Feltöltés\",\n        \"wanDownload\": \"WAN Letöltés\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Nyomtató Állapota\",\n        \"print_status\": \"Nyomtatás Állapota\",\n        \"print_progress\": \"Folyamat\",\n        \"layers\": \"Rétegek\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Szerszám hőmérséklet\",\n        \"temp_bed\": \"Ágy Hőmérséklet\",\n        \"job_completion\": \"Teljesítés\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Eredeti IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Átlagos terhelés\",\n        \"memory\": \"RAM Használat\",\n        \"wanStatus\": \"WAN Állapot\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Lemezhasználat\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Adattár\",\n        \"failed_tasks_24h\": \"Sikertelen feladatok 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memória\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Fényképek\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Tárhely\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Futó Webhelyek\",\n        \"down\": \"Nem Elérhető Webhelyek\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Incidens\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Archívum\",\n        \"chapters\": \"Fejezetek\",\n        \"categories\": \"Kategóriák\"\n    },\n    \"komga\": {\n        \"libraries\": \"Könyvtárak\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Problémák\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"Emberek\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Idő\"\n    },\n    \"firefly\": {\n        \"networth\": \"Nettó érték\",\n        \"budget\": \"Költségkeret\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Műszerfalak\",\n        \"datasources\": \"Adatforrások\",\n        \"totalalerts\": \"Összes Riasztás\",\n        \"alertstriggered\": \"Aktivált riasztások\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Processzor Terhelés\",\n        \"memoryusage\": \"Memória Használat\",\n        \"freespace\": \"Szabad hely\",\n        \"activeusers\": \"Aktív Felhasználók\",\n        \"numfiles\": \"Fájlok\",\n        \"numshares\": \"Megosztott Elemek\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Méret\",\n        \"lastrun\": \"Legutóbbi futtatás\",\n        \"nextrun\": \"Következő Futtatás\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Aktív Dolgozók\",\n        \"total_workers\": \"Összes Dolgozó\",\n        \"records_total\": \"Várólista Hossza\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Szerverek\",\n        \"nodes\": \"Node-ok\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Célpontok Futnak\",\n        \"targets_down\": \"Célpontok Állnak\",\n        \"targets_total\": \"Összes Célpont\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"Egy év\",\n        \"gross_percent_max\": \"Mindig\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcast\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Időtartam\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Emberek otthon\",\n        \"lights_on\": \"Fények bekapcsolva\",\n        \"switches_on\": \"Kapcsolók felkapcsolva\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Nyomonkövetés\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Szerzők\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Eredmény\",\n        \"status\": \"Status\",\n        \"buildId\": \"Gyártás ID\",\n        \"succeeded\": \"Sikerült\",\n        \"notStarted\": \"Nem indult\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Megszakítva\",\n        \"inProgress\": \"Folyamatban\",\n        \"totalPrs\": \"Minden PR\",\n        \"myPrs\": \"Saját PR-ek\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Név\",\n        \"map\": \"Térkép\",\n        \"currentPlayers\": \"Jelenlegi játékosok\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Max. játékosok\",\n        \"bots\": \"Botok\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"OK\",\n        \"errored\": \"Hibák\",\n        \"noRecent\": \"Elavult\",\n        \"totalUsed\": \"Felhasznált tárhely\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Receptek\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Címkék\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Letöltés folyamatban\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"Átlag CPU terhelés (5p)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Továbbított\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Utolsó leállás\",\n        \"downDuration\": \"Leállás ideje\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Még nincs ellenőrizve\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Elérhetetlennek tűnik\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"Mozikban\",\n        \"physicalRelease\": \"Fizikai kiadás\",\n        \"digitalRelease\": \"Digitális kiadás\",\n        \"noEventsToday\": \"Ezen a napon nincsenek események!\",\n        \"noEventsFound\": \"Nem található esemény\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Felület\",\n        \"totalRoms\": \"Játékok\",\n        \"saves\": \"Mentések\",\n        \"states\": \"Állapotok\",\n        \"screenshots\": \"Képernyőképek\",\n        \"totalfilesize\": \"Teljes méret\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"E-mail fiókok\",\n        \"mails\": \"Mailek\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Figyelmeztetések\",\n        \"criticals\": \"Kritikusok\"\n    },\n    \"plantit\": {\n        \"events\": \"Események\",\n        \"plants\": \"Növények\",\n        \"photos\": \"Photos\",\n        \"species\": \"Fajok\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Üzenetek\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Pull request-ek\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Jelenetek\",\n        \"scenesPlayed\": \"Lejátszott jelenetek\",\n        \"playCount\": \"Összes leátszás\",\n        \"playDuration\": \"Nézett idő\",\n        \"sceneSize\": \"Jelenetek mérete\",\n        \"sceneDuration\": \"Jelenetek hossza\",\n        \"images\": \"Képek\",\n        \"imageSize\": \"Képek mérete\",\n        \"galleries\": \"Galériák\",\n        \"performers\": \"Előadók\",\n        \"studios\": \"Stúdiók\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O szám\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Kulcsszavak\"\n    },\n    \"homebox\": {\n        \"items\": \"Tárgyak\",\n        \"totalWithWarranty\": \"Garanciával\",\n        \"locations\": \"Helyek\",\n        \"labels\": \"Címkék\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Teljes érték\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Kitiltások\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxyzott\",\n        \"auth\": \"Hitelesítéssel\",\n        \"outdated\": \"Elavult\",\n        \"banned\": \"Kitiltott\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Tőzsde\",\n        \"loading\": \"Betöltés\",\n        \"open\": \"Nyitva - US Piac\",\n        \"closed\": \"Zárva - US Piac\",\n        \"invalidConfiguration\": \"Érvénytelen konfiguráció\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Kamerák\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Linkek\",\n        \"collections\": \"Gyűjtemény\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Nem titkosított\",\n        \"information\": \"Information\",\n        \"warning\": \"Figyelmeztetés\",\n        \"average\": \"Átlag\",\n        \"high\": \"Magas\",\n        \"disaster\": \"Katasztrófa\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Jármű\",\n        \"vehicles\": \"Járművek\",\n        \"serviceRecords\": \"Szolgáltatások nyílvántartása\",\n        \"reminders\": \"Emlékeztetők\",\n        \"nextReminder\": \"Következő emlékeztető\",\n        \"none\": \"Semmi\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Aktív Projektek\",\n        \"tasks7d\": \"Hátralévő feladatok a héten\",\n        \"tasksOverdue\": \"Lejárt feladatok\",\n        \"tasksInProgress\": \"Folyamatban levő Feladatok\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Rendszerek\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Lemez\",\n        \"network\": \"Hálózat\"\n    },\n    \"argocd\": {\n        \"apps\": \"Alkalmazások\",\n        \"synced\": \"Szinkronizált\",\n        \"outOfSync\": \"Nincs szinkronban\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Leépült\",\n        \"progressing\": \"Halad\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Felfüggesztett\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Csoportok\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Merge kérések\",\n        \"projects\": \"Projektek\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bookmarks\",\n        \"favorites\": \"Favorites\",\n        \"archived\": \"Archived\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Lists\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Other\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/id/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"bln\",\n        \"days\": \"h\",\n        \"hours\": \"j\",\n        \"minutes\": \"m\",\n        \"seconds\": \"d\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Widget Tidak Ditemukan: {{type}}\",\n        \"api_error\": \"API Error\",\n        \"information\": \"Informasi\",\n        \"status\": \"Status\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Error Baku\",\n        \"response_data\": \"Data Respons\"\n    },\n    \"weather\": {\n        \"current\": \"Lokasi Saat Ini\",\n        \"allow\": \"Klik untuk mengizinkan\",\n        \"updating\": \"Memperbarui\",\n        \"wait\": \"Harap tunggu\"\n    },\n    \"search\": {\n        \"placeholder\": \"Telusuri…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"MEM\",\n        \"total\": \"Total\",\n        \"free\": \"Luang\",\n        \"used\": \"Digunakan\",\n        \"load\": \"Beban\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Maks\",\n        \"uptime\": \"Waktu Aktif\"\n    },\n    \"unifi\": {\n        \"users\": \"Pengguna\",\n        \"uptime\": \"Waktu Aktif\",\n        \"days\": \"Hari-hari\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Perangkat\",\n        \"lan_devices\": \"Perangkat LAN\",\n        \"wlan_devices\": \"Perangkat WLAN\",\n        \"lan_users\": \"Pengguna LAN\",\n        \"wlan_users\": \"Pengguna WLAN\",\n        \"up\": \"UP\",\n        \"down\": \"Mati\",\n        \"wait\": \"Please wait\",\n        \"empty_data\": \"Status subsistem tdk diketahui\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Berjalan\",\n        \"offline\": \"Offline\",\n        \"error\": \"Error\",\n        \"unknown\": \"Tidak Diketahui\",\n        \"healthy\": \"Lancar\",\n        \"starting\": \"Memulai\",\n        \"unhealthy\": \"Tidak Lancar\",\n        \"not_found\": \"Tidak Ditemukan\",\n        \"exited\": \"Terkeluar\",\n        \"partial\": \"Sebagian\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"Ping\",\n        \"down\": \"Mati\",\n        \"up\": \"Hidup\",\n        \"not_available\": \"Tidak Tersedia\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP Status\",\n        \"error\": \"Error\",\n        \"response\": \"Respons\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"emby\": {\n        \"playing\": \"Sedang Diputar\",\n        \"transcoding\": \"Mentranskode\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"Tidak ada Strim Aktif\",\n        \"movies\": \"Film\",\n        \"series\": \"Seri\",\n        \"episodes\": \"Episode\",\n        \"songs\": \"Lagu\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Online\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Produksi\",\n        \"battery_soc\": \"Baterai\",\n        \"grid_power\": \"Grid\",\n        \"home_power\": \"Konsumsi\",\n        \"charge_power\": \"Charger\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Unduh\",\n        \"upload\": \"Unggah\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Subskripsi\",\n        \"unread\": \"Belum Dibaca\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Belum dikonfigur\",\n        \"connectionStatusConnecting\": \"Menyambung\",\n        \"connectionStatusAuthenticating\": \"Menotentikasi\",\n        \"connectionStatusPendingDisconnect\": \"Menunggu Terputus\",\n        \"connectionStatusDisconnecting\": \"Sedan Memutus\",\n        \"connectionStatusDisconnected\": \"Terputus\",\n        \"connectionStatusConnected\": \"Tersambung\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"Maks Unduh\",\n        \"maxUp\": \"Maks Unggah\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"Diterima\",\n        \"sent\": \"Terkirim\",\n        \"externalIPAddress\": \"IP Eksternal\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Strim Luar\",\n        \"requests\": \"Request saat ini\",\n        \"requests_failed\": \"Request gagal\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Total yang Diamati\",\n        \"diffsDetected\": \"Perbedaan yang Terdeteksi\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Acara\",\n        \"recordings\": \"Rekaman\",\n        \"scheduled\": \"Terjadwal\",\n        \"passes\": \"Tiket\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"Cek Koneksi ke Plex\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"AP Tersambung\",\n        \"activeUser\": \"Perangakat yang Aktif\",\n        \"alerts\": \"Peringatan\",\n        \"connectedGateways\": \"Connected gateways\",\n        \"connectedSwitches\": \"Switch Tersambung\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Laju Bandwidth\",\n        \"remaining\": \"Sisa\",\n        \"downloaded\": \"Terunduh\"\n    },\n    \"plex\": {\n        \"streams\": \"Stream Berjalan\",\n        \"albums\": \"Albums\",\n        \"movies\": \"Movies\",\n        \"tv\": \"Acara TV\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"Antrian\",\n        \"timeleft\": \"Sisa Waktu\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Aktif\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"Penggunaan CPU\",\n        \"memUsage\": \"Penggunaan MEM\",\n        \"systemTempC\": \"Suhu Sistem\",\n        \"poolUsage\": \"Pengunaan Pool\",\n        \"volumeUsage\": \"Penggunaan Volume\",\n        \"invalid\": \"Tidak valid\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Dicari\",\n        \"queued\": \"Terantrikan\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"Tidak Ditemukan\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"Artis\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"Buku\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Episode Tidak Ditemukan\",\n        \"missingMovies\": \"Film Tidak Ditemukan\"\n    },\n    \"ombi\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Tersetujui\",\n        \"available\": \"Tersedia\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"Perangkat Baru\",\n        \"down_alerts\": \"Peringatan Pemadaman\"\n    },\n    \"pihole\": {\n        \"queries\": \"Kueri\",\n        \"blocked\": \"Terblokir\",\n        \"blocked_percent\": \"% Terblokir\",\n        \"gravity\": \"Gravitasi\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"Terfilter\",\n        \"latency\": \"Latensi\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Terhenti\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Belum Diunduh\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Diunduh & Dibaca\",\n        \"downloadedunread\": \"Diunduh & Belum Dibaca\",\n        \"nondownloadedread\": \"Belum Diunduh & Dibaca\",\n        \"nondownloadedunread\": \"Belum Diunduh & Belum Dibaca\"\n    },\n    \"tailscale\": {\n        \"address\": \"Alamat\",\n        \"expires\": \"Kedaluwarsa\",\n        \"never\": \"Tidak Pernah\",\n        \"last_seen\": \"Terakhir terlihat\",\n        \"now\": \"Sekarang\",\n        \"years\": \"{{number}}thn\",\n        \"weeks\": \"{{number}}mgg\",\n        \"days\": \"{{number}}h\",\n        \"hours\": \"{{number}}j\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}d\",\n        \"ago\": \"{{value}} Yang Lalu\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"Berhasil\",\n        \"totalServerFailure\": \"Gagal\",\n        \"totalNxDomain\": \"Domain NX\",\n        \"totalRefused\": \"Ditolak\",\n        \"totalAuthoritative\": \"Authoritative\",\n        \"totalRecursive\": \"Rekursif\",\n        \"totalCached\": \"Cached\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"Dropped\",\n        \"totalClients\": \"Klien\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Terproses\",\n        \"errored\": \"Error\",\n        \"saved\": \"Tersimpan\"\n    },\n    \"traefik\": {\n        \"routers\": \"Router\",\n        \"services\": \"Layanan\",\n        \"middleware\": \"Middleware\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Mohon menunggu\"\n    },\n    \"npm\": {\n        \"enabled\": \"Aktif\",\n        \"disabled\": \"Nonaktif\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Konfigurasikan satu atau beberapa mata uang kripto untuk dilacak\",\n        \"1hour\": \"1 Jam\",\n        \"1day\": \"1 Hari\",\n        \"7days\": \"7 Hari\",\n        \"30days\": \"30 Hari\"\n    },\n    \"gotify\": {\n        \"apps\": \"Aplikasi\",\n        \"clients\": \"Clients\",\n        \"messages\": \"Pesan\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Pengindeks\",\n        \"numberOfGrabs\": \"Jumlah Ambilan\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"Ambilan Gagal\",\n        \"numberOfFailQueries\": \"Jumlah Kueri Gagal\"\n    },\n    \"jackett\": {\n        \"configured\": \"Konfigurasi\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sesi\",\n        \"numConnections\": \"Jumlah Koneksi\",\n        \"dataRelayed\": \"Data Diteruskan\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"Jumlah Posting\",\n        \"domain_count\": \"Jumlah Domain\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Jumlah Pemain\",\n        \"version\": \"Versi\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Baca\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"Login (24j)\",\n        \"failedLoginsLast24H\": \"Login Gagal (24j)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Suhu\",\n        \"warn\": \"Peringatan\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Penting\",\n        \"read\": \"Read\",\n        \"write\": \"Tulis\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Penanda\",\n        \"service\": \"Layanan\",\n        \"search\": \"Cari\",\n        \"custom\": \"Kustom\",\n        \"visit\": \"Kunjungi\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Saran\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Cerah dan Terang\",\n        \"0-night\": \"Cerah\",\n        \"1-day\": \"Cerah\",\n        \"1-night\": \"Cerah\",\n        \"2-day\": \"Sedikit Berawan\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"Berawan\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"Berkabut\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"Gerimis Ringan\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"Gerimis\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"Gerimis Lebat\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"Gerimis Membeku Ringan\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"Gerimis Membeku\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"Hujan Ringan\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"Hujan\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Hujan Deras\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Hujan Dingin\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Hujan Salju Ringan\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Hujan Salju\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Hujan Salju Lebat\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Hujan Salju Butiran\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Hujan Ringan\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Hujan\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Hujan Lebat\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Hujan Salju\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"Badai Petir\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Badai Petir Hujan Es\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Sistem\",\n        \"updates\": \"Pembaruan\",\n        \"update_available\": \"Pembaruan Tersedia\",\n        \"up_to_date\": \"Terbaru\",\n        \"child_bridges\": \"Bridge Turunan\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Baru\",\n        \"up\": \"Up\",\n        \"grace\": \"Dalam Masa Tenggang\",\n        \"down\": \"Down\",\n        \"paused\": \"Pause\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Ping Terakhir\",\n        \"never\": \"Tidak pernah di ping\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Terpindai\",\n        \"containers_updated\": \"Terbarui\",\n        \"containers_failed\": \"Gagal\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Tertolak\",\n        \"filters\": \"Filter\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Video\",\n        \"channels\": \"Channel\",\n        \"playlists\": \"Daftar Putar\"\n    },\n    \"truenas\": {\n        \"load\": \"Beban Sistem\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Kecepatan\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"IP Publik\",\n        \"region\": \"Region\",\n        \"country\": \"Negara\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tuner\",\n        \"channelNumber\": \"Channel\",\n        \"channelNetwork\": \"Jaringan\",\n        \"signalStrength\": \"Kekuatan Signal\",\n        \"signalQuality\": \"Kualitas\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Klien\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Sukses\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Kotak Masuk\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Sisa Baterai\",\n        \"ups_load\": \"Beban UPS\",\n        \"ups_status\": \"Status UPS\",\n        \"online\": \"Online\",\n        \"on_battery\": \"Memakai Baterai\",\n        \"low_battery\": \"Baterai Lemah\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"Tidak ada Data Perangkat Diterima\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"Beban CPU\",\n        \"memoryUsed\": \"Memori Terpakai\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Leases\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Semua Strim\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"Channel XEPG\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Hari ini\",\n        \"absolutePower\": \"Daya\",\n        \"relativePower\": \"% Daya\",\n        \"limit\": \"Batas\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Memori Aktif\",\n        \"wanUpload\": \"WAN Unggan\",\n        \"wanDownload\": \"WAN Unduh\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Status Printer\",\n        \"print_status\": \"Status Cetakan\",\n        \"print_progress\": \"Progres\",\n        \"layers\": \"Layer\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Suhu Alat\",\n        \"temp_bed\": \"Suhu Fondasi\",\n        \"job_completion\": \"Tugas Selesai\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Sumber IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Beban Rata-rata\",\n        \"memory\": \"Penggunaan Memory\",\n        \"wanStatus\": \"Status WAN\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Penggunaan Disk\",\n        \"wanIP\": \"IP WAN\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datastore\",\n        \"failed_tasks_24h\": \"Tugas Gagal (24j)\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memory\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Foto\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Penyimpanan\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Situs Hidup\",\n        \"down\": \"Situs Mati\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Insiden\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Arsip\",\n        \"chapters\": \"Bab\",\n        \"categories\": \"Kategori\"\n    },\n    \"komga\": {\n        \"libraries\": \"Perpustakaan\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Isu\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"Orang\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Waktu\"\n    },\n    \"firefly\": {\n        \"networth\": \"Kekayaan Bersih\",\n        \"budget\": \"Anggaran\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Dasbor\",\n        \"datasources\": \"Sumber Data\",\n        \"totalalerts\": \"Jumlah Peringatan\",\n        \"alertstriggered\": \"Peringatan Terpicu\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Beban CPU\",\n        \"memoryusage\": \"Beban Memory\",\n        \"freespace\": \"Space Tersedia\",\n        \"activeusers\": \"Pengguna Aktif\",\n        \"numfiles\": \"File\",\n        \"numshares\": \"Item yang Dibagikan\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Ukuran\",\n        \"lastrun\": \"Terakhir Dijalankan\",\n        \"nextrun\": \"Akan Dijalankan Dalam\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Pengguna Aktif\",\n        \"total_workers\": \"Pengguna Total\",\n        \"records_total\": \"Panjang Antrian\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Server\",\n        \"nodes\": \"Node\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Target Aktif\",\n        \"targets_down\": \"Target Nonaktif\",\n        \"targets_total\": \"Target Total\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"Satu Tahun\",\n        \"gross_percent_max\": \"Sepanjang Masa\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcast\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Durasi\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Orang Di Rumah\",\n        \"lights_on\": \"Lampu Nyala\",\n        \"switches_on\": \"Sakelar Nyala\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Pengawasan\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Penulis\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Hasil\",\n        \"status\": \"Status\",\n        \"buildId\": \"Build ID\",\n        \"succeeded\": \"Berhasil\",\n        \"notStarted\": \"Belum Dimulai\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Dibatalkan\",\n        \"inProgress\": \"Sedang Berlangsung\",\n        \"totalPrs\": \"PR Total\",\n        \"myPrs\": \"PR Saya\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Nama\",\n        \"map\": \"Peta\",\n        \"currentPlayers\": \"Jumlah pemain\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Maksimum pemain\",\n        \"bots\": \"Bot\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Error\",\n        \"noRecent\": \"Tertinggal Versi\",\n        \"totalUsed\": \"Storage Terpakai\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Resep\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Tag\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Mengunduh\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"Beban rata2 CPU (5m)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Tersalur\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Terakhir Terhenti\",\n        \"downDuration\": \"Jumlah Waktu Terhenti\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Belum Di Cek\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Sepertinya Mati\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"Tersedia Di Bioskop\",\n        \"physicalRelease\": \"Rilis Fisik\",\n        \"digitalRelease\": \"Rilis Digital\",\n        \"noEventsToday\": \"Tidak ada acara untuk hari ini!\",\n        \"noEventsFound\": \"Tidak ada acara yang ditemukan\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platform\",\n        \"totalRoms\": \"Permainan\",\n        \"saves\": \"Saves\",\n        \"states\": \"Kondisi\",\n        \"screenshots\": \"Tangkapan Layar\",\n        \"totalfilesize\": \"Total Ukuran\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Kotak surat\",\n        \"mails\": \"Surat\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Peringatan\",\n        \"criticals\": \"Kritis\"\n    },\n    \"plantit\": {\n        \"events\": \"Acara\",\n        \"plants\": \"Tanaman\",\n        \"photos\": \"Photos\",\n        \"species\": \"Spesies\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Notifikasi\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Pull Requests\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Adegan\",\n        \"scenesPlayed\": \"Adegan Dimainkan\",\n        \"playCount\": \"Total Dimainkan\",\n        \"playDuration\": \"Waktu Ditonton\",\n        \"sceneSize\": \"Ukuran Adegan\",\n        \"sceneDuration\": \"Durasi Adegan\",\n        \"images\": \"Gambar\",\n        \"imageSize\": \"Ukuran Gambar\",\n        \"galleries\": \"Galeri\",\n        \"performers\": \"Pemain\",\n        \"studios\": \"Studio\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"Jumlah O\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Kata Kunci\"\n    },\n    \"homebox\": {\n        \"items\": \"Items\",\n        \"totalWithWarranty\": \"Dengan Garansi\",\n        \"locations\": \"Lokasi\",\n        \"labels\": \"Label\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Total Nilai\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Bans\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Diproksi\",\n        \"auth\": \"With Auth\",\n        \"outdated\": \"Usang\",\n        \"banned\": \"Banned\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Saham\",\n        \"loading\": \"Memuat\",\n        \"open\": \"Buka - Pasar AS\",\n        \"closed\": \"Tutup - Pasar AS\",\n        \"invalidConfiguration\": \"Konfigurasi Invalid\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Kamera\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Tautan\",\n        \"collections\": \"Koleksi\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Not classified\",\n        \"information\": \"Information\",\n        \"warning\": \"Peringatan\",\n        \"average\": \"Rata-rata\",\n        \"high\": \"Tinggi\",\n        \"disaster\": \"Bencana\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Kendaraan\",\n        \"vehicles\": \"Kendaraan\",\n        \"serviceRecords\": \"Catatan Servis\",\n        \"reminders\": \"Pengingat\",\n        \"nextReminder\": \"Pengingat Berikutnya\",\n        \"none\": \"Tidak ada\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Proyek Aktif\",\n        \"tasks7d\": \"Tugas Jatuh Tempo Minggu Ini\",\n        \"tasksOverdue\": \"Tugas Terlewat\",\n        \"tasksInProgress\": \"Tugas Berlangsung\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Sistem\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Diska\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Apl\",\n        \"synced\": \"Tersinkron\",\n        \"outOfSync\": \"Tidak Sinkron\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Terdegradasi\",\n        \"progressing\": \"Progressing\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Ditangguhkan\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Grup\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Proyek\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bookmarks\",\n        \"favorites\": \"Favorites\",\n        \"archived\": \"Archived\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Lists\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Other\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/it/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"m\",\n        \"days\": \"g\",\n        \"hours\": \"o\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Tipo del Widget Mancante: {{type}}\",\n        \"api_error\": \"Errore API\",\n        \"information\": \"Informazioni\",\n        \"status\": \"Stato\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Errore non processato\",\n        \"response_data\": \"Dati risposta\"\n    },\n    \"weather\": {\n        \"current\": \"Posizione Attuale\",\n        \"allow\": \"Clicca per consentire\",\n        \"updating\": \"Aggiornamento in corso\",\n        \"wait\": \"Attendi per favore\"\n    },\n    \"search\": {\n        \"placeholder\": \"Cerca…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"MEM\",\n        \"total\": \"Totale\",\n        \"free\": \"Libero\",\n        \"used\": \"In utilizzo\",\n        \"load\": \"Carico\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Max\",\n        \"uptime\": \"UP\"\n    },\n    \"unifi\": {\n        \"users\": \"Utenti\",\n        \"uptime\": \"Tempo di attività\",\n        \"days\": \"Giorni\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Dispositivi\",\n        \"lan_devices\": \"Dispositivi LAN\",\n        \"wlan_devices\": \"Dispositivi WLAN\",\n        \"lan_users\": \"Utenti LAN\",\n        \"wlan_users\": \"Utenti WLAN\",\n        \"up\": \"UP\",\n        \"down\": \"DOWN\",\n        \"wait\": \"Please wait\",\n        \"empty_data\": \"Stato del sottosistema sconosciuto\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"In esecuzione\",\n        \"offline\": \"Non in linea\",\n        \"error\": \"Errore\",\n        \"unknown\": \"Sconosciuto\",\n        \"healthy\": \"Sano\",\n        \"starting\": \"In avvio\",\n        \"unhealthy\": \"Non sano\",\n        \"not_found\": \"Non trovato\",\n        \"exited\": \"Uscito\",\n        \"partial\": \"Parziale\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"Ping\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Non disponibile\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"Stato HTTP\",\n        \"error\": \"Error\",\n        \"response\": \"Risposta\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"emby\": {\n        \"playing\": \"In riproduzione\",\n        \"transcoding\": \"Transcodifica\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"Nessuno Stream Attivo\",\n        \"movies\": \"Film\",\n        \"series\": \"Serie\",\n        \"episodes\": \"Episodi\",\n        \"songs\": \"Canzoni\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Serie\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Online\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Produzione\",\n        \"battery_soc\": \"Batteria\",\n        \"grid_power\": \"Griglia\",\n        \"home_power\": \"Consumo\",\n        \"charge_power\": \"Caricatore\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"In download\",\n        \"seed\": \"Seed\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Iscrizioni\",\n        \"unread\": \"Non letto\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Non configurato\",\n        \"connectionStatusConnecting\": \"Connessione in corso\",\n        \"connectionStatusAuthenticating\": \"In fase di autenticazione\",\n        \"connectionStatusPendingDisconnect\": \"In attesa di disconnessione\",\n        \"connectionStatusDisconnecting\": \"Disconnessione in corso\",\n        \"connectionStatusDisconnected\": \"Disconnesso\",\n        \"connectionStatusConnected\": \"Connesso\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"Max. Down\",\n        \"maxUp\": \"Max. Up\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"Ricevuti\",\n        \"sent\": \"Inviati\",\n        \"externalIPAddress\": \"IP Esterno\",\n        \"externalIPv6Address\": \"IPv6 Esterno\",\n        \"externalIPv6Prefix\": \"Prefisso IPv6 Esterno\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstream\",\n        \"requests\": \"Richieste correnti\",\n        \"requests_failed\": \"Richieste fallite\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Totale Osservati\",\n        \"diffsDetected\": \"Differenze Rilevate\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Spettacoli\",\n        \"recordings\": \"Registrazioni\",\n        \"scheduled\": \"Programmati\",\n        \"passes\": \"Tessere\"\n    },\n    \"tautulli\": {\n        \"playing\": \"In riproduzione\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"Controllare la connessione a Plex\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"AP Connessi\",\n        \"activeUser\": \"Dispositivi attivi\",\n        \"alerts\": \"Allarmi\",\n        \"connectedGateways\": \"Gateway connessi\",\n        \"connectedSwitches\": \"Switch connessi\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Rapporto\",\n        \"remaining\": \"Rimanente\",\n        \"downloaded\": \"Scaricato\"\n    },\n    \"plex\": {\n        \"streams\": \"Trasmissioni attive\",\n        \"albums\": \"Album\",\n        \"movies\": \"Movies\",\n        \"tv\": \"Programmi televisivi\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"Coda\",\n        \"timeleft\": \"Tempo Rimanente\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Attivo\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"Utilizzo CPU\",\n        \"memUsage\": \"Utilizzo MEM\",\n        \"systemTempC\": \"Temp sistema\",\n        \"poolUsage\": \"Utilizzo Pool\",\n        \"volumeUsage\": \"Utilizzo Volume\",\n        \"invalid\": \"Invalido\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Richiesti\",\n        \"queued\": \"In coda\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"Mancanti\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"Artisti\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"Libri\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Episodi Mancanti\",\n        \"missingMovies\": \"Film Mancanti\"\n    },\n    \"ombi\": {\n        \"pending\": \"In attesa\",\n        \"approved\": \"Approvati\",\n        \"available\": \"Disponibili\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"Nuovi Dispositivi\",\n        \"down_alerts\": \"Avvisi di Disservizio\"\n    },\n    \"pihole\": {\n        \"queries\": \"Richieste\",\n        \"blocked\": \"Bloccati\",\n        \"blocked_percent\": \"Bloccato %\",\n        \"gravity\": \"Gravity\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"Filtrati\",\n        \"latency\": \"Latenza\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"In esecuzione\",\n        \"stopped\": \"Fermati\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Scaricati\",\n        \"nondownload\": \"Non Scaricato\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Scaricato E Letto\",\n        \"downloadedunread\": \"Scaricato E Non Letto\",\n        \"nondownloadedread\": \"Non Scaricato E Letto\",\n        \"nondownloadedunread\": \"Non Scaricato E Non Letto\"\n    },\n    \"tailscale\": {\n        \"address\": \"Indirizzo\",\n        \"expires\": \"Scade\",\n        \"never\": \"Mai\",\n        \"last_seen\": \"Ultima visualizzazione\",\n        \"now\": \"Adesso\",\n        \"years\": \"{{number}}a\",\n        \"weeks\": \"{{number}}st\",\n        \"days\": \"{{number}}g\",\n        \"hours\": \"{{number}}o\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Fa\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"Successo\",\n        \"totalServerFailure\": \"Fallimenti\",\n        \"totalNxDomain\": \"Domini NX\",\n        \"totalRefused\": \"Rifiutato\",\n        \"totalAuthoritative\": \"Autoritario\",\n        \"totalRecursive\": \"Ricorsivo\",\n        \"totalCached\": \"In cache\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"Saltati\",\n        \"totalClients\": \"Client\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Elaborati\",\n        \"errored\": \"In errore\",\n        \"saved\": \"Salvati\"\n    },\n    \"traefik\": {\n        \"routers\": \"Router\",\n        \"services\": \"Servizi\",\n        \"middleware\": \"Middleware\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Attendere prego\"\n    },\n    \"npm\": {\n        \"enabled\": \"Abilitato\",\n        \"disabled\": \"Disabilitati\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Configurare una o più criptomonete da seguire\",\n        \"1hour\": \"1 Ora\",\n        \"1day\": \"1 Giorno\",\n        \"7days\": \"7 Giorni\",\n        \"30days\": \"30 Giorni\"\n    },\n    \"gotify\": {\n        \"apps\": \"Applicazioni\",\n        \"clients\": \"Clients\",\n        \"messages\": \"Messaggi\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indicizzatori\",\n        \"numberOfGrabs\": \"Grab\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"Grabs Falliti\",\n        \"numberOfFailQueries\": \"Queries Fallite\"\n    },\n    \"jackett\": {\n        \"configured\": \"Configurato\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sessioni\",\n        \"numConnections\": \"Connessioni\",\n        \"dataRelayed\": \"Ritrasmessi\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"Messaggi\",\n        \"domain_count\": \"Domini\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Giocatori\",\n        \"version\": \"Versione\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Letti\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"Accessi (24h)\",\n        \"failedLoginsLast24H\": \"Accessi Falliti (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"Macchine Virtuali\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temp.\",\n        \"warn\": \"Avviso\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Critico\",\n        \"read\": \"Read\",\n        \"write\": \"Scrittura\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem.\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Segnalibro\",\n        \"service\": \"Servizio\",\n        \"search\": \"Cerca\",\n        \"custom\": \"Personalizzato\",\n        \"visit\": \"Visita\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Suggerimenti\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Soleggiato\",\n        \"0-night\": \"Sereno\",\n        \"1-day\": \"Prevalentemente Soleggiato\",\n        \"1-night\": \"Prevalentemente Sereno\",\n        \"2-day\": \"Parzialmente Nuvoloso\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"Nuvoloso\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"Nebbioso\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"Pioggerella Leggera\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"Pioggerella\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"Pioggerella Pesante\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"Leggera Pioggia Gelata\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"Pioggerella Gelata\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"Pioggia Leggera\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"Pioggia\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Pioggia Intensa\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Grandine\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Leggera Nevicata\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Neve\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Nevicata Intensa\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Fiocchi di Neve\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Leggeri Rovesci\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Rovesci\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Intensi Rovesci\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Rovesci di Neve\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"Temporale\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Temporale con grandine\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Sistema\",\n        \"updates\": \"Aggiornamenti\",\n        \"update_available\": \"Aggiornamento Disponibile\",\n        \"up_to_date\": \"Aggiornato\",\n        \"child_bridges\": \"Bridge Figli\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Nuovo\",\n        \"up\": \"Up\",\n        \"grace\": \"Periodo di Tolleranza\",\n        \"down\": \"Down\",\n        \"paused\": \"In Pausa\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Ultimo Ping\",\n        \"never\": \"Ancora nessun ping\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Scansionato\",\n        \"containers_updated\": \"Aggiornato\",\n        \"containers_failed\": \"Fallito\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Rifiutato\",\n        \"filters\": \"Filtri\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Video\",\n        \"channels\": \"Canali\",\n        \"playlists\": \"Playlist\"\n    },\n    \"truenas\": {\n        \"load\": \"Carico di Sistema\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Velocità\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"IP pubblico\",\n        \"region\": \"Località\",\n        \"country\": \"Paese\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Regolatori\",\n        \"channelNumber\": \"Canale\",\n        \"channelNetwork\": \"Rete\",\n        \"signalStrength\": \"Intensità\",\n        \"signalQuality\": \"Qualità\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Client\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Passati\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"In arrivo\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Carica Batteria\",\n        \"ups_load\": \"Carico UPS\",\n        \"ups_status\": \"Stato UPS\",\n        \"online\": \"Online\",\n        \"on_battery\": \"Alimentazione a batteria\",\n        \"low_battery\": \"Batteria Quasi Scarica\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"Nessun dato del dispositivo ricevuto\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"Carico della CPU\",\n        \"memoryUsed\": \"Memoria Utilizzata\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Rilasci\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Tutti gli stream\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"Canali XEPG\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Oggi\",\n        \"absolutePower\": \"Potenza\",\n        \"relativePower\": \"Potenza %\",\n        \"limit\": \"Limite\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Memoria in uso\",\n        \"wanUpload\": \"WAN Upload\",\n        \"wanDownload\": \"WAN Download\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Stato stampante\",\n        \"print_status\": \"Stato Stampante\",\n        \"print_progress\": \"Avanzamento\",\n        \"layers\": \"Livelli\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Temp. utensile\",\n        \"temp_bed\": \"Temp. letto\",\n        \"job_completion\": \"Completamento\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"IP sorgente\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Carico Medio\",\n        \"memory\": \"Uso Memoria\",\n        \"wanStatus\": \"Stato WAN\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Uso Disco\",\n        \"wanIP\": \"IP WAN\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Archivio dati\",\n        \"failed_tasks_24h\": \"Attività Non Riuscite 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memoria\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Foto\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Archiviazione\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Siti On\",\n        \"down\": \"Siti Down\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Incidente\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Archivi\",\n        \"chapters\": \"Capitoli\",\n        \"categories\": \"Categorie\"\n    },\n    \"komga\": {\n        \"libraries\": \"Librerie\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Disponibili\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Problemi\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"Persone\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Tempo\"\n    },\n    \"firefly\": {\n        \"networth\": \"Valore Netto\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Dashboard\",\n        \"datasources\": \"Origine dei Dati\",\n        \"totalalerts\": \"Avvisi Totali\",\n        \"alertstriggered\": \"Avvisi Attivati\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Carico della CPU\",\n        \"memoryusage\": \"Uso della Memoria\",\n        \"freespace\": \"Spazio Libero\",\n        \"activeusers\": \"Utenti Attivi\",\n        \"numfiles\": \"File\",\n        \"numshares\": \"Oggetti Condivisi\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Dimensione\",\n        \"lastrun\": \"Ultima esecuzione\",\n        \"nextrun\": \"Prossima esecuzione\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Lavoratori Attivi\",\n        \"total_workers\": \"Lavoratori Totali\",\n        \"records_total\": \"Lunghezza della Coda\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Server\",\n        \"nodes\": \"Nodi\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Target Attivi\",\n        \"targets_down\": \"Target Non Attivi\",\n        \"targets_total\": \"Targets Totali\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"Un anno\",\n        \"gross_percent_max\": \"Sempre\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcast\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Durata\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Persone a Casa\",\n        \"lights_on\": \"Luci Accese\",\n        \"switches_on\": \"Switch Accesi\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Monitoraggio\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Autori\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Risultato\",\n        \"status\": \"Status\",\n        \"buildId\": \"ID Build\",\n        \"succeeded\": \"Riuscito\",\n        \"notStarted\": \"Non Avviato\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Cancellato\",\n        \"inProgress\": \"In corso\",\n        \"totalPrs\": \"PR Totali\",\n        \"myPrs\": \"Miei PR\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Nome\",\n        \"map\": \"Mappa\",\n        \"currentPlayers\": \"Giocatori attuali\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Giocatori max\",\n        \"bots\": \"Bot\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Errori\",\n        \"noRecent\": \"Obsoleto\",\n        \"totalUsed\": \"Spazio usato\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Ricette\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Tag\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Download in corso\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"Media Carico Cpu (5m)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Trasmessi\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Ultimo periodo di inattività\",\n        \"downDuration\": \"Durata inattività\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Non ancora controllati\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Sembrano non attivi\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"Al cinema\",\n        \"physicalRelease\": \"Release fisici\",\n        \"digitalRelease\": \"Versione digitale\",\n        \"noEventsToday\": \"Nessun evento per oggi!\",\n        \"noEventsFound\": \"Nessun evento trovato\",\n        \"errorWhenLoadingData\": \"Errore durante il caricamento dei dati del calendario\"\n    },\n    \"romm\": {\n        \"platforms\": \"Piattaforme\",\n        \"totalRoms\": \"Giochi\",\n        \"saves\": \"Salvati\",\n        \"states\": \"Stati\",\n        \"screenshots\": \"Screenshot\",\n        \"totalfilesize\": \"Dimensioni totali\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Caselle di posta\",\n        \"mails\": \"Mail\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Avvisi\",\n        \"criticals\": \"Critici\"\n    },\n    \"plantit\": {\n        \"events\": \"Eventi\",\n        \"plants\": \"Piante\",\n        \"photos\": \"Photos\",\n        \"species\": \"Specie\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Notifiche\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Richieste di Pull\",\n        \"repositories\": \"Repository\"\n    },\n    \"stash\": {\n        \"scenes\": \"Scene\",\n        \"scenesPlayed\": \"Scene Riprodotte\",\n        \"playCount\": \"Totale Riproduzioni\",\n        \"playDuration\": \"Tempo Guardato\",\n        \"sceneSize\": \"Dimensione Delle Scene\",\n        \"sceneDuration\": \"Durata Delle Scene\",\n        \"images\": \"Immagini\",\n        \"imageSize\": \"Dimensioni immagine\",\n        \"galleries\": \"Gallerie\",\n        \"performers\": \"Esecutori\",\n        \"studios\": \"Studi\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O Count\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Parole chiave\"\n    },\n    \"homebox\": {\n        \"items\": \"Elementi\",\n        \"totalWithWarranty\": \"Con Garanzia\",\n        \"locations\": \"Luoghi\",\n        \"labels\": \"Etichette\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Valore totale\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Bannati\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxato\",\n        \"auth\": \"Con Autenticazione\",\n        \"outdated\": \"Obsoleto\",\n        \"banned\": \"Bannato\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Azioni\",\n        \"loading\": \"Caricamento\",\n        \"open\": \"Aperto - Mercato USA\",\n        \"closed\": \"Chiuso - Mercato USA\",\n        \"invalidConfiguration\": \"Configurazione non valida\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Telecamere\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Collegamenti\",\n        \"collections\": \"Raccolte\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Non classificato\",\n        \"information\": \"Information\",\n        \"warning\": \"Avviso\",\n        \"average\": \"Media\",\n        \"high\": \"Alto\",\n        \"disaster\": \"Disastri\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Veicolo\",\n        \"vehicles\": \"Veicoli\",\n        \"serviceRecords\": \"Record Di Servizio\",\n        \"reminders\": \"Promemoria\",\n        \"nextReminder\": \"Promemoria Seguente\",\n        \"none\": \"Nessuno\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Progetti attivi\",\n        \"tasks7d\": \"Attività Settimanali\",\n        \"tasksOverdue\": \"Task scaduti\",\n        \"tasksInProgress\": \"Task In Corso\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Sistemi\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Disco\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Applicazioni\",\n        \"synced\": \"Sincronizzato\",\n        \"outOfSync\": \"Non Sincronizzato\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Degradato\",\n        \"progressing\": \"Progressione\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Sospeso\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Gruppi\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Richieste di merge\",\n        \"projects\": \"Progetti\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Segnalibri\",\n        \"favorites\": \"Preferiti\",\n        \"archived\": \"Archiviato\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Liste\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Aggiornamento\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Download\",\n        \"uploads\": \"Caricamenti\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Brani\",\n        \"movies\": \"Film\",\n        \"episodes\": \"Episodi\",\n        \"other\": \"Altro\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Problemi di servizio\",\n        \"hostErrors\": \"Problemi di host\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Abbonamenti\",\n        \"thisMonthlyCost\": \"Questo Mese\",\n        \"nextMonthlyCost\": \"Mese Prossimo\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/ja/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"月\",\n        \"days\": \"日\",\n        \"hours\": \"時間\",\n        \"minutes\": \"分\",\n        \"seconds\": \"秒\"\n    },\n    \"widget\": {\n        \"missing_type\": \"見つからないウィジェットタイプ: {{type}}\",\n        \"api_error\": \"APIエラー\",\n        \"information\": \"情報\",\n        \"status\": \"状態\",\n        \"url\": \"URL\",\n        \"raw_error\": \"生のエラー\",\n        \"response_data\": \"レスポンスデータ\"\n    },\n    \"weather\": {\n        \"current\": \"現在地\",\n        \"allow\": \"クリックで許可\",\n        \"updating\": \"アップデート中\",\n        \"wait\": \"お待ちください\"\n    },\n    \"search\": {\n        \"placeholder\": \"検索…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"MEM\",\n        \"total\": \"合計\",\n        \"free\": \"空き\",\n        \"used\": \"使用\",\n        \"load\": \"ロード\",\n        \"temp\": \"温度\",\n        \"max\": \"最大\",\n        \"uptime\": \"UP\"\n    },\n    \"unifi\": {\n        \"users\": \"ユーザ\",\n        \"uptime\": \"稼働時間\",\n        \"days\": \"日\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"Wi-Fi\",\n        \"devices\": \"デバイス\",\n        \"lan_devices\": \"LAN デバイス\",\n        \"wlan_devices\": \"WLAN デバイス\",\n        \"lan_users\": \"LAN ユーザ\",\n        \"wlan_users\": \"WLAN ユーザ\",\n        \"up\": \"UP\",\n        \"down\": \"下へ\",\n        \"wait\": \"お待ちください\",\n        \"empty_data\": \"サブシステムの状態は不明\"\n    },\n    \"docker\": {\n        \"rx\": \"受信済み\",\n        \"tx\": \"送信済み\",\n        \"mem\": \"メモリ\",\n        \"cpu\": \"CPU\",\n        \"running\": \"起動中\",\n        \"offline\": \"オフライン\",\n        \"error\": \"エラー\",\n        \"unknown\": \"不明\",\n        \"healthy\": \"正常\",\n        \"starting\": \"起動中\",\n        \"unhealthy\": \"非健全\",\n        \"not_found\": \"不明\",\n        \"exited\": \"停止しました\",\n        \"partial\": \"部分的\"\n    },\n    \"ping\": {\n        \"error\": \"エラー\",\n        \"ping\": \"Ping\",\n        \"down\": \"下へ\",\n        \"up\": \"稼働\",\n        \"not_available\": \"利用できません。\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP ステータス\",\n        \"error\": \"エラー\",\n        \"response\": \"応答\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"利用できません\"\n    },\n    \"emby\": {\n        \"playing\": \"再生中\",\n        \"transcoding\": \"変換中\",\n        \"bitrate\": \"ビットレート\",\n        \"no_active\": \"アクティブ・ストリーム無し\",\n        \"movies\": \"映画\",\n        \"series\": \"シリーズ\",\n        \"episodes\": \"エピソード\",\n        \"songs\": \"曲\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"\",\n        \"offline_alt\": \"オフライン\",\n        \"online\": \"オンライン\",\n        \"total\": \"Total\",\n        \"unknown\": \"不明\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"発電量\",\n        \"battery_soc\": \"バッテリー\",\n        \"grid_power\": \"グリッド\",\n        \"home_power\": \"消費\",\n        \"charge_power\": \"チャージャー\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"ダウンロード\",\n        \"upload\": \"アップロード\",\n        \"leech\": \"リーチ\",\n        \"seed\": \"シード\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"購読\",\n        \"unread\": \"未読\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"未設定\",\n        \"connectionStatusConnecting\": \"接続中\",\n        \"connectionStatusAuthenticating\": \"認証中\",\n        \"connectionStatusPendingDisconnect\": \"接続を切断する\",\n        \"connectionStatusDisconnecting\": \"接続を切断中\",\n        \"connectionStatusDisconnected\": \"切断されました\",\n        \"connectionStatusConnected\": \"接続済\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"最大ダウン\",\n        \"maxUp\": \"最大アップ\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"受信済み\",\n        \"sent\": \"送信済み\",\n        \"externalIPAddress\": \"退出ID\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"アップストリーム\",\n        \"requests\": \"現在のリクエスト\",\n        \"requests_failed\": \"失敗したリクエスト\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"全観測数\",\n        \"diffsDetected\": \"変更数\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"表示\",\n        \"recordings\": \"レコーディング\",\n        \"scheduled\": \"予定済\",\n        \"passes\": \"パス\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"Plex接続の確認\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"接続されたAP\",\n        \"activeUser\": \"アクティブデバイス\",\n        \"alerts\": \"アラート\",\n        \"connectedGateways\": \"Connected gateways\",\n        \"connectedSwitches\": \"接続スイッチ\"\n    },\n    \"nzbget\": {\n        \"rate\": \"速度\",\n        \"remaining\": \"残り\",\n        \"downloaded\": \"ダウンロード\"\n    },\n    \"plex\": {\n        \"streams\": \"アクティブストリーム\",\n        \"albums\": \"アルバム\",\n        \"movies\": \"Movies\",\n        \"tv\": \"テレビ番組\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"キュー\",\n        \"timeleft\": \"残り時間\"\n    },\n    \"rutorrent\": {\n        \"active\": \"アクティブ\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU使用量\",\n        \"memUsage\": \"MEM使用量\",\n        \"systemTempC\": \"システム温度\",\n        \"poolUsage\": \"プール使用量\",\n        \"volumeUsage\": \"ボリューム使用量\",\n        \"invalid\": \"無効\"\n    },\n    \"deluge\": {\n        \"download\": \"ダウンロード\",\n        \"upload\": \"アップロード\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"キャッシュ・ヒットバイト\",\n        \"cachemissbytes\": \"キャッシュミスバイト\"\n    },\n    \"downloadstation\": {\n        \"download\": \"ダウンロード\",\n        \"upload\": \"アップロード\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"募集中\",\n        \"queued\": \"待機中\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"不明\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"不明\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"アーティスト\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"書籍\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"欠番エピソード\",\n        \"missingMovies\": \"動画が見つかりません\"\n    },\n    \"ombi\": {\n        \"pending\": \"保留中\",\n        \"approved\": \"承認済\",\n        \"available\": \"利用可\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"新規デバイス\",\n        \"down_alerts\": \"ダウンアラート\"\n    },\n    \"pihole\": {\n        \"queries\": \"クエリ\",\n        \"blocked\": \"ブロック中\",\n        \"blocked_percent\": \"ブロック %\",\n        \"gravity\": \"グラビティ\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"フィルタ済\",\n        \"latency\": \"遅延\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"停止中\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Non-Downloaded\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Downloaded & Read\",\n        \"downloadedunread\": \"Downloaded & Unread\",\n        \"nondownloadedread\": \"Non-Downloaded & Read\",\n        \"nondownloadedunread\": \"Non-Downloaded & Unread\"\n    },\n    \"tailscale\": {\n        \"address\": \"アドレス\",\n        \"expires\": \"失効\",\n        \"never\": \"なし\",\n        \"last_seen\": \"最終日時\",\n        \"now\": \"現在\",\n        \"years\": \"{{number}}年\",\n        \"weeks\": \"{{number}}月\",\n        \"days\": \"{{number}}日\",\n        \"hours\": \"{{number}}時間\",\n        \"minutes\": \"{{number}}分\",\n        \"seconds\": \"{{number}}秒\",\n        \"ago\": \"{{value}} 前\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"成功\",\n        \"totalServerFailure\": \"失敗\",\n        \"totalNxDomain\": \"NXドメイン\",\n        \"totalRefused\": \"拒否\",\n        \"totalAuthoritative\": \"正式\",\n        \"totalRecursive\": \"再帰的\",\n        \"totalCached\": \"キャッシュ済み\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"ドロップ済み\",\n        \"totalClients\": \"クライアント\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"処理済み\",\n        \"errored\": \"エラー\",\n        \"saved\": \"保存\"\n    },\n    \"traefik\": {\n        \"routers\": \"ルーター\",\n        \"services\": \"サービス\",\n        \"middleware\": \"ミドルウェア\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"お待ちください\"\n    },\n    \"npm\": {\n        \"enabled\": \"有効\",\n        \"disabled\": \"無効\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"1つ以上の暗号通貨を設定して追跡\",\n        \"1hour\": \"1時間\",\n        \"1day\": \"1日\",\n        \"7days\": \"7日間\",\n        \"30days\": \"30日間\"\n    },\n    \"gotify\": {\n        \"apps\": \"アプリケーション\",\n        \"clients\": \"Clients\",\n        \"messages\": \"メッセージ\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"インデックス\",\n        \"numberOfGrabs\": \"Grab\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"失敗したグラブ\",\n        \"numberOfFailQueries\": \"失敗クエリー\"\n    },\n    \"jackett\": {\n        \"configured\": \"設定済\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"セッション\",\n        \"numConnections\": \"コネクション\",\n        \"dataRelayed\": \"中継\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"ポスト\",\n        \"domain_count\": \"ドメイン\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"プレイヤー\",\n        \"version\": \"バージョン\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"既読\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"ログイン (24時間)\",\n        \"failedLoginsLast24H\": \"ログイン失敗(24時間)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VM\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"温度\",\n        \"warn\": \"警告\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"クリティカル\",\n        \"read\": \"Read\",\n        \"write\": \"書き込み\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"メモリ\",\n        \"swap\": \"スワップ\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"ブックマーク\",\n        \"service\": \"サービス\",\n        \"search\": \"検索\",\n        \"custom\": \"カスタム\",\n        \"visit\": \"訪問\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"提案\"\n    },\n    \"wmo\": {\n        \"0-day\": \"晴れ\",\n        \"0-night\": \"晴れ\",\n        \"1-day\": \"晴れ時々曇り\",\n        \"1-night\": \"晴れ時々曇り\",\n        \"2-day\": \"曇り時々晴れ\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"曇り\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"霧\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"霧雨\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"小雨\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"霧雨\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"着氷性の霧雨\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"着氷性の小雨\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"小雨\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"雨\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"大雨\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"着氷性の雨\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"小雪\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"雪\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"大雪\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"霧雪\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"弱いにわか雨\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"にわか雨\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"強いにわか雨\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"にわか雪\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"雷雨\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"雷雨・ひょう\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"システム\",\n        \"updates\": \"アップデート\",\n        \"update_available\": \"更新あり\",\n        \"up_to_date\": \"最新\",\n        \"child_bridges\": \"子ブリッジ\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"新着\",\n        \"up\": \"Up\",\n        \"grace\": \"猶予期間中\",\n        \"down\": \"Down\",\n        \"paused\": \"一時停止中\",\n        \"status\": \"Status\",\n        \"last_ping\": \"最後のPing\",\n        \"never\": \"Pingしていません\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"スキャン済\",\n        \"containers_updated\": \"更新済\",\n        \"containers_failed\": \"失敗\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"却下\",\n        \"filters\": \"フィルター\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"ビデオ\",\n        \"channels\": \"チャンネル\",\n        \"playlists\": \"プレイリスト\"\n    },\n    \"truenas\": {\n        \"load\": \"システム負荷\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"速度\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"パブリックIP\",\n        \"region\": \"地域\",\n        \"country\": \"国\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"チューナー\",\n        \"channelNumber\": \"チャンネル\",\n        \"channelNetwork\": \"ネットワーク\",\n        \"signalStrength\": \"強さ\",\n        \"signalQuality\": \"クオリティ\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"クライアント IP\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"合格\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"受信トレイ\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"バッテリー充電\",\n        \"ups_load\": \"UPS 負荷\",\n        \"ups_status\": \"UPS 状態\",\n        \"online\": \"Online\",\n        \"on_battery\": \"バッテリー稼働中\",\n        \"low_battery\": \"バッテリー残量低下\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"デバイス データを受信していません\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"CPU負荷\",\n        \"memoryUsed\": \"メモリ使用量\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"リース\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"すべてのストリーム\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPGチャンネル\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"今日\",\n        \"absolutePower\": \"電源\",\n        \"relativePower\": \"電源 %\",\n        \"limit\": \"リミット\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"アクティブ・メモリ\",\n        \"wanUpload\": \"WANアップロード\",\n        \"wanDownload\": \"WANダウンロード\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"プリンタの状態\",\n        \"print_status\": \"印刷状況\",\n        \"print_progress\": \"進捗状況\",\n        \"layers\": \"レイヤー\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"ツール温度\",\n        \"temp_bed\": \"ベッド温度\",\n        \"job_completion\": \"完了\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"オリジンIP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"読み込み平均\",\n        \"memory\": \"メモリ使用量\",\n        \"wanStatus\": \"WANステータス\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"ディスク使用量\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"データストア\",\n        \"failed_tasks_24h\": \"失敗タスク(24h)\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"メモリ\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"写真\",\n        \"videos\": \"Videos\",\n        \"storage\": \"ストレージ\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"サイトUp\",\n        \"down\": \"サイトDown\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"事件\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"アーカイブ\",\n        \"chapters\": \"チャプター\",\n        \"categories\": \"カテゴリー\"\n    },\n    \"komga\": {\n        \"libraries\": \"ライブラリ\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"稼働時間\",\n        \"volumeAvailable\": \"利用可能\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"課題\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"人\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"時間\"\n    },\n    \"firefly\": {\n        \"networth\": \"Net Worth\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"ダッシュ ボード\",\n        \"datasources\": \"データソース\",\n        \"totalalerts\": \"アラート総数\",\n        \"alertstriggered\": \"トリガーされたアラート\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"CPU負荷\",\n        \"memoryusage\": \"メモリ使用量\",\n        \"freespace\": \"空き容量\",\n        \"activeusers\": \"アクティブユーザー\",\n        \"numfiles\": \"ファイル\",\n        \"numshares\": \"共有アイテム\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"サイズ\",\n        \"lastrun\": \"前回の実行\",\n        \"nextrun\": \"次回の実行\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"アクティブ・ワーカー\",\n        \"total_workers\": \"トータル・ワーカー\",\n        \"records_total\": \"キューの長さ\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"サーバ\",\n        \"nodes\": \"ノード\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"ターゲットUp\",\n        \"targets_down\": \"ターゲット Down\",\n        \"targets_total\": \"ターゲット合計\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"稼働時間\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"1年\",\n        \"gross_percent_max\": \"全期間\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"ポッドキャスト\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"時間\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"ホーム人数\",\n        \"lights_on\": \"点灯\",\n        \"switches_on\": \"スイッチオン\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"モニタリング\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"著者\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"結果\",\n        \"status\": \"Status\",\n        \"buildId\": \"ビルドID\",\n        \"succeeded\": \"成功\",\n        \"notStarted\": \"開始していません\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"キャンセル\",\n        \"inProgress\": \"進行中\",\n        \"totalPrs\": \"合計PR数\",\n        \"myPrs\": \"私のPR\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"名前\",\n        \"map\": \"マップ\",\n        \"currentPlayers\": \"現在のプレーヤー\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"最大プレイヤー数\",\n        \"bots\": \"ボット\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"正常\",\n        \"errored\": \"エラー\",\n        \"noRecent\": \"期限切れ\",\n        \"totalUsed\": \"使用済みストレージ\"\n    },\n    \"mealie\": {\n        \"recipes\": \"レシピ\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"タグ\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"ダウンロード中\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"CPU 平均負荷（5 分）\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"送信済み\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"最後のダウンタイム\",\n        \"downDuration\": \"ダウンタイム時間\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"チェックされていません\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"ダウンしているようです\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"映画館内\",\n        \"physicalRelease\": \"物理的なリリース\",\n        \"digitalRelease\": \"デジタル・リリース\",\n        \"noEventsToday\": \"本日の予定なし\",\n        \"noEventsFound\": \"予定が見つかりません\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"プラットフォーム\",\n        \"totalRoms\": \"ゲーム\",\n        \"saves\": \"保存\",\n        \"states\": \"状態\",\n        \"screenshots\": \"スクリーンショット\",\n        \"totalfilesize\": \"合計サイズ\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Mailboxes\",\n        \"mails\": \"Mails\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"警告\",\n        \"criticals\": \"重大\"\n    },\n    \"plantit\": {\n        \"events\": \"イベント\",\n        \"plants\": \"植物\",\n        \"photos\": \"Photos\",\n        \"species\": \"種\"\n    },\n    \"gitea\": {\n        \"notifications\": \"通知\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"プルリクエスト\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"シーン\",\n        \"scenesPlayed\": \"再生されたシーン\",\n        \"playCount\": \"合計再生数\",\n        \"playDuration\": \"視聴時間\",\n        \"sceneSize\": \"シーンサイズ\",\n        \"sceneDuration\": \"シーンの長さ\",\n        \"images\": \"画像\",\n        \"imageSize\": \"画像サイズ\",\n        \"galleries\": \"ギャラリー\",\n        \"performers\": \"出演者\",\n        \"studios\": \"スタジオ\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O カウント\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"キーワード\"\n    },\n    \"homebox\": {\n        \"items\": \"アイテム\",\n        \"totalWithWarranty\": \"保証付き\",\n        \"locations\": \"場所\",\n        \"labels\": \"ラベル\",\n        \"users\": \"Users\",\n        \"totalValue\": \"合計値\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"禁止\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"プロキシ済\",\n        \"auth\": \"認証あり\",\n        \"outdated\": \"最新の状態ではありません\",\n        \"banned\": \"禁止\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"在庫\",\n        \"loading\": \"読み込み中\",\n        \"open\": \"オープン - 米国市場\",\n        \"closed\": \"クローズ - 米国市場\",\n        \"invalidConfiguration\": \"無効な設定\"\n    },\n    \"frigate\": {\n        \"cameras\": \"カメラ\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"リンク\",\n        \"collections\": \"コレクション\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Not classified\",\n        \"information\": \"Information\",\n        \"warning\": \"警告\",\n        \"average\": \"平均\",\n        \"high\": \"高い\",\n        \"disaster\": \"災害\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"車両\",\n        \"vehicles\": \"Vehicles\",\n        \"serviceRecords\": \"Service Records\",\n        \"reminders\": \"Reminders\",\n        \"nextReminder\": \"Next Reminder\",\n        \"none\": \"None\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Active Projects\",\n        \"tasks7d\": \"Tasks Due This Week\",\n        \"tasksOverdue\": \"Overdue Tasks\",\n        \"tasksInProgress\": \"Tasks In Progress\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Systems\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Disk\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Apps\",\n        \"synced\": \"Synced\",\n        \"outOfSync\": \"Out Of Sync\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Degraded\",\n        \"progressing\": \"Progressing\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Suspended\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Groups\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Projects\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bookmarks\",\n        \"favorites\": \"Favorites\",\n        \"archived\": \"Archived\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Lists\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Other\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/ko/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"개월\",\n        \"days\": \"일\",\n        \"hours\": \"시간\",\n        \"minutes\": \"분\",\n        \"seconds\": \"초\"\n    },\n    \"widget\": {\n        \"missing_type\": \"없는 위젯 유형: {{type}}\",\n        \"api_error\": \"API 오류\",\n        \"information\": \"정보\",\n        \"status\": \"상태\",\n        \"url\": \"URL\",\n        \"raw_error\": \"raw 오류\",\n        \"response_data\": \"응답 데이터\"\n    },\n    \"weather\": {\n        \"current\": \"현재 위치\",\n        \"allow\": \"클릭하여 허용\",\n        \"updating\": \"갱신 중\",\n        \"wait\": \"잠시만 기다리세요\"\n    },\n    \"search\": {\n        \"placeholder\": \"검색…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"메모리\",\n        \"total\": \"총합\",\n        \"free\": \"남음\",\n        \"used\": \"사용\",\n        \"load\": \"부하\",\n        \"temp\": \"온도\",\n        \"max\": \"최대\",\n        \"uptime\": \"가동\"\n    },\n    \"unifi\": {\n        \"users\": \"사용자\",\n        \"uptime\": \"가동 시간\",\n        \"days\": \"일\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"장치\",\n        \"lan_devices\": \"LAN 장치\",\n        \"wlan_devices\": \"WLAN 장치\",\n        \"lan_users\": \"LAN 사용자\",\n        \"wlan_users\": \"WLAN 사용자\",\n        \"up\": \"업\",\n        \"down\": \"다운\",\n        \"wait\": \"잠시만 기다려주세요\",\n        \"empty_data\": \"서브시스템 상태 알 수 없음\"\n    },\n    \"docker\": {\n        \"rx\": \"수신\",\n        \"tx\": \"송신\",\n        \"mem\": \"메모리\",\n        \"cpu\": \"CPU\",\n        \"running\": \"실행 중\",\n        \"offline\": \"중지\",\n        \"error\": \"오류\",\n        \"unknown\": \"알 수 없음\",\n        \"healthy\": \"정상\",\n        \"starting\": \"시작 중\",\n        \"unhealthy\": \"비정상\",\n        \"not_found\": \"찾을 수 없음\",\n        \"exited\": \"종료됨\",\n        \"partial\": \"부분적\"\n    },\n    \"ping\": {\n        \"error\": \"오류\",\n        \"ping\": \"핑\",\n        \"down\": \"다운\",\n        \"up\": \"업\",\n        \"not_available\": \"사용 불가\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP 상태\",\n        \"error\": \"오류\",\n        \"response\": \"응답\",\n        \"down\": \"다운\",\n        \"up\": \"업\",\n        \"not_available\": \"사용 불가\"\n    },\n    \"emby\": {\n        \"playing\": \"재생 중\",\n        \"transcoding\": \"트랜스코딩 중\",\n        \"bitrate\": \"비트레이트\",\n        \"no_active\": \"활성 스트림 없음\",\n        \"movies\": \"영화\",\n        \"series\": \"시리즈\",\n        \"episodes\": \"에피소드\",\n        \"songs\": \"음악\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"오프라인\",\n        \"offline_alt\": \"오프라인\",\n        \"online\": \"온라인\",\n        \"total\": \"전체\",\n        \"unknown\": \"알 수 없음\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"생산량\",\n        \"battery_soc\": \"배터리\",\n        \"grid_power\": \"그리드\",\n        \"home_power\": \"소비량\",\n        \"charge_power\": \"충전전력\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"다운로드\",\n        \"upload\": \"업로드\",\n        \"leech\": \"리치\",\n        \"seed\": \"시드\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"구독\",\n        \"unread\": \"미열람\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"상태\",\n        \"connectionStatusUnconfigured\": \"구성되지 않음\",\n        \"connectionStatusConnecting\": \"연결 중\",\n        \"connectionStatusAuthenticating\": \"인증 중\",\n        \"connectionStatusPendingDisconnect\": \"연결 끊기 보류 중\",\n        \"connectionStatusDisconnecting\": \"연결 끊는 중\",\n        \"connectionStatusDisconnected\": \"연결 끊김\",\n        \"connectionStatusConnected\": \"연결됨\",\n        \"uptime\": \"가동 시간\",\n        \"maxDown\": \"최대 다운로드\",\n        \"maxUp\": \"최대 업로드\",\n        \"down\": \"다운로드\",\n        \"up\": \"업로드\",\n        \"received\": \"수신\",\n        \"sent\": \"송신\",\n        \"externalIPAddress\": \"외부 IP\",\n        \"externalIPv6Address\": \"외부 IPv6\",\n        \"externalIPv6Prefix\": \"외부 IPv6 접두사\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"업스트림\",\n        \"requests\": \"현재 요청\",\n        \"requests_failed\": \"실패한 요청\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"총 관찰 수\",\n        \"diffsDetected\": \"변경 감지됨\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"보기\",\n        \"recordings\": \"녹화\",\n        \"scheduled\": \"예약됨\",\n        \"passes\": \"패스\"\n    },\n    \"tautulli\": {\n        \"playing\": \"재생 중\",\n        \"transcoding\": \"트랜스코딩 중\",\n        \"bitrate\": \"비트레이트\",\n        \"no_active\": \"활성 스트림 없음\",\n        \"plex_connection_error\": \"Plex 연결 확인\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"연결된 AP\",\n        \"activeUser\": \"활성 장치\",\n        \"alerts\": \"경고\",\n        \"connectedGateways\": \"연결된 게이트웨이\",\n        \"connectedSwitches\": \"연결된 스위치\"\n    },\n    \"nzbget\": {\n        \"rate\": \"속도\",\n        \"remaining\": \"남음\",\n        \"downloaded\": \"다운로드됨\"\n    },\n    \"plex\": {\n        \"streams\": \"활성 스트림\",\n        \"albums\": \"앨범\",\n        \"movies\": \"영화\",\n        \"tv\": \"TV 쇼\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"속도\",\n        \"queue\": \"대기열\",\n        \"timeleft\": \"남은 시간\"\n    },\n    \"rutorrent\": {\n        \"active\": \"활성\",\n        \"upload\": \"업로드\",\n        \"download\": \"다운로드\"\n    },\n    \"transmission\": {\n        \"download\": \"다운로드\",\n        \"upload\": \"업로드\",\n        \"leech\": \"리치\",\n        \"seed\": \"시드\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"다운로드\",\n        \"upload\": \"업로드\",\n        \"leech\": \"리치\",\n        \"seed\": \"시드\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU 사용량\",\n        \"memUsage\": \"메모리 사용량\",\n        \"systemTempC\": \"시스템 온도\",\n        \"poolUsage\": \"풀 사용량\",\n        \"volumeUsage\": \"볼륨 사용량\",\n        \"invalid\": \"유효하지 않음\"\n    },\n    \"deluge\": {\n        \"download\": \"다운로드\",\n        \"upload\": \"업로드\",\n        \"leech\": \"리치\",\n        \"seed\": \"시드\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"캐시 히트 용량\",\n        \"cachemissbytes\": \"캐시 미스 용량\"\n    },\n    \"downloadstation\": {\n        \"download\": \"다운로드\",\n        \"upload\": \"업로드\",\n        \"leech\": \"리치\",\n        \"seed\": \"시드\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"요청됨\",\n        \"queued\": \"대기열\",\n        \"series\": \"시리즈\",\n        \"queue\": \"대기열\",\n        \"unknown\": \"알 수 없음\"\n    },\n    \"radarr\": {\n        \"wanted\": \"요청됨\",\n        \"missing\": \"누락됨\",\n        \"queued\": \"대기열\",\n        \"movies\": \"영화\",\n        \"queue\": \"대기열\",\n        \"unknown\": \"알 수 없음\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"요청됨\",\n        \"queued\": \"대기열\",\n        \"artists\": \"아티스트\"\n    },\n    \"readarr\": {\n        \"wanted\": \"요청됨\",\n        \"queued\": \"대기열\",\n        \"books\": \"책\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"누락된 에피소드\",\n        \"missingMovies\": \"누락된 영화\"\n    },\n    \"ombi\": {\n        \"pending\": \"대기 중\",\n        \"approved\": \"승인됨\",\n        \"available\": \"이용 가능\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"전체\",\n        \"connected\": \"연결됨\",\n        \"new_devices\": \"새 장치\",\n        \"down_alerts\": \"다운 알림\"\n    },\n    \"pihole\": {\n        \"queries\": \"쿼리\",\n        \"blocked\": \"차단됨\",\n        \"blocked_percent\": \"차단율 %\",\n        \"gravity\": \"그래비티\"\n    },\n    \"adguard\": {\n        \"queries\": \"쿼리\",\n        \"blocked\": \"차단됨\",\n        \"filtered\": \"필터링됨\",\n        \"latency\": \"지연\"\n    },\n    \"speedtest\": {\n        \"upload\": \"업로드\",\n        \"download\": \"다운로드\",\n        \"ping\": \"핑\"\n    },\n    \"portainer\": {\n        \"running\": \"실행 중\",\n        \"stopped\": \"중지됨\",\n        \"total\": \"전체\"\n    },\n    \"suwayomi\": {\n        \"download\": \"다운로드됨\",\n        \"nondownload\": \"다운로드 안됨\",\n        \"read\": \"읽음\",\n        \"unread\": \"안 읽음\",\n        \"downloadedread\": \"다운로드 & 읽음\",\n        \"downloadedunread\": \"다운로드 & 안 읽음\",\n        \"nondownloadedread\": \"미다운로드 & 읽음\",\n        \"nondownloadedunread\": \"미다운로드 & 안 읽음\"\n    },\n    \"tailscale\": {\n        \"address\": \"주소\",\n        \"expires\": \"만료\",\n        \"never\": \"없음\",\n        \"last_seen\": \"마지막 접속\",\n        \"now\": \"지금\",\n        \"years\": \"{{number}}년\",\n        \"weeks\": \"{{number}}주\",\n        \"days\": \"{{number}}일\",\n        \"hours\": \"{{number}}시간\",\n        \"minutes\": \"{{number}}분\",\n        \"seconds\": \"{{number}}초\",\n        \"ago\": \"{{value}} 전\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"총 쿼리\",\n        \"totalNoError\": \"성공\",\n        \"totalServerFailure\": \"실패\",\n        \"totalNxDomain\": \"NX 도메인\",\n        \"totalRefused\": \"거부됨\",\n        \"totalAuthoritative\": \"권한 있음\",\n        \"totalRecursive\": \"재귀\",\n        \"totalCached\": \"캐시됨\",\n        \"totalBlocked\": \"차단됨\",\n        \"totalDropped\": \"삭제됨\",\n        \"totalClients\": \"클라이언트\"\n    },\n    \"tdarr\": {\n        \"queue\": \"대기열\",\n        \"processed\": \"처리됨\",\n        \"errored\": \"오류\",\n        \"saved\": \"저장됨\"\n    },\n    \"traefik\": {\n        \"routers\": \"라우터\",\n        \"services\": \"서비스\",\n        \"middleware\": \"미들웨어\"\n    },\n    \"trilium\": {\n        \"version\": \"버전\",\n        \"notesCount\": \"노트 수\",\n        \"dbSize\": \"DB 크기\",\n        \"unknown\": \"알 수 없음\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"활성 스트림 없음\",\n        \"please_wait\": \"잠시만 기다리세요\"\n    },\n    \"npm\": {\n        \"enabled\": \"활성\",\n        \"disabled\": \"비활성\",\n        \"total\": \"전체\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"하나 이상의 암호화폐를 설정하여 추적하세요\",\n        \"1hour\": \"1 시간\",\n        \"1day\": \"1 일\",\n        \"7days\": \"7 일\",\n        \"30days\": \"30 일\"\n    },\n    \"gotify\": {\n        \"apps\": \"애플리케이션\",\n        \"clients\": \"클라이언트\",\n        \"messages\": \"메시지\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"인덱서\",\n        \"numberOfGrabs\": \"가져오기\",\n        \"numberOfQueries\": \"쿼리\",\n        \"numberOfFailGrabs\": \"가져오기 실패\",\n        \"numberOfFailQueries\": \"쿼리 실패\"\n    },\n    \"jackett\": {\n        \"configured\": \"구성됨\",\n        \"errored\": \"오류\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"세션\",\n        \"numConnections\": \"연결\",\n        \"dataRelayed\": \"중계됨\",\n        \"transferRate\": \"전송 속도\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"사용자 수\",\n        \"status_count\": \"게시글\",\n        \"domain_count\": \"도메인 수\"\n    },\n    \"medusa\": {\n        \"wanted\": \"요청됨\",\n        \"queued\": \"대기열\",\n        \"series\": \"시리즈\"\n    },\n    \"minecraft\": {\n        \"players\": \"플레이어\",\n        \"version\": \"버전\",\n        \"status\": \"상태\",\n        \"up\": \"온라인\",\n        \"down\": \"오프라인\"\n    },\n    \"miniflux\": {\n        \"read\": \"읽음\",\n        \"unread\": \"안 읽음\"\n    },\n    \"authentik\": {\n        \"users\": \"사용자\",\n        \"loginsLast24H\": \"로그인 (24시간)\",\n        \"failedLoginsLast24H\": \"실패한 로그인 (24시간)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"메모리\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"가상머신\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"부하\",\n        \"wait\": \"잠시만 기다려주세요\",\n        \"temp\": \"온도\",\n        \"_temp\": \"온도\",\n        \"warn\": \"경고\",\n        \"uptime\": \"가동중\",\n        \"total\": \"전체\",\n        \"free\": \"남은 용량\",\n        \"used\": \"사용량\",\n        \"days\": \"일\",\n        \"hours\": \"시간\",\n        \"crit\": \"심각\",\n        \"read\": \"읽기\",\n        \"write\": \"쓰기\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"메모리\",\n        \"swap\": \"스왑\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"북마크\",\n        \"service\": \"서비스\",\n        \"search\": \"검색\",\n        \"custom\": \"사용자 정의\",\n        \"visit\": \"방문\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"추천\"\n    },\n    \"wmo\": {\n        \"0-day\": \"맑음\",\n        \"0-night\": \"맑음\",\n        \"1-day\": \"대체로 맑음\",\n        \"1-night\": \"대체로 맑음\",\n        \"2-day\": \"구름 조금\",\n        \"2-night\": \"구름 조금\",\n        \"3-day\": \"흐림\",\n        \"3-night\": \"흐림\",\n        \"45-day\": \"안개\",\n        \"45-night\": \"안개\",\n        \"48-day\": \"안개\",\n        \"48-night\": \"안개\",\n        \"51-day\": \"가벼운 이슬비\",\n        \"51-night\": \"가벼운 이슬비\",\n        \"53-day\": \"이슬비\",\n        \"53-night\": \"이슬비\",\n        \"55-day\": \"강한 이슬비\",\n        \"55-night\": \"강한 이슬비\",\n        \"56-day\": \"가벼운 어는 이슬비\",\n        \"56-night\": \"가벼운 어는 이슬비\",\n        \"57-day\": \"어는 이슬비\",\n        \"57-night\": \"어는 이슬비\",\n        \"61-day\": \"가벼운 비\",\n        \"61-night\": \"가벼운 비\",\n        \"63-day\": \"비\",\n        \"63-night\": \"비\",\n        \"65-day\": \"폭우\",\n        \"65-night\": \"폭우\",\n        \"66-day\": \"어는 비\",\n        \"66-night\": \"어는 비\",\n        \"67-day\": \"어는 비\",\n        \"67-night\": \"어는 비\",\n        \"71-day\": \"가벼운 눈\",\n        \"71-night\": \"가벼운 눈\",\n        \"73-day\": \"눈\",\n        \"73-night\": \"눈\",\n        \"75-day\": \"폭설\",\n        \"75-night\": \"폭설\",\n        \"77-day\": \"싸락눈\",\n        \"77-night\": \"싸락눈\",\n        \"80-day\": \"가벼운 소나기\",\n        \"80-night\": \"가벼운 소나기\",\n        \"81-day\": \"소나기\",\n        \"81-night\": \"소나기\",\n        \"82-day\": \"강한 소나기\",\n        \"82-night\": \"강한 소나기\",\n        \"85-day\": \"눈 소나기\",\n        \"85-night\": \"눈 소나기\",\n        \"86-day\": \"눈 소나기\",\n        \"86-night\": \"눈 소나기\",\n        \"95-day\": \"뇌우\",\n        \"95-night\": \"뇌우\",\n        \"96-day\": \"우박 동반 뇌우\",\n        \"96-night\": \"우박 동반 뇌우\",\n        \"99-day\": \"우박 동반 뇌우\",\n        \"99-night\": \"우박 동반 뇌우\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"시스템\",\n        \"updates\": \"업데이트\",\n        \"update_available\": \"새 업데이트 있음\",\n        \"up_to_date\": \"최신 상태\",\n        \"child_bridges\": \"하위 브릿지\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"업\",\n        \"pending\": \"대기 중\",\n        \"down\": \"다운\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"신규\",\n        \"up\": \"업\",\n        \"grace\": \"유예 기간\",\n        \"down\": \"다운\",\n        \"paused\": \"일시 중지\",\n        \"status\": \"상태\",\n        \"last_ping\": \"마지막 핑\",\n        \"never\": \"아직 핑 없음\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"스캔됨\",\n        \"containers_updated\": \"업데이트됨\",\n        \"containers_failed\": \"실패\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"승인됨\",\n        \"rejectedPushes\": \"거부됨\",\n        \"filters\": \"필터\",\n        \"indexers\": \"인덱서\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"대기열\",\n        \"videos\": \"비디오\",\n        \"channels\": \"채널\",\n        \"playlists\": \"재생 목록\"\n    },\n    \"truenas\": {\n        \"load\": \"시스템 부하\",\n        \"uptime\": \"가동중\",\n        \"alerts\": \"경고\"\n    },\n    \"pyload\": {\n        \"speed\": \"속도\",\n        \"active\": \"활성\",\n        \"queue\": \"대기열\",\n        \"total\": \"전체\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"공용 IP\",\n        \"region\": \"지역\",\n        \"country\": \"국가\",\n        \"port_forwarded\": \"포트 포워딩됨\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"채널\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"튜너\",\n        \"channelNumber\": \"채널\",\n        \"channelNetwork\": \"네트워크\",\n        \"signalStrength\": \"신호 강도\",\n        \"signalQuality\": \"신호 품질\",\n        \"symbolQuality\": \"심볼 품질\",\n        \"networkRate\": \"비트레이트\",\n        \"clientIP\": \"클라이언트\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"통과\",\n        \"failed\": \"실패\",\n        \"unknown\": \"알 수 없음\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"받은 편지함\",\n        \"total\": \"전체\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"배터리 충전\",\n        \"ups_load\": \"UPS 부하\",\n        \"ups_status\": \"UPS 상태\",\n        \"online\": \"온라인\",\n        \"on_battery\": \"배터리 사용 중\",\n        \"low_battery\": \"배터리 부족\"\n    },\n    \"nextdns\": {\n        \"wait\": \"잠시만 기다려주세요\",\n        \"no_devices\": \"수신된 장치 데이터 없음\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"CPU 부하\",\n        \"memoryUsed\": \"메모리 사용량\",\n        \"uptime\": \"가동 시간\",\n        \"numberOfLeases\": \"할당\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"모든 스트림\",\n        \"streams_active\": \"활성 스트림\",\n        \"streams_xepg\": \"XEPG 채널\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"오늘\",\n        \"absolutePower\": \"전력\",\n        \"relativePower\": \"전력 %\",\n        \"limit\": \"제한\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU 부하\",\n        \"memory\": \"활성 메모리\",\n        \"wanUpload\": \"WAN 업로드\",\n        \"wanDownload\": \"WAN 다운로드\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"프린터 상태\",\n        \"print_status\": \"인쇄 상태\",\n        \"print_progress\": \"진행\",\n        \"layers\": \"레이어\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"상태\",\n        \"temp_tool\": \"도구 온도\",\n        \"temp_bed\": \"베드 온도\",\n        \"job_completion\": \"완료\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"서버 IP\",\n        \"status\": \"상태\"\n    },\n    \"pfsense\": {\n        \"load\": \"평균 부하\",\n        \"memory\": \"메모리 사용량\",\n        \"wanStatus\": \"WAN 상태\",\n        \"up\": \"업\",\n        \"down\": \"다운\",\n        \"temp\": \"온도\",\n        \"disk\": \"디스크 사용량\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"데이터스토어\",\n        \"failed_tasks_24h\": \"실패한 작업 (24시간)\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"메모리\"\n    },\n    \"immich\": {\n        \"users\": \"사용자\",\n        \"photos\": \"사진\",\n        \"videos\": \"비디오\",\n        \"storage\": \"저장 공간\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"사이트 업\",\n        \"down\": \"사이트 다운\",\n        \"uptime\": \"가동 시간\",\n        \"incident\": \"사건\",\n        \"m\": \"분\"\n    },\n    \"atsumeru\": {\n        \"series\": \"시리즈\",\n        \"archives\": \"아카이브\",\n        \"chapters\": \"챕터\",\n        \"categories\": \"카테고리\"\n    },\n    \"komga\": {\n        \"libraries\": \"라이브러리\",\n        \"series\": \"시리즈\",\n        \"books\": \"책\"\n    },\n    \"diskstation\": {\n        \"days\": \"일\",\n        \"uptime\": \"가동 시간\",\n        \"volumeAvailable\": \"사용 가능\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"시리즈\",\n        \"issues\": \"이슈\",\n        \"wanted\": \"요청됨\"\n    },\n    \"photoprism\": {\n        \"albums\": \"앨범\",\n        \"photos\": \"사진\",\n        \"videos\": \"비디오\",\n        \"people\": \"인물\"\n    },\n    \"fileflows\": {\n        \"queue\": \"대기열\",\n        \"processing\": \"처리 중\",\n        \"processed\": \"처리됨\",\n        \"time\": \"시간\"\n    },\n    \"firefly\": {\n        \"networth\": \"순자산\",\n        \"budget\": \"예산\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"대시보드\",\n        \"datasources\": \"데이터 소스\",\n        \"totalalerts\": \"총 경고\",\n        \"alertstriggered\": \"트리거된 경고\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"CPU 부하\",\n        \"memoryusage\": \"메모리 사용량\",\n        \"freespace\": \"여유 공간\",\n        \"activeusers\": \"활성 사용자\",\n        \"numfiles\": \"파일 수\",\n        \"numshares\": \"공유 수\"\n    },\n    \"kopia\": {\n        \"status\": \"상태\",\n        \"size\": \"크기\",\n        \"lastrun\": \"마지막 실행\",\n        \"nextrun\": \"다음 실행\",\n        \"failed\": \"실패\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"활성 워커\",\n        \"total_workers\": \"전체 워커\",\n        \"records_total\": \"대기열 길이\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"서버\",\n        \"nodes\": \"노드\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"대상 업\",\n        \"targets_down\": \"대상 다운\",\n        \"targets_total\": \"총 대상\"\n    },\n    \"gatus\": {\n        \"up\": \"사이트 업\",\n        \"down\": \"사이트 다운\",\n        \"uptime\": \"가동 시간\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"오늘\",\n        \"gross_percent_1y\": \"1년\",\n        \"gross_percent_max\": \"전체 기간\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"팟캐스트\",\n        \"books\": \"책\",\n        \"podcastsDuration\": \"지속 시간\",\n        \"booksDuration\": \"지속 시간\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"집에 있는 사람\",\n        \"lights_on\": \"켜진 조명\",\n        \"switches_on\": \"켜진 스위치\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"모니터링\",\n        \"updates\": \"업데이트\"\n    },\n    \"calibreweb\": {\n        \"books\": \"책\",\n        \"authors\": \"저자\",\n        \"categories\": \"카테고리\",\n        \"series\": \"시리즈\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"대기열\",\n        \"downloadBytesRemaining\": \"남음\",\n        \"downloadTotalBytes\": \"크기\",\n        \"downloadSpeed\": \"속도\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"시리즈\",\n        \"totalFiles\": \"파일\"\n    },\n    \"azuredevops\": {\n        \"result\": \"결과\",\n        \"status\": \"상태\",\n        \"buildId\": \"빌드 ID\",\n        \"succeeded\": \"성공\",\n        \"notStarted\": \"시작 안 함\",\n        \"failed\": \"실패\",\n        \"canceled\": \"취소됨\",\n        \"inProgress\": \"진행 중\",\n        \"totalPrs\": \"총 PR\",\n        \"myPrs\": \"내 PR\",\n        \"approved\": \"승인됨\"\n    },\n    \"gamedig\": {\n        \"status\": \"상태\",\n        \"online\": \"온라인\",\n        \"offline\": \"오프라인\",\n        \"name\": \"이름\",\n        \"map\": \"맵\",\n        \"currentPlayers\": \"현재 플레이어\",\n        \"players\": \"플레이어\",\n        \"maxPlayers\": \"최대 플레이어\",\n        \"bots\": \"봇\",\n        \"ping\": \"핑\"\n    },\n    \"urbackup\": {\n        \"ok\": \"정상\",\n        \"errored\": \"오류\",\n        \"noRecent\": \"오래됨\",\n        \"totalUsed\": \"사용된 저장 공간\"\n    },\n    \"mealie\": {\n        \"recipes\": \"레시피\",\n        \"users\": \"사용자\",\n        \"categories\": \"카테고리\",\n        \"tags\": \"태그\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"다운로드 중\",\n        \"total\": \"전체\",\n        \"running\": \"실행 중\",\n        \"stopped\": \"중지됨\",\n        \"passed\": \"통과\",\n        \"failed\": \"실패\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"가동 시간\",\n        \"cpuLoad\": \"CPU 부하 평균 (5분)\",\n        \"up\": \"업로드\",\n        \"down\": \"다운로드\",\n        \"bytesTx\": \"송신\",\n        \"bytesRx\": \"수신\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"상태\",\n        \"uptime\": \"가동 시간\",\n        \"lastDown\": \"마지막 다운타임\",\n        \"downDuration\": \"다운타임 기간\",\n        \"sitesUp\": \"사이트 업\",\n        \"sitesDown\": \"사이트 다운\",\n        \"paused\": \"일시 중지\",\n        \"notyetchecked\": \"아직 확인 안 됨\",\n        \"up\": \"업\",\n        \"seemsdown\": \"다운된 것 같음\",\n        \"down\": \"다운\",\n        \"unknown\": \"알 수 없음\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"상영 중\",\n        \"physicalRelease\": \"실물 출시\",\n        \"digitalRelease\": \"디지털 출시\",\n        \"noEventsToday\": \"오늘 예정된 이벤트 없음!\",\n        \"noEventsFound\": \"이벤트 없음\",\n        \"errorWhenLoadingData\": \"캘린더 데이터 로딩 중 오류\"\n    },\n    \"romm\": {\n        \"platforms\": \"플랫폼\",\n        \"totalRoms\": \"게임\",\n        \"saves\": \"세이브\",\n        \"states\": \"상태\",\n        \"screenshots\": \"스크린샷\",\n        \"totalfilesize\": \"전체 파일 크기\"\n    },\n    \"mailcow\": {\n        \"domains\": \"도메인\",\n        \"mailboxes\": \"메일박스\",\n        \"mails\": \"메일\",\n        \"storage\": \"저장 공간\"\n    },\n    \"netdata\": {\n        \"warnings\": \"경고\",\n        \"criticals\": \"심각\"\n    },\n    \"plantit\": {\n        \"events\": \"이벤트\",\n        \"plants\": \"식물\",\n        \"photos\": \"사진\",\n        \"species\": \"종\"\n    },\n    \"gitea\": {\n        \"notifications\": \"알림\",\n        \"issues\": \"이슈\",\n        \"pulls\": \"풀 리퀘스트\",\n        \"repositories\": \"저장소\"\n    },\n    \"stash\": {\n        \"scenes\": \"장면\",\n        \"scenesPlayed\": \"재생된 장면\",\n        \"playCount\": \"총 재생 수\",\n        \"playDuration\": \"시청 시간\",\n        \"sceneSize\": \"장면 크기\",\n        \"sceneDuration\": \"장면 길이\",\n        \"images\": \"이미지\",\n        \"imageSize\": \"이미지 크기\",\n        \"galleries\": \"갤러리\",\n        \"performers\": \"출연자\",\n        \"studios\": \"스튜디오\",\n        \"movies\": \"영화\",\n        \"tags\": \"태그\",\n        \"oCount\": \"O 카운트\"\n    },\n    \"tandoor\": {\n        \"users\": \"사용자\",\n        \"recipes\": \"레시피\",\n        \"keywords\": \"키워드\"\n    },\n    \"homebox\": {\n        \"items\": \"항목\",\n        \"totalWithWarranty\": \"보증 있음\",\n        \"locations\": \"위치\",\n        \"labels\": \"라벨\",\n        \"users\": \"사용자\",\n        \"totalValue\": \"총 가치\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"경고\",\n        \"bans\": \"차단\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"연결됨\",\n        \"enabled\": \"활성\",\n        \"disabled\": \"비활성\",\n        \"total\": \"전체\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"프록시됨\",\n        \"auth\": \"인증 사용\",\n        \"outdated\": \"오래됨\",\n        \"banned\": \"차단됨\"\n    },\n    \"myspeed\": {\n        \"ping\": \"핑\",\n        \"download\": \"다운로드\",\n        \"upload\": \"업로드\"\n    },\n    \"stocks\": {\n        \"stocks\": \"주식\",\n        \"loading\": \"로딩 중\",\n        \"open\": \"개장 - 미국 시장\",\n        \"closed\": \"폐장 - 미국 시장\",\n        \"invalidConfiguration\": \"잘못된 구성\"\n    },\n    \"frigate\": {\n        \"cameras\": \"카메라\",\n        \"uptime\": \"가동 중\",\n        \"version\": \"버전\"\n    },\n    \"linkwarden\": {\n        \"links\": \"링크\",\n        \"collections\": \"컬렉션\",\n        \"tags\": \"태그\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"미분류\",\n        \"information\": \"정보\",\n        \"warning\": \"경고\",\n        \"average\": \"평균\",\n        \"high\": \"높음\",\n        \"disaster\": \"재해\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"차량\",\n        \"vehicles\": \"차량 목록\",\n        \"serviceRecords\": \"정비 기록\",\n        \"reminders\": \"알림\",\n        \"nextReminder\": \"다음 알림\",\n        \"none\": \"없음\"\n    },\n    \"vikunja\": {\n        \"projects\": \"활성 프로젝트\",\n        \"tasks7d\": \"이번 주 마감 작업\",\n        \"tasksOverdue\": \"기한 지난 작업\",\n        \"tasksInProgress\": \"진행 중 작업\"\n    },\n    \"headscale\": {\n        \"name\": \"이름\",\n        \"address\": \"주소\",\n        \"last_seen\": \"마지막 접속\",\n        \"status\": \"상태\",\n        \"online\": \"온라인\",\n        \"offline\": \"오프라인\"\n    },\n    \"beszel\": {\n        \"name\": \"이름\",\n        \"systems\": \"시스템\",\n        \"up\": \"업\",\n        \"down\": \"다운\",\n        \"paused\": \"일시 중지\",\n        \"pending\": \"대기 중\",\n        \"status\": \"상태\",\n        \"updated\": \"업데이트됨\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"메모리\",\n        \"disk\": \"디스크\",\n        \"network\": \"네트워크\"\n    },\n    \"argocd\": {\n        \"apps\": \"앱\",\n        \"synced\": \"동기화됨\",\n        \"outOfSync\": \"동기화 안됨\",\n        \"healthy\": \"정상\",\n        \"degraded\": \"저하됨\",\n        \"progressing\": \"진행 중\",\n        \"missing\": \"누락됨\",\n        \"suspended\": \"일시 중단됨\"\n    },\n    \"spoolman\": {\n        \"loading\": \"로딩 중\"\n    },\n    \"gitlab\": {\n        \"groups\": \"그룹\",\n        \"issues\": \"이슈\",\n        \"merges\": \"병합 요청\",\n        \"projects\": \"프로젝트\"\n    },\n    \"apcups\": {\n        \"status\": \"상태\",\n        \"load\": \"부하\",\n        \"bcharge\": \"배터리 충전량\",\n        \"timeleft\": \"남은 시간\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"북마크\",\n        \"favorites\": \"즐겨찾기\",\n        \"archived\": \"보관됨\",\n        \"highlights\": \"하이라이트\",\n        \"lists\": \"목록\",\n        \"tags\": \"태그\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"네트워크\",\n        \"connected\": \"연결됨\",\n        \"disconnected\": \"연결 끊김\",\n        \"updateStatus\": \"업데이트\",\n        \"update_yes\": \"가능\",\n        \"update_no\": \"최신 상태\",\n        \"downloads\": \"다운로드\",\n        \"uploads\": \"업로드\",\n        \"sharedFiles\": \"파일\"\n    },\n    \"jellystat\": {\n        \"songs\": \"음악\",\n        \"movies\": \"영화\",\n        \"episodes\": \"에피소드\",\n        \"other\": \"기타\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"서비스 문제\",\n        \"hostErrors\": \"호스트 문제\"\n    },\n    \"komodo\": {\n        \"total\": \"전체\",\n        \"running\": \"실행 중\",\n        \"stopped\": \"중지됨\",\n        \"down\": \"다운\",\n        \"unhealthy\": \"비정상\",\n        \"unknown\": \"알 수 없음\",\n        \"servers\": \"서버\",\n        \"stacks\": \"스택\",\n        \"containers\": \"컨테이너\"\n    },\n    \"filebrowser\": {\n        \"available\": \"사용 가능\",\n        \"used\": \"사용됨\",\n        \"total\": \"전체\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"구독 중\",\n        \"thisMonthlyCost\": \"이번 달 비용\",\n        \"nextMonthlyCost\": \"다음 달 비용\",\n        \"previousMonthlyCost\": \"지난달 비용\",\n        \"nextRenewingSubscription\": \"다음 결제\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"시작됨\",\n        \"STOPPED\": \"중지됨\",\n        \"NEW_ARRAY\": \"새 어레이\",\n        \"RECON_DISK\": \"디스크 재구성 중\",\n        \"DISABLE_DISK\": \"디스크 비활성화됨\",\n        \"SWAP_DSBL\": \"스왑 비활성화\",\n        \"INVALID_EXPANSION\": \"잘못된 확장\",\n        \"PARITY_NOT_BIGGEST\": \"패리티가 가장 크지 않음\",\n        \"TOO_MANY_MISSING_DISKS\": \"누락된 디스크가 너무 많음\",\n        \"NEW_DISK_TOO_SMALL\": \"새 디스크가 너무 작음\",\n        \"NO_DATA_DISKS\": \"데이터 디스크 없음\",\n        \"notifications\": \"알림\",\n        \"status\": \"상태\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"사용된 메모리\",\n        \"memoryAvailable\": \"사용 가능한 메모리\",\n        \"arrayUsed\": \"사용된 어레이\",\n        \"arrayFree\": \"남은 어레이\",\n        \"poolUsed\": \"{{pool}} 사용됨\",\n        \"poolFree\": \"{{pool}} 남음\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"플랜\",\n        \"num_success_30\": \"성공\",\n        \"num_failure_30\": \"실패\",\n        \"num_success_latest\": \"성공 중\",\n        \"num_failure_latest\": \"실패 중\",\n        \"bytes_added_30\": \"추가된 용량\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/lv/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"mo\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Iztrūkst logrīka tips: {{type}}\",\n        \"api_error\": \"API kļūda\",\n        \"information\": \"Informācija\",\n        \"status\": \"Statuss\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Kļūda\",\n        \"response_data\": \"Atbilde\"\n    },\n    \"weather\": {\n        \"current\": \"Pašreizējā atrašanās vieta\",\n        \"allow\": \"Piemiedziet, lai atļaut\",\n        \"updating\": \"Atjaunina\",\n        \"wait\": \"Lūdzu, uzgaidiet\"\n    },\n    \"search\": {\n        \"placeholder\": \"Meklēt…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"MEM\",\n        \"total\": \"Kopā\",\n        \"free\": \"Brīvs\",\n        \"used\": \"Izmantojas\",\n        \"load\": \"Ielādē\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Max\",\n        \"uptime\": \"UP\"\n    },\n    \"unifi\": {\n        \"users\": \"Lietotāji\",\n        \"uptime\": \"Uptime\",\n        \"days\": \"Dienas\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Ierīces\",\n        \"lan_devices\": \"LAN ierīces\",\n        \"wlan_devices\": \"WLAN ierīces\",\n        \"lan_users\": \"LAN lietotāji\",\n        \"wlan_users\": \"WLAN lietotāji\",\n        \"up\": \"UP\",\n        \"down\": \"NEDARBOJAS\",\n        \"wait\": \"Please wait\",\n        \"empty_data\": \"Subsystem status unknown\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Running\",\n        \"offline\": \"Bezsaistē\",\n        \"error\": \"Kļūda\",\n        \"unknown\": \"Nezināms\",\n        \"healthy\": \"Healthy\",\n        \"starting\": \"Starting\",\n        \"unhealthy\": \"Unhealthy\",\n        \"not_found\": \"Not Found\",\n        \"exited\": \"Exited\",\n        \"partial\": \"Partial\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"Ping\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP status\",\n        \"error\": \"Error\",\n        \"response\": \"Response\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"emby\": {\n        \"playing\": \"Atskaņo\",\n        \"transcoding\": \"Pārkodē\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"Nav aktīvu straumju\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Online\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Production\",\n        \"battery_soc\": \"Battery\",\n        \"grid_power\": \"Grid\",\n        \"home_power\": \"Consumption\",\n        \"charge_power\": \"Charger\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Lejupielāde\",\n        \"upload\": \"Augšupielāde\",\n        \"leech\": \"Ņēmēji\",\n        \"seed\": \"Devēji\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Subscriptions\",\n        \"unread\": \"Unread\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Unconfigured\",\n        \"connectionStatusConnecting\": \"Connecting\",\n        \"connectionStatusAuthenticating\": \"Authenticating\",\n        \"connectionStatusPendingDisconnect\": \"Pending Disconnect\",\n        \"connectionStatusDisconnecting\": \"Disconnecting\",\n        \"connectionStatusDisconnected\": \"Disconnected\",\n        \"connectionStatusConnected\": \"Connected\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"Max. Down\",\n        \"maxUp\": \"Max. Up\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"Received\",\n        \"sent\": \"Sent\",\n        \"externalIPAddress\": \"Ext. IP\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Current requests\",\n        \"requests_failed\": \"Failed requests\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Kopā novēro\",\n        \"diffsDetected\": \"Atšķirības atrastas\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Shows\",\n        \"recordings\": \"Recordings\",\n        \"scheduled\": \"Scheduled\",\n        \"passes\": \"Passes\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"Check Plex Connection\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Savienotie piekļuves punkti\",\n        \"activeUser\": \"Aktīvās ierīces\",\n        \"alerts\": \"Paziņojumi\",\n        \"connectedGateways\": \"Connected gateways\",\n        \"connectedSwitches\": \"Savienotie komutatori\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Rate\",\n        \"remaining\": \"Palika\",\n        \"downloaded\": \"Lejupielādēts\"\n    },\n    \"plex\": {\n        \"streams\": \"Aktīvās straumes\",\n        \"albums\": \"Albums\",\n        \"movies\": \"Movies\",\n        \"tv\": \"TV pārraides\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"Rindā\",\n        \"timeleft\": \"Atlikušais laiks\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Aktīvs\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU Usage\",\n        \"memUsage\": \"MEM Usage\",\n        \"systemTempC\": \"System Temp\",\n        \"poolUsage\": \"Pool Usage\",\n        \"volumeUsage\": \"Volume Usage\",\n        \"invalid\": \"Invalid\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"Missing\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"Artists\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"Grāmatas\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Missing Episodes\",\n        \"missingMovies\": \"Missing Movies\"\n    },\n    \"ombi\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"New Devices\",\n        \"down_alerts\": \"Down Alerts\"\n    },\n    \"pihole\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"blocked_percent\": \"Blocked %\",\n        \"gravity\": \"Gravity\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"Filtered\",\n        \"latency\": \"Latency\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Non-Downloaded\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Downloaded & Read\",\n        \"downloadedunread\": \"Downloaded & Unread\",\n        \"nondownloadedread\": \"Non-Downloaded & Read\",\n        \"nondownloadedunread\": \"Non-Downloaded & Unread\"\n    },\n    \"tailscale\": {\n        \"address\": \"Address\",\n        \"expires\": \"Expires\",\n        \"never\": \"Never\",\n        \"last_seen\": \"Last Seen\",\n        \"now\": \"Now\",\n        \"years\": \"{{number}}y\",\n        \"weeks\": \"{{number}}w\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Ago\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"Success\",\n        \"totalServerFailure\": \"Failures\",\n        \"totalNxDomain\": \"NX Domains\",\n        \"totalRefused\": \"Refused\",\n        \"totalAuthoritative\": \"Authoritative\",\n        \"totalRecursive\": \"Recursive\",\n        \"totalCached\": \"Cached\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"Dropped\",\n        \"totalClients\": \"Clients\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Processed\",\n        \"errored\": \"Errored\",\n        \"saved\": \"Saved\"\n    },\n    \"traefik\": {\n        \"routers\": \"Routers\",\n        \"services\": \"Services\",\n        \"middleware\": \"Middleware\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Please Wait\"\n    },\n    \"npm\": {\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Configure one or more crypto currencies to track\",\n        \"1hour\": \"1 Hour\",\n        \"1day\": \"1 Day\",\n        \"7days\": \"7 Days\",\n        \"30days\": \"30 Days\"\n    },\n    \"gotify\": {\n        \"apps\": \"Applications\",\n        \"clients\": \"Clients\",\n        \"messages\": \"Messages\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indexers\",\n        \"numberOfGrabs\": \"Grabs\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"Fail Grabs\",\n        \"numberOfFailQueries\": \"Fail Queries\"\n    },\n    \"jackett\": {\n        \"configured\": \"Configured\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sessions\",\n        \"numConnections\": \"Connections\",\n        \"dataRelayed\": \"Relayed\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"Posts\",\n        \"domain_count\": \"Domains\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Players\",\n        \"version\": \"Version\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Read\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"Logins (24h)\",\n        \"failedLoginsLast24H\": \"Failed Logins (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temp\",\n        \"warn\": \"Warn\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Crit\",\n        \"read\": \"Read\",\n        \"write\": \"Write\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Bookmark\",\n        \"service\": \"Service\",\n        \"search\": \"Search\",\n        \"custom\": \"Custom\",\n        \"visit\": \"Visit\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Suggestion\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Saulains\",\n        \"0-night\": \"Skaidrs\",\n        \"1-day\": \"Galvenokārt saulains\",\n        \"1-night\": \"Galvenokārt skaidrs\",\n        \"2-day\": \"Daļēji apmācies\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"Apmācies\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"Miglains\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"Neliels lietus\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"Lietus\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"Spēcīgs lietus\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"Neliels stindzinošs lietus\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"Sasalstošs lietus\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"Viegls lietus\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"Lietus\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Spēcīgs lietus\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Ledains lietus\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Neliels sniegs\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Sniegs\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Heavy Snow\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Snow Grains\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Light Showers\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Showers\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Heavy Showers\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Snow Showers\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"Thunderstorm\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Thunderstorm With Hail\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"System\",\n        \"updates\": \"Updates\",\n        \"update_available\": \"Update Available\",\n        \"up_to_date\": \"Up to Date\",\n        \"child_bridges\": \"Child Bridges\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"New\",\n        \"up\": \"Up\",\n        \"grace\": \"In Grace Period\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Last Ping\",\n        \"never\": \"No pings yet\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Scanned\",\n        \"containers_updated\": \"Updated\",\n        \"containers_failed\": \"Failed\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Rejected\",\n        \"filters\": \"Filters\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Videos\",\n        \"channels\": \"Channels\",\n        \"playlists\": \"Playlists\"\n    },\n    \"truenas\": {\n        \"load\": \"System Load\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Speed\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Public IP\",\n        \"region\": \"Region\",\n        \"country\": \"Country\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tuners\",\n        \"channelNumber\": \"Channel\",\n        \"channelNetwork\": \"Network\",\n        \"signalStrength\": \"Strength\",\n        \"signalQuality\": \"Quality\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Client\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Inbox\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Battery Charge\",\n        \"ups_load\": \"UPS Load\",\n        \"ups_status\": \"UPS Status\",\n        \"online\": \"Online\",\n        \"on_battery\": \"On Battery\",\n        \"low_battery\": \"Low Battery\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"No Device Data Received\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"CPU Load\",\n        \"memoryUsed\": \"Memory Used\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Leases\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"All Streams\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG Channels\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Today\",\n        \"absolutePower\": \"Power\",\n        \"relativePower\": \"Power %\",\n        \"limit\": \"Limit\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Active Memory\",\n        \"wanUpload\": \"WAN Upload\",\n        \"wanDownload\": \"WAN Download\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Printer State\",\n        \"print_status\": \"Print Status\",\n        \"print_progress\": \"Progress\",\n        \"layers\": \"Layers\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Tool temp\",\n        \"temp_bed\": \"Bed temp\",\n        \"job_completion\": \"Completion\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Origin IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Load Avg\",\n        \"memory\": \"Mem Usage\",\n        \"wanStatus\": \"WAN Status\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Disk Usage\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datastore\",\n        \"failed_tasks_24h\": \"Failed Tasks 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memory\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Storage\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Incident\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Archives\",\n        \"chapters\": \"Chapters\",\n        \"categories\": \"Categories\"\n    },\n    \"komga\": {\n        \"libraries\": \"Libraries\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Issues\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"People\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Time\"\n    },\n    \"firefly\": {\n        \"networth\": \"Net Worth\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Dashboards\",\n        \"datasources\": \"Data Sources\",\n        \"totalalerts\": \"Total Alerts\",\n        \"alertstriggered\": \"Alerts Triggered\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Cpu Load\",\n        \"memoryusage\": \"Memory Usage\",\n        \"freespace\": \"Free Space\",\n        \"activeusers\": \"Active Users\",\n        \"numfiles\": \"Files\",\n        \"numshares\": \"Shared Items\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Size\",\n        \"lastrun\": \"Last Run\",\n        \"nextrun\": \"Next Run\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Active Workers\",\n        \"total_workers\": \"Total Workers\",\n        \"records_total\": \"Queue Length\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servers\",\n        \"nodes\": \"Nodes\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Targets Up\",\n        \"targets_down\": \"Targets Down\",\n        \"targets_total\": \"Total Targets\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"One year\",\n        \"gross_percent_max\": \"All time\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasts\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Duration\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"People Home\",\n        \"lights_on\": \"Lights On\",\n        \"switches_on\": \"Switches On\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Monitoring\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Authors\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Result\",\n        \"status\": \"Status\",\n        \"buildId\": \"Build ID\",\n        \"succeeded\": \"Succeeded\",\n        \"notStarted\": \"Not Started\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Canceled\",\n        \"inProgress\": \"In Progress\",\n        \"totalPrs\": \"Total PRs\",\n        \"myPrs\": \"My PRs\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Name\",\n        \"map\": \"Map\",\n        \"currentPlayers\": \"Current players\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Max players\",\n        \"bots\": \"Bots\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Errors\",\n        \"noRecent\": \"Out of Date\",\n        \"totalUsed\": \"Used Storage\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Recipes\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Tags\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Downloading\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"CPU Load Avg (5m)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Transmitted\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Last Downtime\",\n        \"downDuration\": \"Downtime Duration\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Not Yet Checked\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Seems Down\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"In cinemas\",\n        \"physicalRelease\": \"Physical release\",\n        \"digitalRelease\": \"Digital release\",\n        \"noEventsToday\": \"No events for today!\",\n        \"noEventsFound\": \"No events found\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platforms\",\n        \"totalRoms\": \"Games\",\n        \"saves\": \"Saves\",\n        \"states\": \"States\",\n        \"screenshots\": \"Screenshots\",\n        \"totalfilesize\": \"Total Size\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Mailboxes\",\n        \"mails\": \"Mails\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Warnings\",\n        \"criticals\": \"Criticals\"\n    },\n    \"plantit\": {\n        \"events\": \"Events\",\n        \"plants\": \"Plants\",\n        \"photos\": \"Photos\",\n        \"species\": \"Species\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Notifications\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Pull Requests\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Scenes\",\n        \"scenesPlayed\": \"Scenes Played\",\n        \"playCount\": \"Total Plays\",\n        \"playDuration\": \"Time Watched\",\n        \"sceneSize\": \"Scenes Size\",\n        \"sceneDuration\": \"Scenes Duration\",\n        \"images\": \"Images\",\n        \"imageSize\": \"Images Size\",\n        \"galleries\": \"Galleries\",\n        \"performers\": \"Performers\",\n        \"studios\": \"Studios\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O Count\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Keywords\"\n    },\n    \"homebox\": {\n        \"items\": \"Items\",\n        \"totalWithWarranty\": \"With Warranty\",\n        \"locations\": \"Locations\",\n        \"labels\": \"Labels\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Total Value\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Bans\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxied\",\n        \"auth\": \"With Auth\",\n        \"outdated\": \"Outdated\",\n        \"banned\": \"Banned\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Stocks\",\n        \"loading\": \"Loading\",\n        \"open\": \"Open - US Market\",\n        \"closed\": \"Closed - US Market\",\n        \"invalidConfiguration\": \"Invalid Configuration\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Cameras\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Links\",\n        \"collections\": \"Collections\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Not classified\",\n        \"information\": \"Information\",\n        \"warning\": \"Warning\",\n        \"average\": \"Average\",\n        \"high\": \"High\",\n        \"disaster\": \"Disaster\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vehicle\",\n        \"vehicles\": \"Vehicles\",\n        \"serviceRecords\": \"Service Records\",\n        \"reminders\": \"Reminders\",\n        \"nextReminder\": \"Next Reminder\",\n        \"none\": \"None\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Active Projects\",\n        \"tasks7d\": \"Tasks Due This Week\",\n        \"tasksOverdue\": \"Overdue Tasks\",\n        \"tasksInProgress\": \"Tasks In Progress\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Systems\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Disk\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Apps\",\n        \"synced\": \"Synced\",\n        \"outOfSync\": \"Out Of Sync\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Degraded\",\n        \"progressing\": \"Progressing\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Suspended\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Groups\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Projects\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bookmarks\",\n        \"favorites\": \"Favorites\",\n        \"archived\": \"Archived\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Lists\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Other\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/ms/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"bln\",\n        \"days\": \"h\",\n        \"hours\": \"j\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Jenis Widget Hilang: {{type}}\",\n        \"api_error\": \"Masalah API\",\n        \"information\": \"Informasi\",\n        \"status\": \"Status\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Ralat Mentah\",\n        \"response_data\": \"Data Respon\"\n    },\n    \"weather\": {\n        \"current\": \"Lokasi Sekarang\",\n        \"allow\": \"Klik untuk benarkan\",\n        \"updating\": \"Mengemas kini\",\n        \"wait\": \"Sila tunggu\"\n    },\n    \"search\": {\n        \"placeholder\": \"Carian…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"MEM\",\n        \"total\": \"Jumlah\",\n        \"free\": \"Bebas\",\n        \"used\": \"Telah diguna\",\n        \"load\": \"Beban\",\n        \"temp\": \"SUHU\",\n        \"max\": \"Tertinggi\",\n        \"uptime\": \"HIDUP\"\n    },\n    \"unifi\": {\n        \"users\": \"Pengguna\",\n        \"uptime\": \"Masa Hidup\",\n        \"days\": \"Hari\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Peranti\",\n        \"lan_devices\": \"Peranti LAN\",\n        \"wlan_devices\": \"Peranti WLAN\",\n        \"lan_users\": \"Pengguna LAN\",\n        \"wlan_users\": \"Pengguna WLAN\",\n        \"up\": \"UP\",\n        \"down\": \"MATI\",\n        \"wait\": \"Please wait\",\n        \"empty_data\": \"Status subsistem tak diketahui\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Sedang jalan\",\n        \"offline\": \"Luar talian\",\n        \"error\": \"Ralat\",\n        \"unknown\": \"Tidak Diketahui\",\n        \"healthy\": \"Sihat\",\n        \"starting\": \"Bermula\",\n        \"unhealthy\": \"Kurang sihat\",\n        \"not_found\": \"Tidak dijumpai\",\n        \"exited\": \"Dimatikan\",\n        \"partial\": \"Sebahagian\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"Ping\",\n        \"down\": \"Mati\",\n        \"up\": \"Hidup\",\n        \"not_available\": \"Tidak dijumpai\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"Status HTTP\",\n        \"error\": \"Error\",\n        \"response\": \"Tindak balas\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"emby\": {\n        \"playing\": \"Sedang dimainkan\",\n        \"transcoding\": \"Transkoding\",\n        \"bitrate\": \"Kadar bit\",\n        \"no_active\": \"Tiada Strim Aktif\",\n        \"movies\": \"Filem\",\n        \"series\": \"Siri\",\n        \"episodes\": \"Episod\",\n        \"songs\": \"Lagu\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Dalam Talian\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Produksi\",\n        \"battery_soc\": \"Bateri\",\n        \"grid_power\": \"Grid\",\n        \"home_power\": \"Penggunaan\",\n        \"charge_power\": \"Pengecas\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Muat turun\",\n        \"upload\": \"Muat naik\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Langganan\",\n        \"unread\": \"Belum dibaca\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Belum disuai\",\n        \"connectionStatusConnecting\": \"Menyambung\",\n        \"connectionStatusAuthenticating\": \"Pengesahan\",\n        \"connectionStatusPendingDisconnect\": \"Tunggu untuk Putus\",\n        \"connectionStatusDisconnecting\": \"Putuskan\",\n        \"connectionStatusDisconnected\": \"Sambungan Terputus\",\n        \"connectionStatusConnected\": \"Connected\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"Mati Maksima\",\n        \"maxUp\": \"Hidup Maksima\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"Diterima\",\n        \"sent\": \"Telah dihantar\",\n        \"externalIPAddress\": \"IP Luaran\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Permintaan semasa\",\n        \"requests_failed\": \"Permintaan gagal\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Jumlah Diperhatikan\",\n        \"diffsDetected\": \"Perbezaan Dikesan\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Papar\",\n        \"recordings\": \"Rakaman\",\n        \"scheduled\": \"Dijadualkan\",\n        \"passes\": \"Lulus\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"Check Plex Connection\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Connected APs\",\n        \"activeUser\": \"Peranti aktif\",\n        \"alerts\": \"Perhatian\",\n        \"connectedGateways\": \"Connected gateways\",\n        \"connectedSwitches\": \"Connected switches\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Kadar\",\n        \"remaining\": \"Baki\",\n        \"downloaded\": \"Telah Muat Turun\"\n    },\n    \"plex\": {\n        \"streams\": \"Strim Aktif\",\n        \"albums\": \"Album\",\n        \"movies\": \"Movies\",\n        \"tv\": \"Rancangan TV\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"Barisan\",\n        \"timeleft\": \"Masa Tinggal\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Aktif\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU Usage\",\n        \"memUsage\": \"MEM Usage\",\n        \"systemTempC\": \"System Temp\",\n        \"poolUsage\": \"Pool Usage\",\n        \"volumeUsage\": \"Volume Usage\",\n        \"invalid\": \"Invalid\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Mahu\",\n        \"queued\": \"Dibaris Gilir\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"Hilang\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"Artis\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"Buku\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Episod Yang Hilang\",\n        \"missingMovies\": \"Filem Yang Hilang\"\n    },\n    \"ombi\": {\n        \"pending\": \"Tertunda\",\n        \"approved\": \"Lulus\",\n        \"available\": \"Sudah Ada\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"New Devices\",\n        \"down_alerts\": \"Down Alerts\"\n    },\n    \"pihole\": {\n        \"queries\": \"Permintaan\",\n        \"blocked\": \"Disekat\",\n        \"blocked_percent\": \"Blocked %\",\n        \"gravity\": \"Gravity\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"Ditapis\",\n        \"latency\": \"Kependaman\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Terhenti\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Non-Downloaded\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Downloaded & Read\",\n        \"downloadedunread\": \"Downloaded & Unread\",\n        \"nondownloadedread\": \"Non-Downloaded & Read\",\n        \"nondownloadedunread\": \"Non-Downloaded & Unread\"\n    },\n    \"tailscale\": {\n        \"address\": \"Address\",\n        \"expires\": \"Expires\",\n        \"never\": \"Never\",\n        \"last_seen\": \"Last Seen\",\n        \"now\": \"Sekarang\",\n        \"years\": \"{{number}}y\",\n        \"weeks\": \"{{number}}w\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Lepas\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"Success\",\n        \"totalServerFailure\": \"Failures\",\n        \"totalNxDomain\": \"NX Domains\",\n        \"totalRefused\": \"Refused\",\n        \"totalAuthoritative\": \"Authoritative\",\n        \"totalRecursive\": \"Recursive\",\n        \"totalCached\": \"Cached\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"Dropped\",\n        \"totalClients\": \"Klien\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Sudah diprosess\",\n        \"errored\": \"Ralat\",\n        \"saved\": \"Simpan\"\n    },\n    \"traefik\": {\n        \"routers\": \"Router\",\n        \"services\": \"Servis\",\n        \"middleware\": \"Perisian Tengah\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Sila tunggu\"\n    },\n    \"npm\": {\n        \"enabled\": \"Didayakan\",\n        \"disabled\": \"Dinyahdayakan\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Konfigurasikan satu atau lebih matawang crypto untuk dipantau\",\n        \"1hour\": \"1 Jam\",\n        \"1day\": \"1 Hari\",\n        \"7days\": \"7 Hari\",\n        \"30days\": \"30 Hari\"\n    },\n    \"gotify\": {\n        \"apps\": \"Aplikasi\",\n        \"clients\": \"Clients\",\n        \"messages\": \"Mesej\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Pengindeks\",\n        \"numberOfGrabs\": \"Capai\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"Capai Yang Ggagal\",\n        \"numberOfFailQueries\": \"Permintaan Yang Gagal\"\n    },\n    \"jackett\": {\n        \"configured\": \"Telah Dikonfigurasi\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sesi\",\n        \"numConnections\": \"Penyambungan\",\n        \"dataRelayed\": \"Disalurkan\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"Pos\",\n        \"domain_count\": \"Domain\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Senarai pemain\",\n        \"version\": \"Versi\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Baca\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"Logmasuk (24j)\",\n        \"failedLoginsLast24H\": \"Logmasuk Gagal (24j)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LCX\",\n        \"vms\": \"Mesin Maya\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Suhu\",\n        \"warn\": \"Amaran\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Krit\",\n        \"read\": \"Read\",\n        \"write\": \"Tulis\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Penukaran\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Tandabuku\",\n        \"service\": \"Servis\",\n        \"search\": \"Carian\",\n        \"custom\": \"Khusus\",\n        \"visit\": \"Lawat\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Cadangan\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Terik\",\n        \"0-night\": \"Cerah\",\n        \"1-day\": \"Sebahagian Besar Terik\",\n        \"1-night\": \"Sebahagian Besar Cerah\",\n        \"2-day\": \"Sebahagian Mendung\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"Mendung\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"Berkabus\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"Gerimis\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"Renyai\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"Renyai Kuat\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"Gerimis Sejuk Ringan\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"Gerimis Sejuk\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"Hujan Renyai\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"Hujan\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Hujan Lebat\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Hujan Sejuk\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Salji Ringan\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Salji\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Salji Lebat\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Butiran Salji\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Rintik Ringan\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Rintik\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Rintik Lebat\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Rintik Salji\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"Ribut\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Ribut Hujan Batu\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Sistem\",\n        \"updates\": \"Kemaskini\",\n        \"update_available\": \"Kemaskini Tersedia\",\n        \"up_to_date\": \"Terkemaskini\",\n        \"child_bridges\": \"Jambatan Anak\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Baharu\",\n        \"up\": \"Up\",\n        \"grace\": \"Tempoh Aman\",\n        \"down\": \"Down\",\n        \"paused\": \"Tangguh\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Ping terakhir\",\n        \"never\": \"Tiada ping\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Terimbas\",\n        \"containers_updated\": \"Dikemaskini\",\n        \"containers_failed\": \"Gagal\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Ditolak\",\n        \"filters\": \"Tapisan\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Video\",\n        \"channels\": \"Saluran\",\n        \"playlists\": \"Senarai Siar\"\n    },\n    \"truenas\": {\n        \"load\": \"Beban Sistem\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Kelajuan\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"IP Awam\",\n        \"region\": \"Rantau\",\n        \"country\": \"Negara\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Penala\",\n        \"channelNumber\": \"Saluran\",\n        \"channelNetwork\": \"Rangkaian\",\n        \"signalStrength\": \"Kekuatan\",\n        \"signalQuality\": \"Kualiti\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Klien\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Lulus\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Peti Masuk\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Bateri dicas\",\n        \"ups_load\": \"Beban UPS\",\n        \"ups_status\": \"Status UPS\",\n        \"online\": \"Online\",\n        \"on_battery\": \"Guna bateri\",\n        \"low_battery\": \"Bateri lemah\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"Tiada Data Diterima Peranti\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"Beban CPU\",\n        \"memoryUsed\": \"Penggunaan memori\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Sewaan\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Semua Strim\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"Saluran XEPG\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Hari ini\",\n        \"absolutePower\": \"Kuasa\",\n        \"relativePower\": \"Kuasa %\",\n        \"limit\": \"Had/Batas\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Active Memory\",\n        \"wanUpload\": \"WAN Upload\",\n        \"wanDownload\": \"WAN Download\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Printer State\",\n        \"print_status\": \"Print Status\",\n        \"print_progress\": \"Kemajuan\",\n        \"layers\": \"Layers\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Tool temp\",\n        \"temp_bed\": \"Bed temp\",\n        \"job_completion\": \"Completion\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Origin IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Load Avg\",\n        \"memory\": \"Mem Usage\",\n        \"wanStatus\": \"WAN Status\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Disk Usage\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datastore\",\n        \"failed_tasks_24h\": \"Failed Tasks 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memory\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Gambar\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Storage\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Incident\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Archives\",\n        \"chapters\": \"Chapters\",\n        \"categories\": \"Memori\"\n    },\n    \"komga\": {\n        \"libraries\": \"Libraries\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Issues\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"People\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Time\"\n    },\n    \"firefly\": {\n        \"networth\": \"Net Worth\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Dashboards\",\n        \"datasources\": \"Data Sources\",\n        \"totalalerts\": \"Total Alerts\",\n        \"alertstriggered\": \"Alerts Triggered\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Cpu Load\",\n        \"memoryusage\": \"Memory Usage\",\n        \"freespace\": \"Free Space\",\n        \"activeusers\": \"Active Users\",\n        \"numfiles\": \"Files\",\n        \"numshares\": \"Shared Items\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Size\",\n        \"lastrun\": \"Last Run\",\n        \"nextrun\": \"Next Run\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Active Workers\",\n        \"total_workers\": \"Total Workers\",\n        \"records_total\": \"Queue Length\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servers\",\n        \"nodes\": \"Nodes\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Targets Up\",\n        \"targets_down\": \"Sasaran Mati\",\n        \"targets_total\": \"Jumlah Sasaran\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"Satu tahun\",\n        \"gross_percent_max\": \"Sepanjang masa\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podkas\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Tempoh\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Orang Dirumah\",\n        \"lights_on\": \"Hidupkan Lampu\",\n        \"switches_on\": \"Hidupkan Suis\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Pemantauan\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Pengarang/Penulis\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Keputusan\",\n        \"status\": \"Status\",\n        \"buildId\": \"ID Binaan\",\n        \"succeeded\": \"Berjaya\",\n        \"notStarted\": \"Belum Bermula\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Dibatalkan\",\n        \"inProgress\": \"Sedang Diproses\",\n        \"totalPrs\": \"Jumlah PR\",\n        \"myPrs\": \"PR Sendiri\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Nama\",\n        \"map\": \"Peta\",\n        \"currentPlayers\": \"Pemain Semasa\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Bilangan peserta maksimum\",\n        \"bots\": \"Bot\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Ralat\",\n        \"noRecent\": \"Luput tarikh\",\n        \"totalUsed\": \"Storan digunakan\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Resipi\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Tanda nama\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Sedang muat turun\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"Purata Beban CPU (5m)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Terpancar\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Masa Mati Terakhir\",\n        \"downDuration\": \"Jangkamasa Kematian\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Belum Disemak\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Seperti Mati\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"Di pawagam\",\n        \"physicalRelease\": \"Edaran fizikal\",\n        \"digitalRelease\": \"Edaran digital\",\n        \"noEventsToday\": \"Tiada agenda untuk hari ini!\",\n        \"noEventsFound\": \"Tiada agenda dijumpai\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platform\",\n        \"totalRoms\": \"Games\",\n        \"saves\": \"Saves\",\n        \"states\": \"States\",\n        \"screenshots\": \"Screenshots\",\n        \"totalfilesize\": \"Total Size\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Mailboxes\",\n        \"mails\": \"Mails\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Amaran\",\n        \"criticals\": \"Kritikal\"\n    },\n    \"plantit\": {\n        \"events\": \"Events\",\n        \"plants\": \"Plants\",\n        \"photos\": \"Photos\",\n        \"species\": \"Species\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Notifications\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Pull Requests\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Adegan\",\n        \"scenesPlayed\": \"Scenes Played\",\n        \"playCount\": \"Total Plays\",\n        \"playDuration\": \"Time Watched\",\n        \"sceneSize\": \"Scenes Size\",\n        \"sceneDuration\": \"Scenes Duration\",\n        \"images\": \"Images\",\n        \"imageSize\": \"Images Size\",\n        \"galleries\": \"Galleries\",\n        \"performers\": \"Performers\",\n        \"studios\": \"Studios\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O Count\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Keywords\"\n    },\n    \"homebox\": {\n        \"items\": \"Items\",\n        \"totalWithWarranty\": \"With Warranty\",\n        \"locations\": \"Lokasi\",\n        \"labels\": \"Labels\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Jumlah nilai\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Bans\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxied\",\n        \"auth\": \"With Auth\",\n        \"outdated\": \"Outdated\",\n        \"banned\": \"Banned\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Stocks\",\n        \"loading\": \"Loading\",\n        \"open\": \"Open - US Market\",\n        \"closed\": \"Closed - US Market\",\n        \"invalidConfiguration\": \"Invalid Configuration\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Cameras\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Links\",\n        \"collections\": \"Collections\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Not classified\",\n        \"information\": \"Information\",\n        \"warning\": \"Warning\",\n        \"average\": \"Average\",\n        \"high\": \"High\",\n        \"disaster\": \"Disaster\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vehicle\",\n        \"vehicles\": \"Vehicles\",\n        \"serviceRecords\": \"Service Records\",\n        \"reminders\": \"Reminders\",\n        \"nextReminder\": \"Next Reminder\",\n        \"none\": \"None\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Active Projects\",\n        \"tasks7d\": \"Tasks Due This Week\",\n        \"tasksOverdue\": \"Overdue Tasks\",\n        \"tasksInProgress\": \"Tasks In Progress\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Systems\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Disk\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Apps\",\n        \"synced\": \"Synced\",\n        \"outOfSync\": \"Out Of Sync\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Degraded\",\n        \"progressing\": \"Progressing\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Suspended\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Groups\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Projects\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bookmarks\",\n        \"favorites\": \"Favorites\",\n        \"archived\": \"Archived\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Lists\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Other\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/nb-NO/common.json",
    "content": "{\n    \"widget\": {\n        \"missing_type\": \"Manglende miniprogramstype: {{type}}\",\n        \"api_error\": \"API-feil\",\n        \"status\": \"Status\",\n        \"information\": \"Information\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Raw Error\",\n        \"response_data\": \"Response Data\"\n    },\n    \"search\": {\n        \"placeholder\": \"Søk …\"\n    },\n    \"resources\": {\n        \"total\": \"Totalt\",\n        \"free\": \"Ledig\",\n        \"used\": \"Brukt\",\n        \"load\": \"Last inn\",\n        \"cpu\": \"CPU\",\n        \"mem\": \"MEM\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Max\",\n        \"uptime\": \"UP\",\n        \"months\": \"mo\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"minutes\": \"m\"\n    },\n    \"docker\": {\n        \"rx\": \"Mottatt\",\n        \"tx\": \"Sendt\",\n        \"mem\": \"Minne\",\n        \"cpu\": \"Prosessor\",\n        \"offline\": \"Frakoblet\",\n        \"error\": \"Error\",\n        \"unknown\": \"Unknown\",\n        \"running\": \"Running\",\n        \"starting\": \"Starting\",\n        \"exited\": \"Exited\",\n        \"unhealthy\": \"Unhealthy\",\n        \"not_found\": \"Not Found\",\n        \"partial\": \"Partial\",\n        \"healthy\": \"Healthy\"\n    },\n    \"emby\": {\n        \"playing\": \"Spiller\",\n        \"transcoding\": \"Transkoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"Ingen aktive strømmer\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Spiller\",\n        \"transcoding\": \"Transkoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"Ingen aktive strømmer\",\n        \"plex_connection_error\": \"Check Plex Connection\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Aktiv\",\n        \"upload\": \"Opplasting\",\n        \"download\": \"Nedlasting\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Ønsket\",\n        \"queued\": \"I kø\",\n        \"series\": \"Serie\",\n        \"unknown\": \"Unknown\",\n        \"queue\": \"Queue\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Ønsket\",\n        \"queued\": \"I kø\",\n        \"movies\": \"Filmer\",\n        \"missing\": \"Missing\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"Books\"\n    },\n    \"ombi\": {\n        \"pending\": \"Venter\",\n        \"approved\": \"Godkjent\",\n        \"available\": \"Tilgjengelig\"\n    },\n    \"jellyseerr\": {\n        \"pending\": \"Venter\",\n        \"approved\": \"Godkjent\",\n        \"available\": \"Tilgjengelig\"\n    },\n    \"pihole\": {\n        \"queries\": \"Spørringer\",\n        \"blocked\": \"Blokkert\",\n        \"gravity\": \"Gravitet\",\n        \"blocked_percent\": \"Blocked %\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Opplasting\",\n        \"download\": \"Nedlasting\",\n        \"ping\": \"Ekkoforespørsel\"\n    },\n    \"portainer\": {\n        \"running\": \"Kjører\",\n        \"stopped\": \"Stoppet\",\n        \"total\": \"Totalt\"\n    },\n    \"traefik\": {\n        \"routers\": \"Rutere\",\n        \"services\": \"Tjenester\",\n        \"middleware\": \"Midtvare\"\n    },\n    \"npm\": {\n        \"enabled\": \"Påskrudd\",\n        \"disabled\": \"Avskrudd\",\n        \"total\": \"Totalt\"\n    },\n    \"weather\": {\n        \"allow\": \"Klikk for å tillate\",\n        \"updating\": \"Oppdaterer …\",\n        \"wait\": \"Vent litt …\",\n        \"current\": \"Nåværende posisjon\"\n    },\n    \"overseerr\": {\n        \"pending\": \"Venter\",\n        \"approved\": \"Godkjent\",\n        \"available\": \"Tilgjengelig\",\n        \"processing\": \"Processing\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Takt\",\n        \"queue\": \"Kø\",\n        \"timeleft\": \"Gjenstående tid\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Takt\",\n        \"downloaded\": \"Nedlastet\",\n        \"remaining\": \"Gjenstående\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Sett opp én eller flere kryptovalutaer å holde øye med\",\n        \"1hour\": \"1 Hour\",\n        \"1day\": \"1 Day\",\n        \"7days\": \"7 Days\",\n        \"30days\": \"30 Days\"\n    },\n    \"gotify\": {\n        \"apps\": \"Programmer\",\n        \"clients\": \"Klienter\",\n        \"messages\": \"Meldinger\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indekserere\",\n        \"numberOfGrabs\": \"Hentninger\",\n        \"numberOfQueries\": \"Spørringer\",\n        \"numberOfFailGrabs\": \"Mislykkede hentinger\",\n        \"numberOfFailQueries\": \"Mislykkede spørringer\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"jackett\": {\n        \"configured\": \"Configured\",\n        \"errored\": \"Errored\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Missing Episodes\",\n        \"missingMovies\": \"Missing Movies\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"Artists\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"Filtered\",\n        \"latency\": \"Latency\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"Posts\",\n        \"domain_count\": \"Domains\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sessions\",\n        \"numConnections\": \"Connections\",\n        \"dataRelayed\": \"Relayed\",\n        \"transferRate\": \"Rate\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"Logins (24h)\",\n        \"failedLoginsLast24H\": \"Failed Logins (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"unifi\": {\n        \"users\": \"Users\",\n        \"uptime\": \"System Uptime\",\n        \"days\": \"Days\",\n        \"wan\": \"WAN\",\n        \"lan_users\": \"LAN Users\",\n        \"wlan_users\": \"WLAN Users\",\n        \"up\": \"UP\",\n        \"down\": \"DOWN\",\n        \"wait\": \"Please wait\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Devices\",\n        \"lan_devices\": \"LAN Devices\",\n        \"wlan_devices\": \"WLAN Devices\",\n        \"empty_data\": \"Subsystem status unknown\"\n    },\n    \"plex\": {\n        \"streams\": \"Active Streams\",\n        \"movies\": \"Movies\",\n        \"tv\": \"TV Shows\",\n        \"albums\": \"Albums\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"uptime\": \"UP\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"load\": \"Load\",\n        \"warn\": \"Warn\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"crit\": \"Crit\",\n        \"read\": \"Read\",\n        \"write\": \"Write\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Swap\",\n        \"_temp\": \"Temp\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Total Observed\",\n        \"diffsDetected\": \"Diffs Detected\"\n    },\n    \"wmo\": {\n        \"2-day\": \"Partly Cloudy\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"Cloudy\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"Foggy\",\n        \"45-night\": \"Foggy\",\n        \"0-day\": \"Sunny\",\n        \"0-night\": \"Clear\",\n        \"1-day\": \"Mainly Sunny\",\n        \"1-night\": \"Mainly Clear\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"Light Drizzle\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"Drizzle\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"Heavy Drizzle\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"Light Freezing Drizzle\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"63-day\": \"Rain\",\n        \"57-day\": \"Freezing Drizzle\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"Light Rain\",\n        \"61-night\": \"Light Rain\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Heavy Rain\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Freezing Rain\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Light Snow\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Snow\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Heavy Snow\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Snow Grains\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Light Showers\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Showers\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Heavy Showers\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Snow Showers\",\n        \"95-day\": \"Thunderstorm\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Thunderstorm With Hail\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Bookmark\",\n        \"service\": \"Service\",\n        \"search\": \"Search\",\n        \"custom\": \"Custom\",\n        \"visit\": \"Visit\",\n        \"url\": \"URL\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"System\",\n        \"updates\": \"Updates\",\n        \"update_available\": \"Update Available\",\n        \"up_to_date\": \"Up to Date\",\n        \"child_bridges\": \"Child Bridges\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Rejected\",\n        \"filters\": \"Filters\",\n        \"indexers\": \"Indexers\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Scanned\",\n        \"containers_updated\": \"Updated\",\n        \"containers_failed\": \"Failed\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Videos\",\n        \"channels\": \"Channels\",\n        \"playlists\": \"Playlists\"\n    },\n    \"truenas\": {\n        \"load\": \"System Load\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\",\n        \"time\": \"{{value, number(style: unit; unitDisplay: long;)}}\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Please Wait\"\n    },\n    \"pyload\": {\n        \"speed\": \"Speed\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Public IP\",\n        \"region\": \"Region\",\n        \"country\": \"Country\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"Ping\",\n        \"up\": \"Up\",\n        \"down\": \"Down\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Inbox\",\n        \"total\": \"Total\"\n    },\n    \"deluge\": {\n        \"leech\": \"Leech\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"seed\": \"Seed\"\n    },\n    \"flood\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Processed\",\n        \"errored\": \"Errored\",\n        \"saved\": \"Saved\"\n    },\n    \"miniflux\": {\n        \"read\": \"Read\",\n        \"unread\": \"Unread\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"No Device Data Received\"\n    },\n    \"common\": {\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Connected APs\",\n        \"activeUser\": \"Active devices\",\n        \"alerts\": \"Alerts\",\n        \"connectedGateway\": \"Connected gateways\",\n        \"connectedSwitches\": \"Connected switches\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"mikrotik\": {\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Leases\",\n        \"cpuLoad\": \"CPU Load\",\n        \"memoryUsed\": \"Memory Used\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"All Streams\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG Channels\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Active Memory\",\n        \"wanUpload\": \"WAN Upload\",\n        \"wanDownload\": \"WAN Download\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Printer State\",\n        \"print_status\": \"Print Status\",\n        \"print_progress\": \"Progress\",\n        \"layers\": \"Layers\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Tool temp\",\n        \"temp_bed\": \"Bed temp\",\n        \"job_completion\": \"Completion\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Origin IP\",\n        \"status\": \"Status\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datastore\",\n        \"failed_tasks_24h\": \"Failed Tasks 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memory\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Storage\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Incident\",\n        \"m\": \"m\"\n    },\n    \"komga\": {\n        \"libraries\": \"Libraries\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Issues\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"People\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Time\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Dashboards\",\n        \"datasources\": \"Data Sources\",\n        \"totalalerts\": \"Total Alerts\",\n        \"alertstriggered\": \"Alerts Triggered\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Cpu Load\",\n        \"memoryusage\": \"Memory Usage\",\n        \"freespace\": \"Free Space\",\n        \"activeusers\": \"Active Users\",\n        \"numfiles\": \"Files\",\n        \"numshares\": \"Shared Items\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Size\",\n        \"lastrun\": \"Last Run\",\n        \"nextrun\": \"Next Run\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Active Workers\",\n        \"total_workers\": \"Total Workers\",\n        \"records_total\": \"Queue Length\"\n    },\n    \"healthchecks\": {\n        \"new\": \"New\",\n        \"up\": \"Online\",\n        \"grace\": \"In Grace Period\",\n        \"down\": \"Offline\",\n        \"paused\": \"Paused\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Last Ping\",\n        \"never\": \"No pings yet\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servers\",\n        \"nodes\": \"Nodes\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Targets Up\",\n        \"targets_down\": \"Targets Down\",\n        \"targets_total\": \"Total Targets\"\n    },\n    \"minecraft\": {\n        \"players\": \"Players\",\n        \"version\": \"Version\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"One year\",\n        \"gross_percent_max\": \"All time\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasts\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Duration\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"People Home\",\n        \"lights_on\": \"Lights On\",\n        \"switches_on\": \"Switches On\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Subscriptions\",\n        \"unread\": \"Unread\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Shows\",\n        \"recordings\": \"Recordings\",\n        \"scheduled\": \"Scheduled\",\n        \"passes\": \"Passes\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Monitoring\",\n        \"updates\": \"Updates\"\n    },\n    \"tailscale\": {\n        \"address\": \"Address\",\n        \"expires\": \"Expires\",\n        \"never\": \"Never\",\n        \"last_seen\": \"Last Seen\",\n        \"now\": \"Now\",\n        \"years\": \"{{number}}y\",\n        \"weeks\": \"{{number}}w\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Ago\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU Usage\",\n        \"memUsage\": \"MEM Usage\",\n        \"systemTempC\": \"System Temp\",\n        \"poolUsage\": \"Pool Usage\",\n        \"volumeUsage\": \"Volume Usage\",\n        \"invalid\": \"Invalid\"\n    },\n    \"pfsense\": {\n        \"load\": \"Load Avg\",\n        \"memory\": \"Mem Usage\",\n        \"wanStatus\": \"WAN Status\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Disk Usage\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Current requests\",\n        \"requests_failed\": \"Failed requests\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Production\",\n        \"battery_soc\": \"Battery\",\n        \"grid_power\": \"Grid\",\n        \"home_power\": \"Consumption\",\n        \"charge_power\": \"Charger\",\n        \"watt_hour\": \"Wh\"\n    },\n    \"pialert\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"New Devices\",\n        \"down_alerts\": \"Down Alerts\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue Count\",\n        \"downloadSpeed\": \"Download Speed\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Filer\"\n    },\n    \"gamedig\": {\n        \"name\": \"Name\",\n        \"map\": \"Map\",\n        \"currentPlayers\": \"Current players\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Max players\",\n        \"bots\": \"Bots\",\n        \"ping\": \"Ping\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Result\",\n        \"status\": \"Status\",\n        \"buildId\": \"Build ID\",\n        \"succeeded\": \"Succeeded\",\n        \"notStarted\": \"Not Started\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Canceled\",\n        \"inProgress\": \"In Progress\",\n        \"totalPrs\": \"Total PRs\",\n        \"myPrs\": \"My PRs\",\n        \"approved\": \"Approved\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Errors\",\n        \"noRecent\": \"Out of Date\",\n        \"totalUsed\": \"Used Storage\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Downloading\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Recipes\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Tags\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Archives\",\n        \"chapters\": \"Chapters\",\n        \"categories\": \"Categories\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Authors\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"uptimerobot\": {\n        \"uptime\": \"Uptime\",\n        \"status\": \"Status\",\n        \"lastDown\": \"Last Downtime\",\n        \"downDuration\": \"Downtime Duration\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Not Yet Checked\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Seems Down\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"opendtu\": {\n        \"relativePower\": \"Power %\",\n        \"yieldDay\": \"Today\",\n        \"limit\": \"Limit\",\n        \"absolutePower\": \"Power\"\n    },\n    \"calendar\": {\n        \"physicalRelease\": \"Physical release\",\n        \"inCinemas\": \"In cinemas\",\n        \"digitalRelease\": \"Digital release\"\n    }\n}\n"
  },
  {
    "path": "public/locales/nl/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"mnd\",\n        \"days\": \"d\",\n        \"hours\": \"u\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Ontbrekende Widget Type: {{type}}\",\n        \"api_error\": \"API fout\",\n        \"information\": \"Informatie\",\n        \"status\": \"Status\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Raw fout\",\n        \"response_data\": \"Responsgegevens\"\n    },\n    \"weather\": {\n        \"current\": \"Huidige Locatie\",\n        \"allow\": \"Klik om toe te staan\",\n        \"updating\": \"Updaten\",\n        \"wait\": \"Even geduld\"\n    },\n    \"search\": {\n        \"placeholder\": \"Zoeken…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"GEH\",\n        \"total\": \"Totaal\",\n        \"free\": \"Vrij\",\n        \"used\": \"Gebruikt\",\n        \"load\": \"Belasting\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Max\",\n        \"uptime\": \"UP\"\n    },\n    \"unifi\": {\n        \"users\": \"Gebruikers\",\n        \"uptime\": \"Online\",\n        \"days\": \"Dagen\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Apparaten\",\n        \"lan_devices\": \"LAN Apparaten\",\n        \"wlan_devices\": \"WLAN Apparaten\",\n        \"lan_users\": \"LAN Gebruikers\",\n        \"wlan_users\": \"WLAN Gebruikers\",\n        \"up\": \"Online\",\n        \"down\": \"OFFLINE\",\n        \"wait\": \"Even geduld\",\n        \"empty_data\": \"Subsysteem status onbekend\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"GEH\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Actief\",\n        \"offline\": \"Offline\",\n        \"error\": \"Fout\",\n        \"unknown\": \"Onbekend\",\n        \"healthy\": \"Gezond\",\n        \"starting\": \"Starten\",\n        \"unhealthy\": \"Ongezond\",\n        \"not_found\": \"Niet Gevonden\",\n        \"exited\": \"Gestopt\",\n        \"partial\": \"Gedeeltelijk\"\n    },\n    \"ping\": {\n        \"error\": \"Fout\",\n        \"ping\": \"Ping\",\n        \"down\": \"Offline\",\n        \"up\": \"Online\",\n        \"not_available\": \"Niet Beschikbaar\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP status\",\n        \"error\": \"Fout\",\n        \"response\": \"Reactie\",\n        \"down\": \"Offline\",\n        \"up\": \"Online\",\n        \"not_available\": \"Niet Beschikbaar\"\n    },\n    \"emby\": {\n        \"playing\": \"Afspelen\",\n        \"transcoding\": \"Transcodering\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"Geen Actieve Streams\",\n        \"movies\": \"Films\",\n        \"series\": \"Series\",\n        \"episodes\": \"Afleveringen\",\n        \"songs\": \"Nummers\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Bereikbaar\",\n        \"total\": \"Totaal\",\n        \"unknown\": \"Onbekend\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Productie\",\n        \"battery_soc\": \"Batterij\",\n        \"grid_power\": \"Netstroom\",\n        \"home_power\": \"Consumptie\",\n        \"charge_power\": \"Oplader\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Delen\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Abonnementen\",\n        \"unread\": \"Ongelezen\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Niet-geconfigureerd\",\n        \"connectionStatusConnecting\": \"Bezig met verbinden\",\n        \"connectionStatusAuthenticating\": \"Verificatie\",\n        \"connectionStatusPendingDisconnect\": \"In afwachting van loskoppelen\",\n        \"connectionStatusDisconnecting\": \"Verbinding verbreken\",\n        \"connectionStatusDisconnected\": \"Verbinding verbroken\",\n        \"connectionStatusConnected\": \"Verbonden\",\n        \"uptime\": \"Tijd online\",\n        \"maxDown\": \"Max. Download\",\n        \"maxUp\": \"Max. Upload\",\n        \"down\": \"Offline\",\n        \"up\": \"Online\",\n        \"received\": \"Ontvangen\",\n        \"sent\": \"Verzonden\",\n        \"externalIPAddress\": \"Ext. IP\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Huidige verzoeken\",\n        \"requests_failed\": \"Gefaalde verzoeken\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Totaal waargenomen\",\n        \"diffsDetected\": \"Verschillen Gedetecteerd\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Shows\",\n        \"recordings\": \"Opnames\",\n        \"scheduled\": \"Gepland\",\n        \"passes\": \"Gepasseerd\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Speelt\",\n        \"transcoding\": \"Bezig met transcoderen\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"Geen Actieve Streams\",\n        \"plex_connection_error\": \"Controleer Plex Connectie\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Verbonden APs\",\n        \"activeUser\": \"Actieve apparaten\",\n        \"alerts\": \"Meldingen\",\n        \"connectedGateways\": \"Verbonden gateways\",\n        \"connectedSwitches\": \"Verbonden switches\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Rate\",\n        \"remaining\": \"Resterend\",\n        \"downloaded\": \"Gedownload\"\n    },\n    \"plex\": {\n        \"streams\": \"Actieve Streams\",\n        \"albums\": \"Albums\",\n        \"movies\": \"Films\",\n        \"tv\": \"TV Series\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"Wachtrij\",\n        \"timeleft\": \"Resterende Tijd\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Actief\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Delen\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Delen\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU Verbruik\",\n        \"memUsage\": \"MEM Gebruik\",\n        \"systemTempC\": \"Systeem Temperatuur\",\n        \"poolUsage\": \"Pool Gebruik\",\n        \"volumeUsage\": \"Volume Gebruik\",\n        \"invalid\": \"ongeldig\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Gezocht\",\n        \"queued\": \"Wachtrij\",\n        \"series\": \"Series\",\n        \"queue\": \"Wachtrij\",\n        \"unknown\": \"Onbekend\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Gezocht\",\n        \"missing\": \"Ontbreekt\",\n        \"queued\": \"In de wachtrij\",\n        \"movies\": \"Films\",\n        \"queue\": \"Wachtrij\",\n        \"unknown\": \"Onbekend\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Gezocht\",\n        \"queued\": \"In de wachtrij\",\n        \"artists\": \"Artiesten\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Gezocht\",\n        \"queued\": \"In de wachtrij\",\n        \"books\": \"Boeken\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Ontbrekende Afleveringen\",\n        \"missingMovies\": \"Ontbrekende Films\"\n    },\n    \"ombi\": {\n        \"pending\": \"In afwachting\",\n        \"approved\": \"Goedgekeurd\",\n        \"available\": \"Beschikbaar\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Totaal\",\n        \"connected\": \"Verbonden\",\n        \"new_devices\": \"Nieuwe Apparaten\",\n        \"down_alerts\": \"Geen verbinding\"\n    },\n    \"pihole\": {\n        \"queries\": \"Verzoeken\",\n        \"blocked\": \"Geblokkeerd\",\n        \"blocked_percent\": \"Geblokkeerde %\",\n        \"gravity\": \"Gravity\"\n    },\n    \"adguard\": {\n        \"queries\": \"Verzoeken\",\n        \"blocked\": \"Geblokkeerd\",\n        \"filtered\": \"Gefilterd\",\n        \"latency\": \"Latentie\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Actief\",\n        \"stopped\": \"Gestopt\",\n        \"total\": \"Totaal\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Gedownload\",\n        \"nondownload\": \"Niet gedownload\",\n        \"read\": \"Lezen\",\n        \"unread\": \"Ongelezen\",\n        \"downloadedread\": \"Gedownload & gelezen\",\n        \"downloadedunread\": \"Gedownload & ongelezen\",\n        \"nondownloadedread\": \"Niet-gedownload & gelezen\",\n        \"nondownloadedunread\": \"Niet-gedownload & ongelezen\"\n    },\n    \"tailscale\": {\n        \"address\": \"Adres\",\n        \"expires\": \"Verloopt\",\n        \"never\": \"Nooit\",\n        \"last_seen\": \"Laatst Gezien\",\n        \"now\": \"Nu\",\n        \"years\": \"{{number}}j\",\n        \"weeks\": \"{{number}}w\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}u\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Geleden\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Verzoeken\",\n        \"totalNoError\": \"Geslaagd\",\n        \"totalServerFailure\": \"Gefaald\",\n        \"totalNxDomain\": \"NX Domains\",\n        \"totalRefused\": \"Geweigerd\",\n        \"totalAuthoritative\": \"Authoritative\",\n        \"totalRecursive\": \"Recursive\",\n        \"totalCached\": \"Gecached\",\n        \"totalBlocked\": \"Geblokkeerd\",\n        \"totalDropped\": \"Dropped\",\n        \"totalClients\": \"Cliënten\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Wachtrij\",\n        \"processed\": \"Verwerkt\",\n        \"errored\": \"Fout\",\n        \"saved\": \"Opgeslagen\"\n    },\n    \"traefik\": {\n        \"routers\": \"Routers\",\n        \"services\": \"Diensten\",\n        \"middleware\": \"Middleware\"\n    },\n    \"trilium\": {\n        \"version\": \"Versie\",\n        \"notesCount\": \"Notities\",\n        \"dbSize\": \"Database grootte\",\n        \"unknown\": \"Onbekend\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"Geen Actieve Streams\",\n        \"please_wait\": \"Even geduld aub\"\n    },\n    \"npm\": {\n        \"enabled\": \"Ingeschakeld\",\n        \"disabled\": \"Uitgeschakeld\",\n        \"total\": \"Totaal\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Configureer een of meer crypto eenheden om bij te houden\",\n        \"1hour\": \"1 Uur\",\n        \"1day\": \"1 Dag\",\n        \"7days\": \"7 Dagen\",\n        \"30days\": \"30 Dagen\"\n    },\n    \"gotify\": {\n        \"apps\": \"Applicaties\",\n        \"clients\": \"Cliënten\",\n        \"messages\": \"Berichten\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indexeerders\",\n        \"numberOfGrabs\": \"Grabs\",\n        \"numberOfQueries\": \"Verzoeken\",\n        \"numberOfFailGrabs\": \"Ophalen mislukt\",\n        \"numberOfFailQueries\": \"Mislukte verzoeken\"\n    },\n    \"jackett\": {\n        \"configured\": \"Geconfigureerd\",\n        \"errored\": \"Mislukt\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sessies\",\n        \"numConnections\": \"Verbindingen\",\n        \"dataRelayed\": \"Omgeleid\",\n        \"transferRate\": \"Doorvoersnelheid\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Gebruikers\",\n        \"status_count\": \"Berichten\",\n        \"domain_count\": \"Domeinen\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Gezocht\",\n        \"queued\": \"In de wachtrij\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Spelers\",\n        \"version\": \"Versie\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Gelezen\",\n        \"unread\": \"Ongelezen\"\n    },\n    \"authentik\": {\n        \"users\": \"Gebruikers\",\n        \"loginsLast24H\": \"Logins (24u)\",\n        \"failedLoginsLast24H\": \"Mislukte Logins (24u)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"GEH\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VM's\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Belasting\",\n        \"wait\": \"Even geduld\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temp\",\n        \"warn\": \"Waarschuwing\",\n        \"uptime\": \"Online\",\n        \"total\": \"Totaal\",\n        \"free\": \"Vrij\",\n        \"used\": \"Gebruikt\",\n        \"days\": \"d\",\n        \"hours\": \"u\",\n        \"crit\": \"Kritiek\",\n        \"read\": \"Lezen\",\n        \"write\": \"Schrijven\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Bladwijzer\",\n        \"service\": \"Dienst\",\n        \"search\": \"Zoek\",\n        \"custom\": \"Aangepast\",\n        \"visit\": \"Bezoek\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Suggestie\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Zonnig\",\n        \"0-night\": \"Helder\",\n        \"1-day\": \"Overwegend Zonnig\",\n        \"1-night\": \"Overwegend Helder\",\n        \"2-day\": \"Gedeeltelijk Bewolkt\",\n        \"2-night\": \"Gedeeltelijk Bewolkt\",\n        \"3-day\": \"Bewolkt\",\n        \"3-night\": \"Bewolkt\",\n        \"45-day\": \"Mistig\",\n        \"45-night\": \"Mistig\",\n        \"48-day\": \"Mistig\",\n        \"48-night\": \"Mistig\",\n        \"51-day\": \"Motregen\",\n        \"51-night\": \"Motregen\",\n        \"53-day\": \"Druilerig\",\n        \"53-night\": \"Druilerig\",\n        \"55-day\": \"Zware motregen\",\n        \"55-night\": \"Zware motregen\",\n        \"56-day\": \"Lichte opvriezende motregen\",\n        \"56-night\": \"Lichte opvriezende motregen\",\n        \"57-day\": \"Opvriezende motregen\",\n        \"57-night\": \"Opvriezende motregen\",\n        \"61-day\": \"Lichte Regen\",\n        \"61-night\": \"Lichte Regen\",\n        \"63-day\": \"Regen\",\n        \"63-night\": \"Regen\",\n        \"65-day\": \"Hevige Regen\",\n        \"65-night\": \"Hevige Regen\",\n        \"66-day\": \"Opvriezende regen\",\n        \"66-night\": \"Opvriezende regen\",\n        \"67-day\": \"Opvriezende regen\",\n        \"67-night\": \"Opvriezende regen\",\n        \"71-day\": \"Lichte Sneeuw\",\n        \"71-night\": \"Lichte Sneeuw\",\n        \"73-day\": \"Sneeuw\",\n        \"73-night\": \"Sneeuw\",\n        \"75-day\": \"Hevige Sneeuw\",\n        \"75-night\": \"Hevige Sneeuw\",\n        \"77-day\": \"Sneeuw korrels\",\n        \"77-night\": \"Sneeuw korrels\",\n        \"80-day\": \"Lichte regenbui\",\n        \"80-night\": \"Lichte buien\",\n        \"81-day\": \"Regenbui\",\n        \"81-night\": \"Buien\",\n        \"82-day\": \"Zware Regenbuien\",\n        \"82-night\": \"Zware Regenbuien\",\n        \"85-day\": \"Sneeuwbuien\",\n        \"85-night\": \"Sneeuwbuien\",\n        \"86-day\": \"Sneeuwbuien\",\n        \"86-night\": \"Sneeuwbuien\",\n        \"95-day\": \"Onweersbui\",\n        \"95-night\": \"Onweersbui\",\n        \"96-day\": \"Onweersbui Met Hagel\",\n        \"96-night\": \"Onweersbui Met Hagel\",\n        \"99-day\": \"Onweersbui Met Hagel\",\n        \"99-night\": \"Onweersbui Met Hagel\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Systeem\",\n        \"updates\": \"Updates\",\n        \"update_available\": \"Update Beschikbaar\",\n        \"up_to_date\": \"Bijgewerkt\",\n        \"child_bridges\": \"Onderliggende bridges\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Online\",\n        \"pending\": \"In afwachting\",\n        \"down\": \"Offline\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Nieuw\",\n        \"up\": \"Online\",\n        \"grace\": \"In de respijt periode\",\n        \"down\": \"Offline\",\n        \"paused\": \"Gepauzeerd\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Laatste Ping\",\n        \"never\": \"Nog geen pings\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Gescanned\",\n        \"containers_updated\": \"Bijgewerkt\",\n        \"containers_failed\": \"Gefaald\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Goedgekeurd\",\n        \"rejectedPushes\": \"Afgewezen\",\n        \"filters\": \"Filters\",\n        \"indexers\": \"Indexeerders\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Wachtrij\",\n        \"videos\": \"Video's\",\n        \"channels\": \"Kanalen\",\n        \"playlists\": \"Speellijsten\"\n    },\n    \"truenas\": {\n        \"load\": \"Belasting\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Waarschuwingen\"\n    },\n    \"pyload\": {\n        \"speed\": \"Snelheid\",\n        \"active\": \"Actief\",\n        \"queue\": \"Wachtrij\",\n        \"total\": \"Totaal\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Publiek IP\",\n        \"region\": \"Regio\",\n        \"country\": \"Land\",\n        \"port_forwarded\": \"Poort doorgeschakeld\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Kanalen\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tuners\",\n        \"channelNumber\": \"Kanaal\",\n        \"channelNetwork\": \"Netwerk\",\n        \"signalStrength\": \"Sterkte\",\n        \"signalQuality\": \"Kwaliteit\",\n        \"symbolQuality\": \"Kwaliteit\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Client\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Geslaagd\",\n        \"failed\": \"Gefaald\",\n        \"unknown\": \"Onbekend\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Postvak In\",\n        \"total\": \"Totaal\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Batterij opladen\",\n        \"ups_load\": \"UPS-belasting\",\n        \"ups_status\": \"UPS status\",\n        \"online\": \"Online\",\n        \"on_battery\": \"Op batterij\",\n        \"low_battery\": \"Batterij bijna leeg\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Even geduld\",\n        \"no_devices\": \"Geen Apparaat Data Ontvangen\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"CPU Belasting\",\n        \"memoryUsed\": \"Geheugen Gebruikt\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Leases\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Alle Streams\",\n        \"streams_active\": \"Actieve Streams\",\n        \"streams_xepg\": \"XEPG Kanalen\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Vandaag\",\n        \"absolutePower\": \"Vermogen\",\n        \"relativePower\": \"Vermogen %\",\n        \"limit\": \"Limiet\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Belasting\",\n        \"memory\": \"Actief Geheugen\",\n        \"wanUpload\": \"WAN Upload\",\n        \"wanDownload\": \"WAN Download\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Printer Status\",\n        \"print_status\": \"Print Status\",\n        \"print_progress\": \"Voortgang\",\n        \"layers\": \"Lagen\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Tool temp\",\n        \"temp_bed\": \"Bed temp\",\n        \"job_completion\": \"Voltooiing\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Bron IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Gem. Load\",\n        \"memory\": \"Mem Gebruik\",\n        \"wanStatus\": \"WAN Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Schijf Gebruik\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Data Opslag\",\n        \"failed_tasks_24h\": \"Gefaalde taken 24u\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Geheugen\"\n    },\n    \"immich\": {\n        \"users\": \"Gebruikers\",\n        \"photos\": \"Foto's\",\n        \"videos\": \"Video's\",\n        \"storage\": \"Opslag\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Sites Bereikbaar\",\n        \"down\": \"Sites Onbereikbaar\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Incident\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Archieven\",\n        \"chapters\": \"Hoofdstukken\",\n        \"categories\": \"Categorieën\"\n    },\n    \"komga\": {\n        \"libraries\": \"Bibliotheken\",\n        \"series\": \"Series\",\n        \"books\": \"Boeken\"\n    },\n    \"diskstation\": {\n        \"days\": \"Dagen\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Beschikbaar\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Problemen\",\n        \"wanted\": \"Gezocht\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Foto's\",\n        \"videos\": \"Video's\",\n        \"people\": \"Personen\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Wachtrij\",\n        \"processing\": \"Verwerken\",\n        \"processed\": \"Verwerkt\",\n        \"time\": \"Tijd\"\n    },\n    \"firefly\": {\n        \"networth\": \"Totaal eigen vermogen\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Dashboards\",\n        \"datasources\": \"Data Bronnen\",\n        \"totalalerts\": \"Totaal Alerts\",\n        \"alertstriggered\": \"Getriggerde Alerts\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Cpu Belasting\",\n        \"memoryusage\": \"Geheugen Gebruik\",\n        \"freespace\": \"Vrije Ruimte\",\n        \"activeusers\": \"Actieve Gebruikers\",\n        \"numfiles\": \"Bestanden\",\n        \"numshares\": \"Gedeelde items\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Grootte\",\n        \"lastrun\": \"Laatste Run\",\n        \"nextrun\": \"Volgende Run\",\n        \"failed\": \"Gefaald\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Actieve Werkers\",\n        \"total_workers\": \"Totale Werkers\",\n        \"records_total\": \"Wachtrij Lengte\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servers\",\n        \"nodes\": \"Nodes\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Doelen bereikbaar\",\n        \"targets_down\": \"Doelen onbereikbaar\",\n        \"targets_total\": \"Totaal aantal doelen\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Online\",\n        \"down\": \"Sites Offline\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Vandaag\",\n        \"gross_percent_1y\": \"Een jaar\",\n        \"gross_percent_max\": \"Altijd\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasts\",\n        \"books\": \"Boeken\",\n        \"podcastsDuration\": \"Duur\",\n        \"booksDuration\": \"Duur\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Mensen thuis\",\n        \"lights_on\": \"Lichten aan\",\n        \"switches_on\": \"Schakelaars aan\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Monitoren\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Boeken\",\n        \"authors\": \"Auteurs\",\n        \"categories\": \"Categorieën\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Wachtrij\",\n        \"downloadBytesRemaining\": \"Resterend\",\n        \"downloadTotalBytes\": \"Grootte\",\n        \"downloadSpeed\": \"Snelheid\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Bestanden\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Resultaat\",\n        \"status\": \"Status\",\n        \"buildId\": \"Build ID\",\n        \"succeeded\": \"Geslaagd\",\n        \"notStarted\": \"Niet gestart\",\n        \"failed\": \"Gefaald\",\n        \"canceled\": \"Afgebroken\",\n        \"inProgress\": \"Voortgaand\",\n        \"totalPrs\": \"Totaal PRs\",\n        \"myPrs\": \"Mijn PR's\",\n        \"approved\": \"Goedgekeurd\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Naam\",\n        \"map\": \"Kaart\",\n        \"currentPlayers\": \"Huidige spelers\",\n        \"players\": \"Spelers\",\n        \"maxPlayers\": \"Max spelers\",\n        \"bots\": \"Bots\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Fouten\",\n        \"noRecent\": \"Verouderd\",\n        \"totalUsed\": \"Gebruikte opslag\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Recepten\",\n        \"users\": \"Gebruikers\",\n        \"categories\": \"Categorieën\",\n        \"tags\": \"Label\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Downloaden\",\n        \"total\": \"Totaal\",\n        \"running\": \"Actief\",\n        \"stopped\": \"Gestopt\",\n        \"passed\": \"Geslaagd\",\n        \"failed\": \"Gefaald\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"CPU Load Gem. (5m)\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\",\n        \"bytesTx\": \"Verzonden\",\n        \"bytesRx\": \"Ontvangen\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Laatste Downtime\",\n        \"downDuration\": \"Duur Downtime\",\n        \"sitesUp\": \"Sites Online\",\n        \"sitesDown\": \"Sites Offline\",\n        \"paused\": \"Gepauzeerd\",\n        \"notyetchecked\": \"Nog niet gecontroleerd\",\n        \"up\": \"Online\",\n        \"seemsdown\": \"Lijkt onbereikbaar\",\n        \"down\": \"Offline\",\n        \"unknown\": \"Onbekend\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"In de bioscoop\",\n        \"physicalRelease\": \"Fysieke versie\",\n        \"digitalRelease\": \"Digitale versie\",\n        \"noEventsToday\": \"Geen gebeurtenissen voor vandaag!\",\n        \"noEventsFound\": \"Geen gebeurtenissen gevonden\",\n        \"errorWhenLoadingData\": \"Fout bij het laden van kalender gegevens\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platformen\",\n        \"totalRoms\": \"Games\",\n        \"saves\": \"Saves\",\n        \"states\": \"States\",\n        \"screenshots\": \"Screenshots \",\n        \"totalfilesize\": \"Totale grootte\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domeinen\",\n        \"mailboxes\": \"Mailboxen\",\n        \"mails\": \"Mails\",\n        \"storage\": \"Opslag\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Waarschuwingen\",\n        \"criticals\": \"Kritiek\"\n    },\n    \"plantit\": {\n        \"events\": \"Gebeurtenissen\",\n        \"plants\": \"Planten\",\n        \"photos\": \"Foto's\",\n        \"species\": \"Soorten\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Notificaties\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Pull Requests\",\n        \"repositories\": \"Bronnen\"\n    },\n    \"stash\": {\n        \"scenes\": \"Scènes\",\n        \"scenesPlayed\": \"Afgespeelde scènes\",\n        \"playCount\": \"Totaal aantal keer gespeeld\",\n        \"playDuration\": \"Tijd Bekeken\",\n        \"sceneSize\": \"Grootte Scènes\",\n        \"sceneDuration\": \"Duur scènes\",\n        \"images\": \"Afbeeldingen\",\n        \"imageSize\": \"Afbeeldingsgrootte\",\n        \"galleries\": \"Galerijen\",\n        \"performers\": \"Uitvoerenden\",\n        \"studios\": \"Studio's\",\n        \"movies\": \"Films\",\n        \"tags\": \"Labels\",\n        \"oCount\": \"O Aantal\"\n    },\n    \"tandoor\": {\n        \"users\": \"Gebruikers\",\n        \"recipes\": \"Recepten\",\n        \"keywords\": \"Trefwoorden\"\n    },\n    \"homebox\": {\n        \"items\": \"Items\",\n        \"totalWithWarranty\": \"Met garantie\",\n        \"locations\": \"Locaties\",\n        \"labels\": \"Labels\",\n        \"users\": \"Gebruikers\",\n        \"totalValue\": \"Totale waarde\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Waarschuwingen\",\n        \"bans\": \"Bans\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Verbonden\",\n        \"enabled\": \"Ingeschakeld\",\n        \"disabled\": \"Uitgeschakeld\",\n        \"total\": \"Totaal\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxied\",\n        \"auth\": \"With Auth\",\n        \"outdated\": \"Verouderd\",\n        \"banned\": \"Verbannen\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Aandelen\",\n        \"loading\": \"Laden\",\n        \"open\": \"Open - US Market\",\n        \"closed\": \"Closed - US Market\",\n        \"invalidConfiguration\": \"Ongeldige configuratie\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Camera's\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Versie\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Links\",\n        \"collections\": \"Collections\",\n        \"tags\": \"Labels\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Niet geclassificeerd\",\n        \"information\": \"Informatie\",\n        \"warning\": \"Waarschuwingen\",\n        \"average\": \"Average\",\n        \"high\": \"High\",\n        \"disaster\": \"Disaster\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Voertuig\",\n        \"vehicles\": \"Voertuigen\",\n        \"serviceRecords\": \"Service Historie\",\n        \"reminders\": \"Herinneringen\",\n        \"nextReminder\": \"Volgende Herinnering\",\n        \"none\": \"Geen\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Actieve Projecten\",\n        \"tasks7d\": \"Taken Die Deze Week Af Moeten Zijn\",\n        \"tasksOverdue\": \"Achterstallige Taken\",\n        \"tasksInProgress\": \"Taken In Uitvoering\"\n    },\n    \"headscale\": {\n        \"name\": \"Naam\",\n        \"address\": \"Adres\",\n        \"last_seen\": \"Laatst Gezien\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Naam\",\n        \"systems\": \"Systemen\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\",\n        \"paused\": \"Gepauzeerd\",\n        \"pending\": \"In afwachting\",\n        \"status\": \"Status\",\n        \"updated\": \"Bijgewerkt\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"GEH\",\n        \"disk\": \"Schijf\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Apps\",\n        \"synced\": \"Gesynchroniseerd\",\n        \"outOfSync\": \"Niet gesynchroniseerd\",\n        \"healthy\": \"Gezond\",\n        \"degraded\": \"Gedegradeerd\",\n        \"progressing\": \"Doorvoeren\",\n        \"missing\": \"Ontbreekt\",\n        \"suspended\": \"Onderbroken\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Laden\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Groepen\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Merge Verzoeken\",\n        \"projects\": \"Projecten\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Belasting\",\n        \"bcharge\": \"Batterij opladen\",\n        \"timeleft\": \"Resterende Tijd\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bladwijzers\",\n        \"favorites\": \"Favorieten\",\n        \"archived\": \"Gearchiveerd\",\n        \"highlights\": \"Hoogtepunten\",\n        \"lists\": \"Lijsten\",\n        \"tags\": \"Labels\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Netwerk\",\n        \"connected\": \"Verbonden\",\n        \"disconnected\": \"Verbinding verbroken\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Beschikbaar\",\n        \"update_no\": \"Up to date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Bestanden\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Nummers\",\n        \"movies\": \"Films\",\n        \"episodes\": \"Afleveringen\",\n        \"other\": \"Overig\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service problemen\",\n        \"hostErrors\": \"Host problemen\"\n    },\n    \"komodo\": {\n        \"total\": \"Totaal\",\n        \"running\": \"Actief\",\n        \"stopped\": \"Gestopt\",\n        \"down\": \"Offline\",\n        \"unhealthy\": \"Ongezond\",\n        \"unknown\": \"Onbekend\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Beschikbaar\",\n        \"used\": \"Gebruikt\",\n        \"total\": \"Totaal\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Inschrijvingen\",\n        \"thisMonthlyCost\": \"Deze maand\",\n        \"nextMonthlyCost\": \"Volgende maand\",\n        \"previousMonthlyCost\": \"Vorige maand\",\n        \"nextRenewingSubscription\": \"Volgende betaling\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Gestart\",\n        \"STOPPED\": \"Gestopt\",\n        \"NEW_ARRAY\": \"Nieuwe array\",\n        \"RECON_DISK\": \"Reconstrueer Schijf\",\n        \"DISABLE_DISK\": \"Schijf uitgeschakeld\",\n        \"SWAP_DSBL\": \"Swap uitschakelen\",\n        \"INVALID_EXPANSION\": \"Ongeldige Uitbreiding\",\n        \"PARITY_NOT_BIGGEST\": \"Pariteit niet Grootste\",\n        \"TOO_MANY_MISSING_DISKS\": \"Te veel ontbrekende schijven\",\n        \"NEW_DISK_TOO_SMALL\": \"Nieuwe schijf te klein\",\n        \"NO_DATA_DISKS\": \"Geen data-schijven\",\n        \"notifications\": \"Meldingen\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Geheugen Gebruikt\",\n        \"memoryAvailable\": \"Geheugen Beschikbaar\",\n        \"arrayUsed\": \"Array Gebruikt\",\n        \"arrayFree\": \"Array beschikbaar\",\n        \"poolUsed\": \"{{pool}} gebruikt\",\n        \"poolFree\": \"{{pool}} Beschikbaar\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Pakketten\",\n        \"num_success_30\": \"Geslaagd\",\n        \"num_failure_30\": \"Gefaald\",\n        \"num_success_latest\": \"Geslaagd\",\n        \"num_failure_latest\": \"Mislukt\",\n        \"bytes_added_30\": \"Bytes toegevoegd\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Nummers\",\n        \"time\": \"Tijd\",\n        \"artists\": \"Artiesten\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/no/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"mnd\",\n        \"days\": \"d\",\n        \"hours\": \"t\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Manglende Widget Type: {{type}}\",\n        \"api_error\": \"API-feil\",\n        \"information\": \"Informasjon\",\n        \"status\": \"Status\",\n        \"url\": \"Nettadresse\",\n        \"raw_error\": \"Rå feil\",\n        \"response_data\": \"Responsdata\"\n    },\n    \"weather\": {\n        \"current\": \"Gjeldende posisjon\",\n        \"allow\": \"Trykk for å tillate\",\n        \"updating\": \"Oppdaterer\",\n        \"wait\": \"Vennligst vent\"\n    },\n    \"search\": {\n        \"placeholder\": \"Søk…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"MEM\",\n        \"total\": \"Totalt\",\n        \"free\": \"Ledig\",\n        \"used\": \"Brukt\",\n        \"load\": \"Last\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Maks\",\n        \"uptime\": \"OPP\"\n    },\n    \"unifi\": {\n        \"users\": \"Brukere\",\n        \"uptime\": \"Oppetid\",\n        \"days\": \"Dager\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Enheter\",\n        \"lan_devices\": \"LAN-enheter\",\n        \"wlan_devices\": \"WLAN-enheter\",\n        \"lan_users\": \"LAN Brukere\",\n        \"wlan_users\": \"WLAN Brukere\",\n        \"up\": \"UP\",\n        \"down\": \"NEDE\",\n        \"wait\": \"Please wait\",\n        \"empty_data\": \"Ukjent undersystemstatus\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Kjører\",\n        \"offline\": \"Frakoblet\",\n        \"error\": \"Feil\",\n        \"unknown\": \"Ukjent\",\n        \"healthy\": \"Friskt\",\n        \"starting\": \"Starter\",\n        \"unhealthy\": \"Usunn\",\n        \"not_found\": \"Not Found\",\n        \"exited\": \"Exited\",\n        \"partial\": \"Delvis\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"Responstid\",\n        \"down\": \"Nede\",\n        \"up\": \"Oppe\",\n        \"not_available\": \"Ikke tilgjengelig\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP status\",\n        \"error\": \"Error\",\n        \"response\": \"Svar\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"emby\": {\n        \"playing\": \"Spiller\",\n        \"transcoding\": \"Transkoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"Ingen aktive strømminger\",\n        \"movies\": \"Film\",\n        \"series\": \"Serie\",\n        \"episodes\": \"Episoder\",\n        \"songs\": \"Sanger\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"På nett\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Produksjon\",\n        \"battery_soc\": \"Batteri\",\n        \"grid_power\": \"Nett\",\n        \"home_power\": \"Forbruk\",\n        \"charge_power\": \"Lader\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Last ned\",\n        \"upload\": \"Opplastning\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Abonnementer\",\n        \"unread\": \"Ulest\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Ikke konfigurert\",\n        \"connectionStatusConnecting\": \"Kobler til\",\n        \"connectionStatusAuthenticating\": \"Autentisering\",\n        \"connectionStatusPendingDisconnect\": \"Venter på frakobling\",\n        \"connectionStatusDisconnecting\": \"Kobler fra\",\n        \"connectionStatusDisconnected\": \"Frakoblet\",\n        \"connectionStatusConnected\": \"Tilkoblet\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"Maks. Ned\",\n        \"maxUp\": \"Max. Opp\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"Mottatt\",\n        \"sent\": \"Sendt\",\n        \"externalIPAddress\": \"Ekstern IP\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Oppstrøms\",\n        \"requests\": \"Aktuelle forespørsler\",\n        \"requests_failed\": \"Mislykkede forespørsler\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Totalt sett\",\n        \"diffsDetected\": \"Diffs oppdaget\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Shows\",\n        \"recordings\": \"Opptak\",\n        \"scheduled\": \"Tidsplan\",\n        \"passes\": \"Pasninger\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"Kontroller Plex tilkoblingen\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Tilkoblede AP'er\",\n        \"activeUser\": \"Aktive enheter\",\n        \"alerts\": \"Varsler\",\n        \"connectedGateways\": \"Connected gateways\",\n        \"connectedSwitches\": \"Tilkoblede switcher\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Ranger\",\n        \"remaining\": \"Gjenstående\",\n        \"downloaded\": \"Nedlastede\"\n    },\n    \"plex\": {\n        \"streams\": \"Aktive strømmninger\",\n        \"albums\": \"Album\",\n        \"movies\": \"Movies\",\n        \"tv\": \"TV serier\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"Kø\",\n        \"timeleft\": \"Gjenstående tid\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Aktiv\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU Bruk\",\n        \"memUsage\": \"Minnebruk\",\n        \"systemTempC\": \"System temp\",\n        \"poolUsage\": \"Pool Bruk\",\n        \"volumeUsage\": \"Volumbruk\",\n        \"invalid\": \"Ugyldig\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Ønsket\",\n        \"queued\": \"Ventende\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"Mangler\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"Artister\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"Bøker\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Manglende episoder\",\n        \"missingMovies\": \"Manglende filmer\"\n    },\n    \"ombi\": {\n        \"pending\": \"Ventende\",\n        \"approved\": \"Godkjent\",\n        \"available\": \"Tilgjengelig\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"Nye enheter\",\n        \"down_alerts\": \"Down Alerts\"\n    },\n    \"pihole\": {\n        \"queries\": \"Spørringer\",\n        \"blocked\": \"Blokkert\",\n        \"blocked_percent\": \"Blokkert %\",\n        \"gravity\": \"Gravitasjon\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"Filtrert\",\n        \"latency\": \"Responstid\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stoppet\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Non-Downloaded\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Downloaded & Read\",\n        \"downloadedunread\": \"Downloaded & Unread\",\n        \"nondownloadedread\": \"Non-Downloaded & Read\",\n        \"nondownloadedunread\": \"Non-Downloaded & Unread\"\n    },\n    \"tailscale\": {\n        \"address\": \"Adresse\",\n        \"expires\": \"Utgår\",\n        \"never\": \"Aldri\",\n        \"last_seen\": \"Sist sett\",\n        \"now\": \"Nå\",\n        \"years\": \"{{number}}y\",\n        \"weeks\": \"{{number}}w\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Ago\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"Success\",\n        \"totalServerFailure\": \"Failures\",\n        \"totalNxDomain\": \"NX Domains\",\n        \"totalRefused\": \"Refused\",\n        \"totalAuthoritative\": \"Authoritative\",\n        \"totalRecursive\": \"Recursive\",\n        \"totalCached\": \"Cached\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"Dropped\",\n        \"totalClients\": \"Klienter\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Behandlet\",\n        \"errored\": \"Feilet\",\n        \"saved\": \"Lagret\"\n    },\n    \"traefik\": {\n        \"routers\": \"Rutere\",\n        \"services\": \"Tjenester\",\n        \"middleware\": \"Mellomvare\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Vennligst vent\"\n    },\n    \"npm\": {\n        \"enabled\": \"Aktivert\",\n        \"disabled\": \"Deaktivert\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Konfigurer én eller flere krypteringsvalutaer som skal spores\",\n        \"1hour\": \"Én time\",\n        \"1day\": \"Én dag\",\n        \"7days\": \"7 dager\",\n        \"30days\": \"30 dager\"\n    },\n    \"gotify\": {\n        \"apps\": \"Applikasjoner\",\n        \"clients\": \"Clients\",\n        \"messages\": \"Meldinger\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indeksere\",\n        \"numberOfGrabs\": \"Tatt\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"Feil ved henting\",\n        \"numberOfFailQueries\": \"Spørring mislyktes\"\n    },\n    \"jackett\": {\n        \"configured\": \"Konfigurert\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sesjoner\",\n        \"numConnections\": \"Tilkoblinger\",\n        \"dataRelayed\": \"Videresendt\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"Innlegg\",\n        \"domain_count\": \"Domener\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Spillere\",\n        \"version\": \"Versjon\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Read\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"Logins (24h)\",\n        \"failedLoginsLast24H\": \"Mislykket innlogginger (24t)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temp\",\n        \"warn\": \"Advarsel\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Crit\",\n        \"read\": \"Read\",\n        \"write\": \"Skriv\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Bokmerke\",\n        \"service\": \"Tjeneste\",\n        \"search\": \"Søk\",\n        \"custom\": \"Egendefinert\",\n        \"visit\": \"Besøk\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Forslag\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Solfylt\",\n        \"0-night\": \"Klart\",\n        \"1-day\": \"Lettskyet\",\n        \"1-night\": \"Lettskyet\",\n        \"2-day\": \"Delvis skyet\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"Skyet\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"Tåke\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"Lett yr\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"Yr\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"Tungt Regn\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"Lett underkjølt regn\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"Underkjølt Regn\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"Lett regn\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"Regn\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Kraftig regn\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Underkjølt regn\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Lett snøvær\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Snø\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Tett snø\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Snøkorn\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Lette Regnbyger\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Regnbyger\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Tunge regnbyger\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Snøbyger\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"Tordenbyger\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Tordenvær med hagl\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"System\",\n        \"updates\": \"Oppdateringer\",\n        \"update_available\": \"Oppdatering tilgjengelig\",\n        \"up_to_date\": \"Oppdatert\",\n        \"child_bridges\": \"Child Bridges\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Ny\",\n        \"up\": \"Up\",\n        \"grace\": \"I rammeperiode\",\n        \"down\": \"Down\",\n        \"paused\": \"Pauset\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Siste Ping\",\n        \"never\": \"Ingen ping ennå\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Skannet\",\n        \"containers_updated\": \"Oppdatert\",\n        \"containers_failed\": \"Mislyktes\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Avvist\",\n        \"filters\": \"Filtre\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Videoer\",\n        \"channels\": \"Kanal\",\n        \"playlists\": \"Spillelister\"\n    },\n    \"truenas\": {\n        \"load\": \"Last på systemet\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Hastighet\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Offentlig IP\",\n        \"region\": \"Region\",\n        \"country\": \"Land\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tunere\",\n        \"channelNumber\": \"Kanal\",\n        \"channelNetwork\": \"Nettverk\",\n        \"signalStrength\": \"Styrke\",\n        \"signalQuality\": \"Kvalitet\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Klient\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Bestått\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Innboks\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Batteriladning\",\n        \"ups_load\": \"UPS last\",\n        \"ups_status\": \"UPS status\",\n        \"online\": \"Online\",\n        \"on_battery\": \"På batteri\",\n        \"low_battery\": \"Lavt batterinivå\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"Ingen enhetsdata mottatt\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"Prosessorbelastning\",\n        \"memoryUsed\": \"Minne brukt\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Leases\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Alle strømminger\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG Kanaler\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Idag\",\n        \"absolutePower\": \"Effekt\",\n        \"relativePower\": \"Effekt %\",\n        \"limit\": \"Grense\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Aktiv minne\",\n        \"wanUpload\": \"WAN Opplasting\",\n        \"wanDownload\": \"WAN Nedlasting\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Skriver tilstand\",\n        \"print_status\": \"Print Status\",\n        \"print_progress\": \"Progresjon\",\n        \"layers\": \"Lag\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Verktøy temperatur\",\n        \"temp_bed\": \"Seng temperatur\",\n        \"job_completion\": \"Completion\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Origin IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Load Avg\",\n        \"memory\": \"Mem Usage\",\n        \"wanStatus\": \"WAN Status\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Disk Usage\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datastore\",\n        \"failed_tasks_24h\": \"Failed Tasks 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memory\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Lagring\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Nettsteder opp\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Incident\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Archives\",\n        \"chapters\": \"Chapters\",\n        \"categories\": \"Categories\"\n    },\n    \"komga\": {\n        \"libraries\": \"Libraries\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Issues\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"People\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Time\"\n    },\n    \"firefly\": {\n        \"networth\": \"Net Worth\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Dashboards\",\n        \"datasources\": \"Data Sources\",\n        \"totalalerts\": \"Total Alerts\",\n        \"alertstriggered\": \"Alerts Triggered\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Cpu Load\",\n        \"memoryusage\": \"Memory Usage\",\n        \"freespace\": \"Free Space\",\n        \"activeusers\": \"Active Users\",\n        \"numfiles\": \"Files\",\n        \"numshares\": \"Shared Items\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Size\",\n        \"lastrun\": \"Last Run\",\n        \"nextrun\": \"Next Run\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Active Workers\",\n        \"total_workers\": \"Totalt antall Arbeidere\",\n        \"records_total\": \"Kø lengde\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servere\",\n        \"nodes\": \"Noder\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Mål oppe\",\n        \"targets_down\": \"Mål nede\",\n        \"targets_total\": \"Totalt antall mål\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"Ett år\",\n        \"gross_percent_max\": \"Gjennom tidene\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podkaster\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Varighet\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Personer hjemme\",\n        \"lights_on\": \"Lys på\",\n        \"switches_on\": \"Slår På\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Overvåker\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Forfattere\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Resultat\",\n        \"status\": \"Status\",\n        \"buildId\": \"Produksjons ID\",\n        \"succeeded\": \"Vellykket\",\n        \"notStarted\": \"Ikke startet\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Avbrutt\",\n        \"inProgress\": \"Pågående\",\n        \"totalPrs\": \"Totalt PR-er\",\n        \"myPrs\": \"Mine PR'er\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Navn\",\n        \"map\": \"Kart\",\n        \"currentPlayers\": \"Aktuelle spillere\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Maks spillere\",\n        \"bots\": \"Bots\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Feil\",\n        \"noRecent\": \"Utdatert\",\n        \"totalUsed\": \"Brukt lagringsplass\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Oppskrifter\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Stikkord\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Nedlaster\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"CPU-belastning snitt (5m)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Sendt\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Siste nedetid\",\n        \"downDuration\": \"Varighet på nedetid\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Ikke sjekket enda\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Virker nede\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"På Kino\",\n        \"physicalRelease\": \"Fysisk utslipp\",\n        \"digitalRelease\": \"Digital utgivelse\",\n        \"noEventsToday\": \"Ingen hendelser for i dag!\",\n        \"noEventsFound\": \"Ingen hendelser funnet\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Plattformer\",\n        \"totalRoms\": \"Games\",\n        \"saves\": \"Saves\",\n        \"states\": \"States\",\n        \"screenshots\": \"Screenshots\",\n        \"totalfilesize\": \"Total Size\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Mailboxes\",\n        \"mails\": \"Mails\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Advarsler\",\n        \"criticals\": \"Kritiske\"\n    },\n    \"plantit\": {\n        \"events\": \"Begivenheter\",\n        \"plants\": \"Planter\",\n        \"photos\": \"Photos\",\n        \"species\": \"Arter\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Varslinger\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Forespørsel\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Scener\",\n        \"scenesPlayed\": \"Scener avspilt\",\n        \"playCount\": \"Totalt Spillt\",\n        \"playDuration\": \"Tid Sett\",\n        \"sceneSize\": \"Scenesstørrelse\",\n        \"sceneDuration\": \"Scener Varighet\",\n        \"images\": \"Bilder\",\n        \"imageSize\": \"Bildestørrelse\",\n        \"galleries\": \"Gallerier\",\n        \"performers\": \"Utøvere\",\n        \"studios\": \"Studios\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O antall\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Nøkkelord\"\n    },\n    \"homebox\": {\n        \"items\": \"Enheter\",\n        \"totalWithWarranty\": \"Med garanti\",\n        \"locations\": \"Posisjon\",\n        \"labels\": \"Etiketter\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Totalverdi\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Utestengelse\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxied\",\n        \"auth\": \"With Auth\",\n        \"outdated\": \"Outdated\",\n        \"banned\": \"Banned\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Stocks\",\n        \"loading\": \"Loading\",\n        \"open\": \"Open - US Market\",\n        \"closed\": \"Closed - US Market\",\n        \"invalidConfiguration\": \"Invalid Configuration\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Cameras\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Links\",\n        \"collections\": \"Collections\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Not classified\",\n        \"information\": \"Information\",\n        \"warning\": \"Warning\",\n        \"average\": \"Average\",\n        \"high\": \"High\",\n        \"disaster\": \"Disaster\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vehicle\",\n        \"vehicles\": \"Vehicles\",\n        \"serviceRecords\": \"Service Records\",\n        \"reminders\": \"Reminders\",\n        \"nextReminder\": \"Next Reminder\",\n        \"none\": \"None\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Active Projects\",\n        \"tasks7d\": \"Tasks Due This Week\",\n        \"tasksOverdue\": \"Overdue Tasks\",\n        \"tasksInProgress\": \"Tasks In Progress\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Systems\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Disk\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Apps\",\n        \"synced\": \"Synced\",\n        \"outOfSync\": \"Out Of Sync\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Degraded\",\n        \"progressing\": \"Progressing\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Suspended\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Groups\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Projects\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bookmarks\",\n        \"favorites\": \"Favorites\",\n        \"archived\": \"Archived\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Lists\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Other\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/pl/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"mc\",\n        \"days\": \"d\",\n        \"hours\": \"g\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Brakujący typ widżetu: {{type}}\",\n        \"api_error\": \"Błąd API\",\n        \"information\": \"Informacje\",\n        \"status\": \"Stan\",\n        \"url\": \"Adres URL\",\n        \"raw_error\": \"Niesformatowany błąd\",\n        \"response_data\": \"Dane odpowiedzi\"\n    },\n    \"weather\": {\n        \"current\": \"Aktualna lokalizacja\",\n        \"allow\": \"Kliknij, aby zezwolić\",\n        \"updating\": \"Aktualizacja\",\n        \"wait\": \"Proszę czekać\"\n    },\n    \"search\": {\n        \"placeholder\": \"Szukaj…\"\n    },\n    \"resources\": {\n        \"cpu\": \"Procesor\",\n        \"mem\": \"RAM\",\n        \"total\": \"Całkowite\",\n        \"free\": \"Wolne\",\n        \"used\": \"Użyte\",\n        \"load\": \"Obciążenie\",\n        \"temp\": \"TEMP.\",\n        \"max\": \"Maks\",\n        \"uptime\": \"CZAS\"\n    },\n    \"unifi\": {\n        \"users\": \"Użytkownicy\",\n        \"uptime\": \"Czas działania\",\n        \"days\": \"Dni\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Urządzenia\",\n        \"lan_devices\": \"Urządzenia LAN\",\n        \"wlan_devices\": \"Urządzenia WLAN\",\n        \"lan_users\": \"Użytkownicy LAN\",\n        \"wlan_users\": \"Użytkownicy WLAN\",\n        \"up\": \"DZIAŁA\",\n        \"down\": \"Pobieranie\",\n        \"wait\": \"Proszę czekać\",\n        \"empty_data\": \"Status podsystemu nieznany\"\n    },\n    \"docker\": {\n        \"rx\": \"Rx\",\n        \"tx\": \"Tx\",\n        \"mem\": \"PAM\",\n        \"cpu\": \"Procesor\",\n        \"running\": \"Działa\",\n        \"offline\": \"Nieosiągalny\",\n        \"error\": \"Błąd\",\n        \"unknown\": \"Nieznany\",\n        \"healthy\": \"Zdrowy\",\n        \"starting\": \"Uruchamianie\",\n        \"unhealthy\": \"Niezdrowy\",\n        \"not_found\": \"Nie znaleziono\",\n        \"exited\": \"Zakończony\",\n        \"partial\": \"Częściowy\"\n    },\n    \"ping\": {\n        \"error\": \"Błąd\",\n        \"ping\": \"Ping\",\n        \"down\": \"Niedostępny\",\n        \"up\": \"Dostępny\",\n        \"not_available\": \"Niedostępny\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"Status HTTP\",\n        \"error\": \"Błąd\",\n        \"response\": \"Odpowiedź\",\n        \"down\": \"Nie działa\",\n        \"up\": \"Działa\",\n        \"not_available\": \"Niedostępny\"\n    },\n    \"emby\": {\n        \"playing\": \"Odtwarzanie\",\n        \"transcoding\": \"Transkodowanie\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"Brak aktywnych strumieni\",\n        \"movies\": \"Filmy\",\n        \"series\": \"Seriale\",\n        \"episodes\": \"Odcinki\",\n        \"songs\": \"Piosenki\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Odtwarza\",\n        \"transcoding\": \"Transkoduje\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"Brak aktywnych strumieni\",\n        \"movies\": \"Filmy\",\n        \"series\": \"Seriale\",\n        \"episodes\": \"Odcinki\",\n        \"songs\": \"Piosenki\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Dostępny\",\n        \"total\": \"Razem\",\n        \"unknown\": \"Nieznany\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Produkcja\",\n        \"battery_soc\": \"Bateria\",\n        \"grid_power\": \"Siatka\",\n        \"home_power\": \"Zużycie\",\n        \"charge_power\": \"Ładowarka\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Pobieranie\",\n        \"upload\": \"Wysyłanie\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Subskrypcje\",\n        \"unread\": \"Nieprzeczytane\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Nieskonfigurowane\",\n        \"connectionStatusConnecting\": \"Łączenie\",\n        \"connectionStatusAuthenticating\": \"Uwierzytelnianie\",\n        \"connectionStatusPendingDisconnect\": \"Oczekujące rozłączenie\",\n        \"connectionStatusDisconnecting\": \"Rozłączanie\",\n        \"connectionStatusDisconnected\": \"Rozłączono\",\n        \"connectionStatusConnected\": \"Połączono\",\n        \"uptime\": \"Czas działania\",\n        \"maxDown\": \"Maks. Pobieranie\",\n        \"maxUp\": \"Maks. Wysyłanie\",\n        \"down\": \"Nie działa\",\n        \"up\": \"Działa\",\n        \"received\": \"Odebrane\",\n        \"sent\": \"Wysłane\",\n        \"externalIPAddress\": \"Pub. IP\",\n        \"externalIPv6Address\": \"Zewn. IPv6\",\n        \"externalIPv6Prefix\": \"Zewn. prefiks IPv6\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Aktualne zapytania\",\n        \"requests_failed\": \"Nieudane zapytania\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Łącznie obserwowanych\",\n        \"diffsDetected\": \"Wykrytych różnic\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Seriale\",\n        \"recordings\": \"Nagrania\",\n        \"scheduled\": \"W kolejce\",\n        \"passes\": \"Przebiegi\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Odtwarza\",\n        \"transcoding\": \"Transkoduje\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"Brak aktywnych strumieni\",\n        \"plex_connection_error\": \"Sprawdź połączenie z Plex\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"Brak aktywnych strumieni\",\n        \"streams\": \"Strumienie\",\n        \"transcodes\": \"Transkodowania\",\n        \"directplay\": \"Odtwarzanie bezpośrednie\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Połączone punkty dostępowe\",\n        \"activeUser\": \"Aktywne urządzenia\",\n        \"alerts\": \"Alarmy\",\n        \"connectedGateways\": \"Połączone bramy\",\n        \"connectedSwitches\": \"Połączone przełączniki\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Szybkość\",\n        \"remaining\": \"Pozostało\",\n        \"downloaded\": \"Pobrano\"\n    },\n    \"plex\": {\n        \"streams\": \"Aktywne strumienie\",\n        \"albums\": \"Albumy\",\n        \"movies\": \"Filmy\",\n        \"tv\": \"Seriale\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Szybkość\",\n        \"queue\": \"Kolejka\",\n        \"timeleft\": \"Pozostało\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Aktywny\",\n        \"upload\": \"Wysyłanie\",\n        \"download\": \"Pobieranie\"\n    },\n    \"transmission\": {\n        \"download\": \"Pobieranie\",\n        \"upload\": \"Wysyłanie\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Pobieranie\",\n        \"upload\": \"Wysyłanie\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"Użycie CPU\",\n        \"memUsage\": \"Użycie pamięci\",\n        \"systemTempC\": \"Temperatura systemu\",\n        \"poolUsage\": \"Wykorzystanie puli\",\n        \"volumeUsage\": \"Wykorzystanie woluminu\",\n        \"invalid\": \"Nieprawidłowy\"\n    },\n    \"deluge\": {\n        \"download\": \"Pobieranie\",\n        \"upload\": \"Wysyłanie\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Trafienia w cache'u\",\n        \"cachemissbytes\": \"Straty cache'u\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Pobieranie\",\n        \"upload\": \"Wysyłanie\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Poszukiwane\",\n        \"queued\": \"W kolejce\",\n        \"series\": \"Seriale\",\n        \"queue\": \"Kolejka\",\n        \"unknown\": \"Nieznany\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Poszukiwane\",\n        \"missing\": \"Brakujące\",\n        \"queued\": \"W kolejce\",\n        \"movies\": \"Filmy\",\n        \"queue\": \"Kolejka\",\n        \"unknown\": \"Nieznane\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Poszukiwane\",\n        \"queued\": \"W kolejce\",\n        \"artists\": \"Artyści\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Poszukiwane\",\n        \"queued\": \"W kolejce\",\n        \"books\": \"Książki\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Brakujące odcinki\",\n        \"missingMovies\": \"Brakujące filmy\"\n    },\n    \"ombi\": {\n        \"pending\": \"Oczekiwane\",\n        \"approved\": \"Zaakceptowane\",\n        \"available\": \"Dostępne\"\n    },\n    \"seerr\": {\n        \"pending\": \"Oczekujące\",\n        \"approved\": \"Zaakceptowane\",\n        \"available\": \"Dostępne\",\n        \"completed\": \"Ukończone\",\n        \"processing\": \"Przetwarzane\",\n        \"issues\": \"Otwarte zgłoszenia\"\n    },\n    \"netalertx\": {\n        \"total\": \"Razem\",\n        \"connected\": \"Połączono\",\n        \"new_devices\": \"Nowe urządzenia\",\n        \"down_alerts\": \"Alerty niedostępności\"\n    },\n    \"pihole\": {\n        \"queries\": \"Zapytania\",\n        \"blocked\": \"Zablokowane\",\n        \"blocked_percent\": \"Zablokowano %\",\n        \"gravity\": \"Grawitacja\"\n    },\n    \"adguard\": {\n        \"queries\": \"Zapytania\",\n        \"blocked\": \"Zablokowane\",\n        \"filtered\": \"Przefiltrowane\",\n        \"latency\": \"Opóźnienia\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Wysyłanie\",\n        \"download\": \"Pobieranie\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Działa\",\n        \"stopped\": \"Zatrzymane\",\n        \"total\": \"Razem\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Pobrano\",\n        \"nondownload\": \"Niepobrane\",\n        \"read\": \"Przeczytane\",\n        \"unread\": \"Nieprzeczytane\",\n        \"downloadedread\": \"Pobrane i przeczytane\",\n        \"downloadedunread\": \"Pobrane i nieprzeczytane\",\n        \"nondownloadedread\": \"Niepobrane i przeczytane\",\n        \"nondownloadedunread\": \"Niepobrane i nieprzeczytane\"\n    },\n    \"tailscale\": {\n        \"address\": \"Adres\",\n        \"expires\": \"Wygasa za\",\n        \"never\": \"Nigdy\",\n        \"last_seen\": \"Ostatnio dostępny\",\n        \"now\": \"Teraz\",\n        \"years\": \"{{number}}rok/lat\",\n        \"weeks\": \"{{number}}tygodni\",\n        \"days\": \"{{number}}dni\",\n        \"hours\": \"{{number}}godzin\",\n        \"minutes\": \"{{number}}miesięcy\",\n        \"seconds\": \"{{number}}sekund\",\n        \"ago\": \"{{value}} temu\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Zapytania\",\n        \"totalNoError\": \"Sukces\",\n        \"totalServerFailure\": \"Porażki\",\n        \"totalNxDomain\": \"Domeny NX\",\n        \"totalRefused\": \"Odrzuconych\",\n        \"totalAuthoritative\": \"Autorytatywne\",\n        \"totalRecursive\": \"Rekursywne\",\n        \"totalCached\": \"Zbuforowane\",\n        \"totalBlocked\": \"Zablokowane\",\n        \"totalDropped\": \"Upuszczone\",\n        \"totalClients\": \"Klienci\"\n    },\n    \"tdarr\": {\n        \"queue\": \"W kolejce\",\n        \"processed\": \"Przetworzone\",\n        \"errored\": \"Błędne\",\n        \"saved\": \"Zapisane\"\n    },\n    \"traefik\": {\n        \"routers\": \"Routery\",\n        \"services\": \"Usługi\",\n        \"middleware\": \"Pośrednicy\"\n    },\n    \"trilium\": {\n        \"version\": \"Wersja\",\n        \"notesCount\": \"Notatki\",\n        \"dbSize\": \"Rozmiar bazy danych\",\n        \"unknown\": \"Nieznane\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"Brak aktywnych strumieni\",\n        \"please_wait\": \"Proszę czekać\"\n    },\n    \"npm\": {\n        \"enabled\": \"Włączone\",\n        \"disabled\": \"Wyłączone\",\n        \"total\": \"Razem\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Wybierz jedną lub więcej kryptowalut do śledzenia\",\n        \"1hour\": \"1 godzina\",\n        \"1day\": \"1 dzień\",\n        \"7days\": \"7 dni\",\n        \"30days\": \"30 dni\"\n    },\n    \"gotify\": {\n        \"apps\": \"Aplikacje\",\n        \"clients\": \"Klienci\",\n        \"messages\": \"Wiadomości\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indeksery\",\n        \"numberOfGrabs\": \"Pochwycenia\",\n        \"numberOfQueries\": \"Zapytania\",\n        \"numberOfFailGrabs\": \"Nieudane pochwycenia\",\n        \"numberOfFailQueries\": \"Nieudane zapytania\"\n    },\n    \"jackett\": {\n        \"configured\": \"Skonfigurowane\",\n        \"errored\": \"Z błędami\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sesje\",\n        \"numConnections\": \"Połączenia\",\n        \"dataRelayed\": \"Przekazane\",\n        \"transferRate\": \"Szybkość\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Użytkownicy\",\n        \"status_count\": \"Posty\",\n        \"domain_count\": \"Domeny\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Poszukiwane\",\n        \"queued\": \"W kolejce\",\n        \"series\": \"Seriale\"\n    },\n    \"minecraft\": {\n        \"players\": \"Gracze\",\n        \"version\": \"Wersja\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Przeczytane\",\n        \"unread\": \"Nieprzeczytane\"\n    },\n    \"authentik\": {\n        \"users\": \"Użytkownicy\",\n        \"loginsLast24H\": \"Logowania (24h)\",\n        \"failedLoginsLast24H\": \"Nieudane logowania (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"RAM\",\n        \"cpu\": \"Procesor\",\n        \"lxc\": \"Kontenery LXC\",\n        \"vms\": \"Maszyn wirtualnych\"\n    },\n    \"glances\": {\n        \"cpu\": \"Procesor\",\n        \"load\": \"Obciążenie\",\n        \"wait\": \"Proszę czekać\",\n        \"temp\": \"TEMP.\",\n        \"_temp\": \"Temperatura\",\n        \"warn\": \"Ostrzeżenie\",\n        \"uptime\": \"DZIAŁA\",\n        \"total\": \"Razem\",\n        \"free\": \"Wolne\",\n        \"used\": \"Użyte\",\n        \"days\": \"d\",\n        \"hours\": \"godz\",\n        \"crit\": \"Krytyczyny\",\n        \"read\": \"Odczyt\",\n        \"write\": \"Zapis\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Pamięć\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Zakładka\",\n        \"service\": \"Usługi\",\n        \"search\": \"Wyszukaj\",\n        \"custom\": \"Niestandardowe\",\n        \"visit\": \"Odwiedź\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Sugestia\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Słoneczny\",\n        \"0-night\": \"Bezchmurny\",\n        \"1-day\": \"Głównie słoneczny\",\n        \"1-night\": \"Głównie bezchmurny\",\n        \"2-day\": \"Częściowo pochmurnie\",\n        \"2-night\": \"Częściowo pochmurnie\",\n        \"3-day\": \"Pochmurnie\",\n        \"3-night\": \"Pochmurnie\",\n        \"45-day\": \"Mgliście\",\n        \"45-night\": \"Mgliście\",\n        \"48-day\": \"Mgliście\",\n        \"48-night\": \"Mgliście\",\n        \"51-day\": \"Lekka mżawka\",\n        \"51-night\": \"Lekka mżawka\",\n        \"53-day\": \"Mżawka\",\n        \"53-night\": \"Mżawka\",\n        \"55-day\": \"Gęsta mżawka\",\n        \"55-night\": \"Gęsta mżawka\",\n        \"56-day\": \"Lekko chłodna mżawka\",\n        \"56-night\": \"Lekko chłodna mżawka\",\n        \"57-day\": \"Chłodna mżawka\",\n        \"57-night\": \"Chłodna mżawka\",\n        \"61-day\": \"Lekki deszcz\",\n        \"61-night\": \"Lekki deszcz\",\n        \"63-day\": \"Deszcz\",\n        \"63-night\": \"Deszcz\",\n        \"65-day\": \"Ciężki deszcz\",\n        \"65-night\": \"Ciężki deszcz\",\n        \"66-day\": \"Mroźny deszcz\",\n        \"66-night\": \"Mroźny deszcz\",\n        \"67-day\": \"Mroźny deszcz\",\n        \"67-night\": \"Mroźny deszcz\",\n        \"71-day\": \"Lekki śnieg\",\n        \"71-night\": \"Lekki śnieg\",\n        \"73-day\": \"Śnieg\",\n        \"73-night\": \"Śnieg\",\n        \"75-day\": \"Ciężki śnieg\",\n        \"75-night\": \"Ciężki śnieg\",\n        \"77-day\": \"Ziarnisty śnieg\",\n        \"77-night\": \"Ziarnisty śnieg\",\n        \"80-day\": \"Lekkie opady\",\n        \"80-night\": \"Lekkie opady\",\n        \"81-day\": \"Opady\",\n        \"81-night\": \"Opady\",\n        \"82-day\": \"Ciężkie opady\",\n        \"82-night\": \"Ciężkie opady\",\n        \"85-day\": \"Opady śniegu\",\n        \"85-night\": \"Opady śniegu\",\n        \"86-day\": \"Opady śniegu\",\n        \"86-night\": \"Opady śniegu\",\n        \"95-day\": \"Burze z piorunami\",\n        \"95-night\": \"Burze z piorunami\",\n        \"96-day\": \"Burza z gradobiciem\",\n        \"96-night\": \"Burza z gradobiciem\",\n        \"99-day\": \"Burza z gradobiciem\",\n        \"99-night\": \"Burza z gradobiciem\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"System\",\n        \"updates\": \"Aktualizacje\",\n        \"update_available\": \"Dostępna aktualizacja\",\n        \"up_to_date\": \"Aktualny\",\n        \"child_bridges\": \"Mostki podrzędne\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Działa\",\n        \"pending\": \"Oczekujące\",\n        \"down\": \"Nie działa\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Nowy\",\n        \"up\": \"Działa\",\n        \"grace\": \"W okresie karencji\",\n        \"down\": \"Nie działa\",\n        \"paused\": \"Wstrzymane\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Ostatni ping\",\n        \"never\": \"Brak pingów\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Zeskanowane\",\n        \"containers_updated\": \"Zaktualizowane\",\n        \"containers_failed\": \"Niepowodzenie\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Zaakceptowane\",\n        \"rejectedPushes\": \"Odrzucone\",\n        \"filters\": \"Filtry\",\n        \"indexers\": \"Indeksery\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"W kolejce\",\n        \"videos\": \"Pliki wideo\",\n        \"channels\": \"Kanały\",\n        \"playlists\": \"Playlisty\"\n    },\n    \"truenas\": {\n        \"load\": \"Obciążenie systemu\",\n        \"uptime\": \"Czas działania\",\n        \"alerts\": \"Alerty\"\n    },\n    \"pyload\": {\n        \"speed\": \"Prędkość\",\n        \"active\": \"Aktywne\",\n        \"queue\": \"W kolejce\",\n        \"total\": \"Razem\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Adres publiczny\",\n        \"region\": \"Region\",\n        \"country\": \"Państwo\",\n        \"port_forwarded\": \"Port otwarty\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Kanały\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tunery\",\n        \"channelNumber\": \"Kanał\",\n        \"channelNetwork\": \"Sieć\",\n        \"signalStrength\": \"Siła sygnału\",\n        \"signalQuality\": \"Jakość\",\n        \"symbolQuality\": \"Jakość\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Klient\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Powodzenie\",\n        \"failed\": \"Nieudane\",\n        \"unknown\": \"Nieznane\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Skrzynka odbiorcza\",\n        \"total\": \"Razem\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Organizacje\",\n        \"sites\": \"Strony\",\n        \"resources\": \"Zasoby\",\n        \"targets\": \"Cele\",\n        \"traffic\": \"Ruch\",\n        \"in\": \"Do\",\n        \"out\": \"Z\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Stan baterii\",\n        \"ups_load\": \"Obciążenie UPS\",\n        \"ups_status\": \"Status UPS\",\n        \"online\": \"Online\",\n        \"on_battery\": \"Na baterii\",\n        \"low_battery\": \"Niski poziom baterii\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Proszę czekać\",\n        \"no_devices\": \"Nie otrzymano danych urządzenia\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"Obciążenie procesora\",\n        \"memoryUsed\": \"Zużyta pamięć\",\n        \"uptime\": \"Czas działania\",\n        \"numberOfLeases\": \"Dzierżawy\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Wszystkie strumienie\",\n        \"streams_active\": \"Aktywne strumienie\",\n        \"streams_xepg\": \"Kanały XEPG\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Dzisiaj\",\n        \"absolutePower\": \"Zasilanie\",\n        \"relativePower\": \"Moc %\",\n        \"limit\": \"Limit\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"Procesor\",\n        \"memory\": \"Pamięć rzeczywista\",\n        \"wanUpload\": \"WAN wysyłanie\",\n        \"wanDownload\": \"WAN pobieranie\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Stan drukarki\",\n        \"print_status\": \"Status wydruku\",\n        \"print_progress\": \"Postęp\",\n        \"layers\": \"Warstwy\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Temperatura narzędzia\",\n        \"temp_bed\": \"Temp. łóżka\",\n        \"job_completion\": \"Ukończono\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"IP Źródła\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Śr. Obciążenie\",\n        \"memory\": \"Użycie pamięci\",\n        \"wanStatus\": \"Status WAN\",\n        \"up\": \"Działa\",\n        \"down\": \"Nie działa\",\n        \"temp\": \"Temperatura\",\n        \"disk\": \"Użycie dysku\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Magazyn danych\",\n        \"failed_tasks_24h\": \"Nieudane zadania 24h\",\n        \"cpu_usage\": \"Procesor\",\n        \"memory_usage\": \"Pamięć\"\n    },\n    \"immich\": {\n        \"users\": \"Użytkownicy\",\n        \"photos\": \"Zdjęcia\",\n        \"videos\": \"Filmy\",\n        \"storage\": \"Pamięć\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Działające\",\n        \"down\": \"Niedziałające\",\n        \"uptime\": \"Czas działania\",\n        \"incident\": \"Incydent\",\n        \"m\": \"min\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Serie\",\n        \"archives\": \"Archiwa\",\n        \"chapters\": \"Rozdziały\",\n        \"categories\": \"Kategorie\"\n    },\n    \"komga\": {\n        \"libraries\": \"Biblioteki\",\n        \"series\": \"Serie\",\n        \"books\": \"Książki\"\n    },\n    \"diskstation\": {\n        \"days\": \"Dni\",\n        \"uptime\": \"Czas działania\",\n        \"volumeAvailable\": \"Dostępne\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Kanały\",\n        \"streams\": \"Strumienie\"\n    },\n    \"mylar\": {\n        \"series\": \"Seriale\",\n        \"issues\": \"Zgłoszenia\",\n        \"wanted\": \"Poszukiwane\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albumy\",\n        \"photos\": \"Zdjęcia\",\n        \"videos\": \"Pliki wideo\",\n        \"people\": \"Ludzie\"\n    },\n    \"fileflows\": {\n        \"queue\": \"W kolejce\",\n        \"processing\": \"Przetwarzane\",\n        \"processed\": \"Przetworzone\",\n        \"time\": \"Czas\"\n    },\n    \"firefly\": {\n        \"networth\": \"Wartość netto\",\n        \"budget\": \"Budżet\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Panel główny\",\n        \"datasources\": \"Źródła danych\",\n        \"totalalerts\": \"Wszystkie alerty\",\n        \"alertstriggered\": \"Wywołane alerty\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Obciążenie CPU\",\n        \"memoryusage\": \"Użycie pamięci\",\n        \"freespace\": \"Wolna przestrzeń\",\n        \"activeusers\": \"Aktywni użytkownicy\",\n        \"numfiles\": \"Pliki\",\n        \"numshares\": \"Udostępnione elementy\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Rozmiar\",\n        \"lastrun\": \"Ostatnie uruchomienie\",\n        \"nextrun\": \"Następne uruchomienie\",\n        \"failed\": \"Nieudane\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Aktywni pracownicy\",\n        \"total_workers\": \"Wszyscy pracownicy\",\n        \"records_total\": \"Długość kolejki\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Serwery\",\n        \"nodes\": \"Węzły\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Cele włączone\",\n        \"targets_down\": \"Cele wyłączone\",\n        \"targets_total\": \"Wszystkich Celi\"\n    },\n    \"gatus\": {\n        \"up\": \"Działające strony\",\n        \"down\": \"Niedziałające strony\",\n        \"uptime\": \"Czas działania\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Dzisiaj\",\n        \"gross_percent_1y\": \"Rok\",\n        \"gross_percent_max\": \"Od początku\",\n        \"net_worth\": \"Wartość netto\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasty\",\n        \"books\": \"Książki\",\n        \"podcastsDuration\": \"Czas trwania\",\n        \"booksDuration\": \"Czas trwania\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Dom ludzi\",\n        \"lights_on\": \"Światła włączone\",\n        \"switches_on\": \"Przełączniki włączone\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Monitoring\",\n        \"updates\": \"Aktualizacje\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Książki\",\n        \"authors\": \"Autorzy\",\n        \"categories\": \"Kategorie\",\n        \"series\": \"Serie\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Biblioteki\",\n        \"books\": \"Książki\",\n        \"reading\": \"Czytane\",\n        \"finished\": \"Skończone\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"W kolejce\",\n        \"downloadBytesRemaining\": \"Pozostało\",\n        \"downloadTotalBytes\": \"Rozmiar\",\n        \"downloadSpeed\": \"Prędkość\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Serie\",\n        \"totalFiles\": \"Pliki\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Wynik\",\n        \"status\": \"Status\",\n        \"buildId\": \"ID kompilacji\",\n        \"succeeded\": \"Ukończono\",\n        \"notStarted\": \"Nierozpoczęte\",\n        \"failed\": \"Niepowodzenie\",\n        \"canceled\": \"Anulowano\",\n        \"inProgress\": \"W trakcie\",\n        \"totalPrs\": \"Łącznie PRs\",\n        \"myPrs\": \"Moje PRs\",\n        \"approved\": \"Zaakceptowane\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Nazwa\",\n        \"map\": \"Mapa\",\n        \"currentPlayers\": \"Gracze online\",\n        \"players\": \"Gracze\",\n        \"maxPlayers\": \"Maksymalna ilość graczy\",\n        \"bots\": \"Boty\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Błędy\",\n        \"noRecent\": \"Nieaktualne\",\n        \"totalUsed\": \"Użyta pamięć\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Przepisy\",\n        \"users\": \"Użytkownicy\",\n        \"categories\": \"Kategorie\",\n        \"tags\": \"Tagi\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Pobieranie\",\n        \"total\": \"Razem\",\n        \"running\": \"Działające\",\n        \"stopped\": \"Zatrzymane\",\n        \"passed\": \"Zaliczony\",\n        \"failed\": \"Nieudany\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Czas działania\",\n        \"cpuLoad\": \"Śr. obciążenie CPU (5m)\",\n        \"up\": \"Działa\",\n        \"down\": \"Nie działa\",\n        \"bytesTx\": \"Przesłane\",\n        \"bytesRx\": \"Odebrano\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Czas działania\",\n        \"lastDown\": \"Ostatni downtime\",\n        \"downDuration\": \"Długość downtime'u\",\n        \"sitesUp\": \"Działające strony\",\n        \"sitesDown\": \"Niedziałające strony\",\n        \"paused\": \"Zatrzymane\",\n        \"notyetchecked\": \"Nie sprawdzono\",\n        \"up\": \"Działa\",\n        \"seemsdown\": \"Możliwe, że wyłączony\",\n        \"down\": \"Nie działa\",\n        \"unknown\": \"Nieznane\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"W kinach\",\n        \"physicalRelease\": \"Wydanie fizyczne\",\n        \"digitalRelease\": \"Wydanie cyfrowe\",\n        \"noEventsToday\": \"Brak wydarzeń na dziś!\",\n        \"noEventsFound\": \"Nie znaleziono wydarzeń\",\n        \"errorWhenLoadingData\": \"Wystąpił błąd podczas ładowania danych kalendarza\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platformy\",\n        \"totalRoms\": \"Gry\",\n        \"saves\": \"Zapisy\",\n        \"states\": \"Stany\",\n        \"screenshots\": \"Screeny\",\n        \"totalfilesize\": \"Rozmiar całkowity\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domeny\",\n        \"mailboxes\": \"Skrzynki\",\n        \"mails\": \"Poczta\",\n        \"storage\": \"Pamięć\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Ostrzeżenia\",\n        \"criticals\": \"Krytyczny\"\n    },\n    \"plantit\": {\n        \"events\": \"Wydarzenia\",\n        \"plants\": \"Rośliny\",\n        \"photos\": \"Zdjęcia\",\n        \"species\": \"Gatunki\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Powiadomienia\",\n        \"issues\": \"Zgłoszenia\",\n        \"pulls\": \"Żądania Pull\",\n        \"repositories\": \"Repozytoria\"\n    },\n    \"stash\": {\n        \"scenes\": \"Sceny\",\n        \"scenesPlayed\": \"Odgrane sceny\",\n        \"playCount\": \"Łącznie odtworzone\",\n        \"playDuration\": \"Łączny czas oglądania\",\n        \"sceneSize\": \"Rozmiar scen\",\n        \"sceneDuration\": \"Czas trwania scen\",\n        \"images\": \"Obrazy\",\n        \"imageSize\": \"Rozmiar obrazów\",\n        \"galleries\": \"Galerie\",\n        \"performers\": \"Artyści\",\n        \"studios\": \"Studia\",\n        \"movies\": \"Filmy\",\n        \"tags\": \"Tagi\",\n        \"oCount\": \"O Licznik\"\n    },\n    \"tandoor\": {\n        \"users\": \"Użytkownicy\",\n        \"recipes\": \"Przepisy\",\n        \"keywords\": \"Słowa kluczowe\"\n    },\n    \"homebox\": {\n        \"items\": \"Elementy\",\n        \"totalWithWarranty\": \"Z gwarancją\",\n        \"locations\": \"Lokalizacje\",\n        \"labels\": \"Etykiety\",\n        \"users\": \"Użytkownicy\",\n        \"totalValue\": \"Wartość całkowita\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerty\",\n        \"bans\": \"Bany\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Połączonych\",\n        \"enabled\": \"Włączone\",\n        \"disabled\": \"Wyłączone\",\n        \"total\": \"Razem\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxy\",\n        \"auth\": \"Z uwietrzytelnieniem\",\n        \"outdated\": \"Nieaktualne\",\n        \"banned\": \"Zbanowano\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Pobieranie\",\n        \"upload\": \"Wysyłanie\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Giełda\",\n        \"loading\": \"Wczytywanie\",\n        \"open\": \"Otwarte - rynek US\",\n        \"closed\": \"Zamknięte - rynek US\",\n        \"invalidConfiguration\": \"Nieprawidłowa konfiguracja\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Kamery\",\n        \"uptime\": \"Czas działania\",\n        \"version\": \"Wersja\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Zapisane linki\",\n        \"collections\": \"Kolekcje\",\n        \"tags\": \"Tagi\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Niezaklasyfikowane\",\n        \"information\": \"Informacja\",\n        \"warning\": \"Ostrzeżenie\",\n        \"average\": \"Średnia\",\n        \"high\": \"Wysokie\",\n        \"disaster\": \"Katastrofa\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Pojazd\",\n        \"vehicles\": \"Pojazdy\",\n        \"serviceRecords\": \"Wpisy serwisowe\",\n        \"reminders\": \"Przypomnienia\",\n        \"nextReminder\": \"Następne przypomnienie\",\n        \"none\": \"Brak\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Aktywne Projekty\",\n        \"tasks7d\": \"Zadania w tym tygodniu\",\n        \"tasksOverdue\": \"Zaległe zadania\",\n        \"tasksInProgress\": \"Zadania w toku\"\n    },\n    \"headscale\": {\n        \"name\": \"Nazwa\",\n        \"address\": \"Adres\",\n        \"last_seen\": \"Ostatnio dostępny\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Nazwa\",\n        \"systems\": \"Systemy\",\n        \"up\": \"Działa\",\n        \"down\": \"Nie działa\",\n        \"paused\": \"Wstrzymane\",\n        \"pending\": \"Oczekujące\",\n        \"status\": \"Status\",\n        \"updated\": \"Zaktualizowane\",\n        \"cpu\": \"Procesor\",\n        \"memory\": \"PAM\",\n        \"disk\": \"Dysk\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Aplikacje\",\n        \"synced\": \"Synchronizowane\",\n        \"outOfSync\": \"Bez synchronizacji\",\n        \"healthy\": \"Zdrowe\",\n        \"degraded\": \"Zdegradowane\",\n        \"progressing\": \"Postępujące\",\n        \"missing\": \"Brakujące\",\n        \"suspended\": \"Zawieszone\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Ładowanie\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Grupy\",\n        \"issues\": \"Zgłoszenia\",\n        \"merges\": \"Żądania scaleń\",\n        \"projects\": \"Projekty\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Obciążenie\",\n        \"bcharge\": \"Naładowanie baterii\",\n        \"timeleft\": \"Pozostały czas\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Zakładki\",\n        \"favorites\": \"Ulubione\",\n        \"archived\": \"Zarchiwizowane\",\n        \"highlights\": \"Wyróżnione\",\n        \"lists\": \"Listy\",\n        \"tags\": \"Tagi\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Sieć\",\n        \"connected\": \"Połączono\",\n        \"disconnected\": \"Rozłączono\",\n        \"updateStatus\": \"Aktualizacja\",\n        \"update_yes\": \"Dostępne\",\n        \"update_no\": \"Aktualny\",\n        \"downloads\": \"Pobieranie\",\n        \"uploads\": \"Przesyłanie\",\n        \"sharedFiles\": \"Pliki\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Piosenki\",\n        \"movies\": \"Filmy\",\n        \"episodes\": \"Odcinki\",\n        \"other\": \"Inne\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Problem z usługą\",\n        \"hostErrors\": \"Problemy hosta\"\n    },\n    \"komodo\": {\n        \"total\": \"Razem\",\n        \"running\": \"Działające\",\n        \"stopped\": \"Zatrzymane\",\n        \"down\": \"Nie działa\",\n        \"unhealthy\": \"Uszkodzony\",\n        \"unknown\": \"Nieznane\",\n        \"servers\": \"Serwery\",\n        \"stacks\": \"Stosy\",\n        \"containers\": \"Kontenery\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Dostępne\",\n        \"used\": \"Użyte\",\n        \"total\": \"Razem\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subskrypcje\",\n        \"thisMonthlyCost\": \"Ten Miesiąc\",\n        \"nextMonthlyCost\": \"Następny miesiąc\",\n        \"previousMonthlyCost\": \"Poprzedni miesiąc\",\n        \"nextRenewingSubscription\": \"Następna płatność\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Rozpoczęte\",\n        \"STOPPED\": \"Zatrzymane\",\n        \"NEW_ARRAY\": \"Nowa macierz\",\n        \"RECON_DISK\": \"Odbudowa dysku\",\n        \"DISABLE_DISK\": \"Dysk wyłączony\",\n        \"SWAP_DSBL\": \"Przestrzeń wymiany wyłączona\",\n        \"INVALID_EXPANSION\": \"Nieprawidłowe rozszerzenie\",\n        \"PARITY_NOT_BIGGEST\": \"Parzystość nie największa\",\n        \"TOO_MANY_MISSING_DISKS\": \"Zbyt wiele brakujących dysków\",\n        \"NEW_DISK_TOO_SMALL\": \"Nowy dysk zbyt mały\",\n        \"NO_DATA_DISKS\": \"Brak dysków danych\",\n        \"notifications\": \"Powiadomienia\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Użyta pamięć\",\n        \"memoryAvailable\": \"Dostępna pamięć\",\n        \"arrayUsed\": \"Użyto macierzy\",\n        \"arrayFree\": \"Wolne na macierzy\",\n        \"poolUsed\": \"Użyto {{pool}}\",\n        \"poolFree\": \"{{pool}} Wolne\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Planowane\",\n        \"num_success_30\": \"Powodzenia\",\n        \"num_failure_30\": \"Niepowodzenia\",\n        \"num_success_latest\": \"Powodzenie\",\n        \"num_failure_latest\": \"Niepowodzenie\",\n        \"bytes_added_30\": \"Dodane bajty\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Piosenki\",\n        \"time\": \"Czas\",\n        \"artists\": \"Wykonawcy\"\n    },\n    \"arcane\": {\n        \"containers\": \"Kontenery\",\n        \"images\": \"Obrazy\",\n        \"image_updates\": \"Aktualizacje obrazów\",\n        \"images_unused\": \"Nieużywane\",\n        \"environment_required\": \"Wymagane ID środowiska\"\n    },\n    \"dockhand\": {\n        \"running\": \"Działające\",\n        \"stopped\": \"Zatrzymane\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Pamięć\",\n        \"images\": \"Obrazy\",\n        \"volumes\": \"Woluminy\",\n        \"events_today\": \"Zdarzenia dzisiaj\",\n        \"pending_updates\": \"Oczekujące aktualizacje\",\n        \"stacks\": \"Stosy\",\n        \"paused\": \"Wstrzymane\",\n        \"total\": \"Razem\",\n        \"environment_not_found\": \"Środowisko nie znalezione\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Zjedzone\",\n        \"burned\": \"Spalone\",\n        \"remaining\": \"Pozostało\",\n        \"steps\": \"Kroki\"\n    }\n}\n"
  },
  {
    "path": "public/locales/pt/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"mês\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"minutes\": \"min\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Tipo de Widget ausente: {{type}}\",\n        \"api_error\": \"Erro da API\",\n        \"information\": \"Informação\",\n        \"status\": \"Estado\",\n        \"url\": \"Endereço URL\",\n        \"raw_error\": \"Erro\",\n        \"response_data\": \"Dados da Resposta\"\n    },\n    \"weather\": {\n        \"current\": \"Localização actual\",\n        \"allow\": \"Clique para permitir\",\n        \"updating\": \"A actualizar\",\n        \"wait\": \"Por favor aguarde\"\n    },\n    \"search\": {\n        \"placeholder\": \"Pesquisar…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"MEM\",\n        \"total\": \"Total\",\n        \"free\": \"Livre\",\n        \"used\": \"Utilizado\",\n        \"load\": \"Carga\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Máximo\",\n        \"uptime\": \"CIMA\"\n    },\n    \"unifi\": {\n        \"users\": \"Utilizadores\",\n        \"uptime\": \"Ligado\",\n        \"days\": \"Dias\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Dispositivos\",\n        \"lan_devices\": \"Dispositivos LAN\",\n        \"wlan_devices\": \"Dispositivos WLAN\",\n        \"lan_users\": \"Utilizadores LAN\",\n        \"wlan_users\": \"Utilizadores WLAN\",\n        \"up\": \"UP\",\n        \"down\": \"Desligado\",\n        \"wait\": \"Please wait\",\n        \"empty_data\": \"Status de Subsistema Desconhecido\"\n    },\n    \"docker\": {\n        \"rx\": \"Rx\",\n        \"tx\": \"Tx\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"A correr\",\n        \"offline\": \"Desligado\",\n        \"error\": \"Erro\",\n        \"unknown\": \"Desconhecido\",\n        \"healthy\": \"Saudável\",\n        \"starting\": \"A iniciar\",\n        \"unhealthy\": \"Não-saudável\",\n        \"not_found\": \"Não Encontrado\",\n        \"exited\": \"Saiu\",\n        \"partial\": \"Parcial\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"Tempo de resposta\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Não Disponível\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"Estado HTTP\",\n        \"error\": \"Error\",\n        \"response\": \"Resposta\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"emby\": {\n        \"playing\": \"A reproduzir\",\n        \"transcoding\": \"Transcodificação\",\n        \"bitrate\": \"Taxa de bits\",\n        \"no_active\": \"Sem streams ativas\",\n        \"movies\": \"Filmes\",\n        \"series\": \"Séries\",\n        \"episodes\": \"Episódios\",\n        \"songs\": \"Canções\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Online\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Produção\",\n        \"battery_soc\": \"Bateria\",\n        \"grid_power\": \"Grelha\",\n        \"home_power\": \"Consumo\",\n        \"charge_power\": \"Carregador\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Descarregar\",\n        \"upload\": \"Carregar\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Semente\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Subscrições\",\n        \"unread\": \"Não lida\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Desconfigurado\",\n        \"connectionStatusConnecting\": \"A conectar\",\n        \"connectionStatusAuthenticating\": \"A Autenticar\",\n        \"connectionStatusPendingDisconnect\": \"Desconexão pendente\",\n        \"connectionStatusDisconnecting\": \"A Desconectar\",\n        \"connectionStatusDisconnected\": \"Desconectado\",\n        \"connectionStatusConnected\": \"Conectado\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"Máx. de Descarga\",\n        \"maxUp\": \"Max. de Envio\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"Recebido\",\n        \"sent\": \"Enviado\",\n        \"externalIPAddress\": \"Endereço IP Externo\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Solicitações actuais\",\n        \"requests_failed\": \"Solicitações falhadas\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Total Observado\",\n        \"diffsDetected\": \"Diferenças Detectadas\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Séries\",\n        \"recordings\": \"Gravações\",\n        \"scheduled\": \"Agendado\",\n        \"passes\": \"Passes\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"Verifique a conexão do Plex\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"APs Ligados\",\n        \"activeUser\": \"Dispositivos activos\",\n        \"alerts\": \"Alertas\",\n        \"connectedGateways\": \"Connected gateways\",\n        \"connectedSwitches\": \"Switches ligados\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Taxa\",\n        \"remaining\": \"Restante\",\n        \"downloaded\": \"Descarregado\"\n    },\n    \"plex\": {\n        \"streams\": \"Streams Activas\",\n        \"albums\": \"Álbuns\",\n        \"movies\": \"Movies\",\n        \"tv\": \"Series de TV\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"Fila\",\n        \"timeleft\": \"Tempo Restante\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Activo\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Semear\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"Utilização do CPU\",\n        \"memUsage\": \"Utilização de Memória\",\n        \"systemTempC\": \"Temperatura do Sistema\",\n        \"poolUsage\": \"Uso de Banco\",\n        \"volumeUsage\": \"Uso do Volume\",\n        \"invalid\": \"Inválido\"\n    },\n    \"deluge\": {\n        \"download\": \"Baixar\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Semear\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"‘Bytes’ de Acerto na Memória transitória\",\n        \"cachemissbytes\": \"‘Bytes’ de Falha de Memória transitória\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Baixar\",\n        \"upload\": \"Envio de Dados\",\n        \"leech\": \"Sanguessuga\",\n        \"seed\": \"Semear\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Desejados\",\n        \"queued\": \"Em fila de espera\",\n        \"series\": \"Séries\",\n        \"queue\": \"Fila\",\n        \"unknown\": \"Desconhecido\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Desejado\",\n        \"missing\": \"Em falta\",\n        \"queued\": \"Na Fila\",\n        \"movies\": \"Filmes\",\n        \"queue\": \"Fila\",\n        \"unknown\": \"Desconhecido\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Desejado\",\n        \"queued\": \"Na Fila\",\n        \"artists\": \"Artistas\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"Livros\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Episódios em Falta\",\n        \"missingMovies\": \"Filmes em Falta\"\n    },\n    \"ombi\": {\n        \"pending\": \"Pendente\",\n        \"approved\": \"Aprovado\",\n        \"available\": \"Disponível\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"Novos Dispositivos\",\n        \"down_alerts\": \"Alertas de Falha\"\n    },\n    \"pihole\": {\n        \"queries\": \"Consultas\",\n        \"blocked\": \"Bloqueado\",\n        \"blocked_percent\": \"Bloqueado %\",\n        \"gravity\": \"Gravidade\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"Filtrado\",\n        \"latency\": \"Latência\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Parado\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Non-Downloaded\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Downloaded & Read\",\n        \"downloadedunread\": \"Downloaded & Unread\",\n        \"nondownloadedread\": \"Non-Downloaded & Read\",\n        \"nondownloadedunread\": \"Non-Downloaded & Unread\"\n    },\n    \"tailscale\": {\n        \"address\": \"Endereço\",\n        \"expires\": \"Expira\",\n        \"never\": \"Nunca\",\n        \"last_seen\": \"Última Vez Visto\",\n        \"now\": \"Agora\",\n        \"years\": \"{{number}}y\",\n        \"weeks\": \"{{number}}w\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Atrás\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"Sucesso\",\n        \"totalServerFailure\": \"Falhas\",\n        \"totalNxDomain\": \"Domínios NX\",\n        \"totalRefused\": \"Recusado\",\n        \"totalAuthoritative\": \"Autoritário\",\n        \"totalRecursive\": \"Recursivo\",\n        \"totalCached\": \"Em Memória transitória\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"Perdidos\",\n        \"totalClients\": \"Clientes\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Processado\",\n        \"errored\": \"Erro\",\n        \"saved\": \"Guardado\"\n    },\n    \"traefik\": {\n        \"routers\": \"Roteadores\",\n        \"services\": \"Serviços\",\n        \"middleware\": \"Middleware\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Por Favor, Aguarde\"\n    },\n    \"npm\": {\n        \"enabled\": \"Activo\",\n        \"disabled\": \"Desabilitado\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Configure uma ou mais criptomoedas para rastrear\",\n        \"1hour\": \"1 Hora\",\n        \"1day\": \"1 Dia\",\n        \"7days\": \"7 Dias\",\n        \"30days\": \"30 Dias\"\n    },\n    \"gotify\": {\n        \"apps\": \"Aplicações\",\n        \"clients\": \"Clients\",\n        \"messages\": \"Mensagens\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indexadores\",\n        \"numberOfGrabs\": \"Agarrados\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"Falhados\",\n        \"numberOfFailQueries\": \"Pesquisas falhadas\"\n    },\n    \"jackett\": {\n        \"configured\": \"Configurado\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sessões\",\n        \"numConnections\": \"Conexões\",\n        \"dataRelayed\": \"Retransmitido\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"Postagens\",\n        \"domain_count\": \"Domínios\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Reprodutores\",\n        \"version\": \"Versão\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Lido\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"Inícios de Sessão (24h)\",\n        \"failedLoginsLast24H\": \"Inícios de Sessão Falhados (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temp\",\n        \"warn\": \"Aviso\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Crítico\",\n        \"read\": \"Read\",\n        \"write\": \"Gravar\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Marcador\",\n        \"service\": \"Serviço\",\n        \"search\": \"Pesquisa\",\n        \"custom\": \"Personalizado\",\n        \"visit\": \"Visitar\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Sugestão\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Solarengo\",\n        \"0-night\": \"Limpo\",\n        \"1-day\": \"Maioritariamente Solarengo\",\n        \"1-night\": \"Maioritariamente Limpo\",\n        \"2-day\": \"Parcialmente Nublado\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"Nublado\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"Nevoeiro\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"Aguaceiros\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"Chuvisco\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"Aguaceiro Forte\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"Granizo Leve\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"Granizo\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"Chuva fraca\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"Chuva\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Chuva forte\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Granizo\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Neve fraca\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Neve\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Neve forte\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Grãos de Neve\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Chuviscos Leves\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Chuviscos\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Chuviscos fortes\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Chuva de Neve\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"Trovoada\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Trovoada com granizo\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Sistema\",\n        \"updates\": \"Actualizações\",\n        \"update_available\": \"Atualização disponível\",\n        \"up_to_date\": \"Atualizado\",\n        \"child_bridges\": \"Child Bridges\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Novo\",\n        \"up\": \"Up\",\n        \"grace\": \"Em Período de Graça\",\n        \"down\": \"Down\",\n        \"paused\": \"Pausa\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Ultimo Ping\",\n        \"never\": \"Nenhum Ping ainda\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Verificado\",\n        \"containers_updated\": \"Atualizado\",\n        \"containers_failed\": \"Falhou\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Rejeitado\",\n        \"filters\": \"Filtros\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Vídeos\",\n        \"channels\": \"Canais\",\n        \"playlists\": \"Listas\"\n    },\n    \"truenas\": {\n        \"load\": \"Carga do sistema\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Velocidade\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"IP público\",\n        \"region\": \"Região\",\n        \"country\": \"País\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Sintonizadores\",\n        \"channelNumber\": \"Canal\",\n        \"channelNetwork\": \"Rede\",\n        \"signalStrength\": \"Potência\",\n        \"signalQuality\": \"Qualidade\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Cliente\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Aprovado\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Caixa de entrada\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Carga da bateria\",\n        \"ups_load\": \"Carga da UPS\",\n        \"ups_status\": \"Estado da UPS\",\n        \"online\": \"Online\",\n        \"on_battery\": \"Em bateria\",\n        \"low_battery\": \"Bateria Fraca\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"Nenhum Dado do Dispositivo Recebido\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"Carga do CPU\",\n        \"memoryUsed\": \"Memória Utilizada\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Concessões\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Todos os Streams\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"Canais XEPG\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Hoje\",\n        \"absolutePower\": \"Potência\",\n        \"relativePower\": \"Potência %\",\n        \"limit\": \"Limite\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Memória Ativa\",\n        \"wanUpload\": \"Envio WAN\",\n        \"wanDownload\": \"WAN Descarga\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Estado da Impressora\",\n        \"print_status\": \"Estado da Impressora\",\n        \"print_progress\": \"Progresso\",\n        \"layers\": \"Camadas\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Temp. Ferramenta\",\n        \"temp_bed\": \"Temp. Cama\",\n        \"job_completion\": \"Conclusão\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"IP Origem\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Carga Média\",\n        \"memory\": \"Uso de memória\",\n        \"wanStatus\": \"WAN Status\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Utilização do Disco\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Armaz. de Dados\",\n        \"failed_tasks_24h\": \"Tarefas Falhas 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memória\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Fotos\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Armazenamento\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Sites no Ar\",\n        \"down\": \"Sites Fora do Ar\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Incidente\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Ficheiros\",\n        \"chapters\": \"Capítulos\",\n        \"categories\": \"Categorias\"\n    },\n    \"komga\": {\n        \"libraries\": \"Bibliotecas\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Problemas\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"Pessoas\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Hora\"\n    },\n    \"firefly\": {\n        \"networth\": \"Net Worth\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Painéis\",\n        \"datasources\": \"Origem de Dados\",\n        \"totalalerts\": \"Total Alertas\",\n        \"alertstriggered\": \"Alertas Desencadeados\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Carga de CPU\",\n        \"memoryusage\": \"Memória Utilizada\",\n        \"freespace\": \"Espaço Livre\",\n        \"activeusers\": \"Utilizadores Activos\",\n        \"numfiles\": \"Ficheiros\",\n        \"numshares\": \"Itens partilhados\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Tamanho\",\n        \"lastrun\": \"Ultima Execução\",\n        \"nextrun\": \"Próxima Execução\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Workers Ativos\",\n        \"total_workers\": \"Total de Trabalhadores\",\n        \"records_total\": \"Comprimento da Fila\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servidores\",\n        \"nodes\": \"Nós\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Alvo ativo\",\n        \"targets_down\": \"Alvo Inactivo\",\n        \"targets_total\": \"Total de Alvos\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"Um ano\",\n        \"gross_percent_max\": \"Desde Sempre\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasts\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Duração\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Pessoas em Casa\",\n        \"lights_on\": \"Luzes Acesas\",\n        \"switches_on\": \"Interruptores Ligados\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"A monitorizar\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Autores\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Resultado\",\n        \"status\": \"Status\",\n        \"buildId\": \"ID da compilação\",\n        \"succeeded\": \"Bem sucedido\",\n        \"notStarted\": \"Não Iniciado\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Cancelado\",\n        \"inProgress\": \"Em progresso\",\n        \"totalPrs\": \"Total de PRs\",\n        \"myPrs\": \"Os Meus PRs\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Nome\",\n        \"map\": \"Mapa\",\n        \"currentPlayers\": \"Jogadores actuais\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Máximo de Jogadores\",\n        \"bots\": \"Bots\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Erros\",\n        \"noRecent\": \"Desactualizado\",\n        \"totalUsed\": \"Espaço utilizado\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Receitas\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Etiquetas\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"A descarregar\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"Carga da CPU média (5m)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Transmitido\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Última Inactividade\",\n        \"downDuration\": \"Duração de Inactividade\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Ainda não verificado\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Parece em Baixo\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"Em cinemas\",\n        \"physicalRelease\": \"Lançamento físico\",\n        \"digitalRelease\": \"Lançamento digital\",\n        \"noEventsToday\": \"Não existem eventos hoje!\",\n        \"noEventsFound\": \"Nenhum evento encontrado\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Plataformas\",\n        \"totalRoms\": \"Jogos\",\n        \"saves\": \"Saves\",\n        \"states\": \"Estados\",\n        \"screenshots\": \"Screenshots\",\n        \"totalfilesize\": \"Tamanho Total\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Caixas de Correio\",\n        \"mails\": \"E-mails\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Avisos\",\n        \"criticals\": \"Críticos\"\n    },\n    \"plantit\": {\n        \"events\": \"Eventos\",\n        \"plants\": \"Plantas\",\n        \"photos\": \"Photos\",\n        \"species\": \"Espécies\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Notificações\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Solicitar pull\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Cenas\",\n        \"scenesPlayed\": \"Cenas reproduzidas\",\n        \"playCount\": \"Total de Reproduções\",\n        \"playDuration\": \"Tempo Assistido\",\n        \"sceneSize\": \"Tamanho das cenas\",\n        \"sceneDuration\": \"Duração das cenas\",\n        \"images\": \"Imagens\",\n        \"imageSize\": \"Tamanho das imagens\",\n        \"galleries\": \"Galerias\",\n        \"performers\": \"Artistas de palco\",\n        \"studios\": \"Estúdios\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"Contagem de O's\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Palavras-chave\"\n    },\n    \"homebox\": {\n        \"items\": \"Items\",\n        \"totalWithWarranty\": \"Com Garantia\",\n        \"locations\": \"Localizações\",\n        \"labels\": \"Etiquetas\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Valor Total\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Banidos\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Com proxy\",\n        \"auth\": \"Com Autorização\",\n        \"outdated\": \"Desactualizado\",\n        \"banned\": \"Banido\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Acções\",\n        \"loading\": \"A carregar\",\n        \"open\": \"Aberto - Mercado dos EUA\",\n        \"closed\": \"Fechado - Mercado dos EUA\",\n        \"invalidConfiguration\": \"Configuração Inválida\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Câmeras\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Links\",\n        \"collections\": \"Colecções\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Não Classificados\",\n        \"information\": \"Information\",\n        \"warning\": \"Avisos\",\n        \"average\": \"Média\",\n        \"high\": \"Elevado\",\n        \"disaster\": \"Desastre\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Veículo\",\n        \"vehicles\": \"Veículos\",\n        \"serviceRecords\": \"Registros de Serviço\",\n        \"reminders\": \"Lembretes\",\n        \"nextReminder\": \"Próximo Lembrete\",\n        \"none\": \"Nenhum\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Active Projects\",\n        \"tasks7d\": \"Tasks Due This Week\",\n        \"tasksOverdue\": \"Overdue Tasks\",\n        \"tasksInProgress\": \"Tasks In Progress\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Systems\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Disk\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Apps\",\n        \"synced\": \"Synced\",\n        \"outOfSync\": \"Out Of Sync\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Degraded\",\n        \"progressing\": \"Progressing\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Suspended\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Groups\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Projects\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bookmarks\",\n        \"favorites\": \"Favorites\",\n        \"archived\": \"Archived\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Lists\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Other\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Assinaturas\",\n        \"thisMonthlyCost\": \"Este mês\",\n        \"nextMonthlyCost\": \"Próximo mês\",\n        \"previousMonthlyCost\": \"Mês anterior\",\n        \"nextRenewingSubscription\": \"Próximo pagamento\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/pt-BR/common.json",
    "content": "{\n    \"sabnzbd\": {\n        \"timeleft\": \"Tempo restante\",\n        \"rate\": \"Taxa\",\n        \"queue\": \"Fila\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Ativo\",\n        \"upload\": \"Envio\",\n        \"download\": \"Download\"\n    },\n    \"portainer\": {\n        \"total\": \"Total\",\n        \"running\": \"Funcionando\",\n        \"stopped\": \"Parado\"\n    },\n    \"coinmarketcap\": {\n        \"7days\": \"7 Dias\",\n        \"configure\": \"Configure uma ou mais criptomoedas para rastrear\",\n        \"1hour\": \"1 Hora\",\n        \"1day\": \"1 Dia\",\n        \"30days\": \"30 Dias\"\n    },\n    \"strelaysrv\": {\n        \"numConnections\": \"Conexões\",\n        \"numActiveSessions\": \"Sessões\",\n        \"dataRelayed\": \"Retransmitido\",\n        \"transferRate\": \"Taxa\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Tipo de Widget ausente: {{type}}\",\n        \"api_error\": \"Erro da API\",\n        \"status\": \"Status\",\n        \"information\": \"Informação\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Erro Bruto\",\n        \"response_data\": \"Dados de Resposta\"\n    },\n    \"weather\": {\n        \"current\": \"Localização atual\",\n        \"allow\": \"Clique para permitir\",\n        \"updating\": \"Atualizando\",\n        \"wait\": \"Por favor aguarde\"\n    },\n    \"search\": {\n        \"placeholder\": \"Buscar…\"\n    },\n    \"resources\": {\n        \"total\": \"Total\",\n        \"free\": \"Livre\",\n        \"used\": \"Usado\",\n        \"load\": \"Carregamento\",\n        \"cpu\": \"CPU\",\n        \"mem\": \"MEM\",\n        \"max\": \"Máximo\",\n        \"temp\": \"TEMP\",\n        \"uptime\": \"LIGADO\",\n        \"months\": \"mês\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"minutes\": \"m\"\n    },\n    \"docker\": {\n        \"rx\": \"Recebido\",\n        \"tx\": \"Transmitido\",\n        \"mem\": \"Memória\",\n        \"cpu\": \"CPU\",\n        \"offline\": \"Desligado\",\n        \"error\": \"Erro\",\n        \"unknown\": \"Desconhecido\",\n        \"running\": \"Executando\",\n        \"starting\": \"Iniciando\",\n        \"unhealthy\": \"Não-saudável\",\n        \"not_found\": \"Não Encontrado\",\n        \"exited\": \"Encerrado\",\n        \"partial\": \"Parcial\",\n        \"healthy\": \"Saudável\"\n    },\n    \"emby\": {\n        \"playing\": \"Reproduzindo\",\n        \"transcoding\": \"Transcodificando\",\n        \"bitrate\": \"Taxa de bits\",\n        \"no_active\": \"Sem transmissões ativas\",\n        \"movies\": \"Filmes\",\n        \"series\": \"Séries\",\n        \"episodes\": \"Episódios\",\n        \"songs\": \"Musicas\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Reproduzindo\",\n        \"transcoding\": \"Transcodificando\",\n        \"bitrate\": \"Taxa de bits\",\n        \"no_active\": \"Sem transmissões ativas\",\n        \"plex_connection_error\": \"Verifique a conexão do Plex\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Taxa\",\n        \"remaining\": \"Restando\",\n        \"downloaded\": \"Baixado\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Desejado\",\n        \"queued\": \"Na fila\",\n        \"series\": \"Séries\",\n        \"queue\": \"Fila\",\n        \"unknown\": \"Desconhecido\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Desejado\",\n        \"queued\": \"Na fila\",\n        \"movies\": \"Filmes\",\n        \"missing\": \"Faltando\",\n        \"queue\": \"Fila\",\n        \"unknown\": \"Desconhecido\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Desejado\",\n        \"queued\": \"Na fila\",\n        \"artists\": \"Artistas\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Desejado\",\n        \"queued\": \"Na fila\",\n        \"books\": \"Livros\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Episódios Ausentes\",\n        \"missingMovies\": \"Filmes Ausentes\"\n    },\n    \"ombi\": {\n        \"pending\": \"Pendente\",\n        \"approved\": \"Aprovado\",\n        \"available\": \"Disponível\"\n    },\n    \"jellyseerr\": {\n        \"pending\": \"Pendente\",\n        \"approved\": \"Aprovado\",\n        \"available\": \"Disponível\"\n    },\n    \"overseerr\": {\n        \"pending\": \"Pendente\",\n        \"approved\": \"Aprovado\",\n        \"available\": \"Disponível\",\n        \"processing\": \"Processando\"\n    },\n    \"pihole\": {\n        \"queries\": \"Consultas\",\n        \"blocked\": \"Bloqueados\",\n        \"gravity\": \"Gravidade\",\n        \"blocked_percent\": \"Bloqueado %\"\n    },\n    \"adguard\": {\n        \"queries\": \"Consultas\",\n        \"blocked\": \"Bloqueado\",\n        \"filtered\": \"Filtrado\",\n        \"latency\": \"Latência\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Envio\",\n        \"download\": \"Receber\",\n        \"ping\": \"Ping\"\n    },\n    \"traefik\": {\n        \"routers\": \"Roteadores\",\n        \"services\": \"Serviços\",\n        \"middleware\": \"Software intermediario\"\n    },\n    \"npm\": {\n        \"enabled\": \"Habilitado\",\n        \"disabled\": \"Desabilitado\",\n        \"total\": \"Total\"\n    },\n    \"gotify\": {\n        \"apps\": \"Aplicações\",\n        \"clients\": \"Clientes\",\n        \"messages\": \"Mensagens\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indexadores\",\n        \"numberOfGrabs\": \"Buscas\",\n        \"numberOfQueries\": \"Consultas\",\n        \"numberOfFailGrabs\": \"Buscas sem êxito\",\n        \"numberOfFailQueries\": \"Consultas Falhas\"\n    },\n    \"jackett\": {\n        \"configured\": \"Configurado\",\n        \"errored\": \"Erro\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Usuários\",\n        \"status_count\": \"Postagens\",\n        \"domain_count\": \"Domínios\"\n    },\n    \"authentik\": {\n        \"users\": \"Usuários\",\n        \"loginsLast24H\": \"Logins (24h)\",\n        \"failedLoginsLast24H\": \"Logins Falhos (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"Memória\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"unifi\": {\n        \"users\": \"Usuários\",\n        \"uptime\": \"Tempo de Funcionamento\",\n        \"days\": \"Dias\",\n        \"wan\": \"WAN\",\n        \"lan_users\": \"Usuarios locais\",\n        \"wlan_users\": \"Usuarios WLAN\",\n        \"up\": \"LIGADO\",\n        \"down\": \"CÁIDO\",\n        \"wait\": \"Por favor aguarde\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Dispositivos\",\n        \"lan_devices\": \"Dispositivos LAN\",\n        \"wlan_devices\": \"Dispositivos WLAN\",\n        \"empty_data\": \"Status de Subsistema Desconhecido\"\n    },\n    \"plex\": {\n        \"streams\": \"Transmissões ativas\",\n        \"movies\": \"Filmes\",\n        \"tv\": \"Séries de TV\",\n        \"albums\": \"Albums\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"wait\": \"Por favor aguarde\",\n        \"temp\": \"TEMP\",\n        \"uptime\": \"LIGADO\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"load\": \"Carga\",\n        \"warn\": \"Aviso\",\n        \"total\": \"Total\",\n        \"free\": \"Livre\",\n        \"used\": \"Usado\",\n        \"crit\": \"Crit\",\n        \"read\": \"Read\",\n        \"write\": \"Write\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Swap\",\n        \"_temp\": \"Temp\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Observados\",\n        \"diffsDetected\": \"Mudanças\"\n    },\n    \"wmo\": {\n        \"1-night\": \"Principalmente Limpo\",\n        \"2-day\": \"Parcialmente Nublado\",\n        \"2-night\": \"Parcialmente nublado\",\n        \"3-day\": \"Nublado\",\n        \"3-night\": \"Nublado\",\n        \"45-day\": \"Névoa\",\n        \"45-night\": \"Névoa\",\n        \"48-day\": \"Névoa\",\n        \"48-night\": \"Névoa\",\n        \"56-night\": \"Leve Garoa Congelante\",\n        \"57-day\": \"Garoa Congelante\",\n        \"99-day\": \"Trovoada Com Granizo\",\n        \"99-night\": \"Trovoada Com Granizo\",\n        \"0-day\": \"Ensolarado\",\n        \"53-day\": \"Garoando\",\n        \"0-night\": \"Limpo\",\n        \"1-day\": \"Principalmente Ensolarado\",\n        \"51-day\": \"Leve Garoa\",\n        \"51-night\": \"Leve Garoa\",\n        \"53-night\": \"Garoando\",\n        \"55-day\": \"Garoa Pesada\",\n        \"55-night\": \"Garoa Pesada\",\n        \"56-day\": \"Leve Garoa Congelante\",\n        \"57-night\": \"Garoa Congelante\",\n        \"61-day\": \"Chuva Leve\",\n        \"61-night\": \"Chuva Leve\",\n        \"63-day\": \"Chuva\",\n        \"63-night\": \"Chuva\",\n        \"65-day\": \"Chuva Pesada\",\n        \"65-night\": \"Chuva Pesada\",\n        \"66-day\": \"Chuva Congelante\",\n        \"66-night\": \"Chuva Congelante\",\n        \"67-day\": \"Chuva Congelante\",\n        \"67-night\": \"Chuva Congelante\",\n        \"71-day\": \"Neve Leve\",\n        \"71-night\": \"Neve Leve\",\n        \"73-day\": \"Neve\",\n        \"73-night\": \"Neve\",\n        \"75-day\": \"Neve Pesada\",\n        \"75-night\": \"Neve Pesada\",\n        \"77-day\": \"Grãos de Neve\",\n        \"77-night\": \"Grãos de Neve\",\n        \"80-day\": \"Chuviscos Leve\",\n        \"80-night\": \"Chuviscos Leve\",\n        \"81-day\": \"Chuviscos\",\n        \"81-night\": \"Chuviscos\",\n        \"82-day\": \"Chuviscos Pesado\",\n        \"82-night\": \"Chuviscos Pesado\",\n        \"85-day\": \"Precipitação de Neve\",\n        \"85-night\": \"Precipitação de Neve\",\n        \"86-day\": \"Precipitação de Neve\",\n        \"86-night\": \"Precipitação de Neve\",\n        \"95-day\": \"Trovoada\",\n        \"95-night\": \"Trovoada\",\n        \"96-day\": \"Trovoada Com Granizo\",\n        \"96-night\": \"Trovoada Com Granizo\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Favorito\",\n        \"service\": \"Serviço\",\n        \"search\": \"Busca\",\n        \"custom\": \"Personalizado\",\n        \"visit\": \"Visitar\",\n        \"url\": \"URL\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Sistema\",\n        \"updates\": \"Atualizações\",\n        \"update_available\": \"Atualização Disponível\",\n        \"up_to_date\": \"Atualizado\",\n        \"child_bridges\": \"Pontes Filhas\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Ligado\",\n        \"pending\": \"Pendente\",\n        \"down\": \"Desligado\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Aprovado\",\n        \"rejectedPushes\": \"Rejeitado\",\n        \"filters\": \"Filtros\",\n        \"indexers\": \"Indexadores\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Escaneado\",\n        \"containers_updated\": \"Atualizado\",\n        \"containers_failed\": \"Falha\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Fila\",\n        \"videos\": \"Vídeos\",\n        \"channels\": \"Canais\",\n        \"playlists\": \"Listas\"\n    },\n    \"truenas\": {\n        \"load\": \"Carga do Sistema\",\n        \"uptime\": \"Tempo Ativo\",\n        \"alerts\": \"Alertas\",\n        \"time\": \"{{value, number(style: unit; unitDisplay: long;)}}\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"Sem transmissões ativas\",\n        \"please_wait\": \"Por favor aguarde\"\n    },\n    \"pyload\": {\n        \"speed\": \"Velocidade\",\n        \"active\": \"Ativo\",\n        \"queue\": \"Fila\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"IP Público\",\n        \"region\": \"Região\",\n        \"country\": \"País\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Canais\",\n        \"hd\": \"HD\"\n    },\n    \"ping\": {\n        \"error\": \"Erro\",\n        \"ping\": \"Ping\",\n        \"up\": \"Up\",\n        \"down\": \"Down\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Passou\",\n        \"failed\": \"Falha\",\n        \"unknown\": \"Desconhecido\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Caixa de Entrada\",\n        \"total\": \"Total\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"flood\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Fila\",\n        \"processed\": \"Processado\",\n        \"errored\": \"Erro\",\n        \"saved\": \"Salvo\"\n    },\n    \"miniflux\": {\n        \"read\": \"Lidos\",\n        \"unread\": \"Não Lidos\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Por favor aguarde\",\n        \"no_devices\": \"Nenhum dado de dispositivo recebido\"\n    },\n    \"common\": {\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Pontos de acesso conectados\",\n        \"activeUser\": \"Dispositivos ativos\",\n        \"alerts\": \"Alertas\",\n        \"connectedGateway\": \"Gateways conectados\",\n        \"connectedSwitches\": \"Interruptores conectados\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"Carga de CPU\",\n        \"memoryUsed\": \"Memória Utilizada\",\n        \"uptime\": \"Tempo Ativo\",\n        \"numberOfLeases\": \"Concessões\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Todos Fluxos\",\n        \"streams_active\": \"Fluxos Ativos\",\n        \"streams_xepg\": \"Canais XEPG\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"Carga de CPU\",\n        \"memory\": \"Memória Ativa\",\n        \"wanUpload\": \"WAN Upload\",\n        \"wanDownload\": \"WAN Download\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Estado da Impressora\",\n        \"print_status\": \"Status da Impressora\",\n        \"print_progress\": \"Progresso\",\n        \"layers\": \"Camadas\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Desejado\",\n        \"queued\": \"Na fila\",\n        \"series\": \"Séries\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Temp. Ferramenta\",\n        \"temp_bed\": \"Temp. Cama\",\n        \"job_completion\": \"Conclusão\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"IP Origem\",\n        \"status\": \"Status\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Armaz. de Dados\",\n        \"failed_tasks_24h\": \"Tarefas Falhas 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memória\"\n    },\n    \"immich\": {\n        \"users\": \"Usuários\",\n        \"photos\": \"Fotos\",\n        \"videos\": \"Vídeos\",\n        \"storage\": \"Armazenamento\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Sites no Ar\",\n        \"down\": \"Sites Fora do Ar\",\n        \"uptime\": \"Tempo Ativo\",\n        \"incident\": \"Incidente\",\n        \"m\": \"m\"\n    },\n    \"komga\": {\n        \"libraries\": \"Bibliotecas\",\n        \"series\": \"Séries\",\n        \"books\": \"Livros\"\n    },\n    \"mylar\": {\n        \"series\": \"Séries\",\n        \"issues\": \"Problemas\",\n        \"wanted\": \"Desejado\"\n    },\n    \"photoprism\": {\n        \"videos\": \"Vídeos\",\n        \"albums\": \"Álbuns\",\n        \"photos\": \"Fotos\",\n        \"people\": \"Pessoa\"\n    },\n    \"diskstation\": {\n        \"days\": \"Dias\",\n        \"uptime\": \"Tempo Ativo\",\n        \"volumeAvailable\": \"Disponível\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Fila\",\n        \"processing\": \"Processando\",\n        \"processed\": \"Processado\",\n        \"time\": \"Hora\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Painéis\",\n        \"datasources\": \"Origem de Dados\",\n        \"totalalerts\": \"Total Alertas\",\n        \"alertstriggered\": \"Alertas Disparados\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Carga de CPU\",\n        \"memoryusage\": \"Memória Utilizada\",\n        \"freespace\": \"Espaço Livre\",\n        \"activeusers\": \"Usuários Ativos\",\n        \"numfiles\": \"Arquivos\",\n        \"numshares\": \"Itens Compartilhados\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Tamanho\",\n        \"lastrun\": \"Ultima Execução\",\n        \"nextrun\": \"Próxima Execução\",\n        \"failed\": \"Falha\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Trabalhadores Ativos\",\n        \"total_workers\": \"Total Trabalhadores\",\n        \"records_total\": \"Comprimento da Fila\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Novo\",\n        \"up\": \"Online\",\n        \"grace\": \"Em Período Gratuito\",\n        \"down\": \"Offline\",\n        \"paused\": \"Pausado\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Ultimo Ping\",\n        \"never\": \"Nenhum ping ainda\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servidores\",\n        \"nodes\": \"Nós\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Alvo ativo\",\n        \"targets_down\": \"Alvo inativo\",\n        \"targets_total\": \"Alvos totais\"\n    },\n    \"minecraft\": {\n        \"players\": \"Reprodutores\",\n        \"version\": \"Versão\",\n        \"status\": \"Status\",\n        \"up\": \"Conectado\",\n        \"down\": \"Desconectado\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Hoje\",\n        \"gross_percent_1y\": \"Um ano\",\n        \"gross_percent_max\": \"Todo periodo\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasts\",\n        \"books\": \"Livros\",\n        \"podcastsDuration\": \"Duração\",\n        \"booksDuration\": \"Duração\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Pessoas em Casa\",\n        \"lights_on\": \"Luzes Ligadas\",\n        \"switches_on\": \"Interruptores Ligados\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Assinaturas\",\n        \"unread\": \"Não lida\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Shows\",\n        \"recordings\": \"Gravações\",\n        \"scheduled\": \"Agendado\",\n        \"passes\": \"Passes\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Monitorando\",\n        \"updates\": \"Atualizações\"\n    },\n    \"tailscale\": {\n        \"address\": \"Endereço\",\n        \"expires\": \"Expira\",\n        \"never\": \"Nunca\",\n        \"last_seen\": \"Visto pela última vez\",\n        \"now\": \"Agora\",\n        \"years\": \"{{number}}a\",\n        \"weeks\": \"{{number}}s\",\n        \"hours\": \"{{number}}h\",\n        \"days\": \"{{number}}d\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Atrás\"\n    },\n    \"qnap\": {\n        \"systemTempC\": \"Temp Sistema\",\n        \"cpuUsage\": \"Uso CPU\",\n        \"memUsage\": \"Uso MEM\",\n        \"poolUsage\": \"Pool Usage\",\n        \"volumeUsage\": \"Uso Volume\",\n        \"invalid\": \"Invalido\"\n    },\n    \"pfsense\": {\n        \"load\": \"Média de carga\",\n        \"memory\": \"Uso Mem\",\n        \"wanStatus\": \"WAN Status\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Uso de disco\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Solicitações atuais\",\n        \"requests_failed\": \"Solicitações com falha\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Produção\",\n        \"battery_soc\": \"Bateria\",\n        \"grid_power\": \"Grade\",\n        \"home_power\": \"Consumo\",\n        \"charge_power\": \"Carregador\",\n        \"watt_hour\": \"Wh\"\n    },\n    \"pialert\": {\n        \"total\": \"Total\",\n        \"connected\": \"Conectado\",\n        \"new_devices\": \"Novos dispositivos\",\n        \"down_alerts\": \"Alertas de Quedas\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Fila\",\n        \"downloadSpeed\": \"Velocidade de download\",\n        \"downloadBytesRemaining\": \"Restante\",\n        \"downloadTotalBytes\": \"Tamanho\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Arquivos\"\n    },\n    \"gamedig\": {\n        \"name\": \"Nome\",\n        \"map\": \"Mapa\",\n        \"currentPlayers\": \"Jogadores atuais\",\n        \"players\": \"Jogadores\",\n        \"maxPlayers\": \"Jogadores Max\",\n        \"bots\": \"Robos\",\n        \"ping\": \"Ping\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Result\",\n        \"status\": \"Status\",\n        \"buildId\": \"Build ID\",\n        \"succeeded\": \"Succeeded\",\n        \"notStarted\": \"Not Started\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Canceled\",\n        \"inProgress\": \"In Progress\",\n        \"totalPrs\": \"Total PRs\",\n        \"myPrs\": \"My PRs\",\n        \"approved\": \"Approved\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Errors\",\n        \"noRecent\": \"Out of Date\",\n        \"totalUsed\": \"Used Storage\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Downloading\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Recipes\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Tags\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Archives\",\n        \"chapters\": \"Chapters\",\n        \"categories\": \"Categories\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Authors\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Last Downtime\",\n        \"downDuration\": \"Downtime Duration\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Not Yet Checked\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Seems Down\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"opendtu\": {\n        \"relativePower\": \"Power %\",\n        \"yieldDay\": \"Today\",\n        \"limit\": \"Limit\",\n        \"absolutePower\": \"Power\"\n    },\n    \"calendar\": {\n        \"physicalRelease\": \"Physical release\",\n        \"inCinemas\": \"In cinemas\",\n        \"digitalRelease\": \"Digital release\"\n    }\n}\n"
  },
  {
    "path": "public/locales/pt_BR/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"M\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Tipo de Widget ausente: {{type}}\",\n        \"api_error\": \"Erros de API\",\n        \"information\": \"Informação\",\n        \"status\": \"Status\",\n        \"url\": \"Endereço URL\",\n        \"raw_error\": \"Erro Bruto\",\n        \"response_data\": \"Dados de Resposta\"\n    },\n    \"weather\": {\n        \"current\": \"Localização atual\",\n        \"allow\": \"Clique para permitir\",\n        \"updating\": \"Atualizando\",\n        \"wait\": \"Por favor, aguarde\"\n    },\n    \"search\": {\n        \"placeholder\": \"Pesquisar…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"MEM\",\n        \"total\": \"Total\",\n        \"free\": \"Livre\",\n        \"used\": \"Utilizado\",\n        \"load\": \"Carga\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Máximo\",\n        \"uptime\": \"ATIVO\"\n    },\n    \"unifi\": {\n        \"users\": \"Usuários\",\n        \"uptime\": \"Ligado\",\n        \"days\": \"Dias\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Dispositivos\",\n        \"lan_devices\": \"Dispositivos LAN\",\n        \"wlan_devices\": \"Dispositivos WLAN\",\n        \"lan_users\": \"Usuários de LAN\",\n        \"wlan_users\": \"Usuários de WLAN\",\n        \"up\": \"ATIVO\",\n        \"down\": \"Desligado\",\n        \"wait\": \"Por favor, aguarde\",\n        \"empty_data\": \"Status do Subsistema desconhecido\"\n    },\n    \"docker\": {\n        \"rx\": \"Rx\",\n        \"tx\": \"Tx\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Executando\",\n        \"offline\": \"Offline\",\n        \"error\": \"Erro\",\n        \"unknown\": \"Desconhecido\",\n        \"healthy\": \"Saudável\",\n        \"starting\": \"A iniciar\",\n        \"unhealthy\": \"Não-saudável\",\n        \"not_found\": \"Não Encontrado\",\n        \"exited\": \"Encerrado\",\n        \"partial\": \"Parcial\"\n    },\n    \"ping\": {\n        \"error\": \"Erro\",\n        \"ping\": \"Tempo de resposta\",\n        \"down\": \"Inativo\",\n        \"up\": \"Ativo\",\n        \"not_available\": \"Não Disponível\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"Estado HTTP\",\n        \"error\": \"Erro\",\n        \"response\": \"Resposta\",\n        \"down\": \"Inativo\",\n        \"up\": \"Ativo\",\n        \"not_available\": \"Não Disponível\"\n    },\n    \"emby\": {\n        \"playing\": \"A reproduzir\",\n        \"transcoding\": \"Transcodificação\",\n        \"bitrate\": \"Taxa de bits\",\n        \"no_active\": \"Sem Streams Ativos\",\n        \"movies\": \"Filmes\",\n        \"series\": \"Séries\",\n        \"episodes\": \"Episódios\",\n        \"songs\": \"Canções\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Disponível\",\n        \"total\": \"Total\",\n        \"unknown\": \"Desconhecido\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Produção\",\n        \"battery_soc\": \"Bateria\",\n        \"grid_power\": \"Grade\",\n        \"home_power\": \"Consumo\",\n        \"charge_power\": \"Carregador\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Descarregar\",\n        \"upload\": \"Carregar\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Semente\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Assinaturas\",\n        \"unread\": \"Não lida\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Não configurado\",\n        \"connectionStatusConnecting\": \"Conectando\",\n        \"connectionStatusAuthenticating\": \"Autenticando\",\n        \"connectionStatusPendingDisconnect\": \"Desconexão Pendente\",\n        \"connectionStatusDisconnecting\": \"Desconectando\",\n        \"connectionStatusDisconnected\": \"Desconectado\",\n        \"connectionStatusConnected\": \"Conectado\",\n        \"uptime\": \"Tempo ativo\",\n        \"maxDown\": \"Tempo de inatividade máximo\",\n        \"maxUp\": \"Máx. Acima\",\n        \"down\": \"Inativo\",\n        \"up\": \"Ativo\",\n        \"received\": \"Recebido\",\n        \"sent\": \"Enviado\",\n        \"externalIPAddress\": \"IP Externo\",\n        \"externalIPv6Address\": \"IPv6 Externo\",\n        \"externalIPv6Prefix\": \"Prefixo IPv6 Externo\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Streams de Envio\",\n        \"requests\": \"Solicitações atuais\",\n        \"requests_failed\": \"Solicitações com falha\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Total Observado\",\n        \"diffsDetected\": \"Diferenças Detetadas\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Programas\",\n        \"recordings\": \"Gravações\",\n        \"scheduled\": \"Agendado\",\n        \"passes\": \"Passes\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Tocando\",\n        \"transcoding\": \"Transcodificando\",\n        \"bitrate\": \"Taxa de bits\",\n        \"no_active\": \"Sem Streams Ativos\",\n        \"plex_connection_error\": \"Verifique a conexão do Plex\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"APs Ligados\",\n        \"activeUser\": \"Dispositivos ativos\",\n        \"alerts\": \"Alertas\",\n        \"connectedGateways\": \"Gateways conectados\",\n        \"connectedSwitches\": \"Switches conectados\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Taxa\",\n        \"remaining\": \"Restante\",\n        \"downloaded\": \"Baixado\"\n    },\n    \"plex\": {\n        \"streams\": \"Streams Ativas\",\n        \"albums\": \"Álbuns\",\n        \"movies\": \"Filmes\",\n        \"tv\": \"Series de TV\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Taxa\",\n        \"queue\": \"Fila\",\n        \"timeleft\": \"Tempo restante\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Ativo\",\n        \"upload\": \"Carregar\",\n        \"download\": \"Descarregar\"\n    },\n    \"transmission\": {\n        \"download\": \"Descarregar\",\n        \"upload\": \"Carregar\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Descarregar\",\n        \"upload\": \"Carregar\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"Uso de CPU\",\n        \"memUsage\": \"Uso de Memória\",\n        \"systemTempC\": \"Temp. do Sistema\",\n        \"poolUsage\": \"Uso do Pool\",\n        \"volumeUsage\": \"Uso do volume\",\n        \"invalid\": \"Inválido\"\n    },\n    \"deluge\": {\n        \"download\": \"Descarregar\",\n        \"upload\": \"Carregar\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Bytes de Acerto de Cache\",\n        \"cachemissbytes\": \"Bytes de Falha de Cache\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Descarregar\",\n        \"upload\": \"Carregar\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Desejada\",\n        \"queued\": \"Em fila\",\n        \"series\": \"Séries\",\n        \"queue\": \"Fila\",\n        \"unknown\": \"Desconhecido\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Desejado\",\n        \"missing\": \"Faltando\",\n        \"queued\": \"Em fila\",\n        \"movies\": \"Filmes\",\n        \"queue\": \"Fila\",\n        \"unknown\": \"Desconhecido\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Desejado\",\n        \"queued\": \"Na fila\",\n        \"artists\": \"Artistas\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Desejado\",\n        \"queued\": \"Na fila\",\n        \"books\": \"Livros\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Episódios Faltantes\",\n        \"missingMovies\": \"Filmes Faltantes\"\n    },\n    \"ombi\": {\n        \"pending\": \"Pendente\",\n        \"approved\": \"Aprovada\",\n        \"available\": \"Disponível\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Conectado\",\n        \"new_devices\": \"Novos dispositivos\",\n        \"down_alerts\": \"Alertas de Inatividade\"\n    },\n    \"pihole\": {\n        \"queries\": \"Consultas\",\n        \"blocked\": \"Bloqueado\",\n        \"blocked_percent\": \"Bloqueado %\",\n        \"gravity\": \"Gravidade\"\n    },\n    \"adguard\": {\n        \"queries\": \"Consultas\",\n        \"blocked\": \"Bloqueado\",\n        \"filtered\": \"Filtrado\",\n        \"latency\": \"Latência\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Enviar\",\n        \"download\": \"Baixar\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Executando\",\n        \"stopped\": \"Parado\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Baixado\",\n        \"nondownload\": \"Não Baixado\",\n        \"read\": \"Lido\",\n        \"unread\": \"Não lido\",\n        \"downloadedread\": \"Baixado e Lido\",\n        \"downloadedunread\": \"Baixado e Não Lido\",\n        \"nondownloadedread\": \"Não Baixado e Lido\",\n        \"nondownloadedunread\": \"Não Baixado e Não Lido\"\n    },\n    \"tailscale\": {\n        \"address\": \"Endereço\",\n        \"expires\": \"Expira em\",\n        \"never\": \"Nunca\",\n        \"last_seen\": \"Visto por último\",\n        \"now\": \"Agora\",\n        \"years\": \"{{number}}a\",\n        \"weeks\": \"{{number}}s\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Atrás\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Consultas\",\n        \"totalNoError\": \"Sucesso\",\n        \"totalServerFailure\": \"Falhas\",\n        \"totalNxDomain\": \"Domínios NX\",\n        \"totalRefused\": \"Recusado\",\n        \"totalAuthoritative\": \"Autoritativo\",\n        \"totalRecursive\": \"Recursivo\",\n        \"totalCached\": \"Em cache\",\n        \"totalBlocked\": \"Bloqueado\",\n        \"totalDropped\": \"Perdidos\",\n        \"totalClients\": \"Clientes\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Fila de espera\",\n        \"processed\": \"Processado\",\n        \"errored\": \"Erro\",\n        \"saved\": \"Guardado\"\n    },\n    \"traefik\": {\n        \"routers\": \"Roteadores\",\n        \"services\": \"Serviços\",\n        \"middleware\": \"\"\n    },\n    \"trilium\": {\n        \"version\": \"Versão\",\n        \"notesCount\": \"Notas\",\n        \"dbSize\": \"Tamanho do banco de dados\",\n        \"unknown\": \"Desconhecido\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"Sem Streams Ativos\",\n        \"please_wait\": \"Por favor, aguarde\"\n    },\n    \"npm\": {\n        \"enabled\": \"Ativo\",\n        \"disabled\": \"Desabilitado\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Configurar uma ou mais moedas\",\n        \"1hour\": \"1 Hora\",\n        \"1day\": \"1 Dia\",\n        \"7days\": \"7 Dias\",\n        \"30days\": \"30 Dias\"\n    },\n    \"gotify\": {\n        \"apps\": \"Aplicações\",\n        \"clients\": \"Clientes\",\n        \"messages\": \"Mensagens\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indexadores\",\n        \"numberOfGrabs\": \"Agarrados\",\n        \"numberOfQueries\": \"Consultas\",\n        \"numberOfFailGrabs\": \"Falhados\",\n        \"numberOfFailQueries\": \"Pesquisas falhadas\"\n    },\n    \"jackett\": {\n        \"configured\": \"Configurado\",\n        \"errored\": \"Falhou\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sessões\",\n        \"numConnections\": \"Conexões\",\n        \"dataRelayed\": \"Retransmitido\",\n        \"transferRate\": \"Taxa\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Usuários\",\n        \"status_count\": \"Postagens\",\n        \"domain_count\": \"Domínios\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Desejado\",\n        \"queued\": \"Na fila\",\n        \"series\": \"Séries\"\n    },\n    \"minecraft\": {\n        \"players\": \"Reprodutores\",\n        \"version\": \"Versão\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Desconectado\"\n    },\n    \"miniflux\": {\n        \"read\": \"Lido\",\n        \"unread\": \"Não lido\"\n    },\n    \"authentik\": {\n        \"users\": \"Usuários\",\n        \"loginsLast24H\": \"Inícios de sessão (24h)\",\n        \"failedLoginsLast24H\": \"Inícios de sessão falhados (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Carga\",\n        \"wait\": \"Por favor, aguarde\",\n        \"temp\": \"TEMPERATURA\",\n        \"_temp\": \"Temperatura\",\n        \"warn\": \"Aviso\",\n        \"uptime\": \"ATIVO\",\n        \"total\": \"Total\",\n        \"free\": \"Livre\",\n        \"used\": \"Utilizado\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Crítico\",\n        \"read\": \"Leitura\",\n        \"write\": \"Escrita\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Memória\",\n        \"swap\": \"Temporário\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Marcador\",\n        \"service\": \"Serviço\",\n        \"search\": \"Busca\",\n        \"custom\": \"Personalizado\",\n        \"visit\": \"Visitar\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Sugestão\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Solarengo\",\n        \"0-night\": \"Limpo\",\n        \"1-day\": \"Maioritariamente ensolarado\",\n        \"1-night\": \"Maioritariamente Limpo\",\n        \"2-day\": \"Parcialmente Nublado\",\n        \"2-night\": \"Parcialmente Nublado\",\n        \"3-day\": \"Nublado\",\n        \"3-night\": \"Nublado\",\n        \"45-day\": \"Nevoeiro\",\n        \"45-night\": \"Nevoeiro\",\n        \"48-day\": \"Nevoeiro\",\n        \"48-night\": \"Nevoeiro\",\n        \"51-day\": \"Aguaceiros\",\n        \"51-night\": \"Leve Garoa\",\n        \"53-day\": \"Chuvisco\",\n        \"53-night\": \"Garoa\",\n        \"55-day\": \"Aguaceiro Forte\",\n        \"55-night\": \"Garoa Forte\",\n        \"56-day\": \"Leve Garoa Congelante\",\n        \"56-night\": \"Garoa Congelante Fraca\",\n        \"57-day\": \"Garoa Congelante\",\n        \"57-night\": \"Garoa Congelante\",\n        \"61-day\": \"Chuva fraca\",\n        \"61-night\": \"Chuva Fraca\",\n        \"63-day\": \"Chuva\",\n        \"63-night\": \"Chuva\",\n        \"65-day\": \"Chuva forte\",\n        \"65-night\": \"Chuva Forte\",\n        \"66-day\": \"Chuva Congelante\",\n        \"66-night\": \"Chuva Congelante\",\n        \"67-day\": \"Chuva Congelante\",\n        \"67-night\": \"Chuva Congelante\",\n        \"71-day\": \"Neve fraca\",\n        \"71-night\": \"Neve Fraca\",\n        \"73-day\": \"Neve\",\n        \"73-night\": \"Neve\",\n        \"75-day\": \"Neve forte\",\n        \"75-night\": \"Neve Forte\",\n        \"77-day\": \"Grãos de Neve\",\n        \"77-night\": \"Grãos de Neve\",\n        \"80-day\": \"Neve fraca\",\n        \"80-night\": \"Pancadas de Chuva Leves\",\n        \"81-day\": \"Chuviscos\",\n        \"81-night\": \"Pancadas de Chuva\",\n        \"82-day\": \"Chuviscos fortes\",\n        \"82-night\": \"Pancadas de Chuva Forte\",\n        \"85-day\": \"Precipitação de Neve\",\n        \"85-night\": \"Pancadas de Neve\",\n        \"86-day\": \"Pancadas de Neve\",\n        \"86-night\": \"Pancadas de Neve\",\n        \"95-day\": \"Trovoada\",\n        \"95-night\": \"Tempestade Com Raios\",\n        \"96-day\": \"Trovoada com granizo\",\n        \"96-night\": \"Tempestade Com Raios e Granizo\",\n        \"99-day\": \"Tempestade Com Raios e Granizo\",\n        \"99-night\": \"Tempestade Com Raios e Granizo\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Sistema\",\n        \"updates\": \"Atualizações\",\n        \"update_available\": \"Atualização disponível\",\n        \"up_to_date\": \"Atualizado\",\n        \"child_bridges\": \"Pontes Filhas\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Ativo\",\n        \"pending\": \"Pendente\",\n        \"down\": \"Inativo\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Novo\",\n        \"up\": \"Ativo\",\n        \"grace\": \"Em Período Gratuito\",\n        \"down\": \"Inativo\",\n        \"paused\": \"Pausado\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Ultimo Ping\",\n        \"never\": \"Nenhum ping ainda\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Verificado\",\n        \"containers_updated\": \"Atualizado\",\n        \"containers_failed\": \"Falhou\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Aprovado\",\n        \"rejectedPushes\": \"Rejeitado\",\n        \"filters\": \"Filtros\",\n        \"indexers\": \"Indexadores\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Fila de espera\",\n        \"videos\": \"Vídeos\",\n        \"channels\": \"Canais\",\n        \"playlists\": \"Listas\"\n    },\n    \"truenas\": {\n        \"load\": \"Carga do sistema\",\n        \"uptime\": \"Tempo ativo\",\n        \"alerts\": \"Alertas\"\n    },\n    \"pyload\": {\n        \"speed\": \"Velocidade\",\n        \"active\": \"Ativo\",\n        \"queue\": \"Fila de espera\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"IP público\",\n        \"region\": \"Região\",\n        \"country\": \"País\",\n        \"port_forwarded\": \"Porta Encaminhada\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Canais\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Sintonizadores\",\n        \"channelNumber\": \"Canal\",\n        \"channelNetwork\": \"Rede\",\n        \"signalStrength\": \"Potência\",\n        \"signalQuality\": \"Qualidade\",\n        \"symbolQuality\": \"Qualidade\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Cliente\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Aprovado\",\n        \"failed\": \"Falhou\",\n        \"unknown\": \"Desconhecido\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Caixa de entrada\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Carga da bateria\",\n        \"ups_load\": \"Carga do UPS\",\n        \"ups_status\": \"Estado UPS\",\n        \"online\": \"Online\",\n        \"on_battery\": \"Na bateria\",\n        \"low_battery\": \"Bateria Fraca\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Por favor, aguarde\",\n        \"no_devices\": \"Nenhum dado do dispositivo recebido\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"Carga do CPU\",\n        \"memoryUsed\": \"Memória Utilizada\",\n        \"uptime\": \"Tempo ativo\",\n        \"numberOfLeases\": \"Concessões\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Todos os Streams\",\n        \"streams_active\": \"Streams Ativas\",\n        \"streams_xepg\": \"Canais XEPG\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Hoje\",\n        \"absolutePower\": \"Energia\",\n        \"relativePower\": \"Energia %\",\n        \"limit\": \"Limite\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"Carga do CPU\",\n        \"memory\": \"Memória Ativa\",\n        \"wanUpload\": \"Envio WAN\",\n        \"wanDownload\": \"WAN Descarga\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Estado da Impressora\",\n        \"print_status\": \"Estado da Impressora\",\n        \"print_progress\": \"Progresso\",\n        \"layers\": \"Camadas\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Temp. Ferramenta\",\n        \"temp_bed\": \"Temp. Cama\",\n        \"job_completion\": \"Conclusão\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"IP Origem\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Carga Média\",\n        \"memory\": \"Uso de memória\",\n        \"wanStatus\": \"Estado WAN\",\n        \"up\": \"Ativo\",\n        \"down\": \"Inativo\",\n        \"temp\": \"Temp.\",\n        \"disk\": \"Uso do disco\",\n        \"wanIP\": \"IP WAN\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Armaz. de Dados\",\n        \"failed_tasks_24h\": \"Tarefas Falhas 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memória\"\n    },\n    \"immich\": {\n        \"users\": \"Usuários\",\n        \"photos\": \"Fotos\",\n        \"videos\": \"Vídeos\",\n        \"storage\": \"Armazenamento\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Sites no Ar\",\n        \"down\": \"Sites Fora do Ar\",\n        \"uptime\": \"Tempo ativo\",\n        \"incident\": \"Incidente\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Séries\",\n        \"archives\": \"Arquivos\",\n        \"chapters\": \"Capítulos\",\n        \"categories\": \"Categorias\"\n    },\n    \"komga\": {\n        \"libraries\": \"Bibliotecas\",\n        \"series\": \"Séries\",\n        \"books\": \"Livros\"\n    },\n    \"diskstation\": {\n        \"days\": \"Dias\",\n        \"uptime\": \"Tempo ativo\",\n        \"volumeAvailable\": \"Disponível\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Séries\",\n        \"issues\": \"Problemas\",\n        \"wanted\": \"Desejado\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Álbuns\",\n        \"photos\": \"Fotos\",\n        \"videos\": \"Vídeos\",\n        \"people\": \"Pessoa\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Fila de espera\",\n        \"processing\": \"Processando\",\n        \"processed\": \"Processado\",\n        \"time\": \"Hora\"\n    },\n    \"firefly\": {\n        \"networth\": \"Valor Líquido\",\n        \"budget\": \"Orçamento\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Painéis\",\n        \"datasources\": \"Origem de Dados\",\n        \"totalalerts\": \"Total Alertas\",\n        \"alertstriggered\": \"Alertas Disparados\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Carga de CPU\",\n        \"memoryusage\": \"Memória Utilizada\",\n        \"freespace\": \"Espaço Livre\",\n        \"activeusers\": \"Utilizadores Ativos\",\n        \"numfiles\": \"Arquivos\",\n        \"numshares\": \"Itens compartilhados\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Tamanho\",\n        \"lastrun\": \"Ultima Execução\",\n        \"nextrun\": \"Próxima Execução\",\n        \"failed\": \"Falhou\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Workers Ativos\",\n        \"total_workers\": \"Total de trabalhadores\",\n        \"records_total\": \"Comprimento da Fila\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servidores\",\n        \"nodes\": \"Nós\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Alvo ativo\",\n        \"targets_down\": \"Alvo inativo\",\n        \"targets_total\": \"Total de Alvos\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites no Ar\",\n        \"down\": \"Sites Fora do Ar\",\n        \"uptime\": \"Tempo ativo\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Hoje\",\n        \"gross_percent_1y\": \"Um ano\",\n        \"gross_percent_max\": \"Todo o tempo\",\n        \"net_worth\": \"Patrimônio Líquido\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasts\",\n        \"books\": \"Livros\",\n        \"podcastsDuration\": \"Duração\",\n        \"booksDuration\": \"Duração\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Pessoas em Casa\",\n        \"lights_on\": \"Luzes Acesas\",\n        \"switches_on\": \"Interruptores Ligados\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Monitorando\",\n        \"updates\": \"Atualizações\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Livros\",\n        \"authors\": \"Autores\",\n        \"categories\": \"Categorias\",\n        \"series\": \"Séries\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Fila de espera\",\n        \"downloadBytesRemaining\": \"Restante\",\n        \"downloadTotalBytes\": \"Tamanho\",\n        \"downloadSpeed\": \"Velocidade\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Séries\",\n        \"totalFiles\": \"Arquivos\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Resultado\",\n        \"status\": \"Status\",\n        \"buildId\": \"ID Compilação\",\n        \"succeeded\": \"Bem-sucedido\",\n        \"notStarted\": \"Não iniciado\",\n        \"failed\": \"Falhou\",\n        \"canceled\": \"Cancelado\",\n        \"inProgress\": \"Em Progresso\",\n        \"totalPrs\": \"Total de PRs\",\n        \"myPrs\": \"Minhas PRs\",\n        \"approved\": \"Aprovado\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Desconectado\",\n        \"name\": \"Nome\",\n        \"map\": \"Mapa\",\n        \"currentPlayers\": \"Jogadores atuais\",\n        \"players\": \"Jogadores\",\n        \"maxPlayers\": \"Número Máximo de Jogadores\",\n        \"bots\": \"Robôs\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Erros\",\n        \"noRecent\": \"Desatualizado\",\n        \"totalUsed\": \"Armazanamento Utilizado\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Receitas\",\n        \"users\": \"Usuários\",\n        \"categories\": \"Categorias\",\n        \"tags\": \"Marcadores\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Baixando\",\n        \"total\": \"Total\",\n        \"running\": \"Executando\",\n        \"stopped\": \"Parado\",\n        \"passed\": \"Aprovado\",\n        \"failed\": \"Falhou\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Tempo ativo\",\n        \"cpuLoad\": \"Carga da CPU média (5m)\",\n        \"up\": \"Ativo\",\n        \"down\": \"Inativo\",\n        \"bytesTx\": \"Transmitido\",\n        \"bytesRx\": \"Recebido\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Tempo ativo\",\n        \"lastDown\": \"Última inatividade\",\n        \"downDuration\": \"Duração de inatividade\",\n        \"sitesUp\": \"Sites no Ar\",\n        \"sitesDown\": \"Sites Fora do Ar\",\n        \"paused\": \"Pausado\",\n        \"notyetchecked\": \"Não conferidos ainda\",\n        \"up\": \"Ativo\",\n        \"seemsdown\": \"Parece Desconectado\",\n        \"down\": \"Inativo\",\n        \"unknown\": \"Desconhecido\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"Nos cinemas\",\n        \"physicalRelease\": \"Versão física\",\n        \"digitalRelease\": \"Versão digital\",\n        \"noEventsToday\": \"Nenhum evento para hoje!\",\n        \"noEventsFound\": \"Nenhum evento encontrado\",\n        \"errorWhenLoadingData\": \"Erro ao carregar dados do calendário\"\n    },\n    \"romm\": {\n        \"platforms\": \"Plataformas\",\n        \"totalRoms\": \"Jogos\",\n        \"saves\": \"Saves\",\n        \"states\": \"Estados\",\n        \"screenshots\": \"Capturas de Tela\",\n        \"totalfilesize\": \"Tamanho total\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domínios\",\n        \"mailboxes\": \"Caixas de e-mail\",\n        \"mails\": \"Mensagens\",\n        \"storage\": \"Armazenamento\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Alertas\",\n        \"criticals\": \"Críticos\"\n    },\n    \"plantit\": {\n        \"events\": \"Eventos\",\n        \"plants\": \"Plantas\",\n        \"photos\": \"Fotos\",\n        \"species\": \"Espécies\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Notificações\",\n        \"issues\": \"Problemas\",\n        \"pulls\": \"Solicitações de Envio\",\n        \"repositories\": \"Repositórios\"\n    },\n    \"stash\": {\n        \"scenes\": \"Cenas\",\n        \"scenesPlayed\": \"Cenas Reproduzidas\",\n        \"playCount\": \"Total de Reproduções\",\n        \"playDuration\": \"Tempo Assistido\",\n        \"sceneSize\": \"Tamanho das cenas\",\n        \"sceneDuration\": \"Duração das cenas\",\n        \"images\": \"Imagens\",\n        \"imageSize\": \"Tamanho da Imagem\",\n        \"galleries\": \"Galerias\",\n        \"performers\": \"Atores\",\n        \"studios\": \"Estúdios\",\n        \"movies\": \"Filmes\",\n        \"tags\": \"Etiquetas\",\n        \"oCount\": \"Contagem 0\"\n    },\n    \"tandoor\": {\n        \"users\": \"Usuários\",\n        \"recipes\": \"Receitas\",\n        \"keywords\": \"Palavras-chave\"\n    },\n    \"homebox\": {\n        \"items\": \"Itens\",\n        \"totalWithWarranty\": \"Com Garantia\",\n        \"locations\": \"Localização\",\n        \"labels\": \"Rótulos\",\n        \"users\": \"Usuários\",\n        \"totalValue\": \"Valor Total\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alertas\",\n        \"bans\": \"Banimentos\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Conectado\",\n        \"enabled\": \"Ativo\",\n        \"disabled\": \"Desativado\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Com proxy\",\n        \"auth\": \"Com Autenticação\",\n        \"outdated\": \"Desatualizado\",\n        \"banned\": \"Banido\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Baixar\",\n        \"upload\": \"Enviar\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Ações\",\n        \"loading\": \"Carregando\",\n        \"open\": \"Abrir - Mercado Americano\",\n        \"closed\": \"Fechado - Mercado americano\",\n        \"invalidConfiguration\": \"Configuração Inválida\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Câmeras\",\n        \"uptime\": \"Tempo ativo\",\n        \"version\": \"Versão\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Links\",\n        \"collections\": \"Coleções\",\n        \"tags\": \"Etiquetas\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Não classificado\",\n        \"information\": \"Informação\",\n        \"warning\": \"Aviso\",\n        \"average\": \"Médio\",\n        \"high\": \"Alto\",\n        \"disaster\": \"Desastre\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Veículo\",\n        \"vehicles\": \"Veículos\",\n        \"serviceRecords\": \"Registros de Serviço\",\n        \"reminders\": \"Lembretes\",\n        \"nextReminder\": \"Próximo Lembrete\",\n        \"none\": \"Nenhum\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Projetos Ativos\",\n        \"tasks7d\": \"Tarefas que vencem nesta semana\",\n        \"tasksOverdue\": \"Tarefas Atrasadas\",\n        \"tasksInProgress\": \"Tarefas em Andamento\"\n    },\n    \"headscale\": {\n        \"name\": \"Nome\",\n        \"address\": \"Endereço\",\n        \"last_seen\": \"Visto por último\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Desconectado\"\n    },\n    \"beszel\": {\n        \"name\": \"Nome\",\n        \"systems\": \"Sistemas\",\n        \"up\": \"Ativo\",\n        \"down\": \"Inativo\",\n        \"paused\": \"Pausado\",\n        \"pending\": \"Pendente\",\n        \"status\": \"Status\",\n        \"updated\": \"Atualizado\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM.\",\n        \"disk\": \"Disco\",\n        \"network\": \"Rede\"\n    },\n    \"argocd\": {\n        \"apps\": \"Aplicativos\",\n        \"synced\": \"Sincronizado\",\n        \"outOfSync\": \"Fora de sincronia\",\n        \"healthy\": \"Saudável\",\n        \"degraded\": \"Degradado\",\n        \"progressing\": \"Progredindo\",\n        \"missing\": \"Faltando\",\n        \"suspended\": \"Suspenso\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Carregando\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Grupos\",\n        \"issues\": \"Problemas\",\n        \"merges\": \"Solicitações de mesclagem\",\n        \"projects\": \"Projetos\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Carga\",\n        \"bcharge\": \"Carga da Bateria\",\n        \"timeleft\": \"Tempo Restante\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Marcadores\",\n        \"favorites\": \"Favoritos\",\n        \"archived\": \"Arquivados\",\n        \"highlights\": \"Destaques\",\n        \"lists\": \"Listas\",\n        \"tags\": \"Etiquetas\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Rede\",\n        \"connected\": \"Conectado\",\n        \"disconnected\": \"Desconectado\",\n        \"updateStatus\": \"Atualize\",\n        \"update_yes\": \"Disponível\",\n        \"update_no\": \"Atualizado\",\n        \"downloads\": \"Transferências\",\n        \"uploads\": \"Envios\",\n        \"sharedFiles\": \"Arquivos\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Músicas\",\n        \"movies\": \"Filmes\",\n        \"episodes\": \"Episódios\",\n        \"other\": \"Outro\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Problemas de serviço\",\n        \"hostErrors\": \"Problemas do host\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Executando\",\n        \"stopped\": \"Parado\",\n        \"down\": \"Inativo\",\n        \"unhealthy\": \"Não-saudável\",\n        \"unknown\": \"Desconhecido\",\n        \"servers\": \"Servidores\",\n        \"stacks\": \"Pilhas\",\n        \"containers\": \"Contêineres\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Disponível\",\n        \"used\": \"Utilizado\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Assinaturas\",\n        \"thisMonthlyCost\": \"Este Mês\",\n        \"nextMonthlyCost\": \"Próximo Mês\",\n        \"previousMonthlyCost\": \"Mês Anterior\",\n        \"nextRenewingSubscription\": \"Próximo Pagamento\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Iniciado\",\n        \"STOPPED\": \"Parado\",\n        \"NEW_ARRAY\": \"Nova Array\",\n        \"RECON_DISK\": \"Reconstruindo Disco\",\n        \"DISABLE_DISK\": \"Disco Desativado\",\n        \"SWAP_DSBL\": \"Swap Desabilitado\",\n        \"INVALID_EXPANSION\": \"Expansão Inválida\",\n        \"PARITY_NOT_BIGGEST\": \"Paridade Não É O Maior Disco\",\n        \"TOO_MANY_MISSING_DISKS\": \"Muitos Discos Ausentes\",\n        \"NEW_DISK_TOO_SMALL\": \"Novo Disco É Muito Pequeno\",\n        \"NO_DATA_DISKS\": \"Sem Discos de Dados\",\n        \"notifications\": \"Notificações\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memória Utilizada\",\n        \"memoryAvailable\": \"Memória Disponível\",\n        \"arrayUsed\": \"Array Utilizado\",\n        \"arrayFree\": \"Array Disponível\",\n        \"poolUsed\": \"{{pool}} Utilizado\",\n        \"poolFree\": \"{{pool}} Livre\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Planos\",\n        \"num_success_30\": \"Sucessos\",\n        \"num_failure_30\": \"Falhas\",\n        \"num_success_latest\": \"Executando com sucesso\",\n        \"num_failure_latest\": \"Falhando\",\n        \"bytes_added_30\": \"Bytes Adicionados\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Músicas\",\n        \"time\": \"Tempo\",\n        \"artists\": \"Artistas\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/ro/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"mo\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Lipsește Tipul de Widget: {{type}}\",\n        \"api_error\": \"Eroare API\",\n        \"information\": \"Informație\",\n        \"status\": \"Stare\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Eroare Raw\",\n        \"response_data\": \"Date de raspuns\"\n    },\n    \"weather\": {\n        \"current\": \"Locația Curentă\",\n        \"allow\": \"Click pentru a permite\",\n        \"updating\": \"Se actualizează\",\n        \"wait\": \"Va rugăm așteptați\"\n    },\n    \"search\": {\n        \"placeholder\": \"Caută…\"\n    },\n    \"resources\": {\n        \"cpu\": \"Procesor\",\n        \"mem\": \"MEM\",\n        \"total\": \"Total\",\n        \"free\": \"Disponibili\",\n        \"used\": \"Utilizați\",\n        \"load\": \"Sarcină\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Maxim\",\n        \"uptime\": \"UP\"\n    },\n    \"unifi\": {\n        \"users\": \"Utilizatori\",\n        \"uptime\": \"Uptime\",\n        \"days\": \"Zile\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Dispozitive\",\n        \"lan_devices\": \"Dispozitive LAN\",\n        \"wlan_devices\": \"Dispozitive WLAN\",\n        \"lan_users\": \"Utilizatori LAN\",\n        \"wlan_users\": \"Utilizatori WLAN\",\n        \"up\": \"UP\",\n        \"down\": \"Oprit\",\n        \"wait\": \"Please wait\",\n        \"empty_data\": \"Starea subsistemului este necunoscut\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Rulează\",\n        \"offline\": \"Offline\",\n        \"error\": \"Eroare\",\n        \"unknown\": \"Necunoscut\",\n        \"healthy\": \"Sănătos\",\n        \"starting\": \"Începe\",\n        \"unhealthy\": \"Nesănătos\",\n        \"not_found\": \"Negăsit\",\n        \"exited\": \"Ieşit\",\n        \"partial\": \"Parțial\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"Ping\",\n        \"down\": \"Jos\",\n        \"up\": \"Sus\",\n        \"not_available\": \"Indisponibil\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"Stare HTTP\",\n        \"error\": \"Error\",\n        \"response\": \"Răspuns\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"emby\": {\n        \"playing\": \"Activ\",\n        \"transcoding\": \"Transcodare\",\n        \"bitrate\": \"Rata de biți\",\n        \"no_active\": \"Niciun stream activ\",\n        \"movies\": \"Filme\",\n        \"series\": \"Serie\",\n        \"episodes\": \"Episoade\",\n        \"songs\": \"Melodii\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Online\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Producție\",\n        \"battery_soc\": \"Baterie\",\n        \"grid_power\": \"Grilă\",\n        \"home_power\": \"Consum\",\n        \"charge_power\": \"Încărcător\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Descarcă\",\n        \"upload\": \"Încarcă\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Abonări\",\n        \"unread\": \"Necitit\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Neconfigurat\",\n        \"connectionStatusConnecting\": \"Connecting\",\n        \"connectionStatusAuthenticating\": \"Authenticating\",\n        \"connectionStatusPendingDisconnect\": \"Pending Disconnect\",\n        \"connectionStatusDisconnecting\": \"Disconnecting\",\n        \"connectionStatusDisconnected\": \"Deconectat\",\n        \"connectionStatusConnected\": \"Conectat\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"Max. Down\",\n        \"maxUp\": \"Max. Up\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"Primit\",\n        \"sent\": \"Trimis\",\n        \"externalIPAddress\": \"Ext. IP\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreamuri\",\n        \"requests\": \"Solicitări curente\",\n        \"requests_failed\": \"Solicitări eșuate\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Total observat\",\n        \"diffsDetected\": \"Diffuri detectate\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Shows\",\n        \"recordings\": \"Înregistrări\",\n        \"scheduled\": \"Programate\",\n        \"passes\": \"Passes\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"Check Plex Connection\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Connected APs\",\n        \"activeUser\": \"Dispozitive active\",\n        \"alerts\": \"Alerte\",\n        \"connectedGateways\": \"Connected gateways\",\n        \"connectedSwitches\": \"Connected switches\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Rată\",\n        \"remaining\": \"Rămas\",\n        \"downloaded\": \"Descărcat\"\n    },\n    \"plex\": {\n        \"streams\": \"Fluxuri active\",\n        \"albums\": \"Albume\",\n        \"movies\": \"Movies\",\n        \"tv\": \"Seriale\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"Coadă\",\n        \"timeleft\": \"Timp rămas\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Activ\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU Usage\",\n        \"memUsage\": \"MEM Usage\",\n        \"systemTempC\": \"Temperatură Sistem\",\n        \"poolUsage\": \"Pool Usage\",\n        \"volumeUsage\": \"Volume Usage\",\n        \"invalid\": \"Invalid\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Dorite\",\n        \"queued\": \"În coadă\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"Lipsește\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"Artiști\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"Cărți\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Episoade lipsă\",\n        \"missingMovies\": \"Filme lipsă\"\n    },\n    \"ombi\": {\n        \"pending\": \"În așteptare\",\n        \"approved\": \"Aprobate\",\n        \"available\": \"Disponibile\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"Dispozitive Noi\",\n        \"down_alerts\": \"Down Alerts\"\n    },\n    \"pihole\": {\n        \"queries\": \"Cereri\",\n        \"blocked\": \"Blocate\",\n        \"blocked_percent\": \"Blocked %\",\n        \"gravity\": \"Gravitație\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"Filtrate\",\n        \"latency\": \"Latentă\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Oprit\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Non-Downloaded\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Downloaded & Read\",\n        \"downloadedunread\": \"Downloaded & Unread\",\n        \"nondownloadedread\": \"Non-Downloaded & Read\",\n        \"nondownloadedunread\": \"Non-Downloaded & Unread\"\n    },\n    \"tailscale\": {\n        \"address\": \"Adresă\",\n        \"expires\": \"Expiră\",\n        \"never\": \"Niciodată\",\n        \"last_seen\": \"Last Seen\",\n        \"now\": \"Acum\",\n        \"years\": \"{{number}}y\",\n        \"weeks\": \"{{number}}w\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Ago\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"Success\",\n        \"totalServerFailure\": \"Failures\",\n        \"totalNxDomain\": \"NX Domains\",\n        \"totalRefused\": \"Refuzat\",\n        \"totalAuthoritative\": \"Authoritative\",\n        \"totalRecursive\": \"Recursiv\",\n        \"totalCached\": \"Cached\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"Dropped\",\n        \"totalClients\": \"Clienți\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Processed\",\n        \"errored\": \"Errored\",\n        \"saved\": \"Salvat\"\n    },\n    \"traefik\": {\n        \"routers\": \"Routere\",\n        \"services\": \"Servicii\",\n        \"middleware\": \"Middleware\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Please Wait\"\n    },\n    \"npm\": {\n        \"enabled\": \"Activat\",\n        \"disabled\": \"Dezactivat\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Configurați una sau mai multe criptomonede pe care să le urmăriți\",\n        \"1hour\": \"1 Oră\",\n        \"1day\": \"1 Zi\",\n        \"7days\": \"7 Zile\",\n        \"30days\": \"30 Zile\"\n    },\n    \"gotify\": {\n        \"apps\": \"Aplicații\",\n        \"clients\": \"Clients\",\n        \"messages\": \"Mesaje\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indexatori\",\n        \"numberOfGrabs\": \"Descărcate\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"Descărcări eșuate\",\n        \"numberOfFailQueries\": \"Cereri eșuate\"\n    },\n    \"jackett\": {\n        \"configured\": \"Configurat\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sesiuni\",\n        \"numConnections\": \"Conexiuni\",\n        \"dataRelayed\": \"Retransmise\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"Postări\",\n        \"domain_count\": \"Domenii\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Jucători\",\n        \"version\": \"Versiune\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Read\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"Autentificări (24h)\",\n        \"failedLoginsLast24H\": \"Conectări eșuate (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"Container\",\n        \"vms\": \"Masini Virtuale\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temperatură\",\n        \"warn\": \"Warn\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Crit\",\n        \"read\": \"Read\",\n        \"write\": \"Write\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Marcaj\",\n        \"service\": \"Serviciu\",\n        \"search\": \"Caută\",\n        \"custom\": \"Personalizat\",\n        \"visit\": \"Vizită\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Sugestie\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Însorit\",\n        \"0-night\": \"Fără nori\",\n        \"1-day\": \"Aproape însorit\",\n        \"1-night\": \"Aproape fără nori\",\n        \"2-day\": \"Parţial Înnorat\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"Înnorat\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"Ceaţă\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"Light Drizzle\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"Drizzle\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"Heavy Drizzle\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"Light Freezing Drizzle\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"Freezing Drizzle\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"Ploaie Ușoară\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"Ploaie\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Heavy Rain\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Freezing Rain\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Light Snow\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Snow\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Heavy Snow\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Snow Grains\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Light Showers\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Showers\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Heavy Showers\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Snow Showers\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"Thunderstorm\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Thunderstorm With Hail\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Sistem\",\n        \"updates\": \"Actualizări\",\n        \"update_available\": \"Actualizare Disponibilă\",\n        \"up_to_date\": \"Actualizat\",\n        \"child_bridges\": \"Child Bridges\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Nou\",\n        \"up\": \"Up\",\n        \"grace\": \"In Grace Period\",\n        \"down\": \"Down\",\n        \"paused\": \"Pauză\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Ultimul Ping\",\n        \"never\": \"No pings yet\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Scanned\",\n        \"containers_updated\": \"Actualizat\",\n        \"containers_failed\": \"Eșuat\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Respinse\",\n        \"filters\": \"Filtre\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Videos\",\n        \"channels\": \"Canale\",\n        \"playlists\": \"Playlists\"\n    },\n    \"truenas\": {\n        \"load\": \"System Load\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Viteză\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Public IP\",\n        \"region\": \"Regiune\",\n        \"country\": \"Țară\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tunere\",\n        \"channelNumber\": \"Canal\",\n        \"channelNetwork\": \"Rețea\",\n        \"signalStrength\": \"Putere\",\n        \"signalQuality\": \"Calitate\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Client\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Inbox\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Încărcare Baterie\",\n        \"ups_load\": \"UPS Load\",\n        \"ups_status\": \"UPS Status\",\n        \"online\": \"Online\",\n        \"on_battery\": \"On Battery\",\n        \"low_battery\": \"Baterie descărcată\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"No Device Data Received\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"CPU Load\",\n        \"memoryUsed\": \"Memorie Utilizată\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Leases\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"All Streams\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG Channels\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Astăzi\",\n        \"absolutePower\": \"Putere\",\n        \"relativePower\": \"Putere %\",\n        \"limit\": \"Limită\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Memorie Activă\",\n        \"wanUpload\": \"WAN Upload\",\n        \"wanDownload\": \"WAN Download\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Starea Imprimantei\",\n        \"print_status\": \"Print Status\",\n        \"print_progress\": \"Progres\",\n        \"layers\": \"Straturi\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Tool temp\",\n        \"temp_bed\": \"Bed temp\",\n        \"job_completion\": \"Completion\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Origin IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Load Avg\",\n        \"memory\": \"Mem Usage\",\n        \"wanStatus\": \"WAN Status\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Utilizare Disc\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datastore\",\n        \"failed_tasks_24h\": \"Failed Tasks 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memorie\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Fotografii\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Storage\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Incident\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Arhive\",\n        \"chapters\": \"Chapters\",\n        \"categories\": \"Categorii\"\n    },\n    \"komga\": {\n        \"libraries\": \"Biblioteci\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Issues\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"Oameni\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Timp\"\n    },\n    \"firefly\": {\n        \"networth\": \"Net Worth\",\n        \"budget\": \"Buget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Dashboards\",\n        \"datasources\": \"Data Sources\",\n        \"totalalerts\": \"Total Alerts\",\n        \"alertstriggered\": \"Alerts Triggered\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Cpu Load\",\n        \"memoryusage\": \"Memorie Utilizată\",\n        \"freespace\": \"Spațiu Liber\",\n        \"activeusers\": \"Utilizatori Activi\",\n        \"numfiles\": \"Fișiere\",\n        \"numshares\": \"Articole Partajate\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Mărime\",\n        \"lastrun\": \"Ultima Rulare\",\n        \"nextrun\": \"Următoarea Rulare\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Muncitori activi\",\n        \"total_workers\": \"Muncitori totali\",\n        \"records_total\": \"Lungimea cozii\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servere\",\n        \"nodes\": \"Noduri\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Targets Up\",\n        \"targets_down\": \"Targets Down\",\n        \"targets_total\": \"Total Targets\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"Un an\",\n        \"gross_percent_max\": \"Tot timpul\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasturi\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Durată\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"People Home\",\n        \"lights_on\": \"Lights On\",\n        \"switches_on\": \"Switches On\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Monitoring\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Autori\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Rezultat\",\n        \"status\": \"Status\",\n        \"buildId\": \"Build ID\",\n        \"succeeded\": \"Succeeded\",\n        \"notStarted\": \"Not Started\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Anulat\",\n        \"inProgress\": \"În Progres\",\n        \"totalPrs\": \"Total PRs\",\n        \"myPrs\": \"My PRs\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Nume\",\n        \"map\": \"Hartă\",\n        \"currentPlayers\": \"Current players\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Max players\",\n        \"bots\": \"Boți\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Erori\",\n        \"noRecent\": \"Out of Date\",\n        \"totalUsed\": \"Used Storage\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Recipes\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Etichete\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Downloading\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"CPU Load Avg (5m)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Transmis\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Last Downtime\",\n        \"downDuration\": \"Downtime Duration\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Not Yet Checked\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Seems Down\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"În cinematografe\",\n        \"physicalRelease\": \"Physical release\",\n        \"digitalRelease\": \"Digital release\",\n        \"noEventsToday\": \"No events for today!\",\n        \"noEventsFound\": \"No events found\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platforme\",\n        \"totalRoms\": \"Jocuri\",\n        \"saves\": \"Salvări\",\n        \"states\": \"States\",\n        \"screenshots\": \"Screenshots\",\n        \"totalfilesize\": \"Mărime Totală\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Cutii poştale\",\n        \"mails\": \"Mails\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Warnings\",\n        \"criticals\": \"Critice\"\n    },\n    \"plantit\": {\n        \"events\": \"Evenimente\",\n        \"plants\": \"Plante\",\n        \"photos\": \"Photos\",\n        \"species\": \"Specii\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Notificări\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Pull Requests\",\n        \"repositories\": \"Repozitorii\"\n    },\n    \"stash\": {\n        \"scenes\": \"Scene\",\n        \"scenesPlayed\": \"Scenes Played\",\n        \"playCount\": \"Total Plays\",\n        \"playDuration\": \"Time Watched\",\n        \"sceneSize\": \"Scenes Size\",\n        \"sceneDuration\": \"Scenes Duration\",\n        \"images\": \"Imagini\",\n        \"imageSize\": \"Images Size\",\n        \"galleries\": \"Galerii\",\n        \"performers\": \"Performers\",\n        \"studios\": \"Studios\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O Count\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Cuvinte cheie\"\n    },\n    \"homebox\": {\n        \"items\": \"Items\",\n        \"totalWithWarranty\": \"Cu Garanție\",\n        \"locations\": \"Locaţii\",\n        \"labels\": \"Etichete\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Total Value\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Bans\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxied\",\n        \"auth\": \"With Auth\",\n        \"outdated\": \"Învechit\",\n        \"banned\": \"Banned\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Acțiuni\",\n        \"loading\": \"Loading\",\n        \"open\": \"Open - US Market\",\n        \"closed\": \"Closed - US Market\",\n        \"invalidConfiguration\": \"Invalid Configuration\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Camere\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Links\",\n        \"collections\": \"Colecții\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Not classified\",\n        \"information\": \"Information\",\n        \"warning\": \"Atenție\",\n        \"average\": \"Medie\",\n        \"high\": \"Înalt\",\n        \"disaster\": \"Dezastru\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vehicul\",\n        \"vehicles\": \"Vehicule\",\n        \"serviceRecords\": \"Service Records\",\n        \"reminders\": \"Reminders\",\n        \"nextReminder\": \"Next Reminder\",\n        \"none\": \"Niciunul\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Active Projects\",\n        \"tasks7d\": \"Tasks Due This Week\",\n        \"tasksOverdue\": \"Overdue Tasks\",\n        \"tasksInProgress\": \"Tasks In Progress\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Sistem\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Disk\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Aplicaţii\",\n        \"synced\": \"Synced\",\n        \"outOfSync\": \"Out Of Sync\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Degraded\",\n        \"progressing\": \"Progressing\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Suspendat\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Grupuri\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Proiecte\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Marcaje\",\n        \"favorites\": \"Favorite\",\n        \"archived\": \"Arhivat\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Liste\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Altele\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/ru/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"мес\",\n        \"days\": \"дней\",\n        \"hours\": \"час\",\n        \"minutes\": \"мин\",\n        \"seconds\": \"сек\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Отсутствует тип виджета: {{type}}\",\n        \"api_error\": \"Ошибка API\",\n        \"information\": \"Информация\",\n        \"status\": \"Статус\",\n        \"url\": \"Ссылка\",\n        \"raw_error\": \"Ошибка сырых данных\",\n        \"response_data\": \"Данные ответа\"\n    },\n    \"weather\": {\n        \"current\": \"Текущее местоположение\",\n        \"allow\": \"Нажмите, чтобы разрешить\",\n        \"updating\": \"Обновление\",\n        \"wait\": \"Пожалуйста, подождите\"\n    },\n    \"search\": {\n        \"placeholder\": \"Поиск…\"\n    },\n    \"resources\": {\n        \"cpu\": \"ЦП\",\n        \"mem\": \"ОЗУ\",\n        \"total\": \"Всего\",\n        \"free\": \"Свободно\",\n        \"used\": \"Использовано\",\n        \"load\": \"Загрузка\",\n        \"temp\": \"Температура\",\n        \"max\": \"Максимально\",\n        \"uptime\": \"Онлайн\"\n    },\n    \"unifi\": {\n        \"users\": \"Пользователи\",\n        \"uptime\": \"Время работы\",\n        \"days\": \"Дней\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Устройства\",\n        \"lan_devices\": \"LAN устройства\",\n        \"wlan_devices\": \"WLAN устройства\",\n        \"lan_users\": \"LAN пользователи\",\n        \"wlan_users\": \"WLAN пользователи\",\n        \"up\": \"В сети\",\n        \"down\": \"Скачивание\",\n        \"wait\": \"Пожалуйста, подождите\",\n        \"empty_data\": \"Статус подсистемы неизвестен\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"Память\",\n        \"cpu\": \"ЦП\",\n        \"running\": \"Запущено\",\n        \"offline\": \"Не в сети\",\n        \"error\": \"Ошибка\",\n        \"unknown\": \"Неизвестен\",\n        \"healthy\": \"Здоровый\",\n        \"starting\": \"Запускается\",\n        \"unhealthy\": \"Нездоровый\",\n        \"not_found\": \"Не найдено\",\n        \"exited\": \"Вышел\",\n        \"partial\": \"Частичный\"\n    },\n    \"ping\": {\n        \"error\": \"Ошибка\",\n        \"ping\": \"Пинг\",\n        \"down\": \"Офлайн\",\n        \"up\": \"Онлайн\",\n        \"not_available\": \"Недоступен\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP статус\",\n        \"error\": \"Ошибка\",\n        \"response\": \"Ответ\",\n        \"down\": \"Не в сети\",\n        \"up\": \"В сети\",\n        \"not_available\": \"Недоступен\"\n    },\n    \"emby\": {\n        \"playing\": \"Воспроизводится\",\n        \"transcoding\": \"Перекодирование\",\n        \"bitrate\": \"Битрейт\",\n        \"no_active\": \"Нет активных потоков\",\n        \"movies\": \"Фильмы\",\n        \"series\": \"Серии\",\n        \"episodes\": \"Эпизоды\",\n        \"songs\": \"Песни\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Воспроизводится\",\n        \"transcoding\": \"Перекодирование\",\n        \"bitrate\": \"Битрейт\",\n        \"no_active\": \"Нет активных потоков\",\n        \"movies\": \"Фильмы\",\n        \"series\": \"Сериалы\",\n        \"episodes\": \"Эпизоды\",\n        \"songs\": \"Песни\"\n    },\n    \"esphome\": {\n        \"offline\": \"Не в сети\",\n        \"offline_alt\": \"Не в сети\",\n        \"online\": \"В сети\",\n        \"total\": \"Всего\",\n        \"unknown\": \"Неизвестно\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Прод\",\n        \"battery_soc\": \"Питание\",\n        \"grid_power\": \"Сетка\",\n        \"home_power\": \"Потребление\",\n        \"charge_power\": \"Зарядка\",\n        \"kilowatt\": \"кВт\"\n    },\n    \"flood\": {\n        \"download\": \"Скачивание\",\n        \"upload\": \"Загрузка\",\n        \"leech\": \"Лич\",\n        \"seed\": \"Сид\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Подписки\",\n        \"unread\": \"Не прочитано\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Статус\",\n        \"connectionStatusUnconfigured\": \"Не настроено\",\n        \"connectionStatusConnecting\": \"Подключение\",\n        \"connectionStatusAuthenticating\": \"Авторизация\",\n        \"connectionStatusPendingDisconnect\": \"Ожидает отключения\",\n        \"connectionStatusDisconnecting\": \"Отключение\",\n        \"connectionStatusDisconnected\": \"Отключено\",\n        \"connectionStatusConnected\": \"Подключено\",\n        \"uptime\": \"Время работы\",\n        \"maxDown\": \"Макс. Загрузка\",\n        \"maxUp\": \"Макс. Отдача\",\n        \"down\": \"Не в сети\",\n        \"up\": \"В сети\",\n        \"received\": \"Получено\",\n        \"sent\": \"Отправлено\",\n        \"externalIPAddress\": \"Внеш. IP\",\n        \"externalIPv6Address\": \"Внешний IPv6\",\n        \"externalIPv6Prefix\": \"Внешний IPv6 префикс\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Входящие каналы\",\n        \"requests\": \"Текущие запросы\",\n        \"requests_failed\": \"Неудачные запросы\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Всего наблюдений\",\n        \"diffsDetected\": \"Обнаружены различия\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Показов\",\n        \"recordings\": \"Записей\",\n        \"scheduled\": \"Запланировано\",\n        \"passes\": \"Пропущено\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Играет\",\n        \"transcoding\": \"Транскодируется\",\n        \"bitrate\": \"Битрейт\",\n        \"no_active\": \"Нет активных стримов\",\n        \"plex_connection_error\": \"Проверка соединения Plex\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"Нет активных потоков\",\n        \"streams\": \"Потоки\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Прямое воспроизведение\",\n        \"bitrate\": \"Битрейт\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Подключенные точки доступа\",\n        \"activeUser\": \"Активные устройства\",\n        \"alerts\": \"Предупреждения\",\n        \"connectedGateways\": \"Подключенные шлюзы\",\n        \"connectedSwitches\": \"Подключенные коммутаторы\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Скорость\",\n        \"remaining\": \"Осталось\",\n        \"downloaded\": \"Загружено\"\n    },\n    \"plex\": {\n        \"streams\": \"Активные потоки\",\n        \"albums\": \"Альбомы\",\n        \"movies\": \"Фильмы\",\n        \"tv\": \"Сериалы\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"\",\n        \"queue\": \"Очередь\",\n        \"timeleft\": \"Осталось\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Активно\",\n        \"upload\": \"Загрузка\",\n        \"download\": \"Скачивание\"\n    },\n    \"transmission\": {\n        \"download\": \"Скачивание\",\n        \"upload\": \"Загрузка\",\n        \"leech\": \"Лич\",\n        \"seed\": \"Сид\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Скачивание\",\n        \"upload\": \"Загрузка\",\n        \"leech\": \"Лич\",\n        \"seed\": \"Сид\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"Использование ЦП\",\n        \"memUsage\": \"Использование ОЗУ\",\n        \"systemTempC\": \"Температура системы\",\n        \"poolUsage\": \"Использование пула\",\n        \"volumeUsage\": \"Использование тома\",\n        \"invalid\": \"Некорректный\"\n    },\n    \"deluge\": {\n        \"download\": \"Скачивание\",\n        \"upload\": \"Загрузка\",\n        \"leech\": \"Лич\",\n        \"seed\": \"Сид\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Хит байты кэша\",\n        \"cachemissbytes\": \"Мисс байты кэша\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Скачивание\",\n        \"upload\": \"Загрузка\",\n        \"leech\": \"Лич\",\n        \"seed\": \"Сид\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Розыск\",\n        \"queued\": \"В очереди\",\n        \"series\": \"Сериалы\",\n        \"queue\": \"Очередь\",\n        \"unknown\": \"Неизвестно\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Требуется\",\n        \"missing\": \"Отсутствует\",\n        \"queued\": \"В очереди\",\n        \"movies\": \"Фильмы\",\n        \"queue\": \"Очередь\",\n        \"unknown\": \"Неизвестно\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Требуется\",\n        \"queued\": \"В очереди\",\n        \"artists\": \"Исполнители\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Требуется\",\n        \"queued\": \"В очереди\",\n        \"books\": \"Книги\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Отсутствуют эпизоды\",\n        \"missingMovies\": \"Отсутствуют фильмы\"\n    },\n    \"ombi\": {\n        \"pending\": \"В обработке\",\n        \"approved\": \"Одобрено\",\n        \"available\": \"Доступно\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Одобрено\",\n        \"available\": \"Доступно\",\n        \"completed\": \"Завершено\",\n        \"processing\": \"Обработка\",\n        \"issues\": \"Открытые задачи\"\n    },\n    \"netalertx\": {\n        \"total\": \"Всего\",\n        \"connected\": \"Подключено\",\n        \"new_devices\": \"Новые устройства\",\n        \"down_alerts\": \"Оповещение о недоступности\"\n    },\n    \"pihole\": {\n        \"queries\": \"Запросы\",\n        \"blocked\": \"Заблокировано\",\n        \"blocked_percent\": \"Заблокировано %\",\n        \"gravity\": \"Плотность\"\n    },\n    \"adguard\": {\n        \"queries\": \"Запросы\",\n        \"blocked\": \"Заблокировано\",\n        \"filtered\": \"Отфильтровано\",\n        \"latency\": \"Задержка\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Загрузка\",\n        \"download\": \"Скачивание\",\n        \"ping\": \"Пинг\"\n    },\n    \"portainer\": {\n        \"running\": \"Запущено\",\n        \"stopped\": \"Остановлено\",\n        \"total\": \"Всего\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Загружено\",\n        \"nondownload\": \"Незагруженные\",\n        \"read\": \"Прочитано\",\n        \"unread\": \"Не прочитано\",\n        \"downloadedread\": \"Загруженные и прочитанные\",\n        \"downloadedunread\": \"Загруженные и непрочитанные\",\n        \"nondownloadedread\": \"Незагруженные и прочитанные\",\n        \"nondownloadedunread\": \"Незагруженные и непрочитанные\"\n    },\n    \"tailscale\": {\n        \"address\": \"Адрес\",\n        \"expires\": \"Истекает\",\n        \"never\": \"Никогда\",\n        \"last_seen\": \"Последнее посещение\",\n        \"now\": \"Только что\",\n        \"years\": \"{{number}}г\",\n        \"weeks\": \"{{number}}нед\",\n        \"days\": \"{{number}}д\",\n        \"hours\": \"{{number}}ч\",\n        \"minutes\": \"{{number}}м\",\n        \"seconds\": \"{{number}}с\",\n        \"ago\": \"{{value}} назад\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Запросы\",\n        \"totalNoError\": \"Успешные\",\n        \"totalServerFailure\": \"Ошибки\",\n        \"totalNxDomain\": \"NX домены\",\n        \"totalRefused\": \"Отказано\",\n        \"totalAuthoritative\": \"Авторитетные\",\n        \"totalRecursive\": \"Рекурсивные\",\n        \"totalCached\": \"Кэш\",\n        \"totalBlocked\": \"Заблокировано\",\n        \"totalDropped\": \"Отброшенные\",\n        \"totalClients\": \"Клиенты\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Очередь\",\n        \"processed\": \"Обработано\",\n        \"errored\": \"Ошибок\",\n        \"saved\": \"Сохранено\"\n    },\n    \"traefik\": {\n        \"routers\": \"Роутеры\",\n        \"services\": \"Сервисы\",\n        \"middleware\": \"Связующее ПО\"\n    },\n    \"trilium\": {\n        \"version\": \"Версия\",\n        \"notesCount\": \"Заметки\",\n        \"dbSize\": \"Размер БД\",\n        \"unknown\": \"Неизвестно\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"Нет активных стримов\",\n        \"please_wait\": \"Пожалуйста, подождите\"\n    },\n    \"npm\": {\n        \"enabled\": \"Включено\",\n        \"disabled\": \"Выключено\",\n        \"total\": \"Всего\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Настройте одну или несколько криптовалют для отслеживания\",\n        \"1hour\": \"1 час\",\n        \"1day\": \"1 день\",\n        \"7days\": \"7 дней\",\n        \"30days\": \"30 дней\"\n    },\n    \"gotify\": {\n        \"apps\": \"Приложения\",\n        \"clients\": \"Клиенты\",\n        \"messages\": \"Сообщения\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Индексаторы\",\n        \"numberOfGrabs\": \"Захваты\",\n        \"numberOfQueries\": \"Запросы\",\n        \"numberOfFailGrabs\": \"Неудачные захваты\",\n        \"numberOfFailQueries\": \"Неудачные запросы\"\n    },\n    \"jackett\": {\n        \"configured\": \"Настроено\",\n        \"errored\": \"Ошибок\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Сессии\",\n        \"numConnections\": \"Соединения\",\n        \"dataRelayed\": \"Ретранслировано\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Пользователи\",\n        \"status_count\": \"Посты\",\n        \"domain_count\": \"Домены\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Требуется\",\n        \"queued\": \"В очереди\",\n        \"series\": \"Серии\"\n    },\n    \"minecraft\": {\n        \"players\": \"Игроки\",\n        \"version\": \"Версия\",\n        \"status\": \"Статус\",\n        \"up\": \"В сети\",\n        \"down\": \"Не в сети\"\n    },\n    \"miniflux\": {\n        \"read\": \"Прочитано\",\n        \"unread\": \"Не прочитано\"\n    },\n    \"authentik\": {\n        \"users\": \"Пользователи\",\n        \"loginsLast24H\": \"Входы (24ч)\",\n        \"failedLoginsLast24H\": \"Неудачные входы (24ч)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"Память\",\n        \"cpu\": \"ЦП\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"Виртуальные машины\"\n    },\n    \"glances\": {\n        \"cpu\": \"ЦП\",\n        \"load\": \"Загрузка\",\n        \"wait\": \"Пожалуйста, подождите\",\n        \"temp\": \"Температура\",\n        \"_temp\": \"Температура\",\n        \"warn\": \"Предупреждение\",\n        \"uptime\": \"Время работы\",\n        \"total\": \"Всего\",\n        \"free\": \"Свободно\",\n        \"used\": \"Использовано\",\n        \"days\": \"д\",\n        \"hours\": \"ч\",\n        \"crit\": \"Крит\",\n        \"read\": \"Чтение\",\n        \"write\": \"Запись\",\n        \"gpu\": \"ГП\",\n        \"mem\": \"ОЗУ\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Закладка\",\n        \"service\": \"Сервис\",\n        \"search\": \"Поиск\",\n        \"custom\": \"Пользовательский\",\n        \"visit\": \"Посетите\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Предложение\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Солнечно\",\n        \"0-night\": \"Ясно\",\n        \"1-day\": \"Переменно солнечно\",\n        \"1-night\": \"Малооблачно\",\n        \"2-day\": \"Частичная облачность\",\n        \"2-night\": \"Частичная облачность\",\n        \"3-day\": \"Облачно\",\n        \"3-night\": \"Облачно\",\n        \"45-day\": \"Туманно\",\n        \"45-night\": \"Туманно\",\n        \"48-day\": \"Туманно\",\n        \"48-night\": \"Туманно\",\n        \"51-day\": \"Легкая морось\",\n        \"51-night\": \"Легкая изморось\",\n        \"53-day\": \"Морось\",\n        \"53-night\": \"Изморось\",\n        \"55-day\": \"Сильная морось\",\n        \"55-night\": \"Сильная изморось\",\n        \"56-day\": \"Легкая морозная морось\",\n        \"56-night\": \"Легкая морозная изморось\",\n        \"57-day\": \"Морозная морось\",\n        \"57-night\": \"Морозная изморось\",\n        \"61-day\": \"Слабый дождь\",\n        \"61-night\": \"Слабый дождь\",\n        \"63-day\": \"Дождь\",\n        \"63-night\": \"Дождь\",\n        \"65-day\": \"Сильный дождь\",\n        \"65-night\": \"Сильный дождь\",\n        \"66-day\": \"Град\",\n        \"66-night\": \"Ледяной дождь\",\n        \"67-day\": \"Ледяной дождь\",\n        \"67-night\": \"Ледяной дождь\",\n        \"71-day\": \"Легкий снег\",\n        \"71-night\": \"Легкий снег\",\n        \"73-day\": \"Снег\",\n        \"73-night\": \"Снег\",\n        \"75-day\": \"Сильный снег\",\n        \"75-night\": \"Сильный снег\",\n        \"77-day\": \"Снежная крупа\",\n        \"77-night\": \"Снежная крупа\",\n        \"80-day\": \"Лёгкие ливни\",\n        \"80-night\": \"Легкие ливни\",\n        \"81-day\": \"Ливни\",\n        \"81-night\": \"Ливни\",\n        \"82-day\": \"Сильные ливни\",\n        \"82-night\": \"Сильные ливни\",\n        \"85-day\": \"Снегопады\",\n        \"85-night\": \"Снегопад\",\n        \"86-day\": \"Снегопад\",\n        \"86-night\": \"Снегопад\",\n        \"95-day\": \"Гроза\",\n        \"95-night\": \"Гроза\",\n        \"96-day\": \"Гроза с градом\",\n        \"96-night\": \"Гроза с градом\",\n        \"99-day\": \"Гроза с градом\",\n        \"99-night\": \"Гроза с градом\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Система\",\n        \"updates\": \"Обновления\",\n        \"update_available\": \"Доступно обновление\",\n        \"up_to_date\": \"Последняя версия\",\n        \"child_bridges\": \"Дочерние мосты\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"В сети\",\n        \"pending\": \"Ожидают\",\n        \"down\": \"Не в сети\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Новый\",\n        \"up\": \"В сети\",\n        \"grace\": \"Пробный период\",\n        \"down\": \"Не в сети\",\n        \"paused\": \"Приостановлено\",\n        \"status\": \"Статус\",\n        \"last_ping\": \"Последний пинг\",\n        \"never\": \"Нет пингов\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Просканировано\",\n        \"containers_updated\": \"Обновленно\",\n        \"containers_failed\": \"Провалено\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Одобрено\",\n        \"rejectedPushes\": \"Отклонено\",\n        \"filters\": \"Фильтры\",\n        \"indexers\": \"Индексаторы\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Очередь\",\n        \"videos\": \"Видео\",\n        \"channels\": \"Каналы\",\n        \"playlists\": \"Плейлисты\"\n    },\n    \"truenas\": {\n        \"load\": \"Нагрузка системы\",\n        \"uptime\": \"Время работы\",\n        \"alerts\": \"Оповещения\"\n    },\n    \"pyload\": {\n        \"speed\": \"Скорость\",\n        \"active\": \"Активно\",\n        \"queue\": \"Очередь\",\n        \"total\": \"Всего\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Публичный IP-адрес\",\n        \"region\": \"Регион\",\n        \"country\": \"Страна\",\n        \"port_forwarded\": \"Порт переадресован\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Каналы\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Тюнеры\",\n        \"channelNumber\": \"Канал\",\n        \"channelNetwork\": \"Сеть\",\n        \"signalStrength\": \"Сила\",\n        \"signalQuality\": \"Качество\",\n        \"symbolQuality\": \"Качество\",\n        \"networkRate\": \"Битрейт\",\n        \"clientIP\": \"Клиент\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Успешно\",\n        \"failed\": \"Провалено\",\n        \"unknown\": \"Неизвестно\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Входящие\",\n        \"total\": \"Всего\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Заряд батареи\",\n        \"ups_load\": \"Нагрузка на UPS\",\n        \"ups_status\": \"Статус UPS\",\n        \"online\": \"В сети\",\n        \"on_battery\": \"От батареи\",\n        \"low_battery\": \"Низкий заряд\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Пожалуйста, подождите\",\n        \"no_devices\": \"Данные устройства не получены\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"Загрузка ЦПУ\",\n        \"memoryUsed\": \"Использовано ОЗУ\",\n        \"uptime\": \"Время работы\",\n        \"numberOfLeases\": \"Аренды\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Все потоки\",\n        \"streams_active\": \"Активные стримы\",\n        \"streams_xepg\": \"Каналы XEPG\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Сегодня\",\n        \"absolutePower\": \"Питание\",\n        \"relativePower\": \"Питание %\",\n        \"limit\": \"Лимит\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"ЦП\",\n        \"memory\": \"Активно ОЗУ\",\n        \"wanUpload\": \"WAN Загрузка\",\n        \"wanDownload\": \"WAN скачивание\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Состояние принтера\",\n        \"print_status\": \"Статус принтера\",\n        \"print_progress\": \"Прогресс\",\n        \"layers\": \"Слои\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Статус\",\n        \"temp_tool\": \"Температура головки\",\n        \"temp_bed\": \"Температура стола\",\n        \"job_completion\": \"Прогресс\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Исходный IP\",\n        \"status\": \"Статус\"\n    },\n    \"pfsense\": {\n        \"load\": \"Средняя нагрузка\",\n        \"memory\": \"Использование ОЗУ\",\n        \"wanStatus\": \"Статус WAN\",\n        \"up\": \"В сети\",\n        \"down\": \"Не в сети\",\n        \"temp\": \"Температура\",\n        \"disk\": \"Использование диска\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Хранилище данных\",\n        \"failed_tasks_24h\": \"Неудачные задачи 24 часа\",\n        \"cpu_usage\": \"ЦП\",\n        \"memory_usage\": \"ОЗУ\"\n    },\n    \"immich\": {\n        \"users\": \"Пользователи\",\n        \"photos\": \"Фото\",\n        \"videos\": \"Видео\",\n        \"storage\": \"Хранилище\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Активные сайты\",\n        \"down\": \"Неактивные сайты\",\n        \"uptime\": \"Время работы\",\n        \"incident\": \"Происшествия\",\n        \"m\": \"м\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Серии\",\n        \"archives\": \"Архивы\",\n        \"chapters\": \"Главы\",\n        \"categories\": \"Категории\"\n    },\n    \"komga\": {\n        \"libraries\": \"Библиотеки\",\n        \"series\": \"Серии\",\n        \"books\": \"Книги\"\n    },\n    \"diskstation\": {\n        \"days\": \"Дней\",\n        \"uptime\": \"Время работы\",\n        \"volumeAvailable\": \"Доступно\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Серии\",\n        \"issues\": \"Вопросы\",\n        \"wanted\": \"Требуется\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Альбомы\",\n        \"photos\": \"Фото\",\n        \"videos\": \"Видео\",\n        \"people\": \"Люди\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Очередь\",\n        \"processing\": \"Обрабатывается\",\n        \"processed\": \"Обработано\",\n        \"time\": \"Время\"\n    },\n    \"firefly\": {\n        \"networth\": \"Общая средства\",\n        \"budget\": \"Бюджет\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Панели\",\n        \"datasources\": \"Источники данных\",\n        \"totalalerts\": \"Предупреждения\",\n        \"alertstriggered\": \"Сработали предупреждения\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Загрузка ЦПУ\",\n        \"memoryusage\": \"Использование ОЗУ\",\n        \"freespace\": \"Свободно места\",\n        \"activeusers\": \"Активные пользователи\",\n        \"numfiles\": \"Файлов\",\n        \"numshares\": \"Опубликованных объектов\"\n    },\n    \"kopia\": {\n        \"status\": \"Статус\",\n        \"size\": \"Размер\",\n        \"lastrun\": \"Последний запуск\",\n        \"nextrun\": \"Следующий запуск\",\n        \"failed\": \"Провалено\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Активные обработчики\",\n        \"total_workers\": \"Всего обработчиков\",\n        \"records_total\": \"Длина очереди\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Серверы\",\n        \"nodes\": \"Ноды\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Активные цели\",\n        \"targets_down\": \"Неактивные цели\",\n        \"targets_total\": \"Всего целей\"\n    },\n    \"gatus\": {\n        \"up\": \"Активные сайты\",\n        \"down\": \"Неактивные сайты\",\n        \"uptime\": \"Время работы\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Сегодня\",\n        \"gross_percent_1y\": \"Один год\",\n        \"gross_percent_max\": \"Все время\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Подкасты\",\n        \"books\": \"Книги\",\n        \"podcastsDuration\": \"Длительность\",\n        \"booksDuration\": \"Длительность\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Люди дома\",\n        \"lights_on\": \"Свет включен\",\n        \"switches_on\": \"Включается\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Мониторинг\",\n        \"updates\": \"Обновления\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Книги\",\n        \"authors\": \"Авторы\",\n        \"categories\": \"Категории\",\n        \"series\": \"Серии\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Очередь\",\n        \"downloadBytesRemaining\": \"Осталось\",\n        \"downloadTotalBytes\": \"Размер\",\n        \"downloadSpeed\": \"Скорость\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Серии\",\n        \"totalFiles\": \"Файлов\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Результат\",\n        \"status\": \"Статус\",\n        \"buildId\": \"ID сборки\",\n        \"succeeded\": \"Успешно\",\n        \"notStarted\": \"Не начато\",\n        \"failed\": \"Провалено\",\n        \"canceled\": \"Отменено\",\n        \"inProgress\": \"В процессе\",\n        \"totalPrs\": \"Всего PR-ов\",\n        \"myPrs\": \"Мои PR-ы\",\n        \"approved\": \"Одобрено\"\n    },\n    \"gamedig\": {\n        \"status\": \"Статус\",\n        \"online\": \"В сети\",\n        \"offline\": \"Не в сети\",\n        \"name\": \"Имя\",\n        \"map\": \"Карта\",\n        \"currentPlayers\": \"Текущее количество игроков\",\n        \"players\": \"Игроков\",\n        \"maxPlayers\": \"Максимум игроков\",\n        \"bots\": \"Ботов\",\n        \"ping\": \"Пинг\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ок\",\n        \"errored\": \"Ошибки\",\n        \"noRecent\": \"Устаревшие\",\n        \"totalUsed\": \"Использовано места\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Рецепты\",\n        \"users\": \"Пользователи\",\n        \"categories\": \"Категории\",\n        \"tags\": \"Теги\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Загрузка\",\n        \"total\": \"Всего\",\n        \"running\": \"Запущено\",\n        \"stopped\": \"Остановлено\",\n        \"passed\": \"Успешно\",\n        \"failed\": \"Провалено\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Время работы\",\n        \"cpuLoad\": \"Средняя нагрузка ЦП (5м)\",\n        \"up\": \"В сети\",\n        \"down\": \"Не в сети\",\n        \"bytesTx\": \"Передано\",\n        \"bytesRx\": \"Получено\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Статус\",\n        \"uptime\": \"Время работы\",\n        \"lastDown\": \"Время последнего падения\",\n        \"downDuration\": \"Длительность падения\",\n        \"sitesUp\": \"Активные сайты\",\n        \"sitesDown\": \"Неактивные сайты\",\n        \"paused\": \"Приостановлен\",\n        \"notyetchecked\": \"Ещё не проверено\",\n        \"up\": \"В сети\",\n        \"seemsdown\": \"Кажется упал :с\",\n        \"down\": \"Не в сети\",\n        \"unknown\": \"Неизвестно\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"В кинотеатрах\",\n        \"physicalRelease\": \"Физический релиз\",\n        \"digitalRelease\": \"Цифровой релиз\",\n        \"noEventsToday\": \"Нет событий на сегодня!\",\n        \"noEventsFound\": \"Событий не найдено\",\n        \"errorWhenLoadingData\": \"Ошибка при загрузке данных календаря\"\n    },\n    \"romm\": {\n        \"platforms\": \"Платформы\",\n        \"totalRoms\": \"Игры\",\n        \"saves\": \"Сохранения\",\n        \"states\": \"Состояния\",\n        \"screenshots\": \"Скриншоты\",\n        \"totalfilesize\": \"Общий объем\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Домены\",\n        \"mailboxes\": \"Почтовые ящики\",\n        \"mails\": \"Письма\",\n        \"storage\": \"Хранилище\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Предупреждения\",\n        \"criticals\": \"Критические\"\n    },\n    \"plantit\": {\n        \"events\": \"События\",\n        \"plants\": \"Растения\",\n        \"photos\": \"Фото\",\n        \"species\": \"Виды\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Уведомления\",\n        \"issues\": \"Проблемы\",\n        \"pulls\": \"Запросы на слияние (Pull Request)\",\n        \"repositories\": \"Репозитории\"\n    },\n    \"stash\": {\n        \"scenes\": \"Сцены\",\n        \"scenesPlayed\": \"Проигранных сцен\",\n        \"playCount\": \"Всего проиграно\",\n        \"playDuration\": \"Просмотрено времени\",\n        \"sceneSize\": \"Размер сцены\",\n        \"sceneDuration\": \"Длительность сцен\",\n        \"images\": \"Изображения\",\n        \"imageSize\": \"Размер изображений\",\n        \"galleries\": \"Галереи\",\n        \"performers\": \"Исполнители\",\n        \"studios\": \"Студии\",\n        \"movies\": \"Фильмы\",\n        \"tags\": \"Теги\",\n        \"oCount\": \"0\"\n    },\n    \"tandoor\": {\n        \"users\": \"Пользователи\",\n        \"recipes\": \"Рецепты\",\n        \"keywords\": \"Ключевые слова\"\n    },\n    \"homebox\": {\n        \"items\": \"Элементы\",\n        \"totalWithWarranty\": \"С гарантией\",\n        \"locations\": \"Местоположения\",\n        \"labels\": \"Ярлыки\",\n        \"users\": \"Пользователи\",\n        \"totalValue\": \"Общая стоимость\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Оповещения\",\n        \"bans\": \"Блокировки\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Подключено\",\n        \"enabled\": \"Включено\",\n        \"disabled\": \"Отключено\",\n        \"total\": \"Всего\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Проксировано\",\n        \"auth\": \"С Авторизацией\",\n        \"outdated\": \"Устаревшие\",\n        \"banned\": \"Заблокированные\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Пинг\",\n        \"download\": \"Скачивание\",\n        \"upload\": \"Загрузка\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Акции\",\n        \"loading\": \"Загрузка\",\n        \"open\": \"Открыто - Рынок США\",\n        \"closed\": \"Закрыто - рынок США\",\n        \"invalidConfiguration\": \"Неверная конфигурация\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Камеры\",\n        \"uptime\": \"Время работы\",\n        \"version\": \"Версия\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Ссылки\",\n        \"collections\": \"Коллекции\",\n        \"tags\": \"Теги\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Не классифицировано\",\n        \"information\": \"Информация\",\n        \"warning\": \"Предупреждение\",\n        \"average\": \"Среднее\",\n        \"high\": \"Высокая\",\n        \"disaster\": \"Чрезвычайное\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Транспорт\",\n        \"vehicles\": \"Транспорты\",\n        \"serviceRecords\": \"Сервисные записи\",\n        \"reminders\": \"Напоминания\",\n        \"nextReminder\": \"Следующее напоминание\",\n        \"none\": \"Отсутствует\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Активные Проекты\",\n        \"tasks7d\": \"Задачи на этой неделе\",\n        \"tasksOverdue\": \"Просроченные задачи\",\n        \"tasksInProgress\": \"Задачи в процессе\"\n    },\n    \"headscale\": {\n        \"name\": \"Имя\",\n        \"address\": \"Адрес\",\n        \"last_seen\": \"Был в посл. раз\",\n        \"status\": \"Статус\",\n        \"online\": \"В сети\",\n        \"offline\": \"Не в сети\"\n    },\n    \"beszel\": {\n        \"name\": \"Имя\",\n        \"systems\": \"Системы\",\n        \"up\": \"В сети\",\n        \"down\": \"Не в сети\",\n        \"paused\": \"На паузе\",\n        \"pending\": \"Ожидают\",\n        \"status\": \"Статус\",\n        \"updated\": \"Обновлено\",\n        \"cpu\": \"ЦП\",\n        \"memory\": \"Память\",\n        \"disk\": \"Диск\",\n        \"network\": \"Сеть\"\n    },\n    \"argocd\": {\n        \"apps\": \"Приложения\",\n        \"synced\": \"Синхронизированные\",\n        \"outOfSync\": \"Не синхронизированные\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Деградированные\",\n        \"progressing\": \"Выполняются\",\n        \"missing\": \"Отсутствует\",\n        \"suspended\": \"Приостановленные\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Идет загрузка\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Группы\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Мердж-реквесты\",\n        \"projects\": \"Проекты\"\n    },\n    \"apcups\": {\n        \"status\": \"Статус\",\n        \"load\": \"Нагрузка\",\n        \"bcharge\": \"Заряд батареи\",\n        \"timeleft\": \"Осталось\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Закладки\",\n        \"favorites\": \"Избранное\",\n        \"archived\": \"Архив\",\n        \"highlights\": \"События\",\n        \"lists\": \"Список\",\n        \"tags\": \"Теги\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Сеть\",\n        \"connected\": \"Подключено\",\n        \"disconnected\": \"Отключено\",\n        \"updateStatus\": \"Обновление\",\n        \"update_yes\": \"Доступно\",\n        \"update_no\": \"Актуально\",\n        \"downloads\": \"Скачивания\",\n        \"uploads\": \"Загрузки\",\n        \"sharedFiles\": \"Файлов\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Песни\",\n        \"movies\": \"Фильмы\",\n        \"episodes\": \"Эпизоды\",\n        \"other\": \"Другое\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Всего\",\n        \"running\": \"Запущено\",\n        \"stopped\": \"Остановлено\",\n        \"down\": \"Не в сети\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Неизвестно\",\n        \"servers\": \"Серверы\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Контейнеры\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Доступно\",\n        \"used\": \"Использовано\",\n        \"total\": \"Всего\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Подписки\",\n        \"thisMonthlyCost\": \"Этот месяц\",\n        \"nextMonthlyCost\": \"Следующий месяц\",\n        \"previousMonthlyCost\": \"Прошлый месяц\",\n        \"nextRenewingSubscription\": \"Следующая оплата\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"Новый массив\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Уведомления\",\n        \"status\": \"Статус\",\n        \"cpu\": \"ЦП\",\n        \"memoryUsed\": \"Использовано ОЗУ\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Ошибки\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Время\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Среда не найдена\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/sk/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"mes\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Chýba typ widgetu: {{type}}\",\n        \"api_error\": \"Chyba API\",\n        \"information\": \"Informácia\",\n        \"status\": \"Stav\",\n        \"url\": \"Odkaz\",\n        \"raw_error\": \"Nevyriešená chyba\",\n        \"response_data\": \"Dáta odpovede\"\n    },\n    \"weather\": {\n        \"current\": \"Aktuálna poloha\",\n        \"allow\": \"Kliknutím povolíte\",\n        \"updating\": \"Prebieha aktualizácia\",\n        \"wait\": \"Počkajte prosím\"\n    },\n    \"search\": {\n        \"placeholder\": \"Hľadať…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"RAM\",\n        \"total\": \"Celkovo\",\n        \"free\": \"Voľné\",\n        \"used\": \"Využité\",\n        \"load\": \"Záťaž\",\n        \"temp\": \"TEPLOTA\",\n        \"max\": \"Max.\",\n        \"uptime\": \"BEŽÍ\"\n    },\n    \"unifi\": {\n        \"users\": \"Používatelia\",\n        \"uptime\": \"Prevádzka\",\n        \"days\": \"Dní\",\n        \"wan\": \"WAN\",\n        \"lan\": \"Lokálna sieť\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Zariadenia\",\n        \"lan_devices\": \"Zariadenia LAN\",\n        \"wlan_devices\": \"Zariadenia WLAN\",\n        \"lan_users\": \"Použ. LAN\",\n        \"wlan_users\": \"Použ. WLAN\",\n        \"up\": \"BEŽÍ\",\n        \"down\": \"NEBEŽÍ\",\n        \"wait\": \"Čakajte, prosím\",\n        \"empty_data\": \"Stav podsystému neznámy\"\n    },\n    \"docker\": {\n        \"rx\": \"Prijaté\",\n        \"tx\": \"Odoslané\",\n        \"mem\": \"RAM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Beží\",\n        \"offline\": \"Nedostupný\",\n        \"error\": \"Chyba\",\n        \"unknown\": \"Neznáme\",\n        \"healthy\": \"Zdravý\",\n        \"starting\": \"Spúšťa sa\",\n        \"unhealthy\": \"Nezdravý\",\n        \"not_found\": \"Nenájdené\",\n        \"exited\": \"Ukončené\",\n        \"partial\": \"Čiastočný\"\n    },\n    \"ping\": {\n        \"error\": \"Chyba\",\n        \"ping\": \"Odozva\",\n        \"down\": \"Sťahovanie\",\n        \"up\": \"Nahrávanie\",\n        \"not_available\": \"Nedostupný\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP stavový kód\",\n        \"error\": \"Chyba\",\n        \"response\": \"Odpoveď\",\n        \"down\": \"Nedostupné\",\n        \"up\": \"Beží\",\n        \"not_available\": \"Nedostupné\"\n    },\n    \"emby\": {\n        \"playing\": \"Prehrávané\",\n        \"transcoding\": \"Prekódovávané\",\n        \"bitrate\": \"Prenosová rýchlosť\",\n        \"no_active\": \"Žiadny aktívny stream\",\n        \"movies\": \"Filmy\",\n        \"series\": \"Seriály\",\n        \"episodes\": \"Epizódy\",\n        \"songs\": \"Skladby\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Prehráva sa\",\n        \"transcoding\": \"Prebieha prekódovanie\",\n        \"bitrate\": \"Prenosová rýchlosť\",\n        \"no_active\": \"Žiadne aktívne vysielania\",\n        \"movies\": \"Filmov\",\n        \"series\": \"Seriálov\",\n        \"episodes\": \"Epizód\",\n        \"songs\": \"Skladieb\"\n    },\n    \"esphome\": {\n        \"offline\": \"Nedostupné\",\n        \"offline_alt\": \"Nedostupné\",\n        \"online\": \"Online\",\n        \"total\": \"Celkom\",\n        \"unknown\": \"Neznáme\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Produkcia\",\n        \"battery_soc\": \"Batéria\",\n        \"grid_power\": \"Mriežka\",\n        \"home_power\": \"Spotreba\",\n        \"charge_power\": \"Nabíjačka\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Sťahovanie\",\n        \"upload\": \"Nahrávanie\",\n        \"leech\": \"Leechované\",\n        \"seed\": \"Seedované\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Odbery\",\n        \"unread\": \"Neprečítané\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Stav\",\n        \"connectionStatusUnconfigured\": \"Nenastavený\",\n        \"connectionStatusConnecting\": \"Pripájanie\",\n        \"connectionStatusAuthenticating\": \"Overovanie\",\n        \"connectionStatusPendingDisconnect\": \"Čakám na odpojenie\",\n        \"connectionStatusDisconnecting\": \"Odpájanie\",\n        \"connectionStatusDisconnected\": \"Odpojené\",\n        \"connectionStatusConnected\": \"Pripojené\",\n        \"uptime\": \"Dostupnosť\",\n        \"maxDown\": \"Max. sťahovanie\",\n        \"maxUp\": \"Max. nahrávanie\",\n        \"down\": \"Nedostupné\",\n        \"up\": \"Beží\",\n        \"received\": \"Prijaté\",\n        \"sent\": \"Odoslané\",\n        \"externalIPAddress\": \"Ext. IP\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Odosielanie dát\",\n        \"requests\": \"Aktuálne požiadavky\",\n        \"requests_failed\": \"Neúspešné požiadavky\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Spolu kontrolovaných\",\n        \"diffsDetected\": \"Nájdených rozdielov\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Relácie\",\n        \"recordings\": \"Záznamy\",\n        \"scheduled\": \"Naplánované\",\n        \"passes\": \"Odvysielané\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Prehráva sa\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Prenosová rýchlosť\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"Skontroluj spojenie s Plex\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Prenosová rýchlosť\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Pripojené prístupové body\",\n        \"activeUser\": \"Aktívne zariadenia\",\n        \"alerts\": \"Upozornenia\",\n        \"connectedGateways\": \"Connected gateways\",\n        \"connectedSwitches\": \"Pripojené prepínače\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Rýchlosť\",\n        \"remaining\": \"Zostávajúce\",\n        \"downloaded\": \"Stiahnuté\"\n    },\n    \"plex\": {\n        \"streams\": \"Aktívne vysielanie\",\n        \"albums\": \"Albumy\",\n        \"movies\": \"Filmov\",\n        \"tv\": \"Seriály\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"V poradí\",\n        \"timeleft\": \"Zostávajúci čas\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Aktívne\",\n        \"upload\": \"Nahrávanie\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Nahrávanie\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Nahrávanie\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"Využitie CPU\",\n        \"memUsage\": \"Využitie pamäte\",\n        \"systemTempC\": \"Teplota systému\",\n        \"poolUsage\": \"Využitie zväzku\",\n        \"volumeUsage\": \"Využitie partície\",\n        \"invalid\": \"Neplatný\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Nahrávanie\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Nahrávanie\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Žiadané\",\n        \"queued\": \"V poradí\",\n        \"series\": \"Series\",\n        \"queue\": \"Poradie\",\n        \"unknown\": \"Neznáme\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"Chýbajúce\",\n        \"queued\": \"V poradí\",\n        \"movies\": \"Filmov\",\n        \"queue\": \"Poradie\",\n        \"unknown\": \"Neznáme\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"V poradí\",\n        \"artists\": \"Interpreti\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"V poradí\",\n        \"books\": \"Knihy\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Chýbajúce epizódy\",\n        \"missingMovies\": \"Chýbajúce filmy\"\n    },\n    \"ombi\": {\n        \"pending\": \"Čakajúce\",\n        \"approved\": \"Schválené\",\n        \"available\": \"Dostupné\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Celkom\",\n        \"connected\": \"Pripojené\",\n        \"new_devices\": \"Nové zariadenia\",\n        \"down_alerts\": \"Upozornenia o výpadkoch\"\n    },\n    \"pihole\": {\n        \"queries\": \"Dopyty\",\n        \"blocked\": \"Zablokované\",\n        \"blocked_percent\": \"Blokované\",\n        \"gravity\": \"Gravity\"\n    },\n    \"adguard\": {\n        \"queries\": \"Požiadaviek\",\n        \"blocked\": \"Blokované\",\n        \"filtered\": \"Filtrované\",\n        \"latency\": \"Odozva\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Nahrávanie\",\n        \"download\": \"Download\",\n        \"ping\": \"Odozva\"\n    },\n    \"portainer\": {\n        \"running\": \"Beží\",\n        \"stopped\": \"Zastavené\",\n        \"total\": \"Celkom\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Non-Downloaded\",\n        \"read\": \"Read\",\n        \"unread\": \"Neprečítané\",\n        \"downloadedread\": \"Downloaded & Read\",\n        \"downloadedunread\": \"Downloaded & Unread\",\n        \"nondownloadedread\": \"Non-Downloaded & Read\",\n        \"nondownloadedunread\": \"Non-Downloaded & Unread\"\n    },\n    \"tailscale\": {\n        \"address\": \"Adresa\",\n        \"expires\": \"Vyprší\",\n        \"never\": \"Nikdy\",\n        \"last_seen\": \"Naposledy videné\",\n        \"now\": \"Teraz\",\n        \"years\": \"{{number}}r\",\n        \"weeks\": \"{{number}}t\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"Pred {{value}}\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Požiadaviek\",\n        \"totalNoError\": \"Success\",\n        \"totalServerFailure\": \"Failures\",\n        \"totalNxDomain\": \"NX Domains\",\n        \"totalRefused\": \"Refused\",\n        \"totalAuthoritative\": \"Authoritative\",\n        \"totalRecursive\": \"Recursive\",\n        \"totalCached\": \"Cached\",\n        \"totalBlocked\": \"Blokované\",\n        \"totalDropped\": \"Dropped\",\n        \"totalClients\": \"Klienti\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Poradie\",\n        \"processed\": \"Spracované\",\n        \"errored\": \"Chybné\",\n        \"saved\": \"Uložené\"\n    },\n    \"traefik\": {\n        \"routers\": \"Smerovače\",\n        \"services\": \"Služby\",\n        \"middleware\": \"Midlvér\"\n    },\n    \"trilium\": {\n        \"version\": \"Verzia\",\n        \"notesCount\": \"Poznámky\",\n        \"dbSize\": \"Veľkosť databázy\",\n        \"unknown\": \"Neznáme\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Počkajte prosím\"\n    },\n    \"npm\": {\n        \"enabled\": \"Povolené\",\n        \"disabled\": \"Zakázané\",\n        \"total\": \"Celkom\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Nastavte jednu alebo viac kryptomien na sledovanie\",\n        \"1hour\": \"1 Hodina\",\n        \"1day\": \"1 Deň\",\n        \"7days\": \"7 Dní\",\n        \"30days\": \"30 Dní\"\n    },\n    \"gotify\": {\n        \"apps\": \"Aplikácie\",\n        \"clients\": \"Klienti\",\n        \"messages\": \"Správy\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indexery\",\n        \"numberOfGrabs\": \"Zachytení\",\n        \"numberOfQueries\": \"Požiadaviek\",\n        \"numberOfFailGrabs\": \"Neúspešné zachytenia\",\n        \"numberOfFailQueries\": \"Neúspešné dopyty\"\n    },\n    \"jackett\": {\n        \"configured\": \"Nastavený\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Relácie\",\n        \"numConnections\": \"Spojenia\",\n        \"dataRelayed\": \"Prenesené\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Používateľov\",\n        \"status_count\": \"Príspevky\",\n        \"domain_count\": \"Domény\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"V poradí\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Hráči\",\n        \"version\": \"Verzia\",\n        \"status\": \"Stav\",\n        \"up\": \"Online\",\n        \"down\": \"Nedostupné\"\n    },\n    \"miniflux\": {\n        \"read\": \"Prečítané\",\n        \"unread\": \"Neprečítané\"\n    },\n    \"authentik\": {\n        \"users\": \"Používateľov\",\n        \"loginsLast24H\": \"Prihlás. (24 hod.)\",\n        \"failedLoginsLast24H\": \"Neúspešné prihlás. (24 hod.)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"RAM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"Virtuálne stroje\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Záťaž\",\n        \"wait\": \"Čakajte, prosím\",\n        \"temp\": \"TEPL\",\n        \"_temp\": \"Teplota\",\n        \"warn\": \"Upozornení\",\n        \"uptime\": \"BEŽÍ\",\n        \"total\": \"Celkom\",\n        \"free\": \"Voľné\",\n        \"used\": \"Využité\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Kritické\",\n        \"read\": \"Read\",\n        \"write\": \"Zápis\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Pamäť\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Záložka\",\n        \"service\": \"Služba\",\n        \"search\": \"Hľadať\",\n        \"custom\": \"Vlastné\",\n        \"visit\": \"Navštíviť\",\n        \"url\": \"URL adresa\",\n        \"searchsuggestion\": \"Návrh\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Slnečno\",\n        \"0-night\": \"Jasno\",\n        \"1-day\": \"Prevažne slnečno\",\n        \"1-night\": \"Prevažne jasno\",\n        \"2-day\": \"Čiastočne zamračené\",\n        \"2-night\": \"Čiastočne zamračené\",\n        \"3-day\": \"Oblačno\",\n        \"3-night\": \"Oblačno\",\n        \"45-day\": \"Hmlisto\",\n        \"45-night\": \"Hmlisto\",\n        \"48-day\": \"Hmlisto\",\n        \"48-night\": \"Hmlisto\",\n        \"51-day\": \"Mierne mrholenie\",\n        \"51-night\": \"Slabé mrholenie\",\n        \"53-day\": \"Mrholenie\",\n        \"53-night\": \"Mrholenie\",\n        \"55-day\": \"Silné mrholenie\",\n        \"55-night\": \"Silné mrholenie\",\n        \"56-day\": \"Mierne mrazivé mrholenie\",\n        \"56-night\": \"Jemné mrznúce mrholenie\",\n        \"57-day\": \"Mrazivé mrholenie\",\n        \"57-night\": \"Mrznúce mrholenie\",\n        \"61-day\": \"Slabý dážď\",\n        \"61-night\": \"Slabý dážď\",\n        \"63-day\": \"Dážď\",\n        \"63-night\": \"Dážď\",\n        \"65-day\": \"Silný dážď\",\n        \"65-night\": \"Silný dážď\",\n        \"66-day\": \"Mrazivý dážď\",\n        \"66-night\": \"Mrznúci dážď\",\n        \"67-day\": \"Mrznúci dážď\",\n        \"67-night\": \"Mrznúci dážď\",\n        \"71-day\": \"Mierne sneženie\",\n        \"71-night\": \"Slabé sneženie\",\n        \"73-day\": \"Sneženie\",\n        \"73-night\": \"Sneženie\",\n        \"75-day\": \"Silné sneženie\",\n        \"75-night\": \"Husté sneženie\",\n        \"77-day\": \"Snehové vločky\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Mierne prehánky\",\n        \"80-night\": \"Mierne prehánky\",\n        \"81-day\": \"Prehánky\",\n        \"81-night\": \"Prehánky\",\n        \"82-day\": \"Silné prehánky\",\n        \"82-night\": \"Silné prehánky\",\n        \"85-day\": \"Snehové prehánky\",\n        \"85-night\": \"Snehové prehánky\",\n        \"86-day\": \"Snehové prehánky\",\n        \"86-night\": \"Snehové prehánky\",\n        \"95-day\": \"Búrka\",\n        \"95-night\": \"Búrka\",\n        \"96-day\": \"Búrka s krupobitím\",\n        \"96-night\": \"Búrka s krupobitím\",\n        \"99-day\": \"Búrka s krupobitím\",\n        \"99-night\": \"Búrka s krupobitím\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Systém\",\n        \"updates\": \"Aktualizácie\",\n        \"update_available\": \"Dostupná aktualizácia\",\n        \"up_to_date\": \"Aktuálny\",\n        \"child_bridges\": \"Podradené premostenia\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Beží\",\n        \"pending\": \"Čakajúce\",\n        \"down\": \"Nedostupné\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Nový\",\n        \"up\": \"Beží\",\n        \"grace\": \"V dodatočnej lehote\",\n        \"down\": \"Nedostupné\",\n        \"paused\": \"Pozastavené\",\n        \"status\": \"Stav\",\n        \"last_ping\": \"Poslendný ping\",\n        \"never\": \"Zatiaľ žiadne ping-y\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Oskenované\",\n        \"containers_updated\": \"Aktualizované\",\n        \"containers_failed\": \"Zlyhané\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Schválené\",\n        \"rejectedPushes\": \"Odmietnuté\",\n        \"filters\": \"Filtre\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Poradie\",\n        \"videos\": \"Videá\",\n        \"channels\": \"Kanály\",\n        \"playlists\": \"Playlisty\"\n    },\n    \"truenas\": {\n        \"load\": \"Záťaž systému\",\n        \"uptime\": \"Dostupnosť\",\n        \"alerts\": \"Upozornenia\"\n    },\n    \"pyload\": {\n        \"speed\": \"Rýchlosť\",\n        \"active\": \"Active\",\n        \"queue\": \"Poradie\",\n        \"total\": \"Celkom\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Verejná IP\",\n        \"region\": \"Región\",\n        \"country\": \"Krajina\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tunery\",\n        \"channelNumber\": \"Kanál\",\n        \"channelNetwork\": \"Sieť\",\n        \"signalStrength\": \"Sila\",\n        \"signalQuality\": \"Kvalita\",\n        \"symbolQuality\": \"Kvalita\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Klient\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Úspešný\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Neznáme\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Schránka správ\",\n        \"total\": \"Celkom\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Nabitie batérie\",\n        \"ups_load\": \"Záťaž UPS\",\n        \"ups_status\": \"Status UPS\",\n        \"online\": \"Online\",\n        \"on_battery\": \"Na batérii\",\n        \"low_battery\": \"Slabá batéria\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Čakajte, prosím\",\n        \"no_devices\": \"Informácie o zariadení nezískané\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"Využitie CPU\",\n        \"memoryUsed\": \"Využitie pamäte\",\n        \"uptime\": \"Dostupnosť\",\n        \"numberOfLeases\": \"Pridelené adresy\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Všetky vysielania\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG kanály\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Dnes\",\n        \"absolutePower\": \"Činný výkon\",\n        \"relativePower\": \"Relatívny výkon\",\n        \"limit\": \"Limit\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"Zátaž procesora\",\n        \"memory\": \"Aktívna pamäť\",\n        \"wanUpload\": \"WAN nahrávanie\",\n        \"wanDownload\": \"WAN sťahovanie\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Stav tlačiarne\",\n        \"print_status\": \"Stav tlače\",\n        \"print_progress\": \"Priebeh\",\n        \"layers\": \"Vrstvy\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Stav\",\n        \"temp_tool\": \"Teplota extrudéra\",\n        \"temp_bed\": \"Teplota podložky\",\n        \"job_completion\": \"Priebeh\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Zdrojová IP\",\n        \"status\": \"Stav\"\n    },\n    \"pfsense\": {\n        \"load\": \"Priemerné zaťaženie\",\n        \"memory\": \"Využitie pamäte\",\n        \"wanStatus\": \"Stav WAN\",\n        \"up\": \"Beží\",\n        \"down\": \"Nedostupné\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Využitie disku\",\n        \"wanIP\": \"IP adresa WAN\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Dátové úložisko\",\n        \"failed_tasks_24h\": \"Zlyhané úlohy za 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Pamäť\"\n    },\n    \"immich\": {\n        \"users\": \"Používateľov\",\n        \"photos\": \"Fotografií\",\n        \"videos\": \"Videí\",\n        \"storage\": \"Úložisko\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Weby dostupné\",\n        \"down\": \"Weby nedostupné\",\n        \"uptime\": \"Dostupnosť\",\n        \"incident\": \"Udalosť\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Archívy\",\n        \"chapters\": \"Kapitoly\",\n        \"categories\": \"Kategórie\"\n    },\n    \"komga\": {\n        \"libraries\": \"Knižnice\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Dostupnosť\",\n        \"volumeAvailable\": \"Dostupné\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Problémy\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albumov\",\n        \"photos\": \"Fotografií\",\n        \"videos\": \"Videí\",\n        \"people\": \"Ľudia\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Poradie\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Spracované\",\n        \"time\": \"Čas\"\n    },\n    \"firefly\": {\n        \"networth\": \"Net Worth\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Panely\",\n        \"datasources\": \"Zdroje dát\",\n        \"totalalerts\": \"Upozornení spolu\",\n        \"alertstriggered\": \"Spustené upozornenia\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Využitie CPU\",\n        \"memoryusage\": \"Využitie pamäte\",\n        \"freespace\": \"Dostupné miesto\",\n        \"activeusers\": \"Aktívni používatelia\",\n        \"numfiles\": \"Súborov\",\n        \"numshares\": \"Zdieľané položky\"\n    },\n    \"kopia\": {\n        \"status\": \"Stav\",\n        \"size\": \"Veľkosť\",\n        \"lastrun\": \"Naposledy spustené\",\n        \"nextrun\": \"Nasledujúce spustenie\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Aktívne Worker-y\",\n        \"total_workers\": \"Spolu Worker-ov\",\n        \"records_total\": \"Dĺžka fronty\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servery\",\n        \"nodes\": \"Uzly\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Dostupné ciele\",\n        \"targets_down\": \"Nedostupné ciele\",\n        \"targets_total\": \"Cieľov spolu\"\n    },\n    \"gatus\": {\n        \"up\": \"Dostupné stránky\",\n        \"down\": \"Nedostupné stránky\",\n        \"uptime\": \"Dostupnosť\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Dnes\",\n        \"gross_percent_1y\": \"Jeden rok\",\n        \"gross_percent_max\": \"Za celý čas\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasty\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Dĺžka\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Ľudia doma\",\n        \"lights_on\": \"Zapnúť svetlá\",\n        \"switches_on\": \"Zapnúť prepínače\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Monitoring\",\n        \"updates\": \"Aktualizácie\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Autori\",\n        \"categories\": \"Kategórie\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Poradie\",\n        \"downloadBytesRemaining\": \"Zostávajúce\",\n        \"downloadTotalBytes\": \"Veľkosť\",\n        \"downloadSpeed\": \"Rýchlosť\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Súborov\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Výsledok\",\n        \"status\": \"Stav\",\n        \"buildId\": \"ID zostavy\",\n        \"succeeded\": \"Úspešný\",\n        \"notStarted\": \"Nespustený\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Zrušený\",\n        \"inProgress\": \"Prebieha\",\n        \"totalPrs\": \"Počet PR-ok\",\n        \"myPrs\": \"Moje PR-ka\",\n        \"approved\": \"Schválené\"\n    },\n    \"gamedig\": {\n        \"status\": \"Stav\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Meno\",\n        \"map\": \"Mapa\",\n        \"currentPlayers\": \"Počet hráčov\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Maximálny počet hráčov\",\n        \"bots\": \"Boti\",\n        \"ping\": \"Odozva\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Chyby\",\n        \"noRecent\": \"Neaktuálny\",\n        \"totalUsed\": \"Použité úložisko\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Recepty\",\n        \"users\": \"Používateľov\",\n        \"categories\": \"Kategórie\",\n        \"tags\": \"Štítky\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Sťahovanie\",\n        \"total\": \"Celkom\",\n        \"running\": \"Beží\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Dostupnosť\",\n        \"cpuLoad\": \"Záťaž CPU priem. (5m)\",\n        \"up\": \"Beží\",\n        \"down\": \"Nedostupné\",\n        \"bytesTx\": \"Prenesených\",\n        \"bytesRx\": \"Prijaté\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Stav\",\n        \"uptime\": \"Dostupnosť\",\n        \"lastDown\": \"Posledný čas nedostupnosti\",\n        \"downDuration\": \"Trvanie nedostupnosti\",\n        \"sitesUp\": \"Dostupné stránky\",\n        \"sitesDown\": \"Nedostupné stránky\",\n        \"paused\": \"Pozastavené\",\n        \"notyetchecked\": \"Neskontrolované\",\n        \"up\": \"Beží\",\n        \"seemsdown\": \"Javí sa nedostupný\",\n        \"down\": \"Nedostupné\",\n        \"unknown\": \"Neznáme\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"V kinách\",\n        \"physicalRelease\": \"Fyzické vydanie\",\n        \"digitalRelease\": \"Digitálne vydanie\",\n        \"noEventsToday\": \"Žiadne udalosti na dnešný deň!\",\n        \"noEventsFound\": \"Žiadne udalosti\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platformy\",\n        \"totalRoms\": \"Games\",\n        \"saves\": \"Saves\",\n        \"states\": \"States\",\n        \"screenshots\": \"Screenshots\",\n        \"totalfilesize\": \"Celková veľkosť\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Mailboxes\",\n        \"mails\": \"Mails\",\n        \"storage\": \"Úložisko\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Upozornenia\",\n        \"criticals\": \"Kritické\"\n    },\n    \"plantit\": {\n        \"events\": \"Udalosti\",\n        \"plants\": \"Rastliny\",\n        \"photos\": \"Fotografií\",\n        \"species\": \"Druhy\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Oznámenia\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Pull requesty\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Scény\",\n        \"scenesPlayed\": \"Prehrané scény\",\n        \"playCount\": \"Celkovo prehraní\",\n        \"playDuration\": \"Pozeraný čas\",\n        \"sceneSize\": \"Veľkosť obrazovky\",\n        \"sceneDuration\": \"Dĺžka scény\",\n        \"images\": \"Obrázky\",\n        \"imageSize\": \"Veľkosť obrázkov\",\n        \"galleries\": \"Galérie\",\n        \"performers\": \"Herci\",\n        \"studios\": \"Štúdiá\",\n        \"movies\": \"Filmov\",\n        \"tags\": \"Štítky\",\n        \"oCount\": \"O Count\"\n    },\n    \"tandoor\": {\n        \"users\": \"Používateľov\",\n        \"recipes\": \"Recepty\",\n        \"keywords\": \"Kľúčové slová\"\n    },\n    \"homebox\": {\n        \"items\": \"Položky\",\n        \"totalWithWarranty\": \"So zárukou\",\n        \"locations\": \"Umiestnenia\",\n        \"labels\": \"Štítky\",\n        \"users\": \"Používateľov\",\n        \"totalValue\": \"Celková hodnota\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Upozornenia\",\n        \"bans\": \"Bany\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Pripojené\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Celkom\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxied\",\n        \"auth\": \"With Auth\",\n        \"outdated\": \"Zastarané\",\n        \"banned\": \"Zabanovaný\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Odozva\",\n        \"download\": \"Download\",\n        \"upload\": \"Nahrávanie\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Stocks\",\n        \"loading\": \"Načítava sa\",\n        \"open\": \"Open - US Market\",\n        \"closed\": \"Closed - US Market\",\n        \"invalidConfiguration\": \"Neplatná konfigurácia\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Kamery\",\n        \"uptime\": \"Dostupnosť\",\n        \"version\": \"Verzia\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Odkazy\",\n        \"collections\": \"Collections\",\n        \"tags\": \"Štítky\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Not classified\",\n        \"information\": \"Informácie\",\n        \"warning\": \"Warning\",\n        \"average\": \"Average\",\n        \"high\": \"High\",\n        \"disaster\": \"Disaster\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vozidlo\",\n        \"vehicles\": \"Vozidlá\",\n        \"serviceRecords\": \"Service Records\",\n        \"reminders\": \"Reminders\",\n        \"nextReminder\": \"Next Reminder\",\n        \"none\": \"Žiadne\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Aktívne projekty\",\n        \"tasks7d\": \"Tasks Due This Week\",\n        \"tasksOverdue\": \"Overdue Tasks\",\n        \"tasksInProgress\": \"Tasks In Progress\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Adresa\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Stav\",\n        \"online\": \"Online\",\n        \"offline\": \"Nedostupné\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Systems\",\n        \"up\": \"Beží\",\n        \"down\": \"Nedostupné\",\n        \"paused\": \"Pozastavené\",\n        \"pending\": \"Čakajúce\",\n        \"status\": \"Stav\",\n        \"updated\": \"Aktualizované\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"RAM\",\n        \"disk\": \"Disk\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Apps\",\n        \"synced\": \"Synced\",\n        \"outOfSync\": \"Out Of Sync\",\n        \"healthy\": \"Zdravý\",\n        \"degraded\": \"Degraded\",\n        \"progressing\": \"Progressing\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Suspended\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Načítava sa\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Skupiny\",\n        \"issues\": \"Problémy\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Projekty\"\n    },\n    \"apcups\": {\n        \"status\": \"Stav\",\n        \"load\": \"Záťaž\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bookmarks\",\n        \"favorites\": \"Obľúbené\",\n        \"archived\": \"Archived\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Zoznamy\",\n        \"tags\": \"Štítky\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Pripojené\",\n        \"disconnected\": \"Odpojené\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Dostupné\",\n        \"update_no\": \"Aktuálne\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Skladieb\",\n        \"movies\": \"Filmov\",\n        \"episodes\": \"Epizód\",\n        \"other\": \"Ostatné\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Celkom\",\n        \"running\": \"Beží\",\n        \"stopped\": \"Zastavené\",\n        \"down\": \"Nedostupné\",\n        \"unhealthy\": \"Nezdravý\",\n        \"unknown\": \"Neznáme\",\n        \"servers\": \"Servery\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Kontajnery\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Dostupné\",\n        \"used\": \"Využité\",\n        \"total\": \"Celkom\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"Tento mesiac\",\n        \"nextMonthlyCost\": \"Ďalší mesiac\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Oznámenia\",\n        \"status\": \"Stav\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/sl/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"mes\",\n        \"days\": \"d\",\n        \"hours\": \"u\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Manjka tip widgeta: {{type}}\",\n        \"api_error\": \"API napaka\",\n        \"information\": \"Informacija\",\n        \"status\": \"Stanje\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Surova napaka\",\n        \"response_data\": \"Podatki iz odgovora\"\n    },\n    \"weather\": {\n        \"current\": \"Trenutna lokacija\",\n        \"allow\": \"Kliknite za dovolitev\",\n        \"updating\": \"Posodabljam\",\n        \"wait\": \"Prosimo počakajte\"\n    },\n    \"search\": {\n        \"placeholder\": \"Iskanje…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"MEM\",\n        \"total\": \"Skupaj\",\n        \"free\": \"Prosto\",\n        \"used\": \"V uporabi\",\n        \"load\": \"Bremenitev\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Maks.\",\n        \"uptime\": \"Gor\"\n    },\n    \"unifi\": {\n        \"users\": \"Uporabniki\",\n        \"uptime\": \"Čas delovanja\",\n        \"days\": \"Dni\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Naprave\",\n        \"lan_devices\": \"LAN naprave\",\n        \"wlan_devices\": \"WLAN naprave\",\n        \"lan_users\": \"LAN uporabniki\",\n        \"wlan_users\": \"WLAN uporabniki\",\n        \"up\": \"UP\",\n        \"down\": \"DOL\",\n        \"wait\": \"Please wait\",\n        \"empty_data\": \"Neznani status podsistema\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Deluje\",\n        \"offline\": \"Ni povezan\",\n        \"error\": \"Napaka\",\n        \"unknown\": \"Neznano\",\n        \"healthy\": \"Zdrav\",\n        \"starting\": \"Se zaganja\",\n        \"unhealthy\": \"Ni zdrav\",\n        \"not_found\": \"Ni najden\",\n        \"exited\": \"V izhodu\",\n        \"partial\": \"Delni\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"Ping\",\n        \"down\": \"Nepovezan\",\n        \"up\": \"Povezan\",\n        \"not_available\": \"Ni na voljo\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP status\",\n        \"error\": \"Error\",\n        \"response\": \"Odgovor\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"emby\": {\n        \"playing\": \"Predvaja\",\n        \"transcoding\": \"Transkodira\",\n        \"bitrate\": \"Pasovna širina\",\n        \"no_active\": \"Ni aktivne vsebine\",\n        \"movies\": \"Filmi\",\n        \"series\": \"Serije\",\n        \"episodes\": \"Epizode\",\n        \"songs\": \"Pesmi\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Na spletu\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Proizvodnja\",\n        \"battery_soc\": \"Baterija\",\n        \"grid_power\": \"Omrežje\",\n        \"home_power\": \"Poraba\",\n        \"charge_power\": \"Polnilec\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Prenos\",\n        \"upload\": \"Nalaganje\",\n        \"leech\": \"Pijavka\",\n        \"seed\": \"Sejanje\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Naročnine\",\n        \"unread\": \"Neprebrano\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Nenastavljeno\",\n        \"connectionStatusConnecting\": \"Se povezuje\",\n        \"connectionStatusAuthenticating\": \"Avtentikacija\",\n        \"connectionStatusPendingDisconnect\": \"Čakanje na prekinitev\",\n        \"connectionStatusDisconnecting\": \"Prekinitev\",\n        \"connectionStatusDisconnected\": \"Prekinjeno\",\n        \"connectionStatusConnected\": \"Povezan\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"Maks. dol\",\n        \"maxUp\": \"Maks. gor\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"Prejeto\",\n        \"sent\": \"Poslano\",\n        \"externalIPAddress\": \"Zun. IP\",\n        \"externalIPv6Address\": \"Eks. IPv6\",\n        \"externalIPv6Prefix\": \"Eks. IPv6-predpona\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Pretok gor\",\n        \"requests\": \"Trenutnih zahtev\",\n        \"requests_failed\": \"Neuspeš. zahtev\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Skupaj opazovano\",\n        \"diffsDetected\": \"Zaznanih sprememb\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Epizode\",\n        \"recordings\": \"Posnetki\",\n        \"scheduled\": \"Načrtovano\",\n        \"passes\": \"Prehodi\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"Preveri Plex povezavo\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Povezanih AP\",\n        \"activeUser\": \"Aktivne naprave\",\n        \"alerts\": \"Opozorila\",\n        \"connectedGateways\": \"Povezani prehodi\",\n        \"connectedSwitches\": \"Povezana stikala\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Razmerje\",\n        \"remaining\": \"Še preostane\",\n        \"downloaded\": \"Preneseno\"\n    },\n    \"plex\": {\n        \"streams\": \"Aktivna vsebina\",\n        \"albums\": \"Albumi\",\n        \"movies\": \"Movies\",\n        \"tv\": \"TV serije\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"Vrsta\",\n        \"timeleft\": \"Preostali čas\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Aktiven\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU\",\n        \"memUsage\": \"MEM\",\n        \"systemTempC\": \"Temperatura\",\n        \"poolUsage\": \"Prostor\",\n        \"volumeUsage\": \"Prostora\",\n        \"invalid\": \"Neveljavno\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Predpomn. zadetki\",\n        \"cachemissbytes\": \"Predpomn. zgrešeno\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Iskano\",\n        \"queued\": \"V vrsti\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"Manjka\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"Avtorji\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"Knjige\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Manjkajoče epizode\",\n        \"missingMovies\": \"Manjkajoči filmi\"\n    },\n    \"ombi\": {\n        \"pending\": \"V teku\",\n        \"approved\": \"Odobreno\",\n        \"available\": \"Na voljo\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"Nova naprave\",\n        \"down_alerts\": \"Alarmi nedelovanja\"\n    },\n    \"pihole\": {\n        \"queries\": \"Poizvedbe\",\n        \"blocked\": \"Blokirano\",\n        \"blocked_percent\": \"Blokirano %\",\n        \"gravity\": \"Gravitacija\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"Filtrirano\",\n        \"latency\": \"Zakasnitev\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Ustavljen\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Nepreneseno\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Preneseno in prebrano\",\n        \"downloadedunread\": \"Preneseno in neprebrano\",\n        \"nondownloadedread\": \"Nepreneseno in prebrano\",\n        \"nondownloadedunread\": \"Nepreneseno in neprebrano\"\n    },\n    \"tailscale\": {\n        \"address\": \"Naslov\",\n        \"expires\": \"Poteče\",\n        \"never\": \"Nikoli\",\n        \"last_seen\": \"Viden\",\n        \"now\": \"Sedaj\",\n        \"years\": \"{{number}}l\",\n        \"weeks\": \"{{number}}t\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}u\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} nazaj\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"Uspeh\",\n        \"totalServerFailure\": \"Neuspehi\",\n        \"totalNxDomain\": \"NX domene\",\n        \"totalRefused\": \"Zavrnjeno\",\n        \"totalAuthoritative\": \"Avtoratitavno\",\n        \"totalRecursive\": \"Rekurzivno\",\n        \"totalCached\": \"Predpomnjeno\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"Izpuščeno\",\n        \"totalClients\": \"Klienti\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Procesiran\",\n        \"errored\": \"Z napako\",\n        \"saved\": \"Shranjen\"\n    },\n    \"traefik\": {\n        \"routers\": \"Usmerjevalniki\",\n        \"services\": \"Servisi\",\n        \"middleware\": \"Vmesna programska oprema\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Prosim počakajte\"\n    },\n    \"npm\": {\n        \"enabled\": \"Omogočen\",\n        \"disabled\": \"Onemogočen\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Nastavite eno ali več kripto valut za sledenje\",\n        \"1hour\": \"1 ura\",\n        \"1day\": \"1 dan\",\n        \"7days\": \"7 dni\",\n        \"30days\": \"30 dni\"\n    },\n    \"gotify\": {\n        \"apps\": \"Aplikacije\",\n        \"clients\": \"Clients\",\n        \"messages\": \"Sporočila\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indekserji\",\n        \"numberOfGrabs\": \"Zajemi\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"Neuspeš. zajem\",\n        \"numberOfFailQueries\": \"Neuspeš. poizvedb\"\n    },\n    \"jackett\": {\n        \"configured\": \"Nastavljeno\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Seje\",\n        \"numConnections\": \"Povezave\",\n        \"dataRelayed\": \"Preusmerjeno\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"Objave\",\n        \"domain_count\": \"Domene\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Igralci\",\n        \"version\": \"Verzija\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Prebrano\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"Prijave (24h)\",\n        \"failedLoginsLast24H\": \"Neveljavne prijave (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VM\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temp\",\n        \"warn\": \"Opoz.\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Krit.\",\n        \"read\": \"Read\",\n        \"write\": \"Zapisano\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Zaznamek\",\n        \"service\": \"Storitev\",\n        \"search\": \"Iskanje\",\n        \"custom\": \"Po meri\",\n        \"visit\": \"Obišči\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Predlog\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Sončno\",\n        \"0-night\": \"Jasno\",\n        \"1-day\": \"Večinoma sončno\",\n        \"1-night\": \"Večinoma jasno\",\n        \"2-day\": \"Delno oblačno\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"Oblačno\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"Megleno\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"Rahlo rosenje\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"Rosenje\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"Močnejše rosenje\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"Lahko zmrzovano pršenje\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"Zmrzovano pršenje\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"Rahel dež\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"Dež\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Močnejši dež\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Zmrznjen dež\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Rahlo sneženje\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Sneg\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Močnejši sneg\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Snežna zrna\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Rahlo pršenje\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Nalivi\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Močnejši nalivi\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Snežne plohe\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"Nevihta\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Nevihta s točo\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Sistem\",\n        \"updates\": \"Posodobitve\",\n        \"update_available\": \"Posodobitve na voljo\",\n        \"up_to_date\": \"Posodobljeno\",\n        \"child_bridges\": \"Otroški mostovi\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Nov\",\n        \"up\": \"Up\",\n        \"grace\": \"V podaljšanem roku\",\n        \"down\": \"Down\",\n        \"paused\": \"Pavziran\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Zadnji Ping\",\n        \"never\": \"Še ni pinga\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Skeniran\",\n        \"containers_updated\": \"Posodobljen\",\n        \"containers_failed\": \"Neuspešno\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Zavrnjen\",\n        \"filters\": \"Filtri\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Videi\",\n        \"channels\": \"Kanali\",\n        \"playlists\": \"Seznami predvajanja\"\n    },\n    \"truenas\": {\n        \"load\": \"Obremenitev sistema\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Hitrost\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Javni IP\",\n        \"region\": \"Regija\",\n        \"country\": \"Država\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Sprejemniki\",\n        \"channelNumber\": \"Kanal\",\n        \"channelNetwork\": \"Omrežje\",\n        \"signalStrength\": \"Moč\",\n        \"signalQuality\": \"Kakovost\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Odjemalec\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Opravljeno\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Prejeto\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Napolnjenost baterije\",\n        \"ups_load\": \"UPS obremenitev\",\n        \"ups_status\": \"UPS status\",\n        \"online\": \"Online\",\n        \"on_battery\": \"Na bateriji\",\n        \"low_battery\": \"Prazna baterija\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"Podatki o napravi niso prejeti\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"CPU obremenitev\",\n        \"memoryUsed\": \"Uporabljen spomin\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Najemi\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Vsi pretoki\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG kanali\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Danes\",\n        \"absolutePower\": \"Napajanje\",\n        \"relativePower\": \"Napajanje %\",\n        \"limit\": \"Limit\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Aktiven spomin\",\n        \"wanUpload\": \"WAN naloženo\",\n        \"wanDownload\": \"WAN prejeto\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Stanje tiskalnika\",\n        \"print_status\": \"Stanje tiskanja\",\n        \"print_progress\": \"Napredek\",\n        \"layers\": \"Sloji\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Temperatura orodja\",\n        \"temp_bed\": \"Temperatura postelje\",\n        \"job_completion\": \"Končano\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Izvorni IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Povp. obremenitev\",\n        \"memory\": \"Poraba spomina\",\n        \"wanStatus\": \"WAN status\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Poraba diska\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Shramba podatkov\",\n        \"failed_tasks_24h\": \"Opravila z napako 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Spomin\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Slike\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Shramba\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Deluje\",\n        \"down\": \"Ne deluje\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Incident\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Arhivi\",\n        \"chapters\": \"Poglavja\",\n        \"categories\": \"Kategorije\"\n    },\n    \"komga\": {\n        \"libraries\": \"Knjižnice\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Težave\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"Ljudje\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Čas\"\n    },\n    \"firefly\": {\n        \"networth\": \"Neto vrednost\",\n        \"budget\": \"Proračun\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Nadzorne plošče\",\n        \"datasources\": \"Viri podatkov\",\n        \"totalalerts\": \"Skupaj alarmov\",\n        \"alertstriggered\": \"Sproženi alarmi\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"CPU obremenitev\",\n        \"memoryusage\": \"Uporabljen spomin\",\n        \"freespace\": \"Prostor na voljo\",\n        \"activeusers\": \"Aktivni uporabniki\",\n        \"numfiles\": \"Datotek\",\n        \"numshares\": \"Deljeno\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Velikost\",\n        \"lastrun\": \"Zadnji zagon\",\n        \"nextrun\": \"Naslednji zagon\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Aktivne niti\",\n        \"total_workers\": \"Skupaj niti\",\n        \"records_total\": \"Dolžina vrste\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Strežniki\",\n        \"nodes\": \"Vozlišča\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Tarče gor\",\n        \"targets_down\": \"Tarče dol\",\n        \"targets_total\": \"Skupaj tarč\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"Eno leto\",\n        \"gross_percent_max\": \"Celoten čas\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasti\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Trajanje\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Ljudje doma\",\n        \"lights_on\": \"Vklopljene luči\",\n        \"switches_on\": \"Vklopljena stikala\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Se spremlja\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Avtorji\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Rezultati\",\n        \"status\": \"Status\",\n        \"buildId\": \"ID gradnje\",\n        \"succeeded\": \"Uspešnih\",\n        \"notStarted\": \"Ni zagnano\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Preklicano\",\n        \"inProgress\": \"V delu\",\n        \"totalPrs\": \"Skupaj PR\",\n        \"myPrs\": \"Moji PR\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Naziv\",\n        \"map\": \"Zemljevid\",\n        \"currentPlayers\": \"Igralcev\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Maks igralcev\",\n        \"bots\": \"Boti\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"V redu\",\n        \"errored\": \"Napake\",\n        \"noRecent\": \"Zastarano\",\n        \"totalUsed\": \"Shramba v uporabi\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Recepti\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Značke\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Prenašanje\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"CPU obremenitev povp. (5m)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Prenešeno\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Zadnjič nepovezan\",\n        \"downDuration\": \"Dolžina izpada\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Še nepreverjeno\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Ne deluje\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"V kinu\",\n        \"physicalRelease\": \"Fizična izdaja\",\n        \"digitalRelease\": \"Digitalna izdaja\",\n        \"noEventsToday\": \"Za danes ni dogodkov!\",\n        \"noEventsFound\": \"Ni dogodkov\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platforme\",\n        \"totalRoms\": \"Igre\",\n        \"saves\": \"Shranitve\",\n        \"states\": \"Stanja\",\n        \"screenshots\": \"Posnetki zaslona\",\n        \"totalfilesize\": \"Skupna velikost\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Nabiralniki\",\n        \"mails\": \"Pošta\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Opozorila\",\n        \"criticals\": \"Kritično\"\n    },\n    \"plantit\": {\n        \"events\": \"Dogodki\",\n        \"plants\": \"Rastline\",\n        \"photos\": \"Photos\",\n        \"species\": \"Vrste\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Obvestila\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Zahteve za prenos\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Scene\",\n        \"scenesPlayed\": \"Predvajane scene\",\n        \"playCount\": \"Skupaj predvajano\",\n        \"playDuration\": \"Čas gledanja\",\n        \"sceneSize\": \"Velikost scene\",\n        \"sceneDuration\": \"Dolžina scene\",\n        \"images\": \"Slike\",\n        \"imageSize\": \"Velikosti slik\",\n        \"galleries\": \"Galerije\",\n        \"performers\": \"Izvajalci\",\n        \"studios\": \"Studiji\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O štetje\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Ključne besede\"\n    },\n    \"homebox\": {\n        \"items\": \"Predmeti\",\n        \"totalWithWarranty\": \"Z garancijo\",\n        \"locations\": \"Lokacije\",\n        \"labels\": \"Oznake\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Skupna vrednost\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Prepovedi\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Čez proxi\",\n        \"auth\": \"Z Auth\",\n        \"outdated\": \"Zastarelo\",\n        \"banned\": \"Prepovedan\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Delnice\",\n        \"loading\": \"Nalaganje\",\n        \"open\": \"Odprto - US trg\",\n        \"closed\": \"Zaprto - US trg\",\n        \"invalidConfiguration\": \"Neveljavna konfiguracija\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Kamere\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Povezave\",\n        \"collections\": \"Zbirke\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Nerazvrščeno\",\n        \"information\": \"Information\",\n        \"warning\": \"Opozorilo\",\n        \"average\": \"Povprečno\",\n        \"high\": \"Visoko\",\n        \"disaster\": \"Katastrofa\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vozilo\",\n        \"vehicles\": \"Vozila\",\n        \"serviceRecords\": \"Zapisi servisov\",\n        \"reminders\": \"Opomniki\",\n        \"nextReminder\": \"Naslednji opomnik\",\n        \"none\": \"Brez\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Aktivni projekti\",\n        \"tasks7d\": \"Potekla opravila tega tedna\",\n        \"tasksOverdue\": \"Potekla opravila\",\n        \"tasksInProgress\": \"Tekoča opravila\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Sistemi\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Disk\",\n        \"network\": \"Mreža\"\n    },\n    \"argocd\": {\n        \"apps\": \"Aplikacije\",\n        \"synced\": \"Sinhro\",\n        \"outOfSync\": \"Ni sinhro\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Degragirano\",\n        \"progressing\": \"V teku\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Prekinjeno\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Skupine\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Združi zahtevke\",\n        \"projects\": \"Projekti\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bookmarks\",\n        \"favorites\": \"Favorites\",\n        \"archived\": \"Archived\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Lists\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Other\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Skupaj\",\n        \"running\": \"Deluje\",\n        \"stopped\": \"Ustavljen\",\n        \"down\": \"Nepovezan\",\n        \"unhealthy\": \"Ni zdrav\",\n        \"unknown\": \"Neznano\",\n        \"servers\": \"Strežniki\",\n        \"stacks\": \"Skladi\",\n        \"containers\": \"Kontejnerji\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/sr/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"мес\",\n        \"days\": \"д\",\n        \"hours\": \"ч\",\n        \"minutes\": \"м\",\n        \"seconds\": \"с\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Недостаје тип виџета: {{type}}\",\n        \"api_error\": \"API Грешка\",\n        \"information\": \"Информација\",\n        \"status\": \"Стање\",\n        \"url\": \"URL адреса\",\n        \"raw_error\": \"Оригинална грешка\",\n        \"response_data\": \"Подаци о одговору\"\n    },\n    \"weather\": {\n        \"current\": \"Тренутна локација\",\n        \"allow\": \"Кликни да дозволиш\",\n        \"updating\": \"Ажурирање\",\n        \"wait\": \"Молим сачекајте\"\n    },\n    \"search\": {\n        \"placeholder\": \"Претражи…\"\n    },\n    \"resources\": {\n        \"cpu\": \"Процесор\",\n        \"mem\": \"Меморија\",\n        \"total\": \"Укупно\",\n        \"free\": \"Слободно\",\n        \"used\": \"У употреби\",\n        \"load\": \"Учитавање\",\n        \"temp\": \"Температура\",\n        \"max\": \"Макс\",\n        \"uptime\": \"Активно\"\n    },\n    \"unifi\": {\n        \"users\": \"Корисника\",\n        \"uptime\": \"Време рада\",\n        \"days\": \"Дана\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Уређаји\",\n        \"lan_devices\": \"LAN уређаји\",\n        \"wlan_devices\": \"WLAN уређаји\",\n        \"lan_users\": \"LAN корисници\",\n        \"wlan_users\": \"WLAN корисници\",\n        \"up\": \"Активно\",\n        \"down\": \"Прекид\",\n        \"wait\": \"Молим сачекајте\",\n        \"empty_data\": \"Статус подсистема непознат\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"Меморија\",\n        \"cpu\": \"Процесор\",\n        \"running\": \"Покренуто\",\n        \"offline\": \"Није на мрежи\",\n        \"error\": \"Грешка\",\n        \"unknown\": \"Непознато\",\n        \"healthy\": \"Здравих\",\n        \"starting\": \"Покретање\",\n        \"unhealthy\": \"Нездравих\",\n        \"not_found\": \"Није пронађено\",\n        \"exited\": \"Напуштених\",\n        \"partial\": \"Делимично\"\n    },\n    \"ping\": {\n        \"error\": \"Грешка\",\n        \"ping\": \"Пинг\",\n        \"down\": \"Доле\",\n        \"up\": \"Горе\",\n        \"not_available\": \"Није доступно\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP Статус\",\n        \"error\": \"Грешка\",\n        \"response\": \"Одговор\",\n        \"down\": \"Доле\",\n        \"up\": \"Горе\",\n        \"not_available\": \"Није доступно\"\n    },\n    \"emby\": {\n        \"playing\": \"Репродукција\",\n        \"transcoding\": \"Транскодирање\",\n        \"bitrate\": \"Проток\",\n        \"no_active\": \"Нема активних стримова\",\n        \"movies\": \"Филмови\",\n        \"series\": \"Серије\",\n        \"episodes\": \"Епизоде\",\n        \"songs\": \"Песме\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Репродукција\",\n        \"transcoding\": \"Транскодирање\",\n        \"bitrate\": \"Проток\",\n        \"no_active\": \"Нема активних стримова\",\n        \"movies\": \"Филмови\",\n        \"series\": \"Серије\",\n        \"episodes\": \"Епизоде\",\n        \"songs\": \"Песме\"\n    },\n    \"esphome\": {\n        \"offline\": \"Није на мрежи\",\n        \"offline_alt\": \"Није на мрежи\",\n        \"online\": \"На мрежи\",\n        \"total\": \"Укупно\",\n        \"unknown\": \"Непознато\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Продукција\",\n        \"battery_soc\": \"Батерија\",\n        \"grid_power\": \"Mreža\",\n        \"home_power\": \"Потрошња\",\n        \"charge_power\": \"Пуњач\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Преузимање\",\n        \"upload\": \"Слање\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Претплате\",\n        \"unread\": \"Непрочитано\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Стање\",\n        \"connectionStatusUnconfigured\": \"Неподешено\",\n        \"connectionStatusConnecting\": \"Повезивање\",\n        \"connectionStatusAuthenticating\": \"Аутентификација\",\n        \"connectionStatusPendingDisconnect\": \"Чекање на прекид везе\",\n        \"connectionStatusDisconnecting\": \"Прекидање\",\n        \"connectionStatusDisconnected\": \"Прекинуто\",\n        \"connectionStatusConnected\": \"Повезано\",\n        \"uptime\": \"Време рада\",\n        \"maxDown\": \"Макс. Преузимање\",\n        \"maxUp\": \"Макс. Слање\",\n        \"down\": \"Доле\",\n        \"up\": \"Горе\",\n        \"received\": \"Примљено\",\n        \"sent\": \"Послато\",\n        \"externalIPAddress\": \"Екст. IP\",\n        \"externalIPv6Address\": \"Екст. IPv6\",\n        \"externalIPv6Prefix\": \"Екст. IPv6-Префикс\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Узводно\",\n        \"requests\": \"Тренутни захтеви\",\n        \"requests_failed\": \"Неуспешни захтеви\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Укупно посматрано\",\n        \"diffsDetected\": \"Откривене разлике\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Емисије\",\n        \"recordings\": \"Сачувано\",\n        \"scheduled\": \"Заказано\",\n        \"passes\": \"Пређено\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Репродукција\",\n        \"transcoding\": \"Транскодирање\",\n        \"bitrate\": \"Проток\",\n        \"no_active\": \"Нема активних стримова\",\n        \"plex_connection_error\": \"Провери везу са Plex-ом\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"Нема активних стримова\",\n        \"streams\": \"Стримови\",\n        \"transcodes\": \"Транскодирање\",\n        \"directplay\": \"Директно репродуковање\",\n        \"bitrate\": \"Проток\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Повезани АПи\",\n        \"activeUser\": \"Активни уређаји\",\n        \"alerts\": \"Упозорења\",\n        \"connectedGateways\": \"Повезани мрежни пролази\",\n        \"connectedSwitches\": \"Повезани мрежни прекидачи\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Стопа\",\n        \"remaining\": \"Преостало\",\n        \"downloaded\": \"Преузето\"\n    },\n    \"plex\": {\n        \"streams\": \"Активно\",\n        \"albums\": \"Албуми\",\n        \"movies\": \"Филмови\",\n        \"tv\": \"ТВ емисије\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Стопа\",\n        \"queue\": \"Ред\",\n        \"timeleft\": \"Преостало време\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Активно\",\n        \"upload\": \"Слање\",\n        \"download\": \"Преузимање\"\n    },\n    \"transmission\": {\n        \"download\": \"Преузимање\",\n        \"upload\": \"Слање\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Преузимање\",\n        \"upload\": \"Слање\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"Искоришћеност процесора\",\n        \"memUsage\": \"Заузеће меморије\",\n        \"systemTempC\": \"Температура система\",\n        \"poolUsage\": \"Заузеће Пула\",\n        \"volumeUsage\": \"Употребљена запремина\",\n        \"invalid\": \"Неважеће\"\n    },\n    \"deluge\": {\n        \"download\": \"Преузимање\",\n        \"upload\": \"Слање\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Бајтови погодака кеша\",\n        \"cachemissbytes\": \"Бајтови промашаја кеша\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Преузимање\",\n        \"upload\": \"Слање\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Тражено\",\n        \"queued\": \"На чекању\",\n        \"series\": \"Серије\",\n        \"queue\": \"Ред\",\n        \"unknown\": \"Непознато\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Тражено\",\n        \"missing\": \"Недостаје\",\n        \"queued\": \"На чекању\",\n        \"movies\": \"Филмови\",\n        \"queue\": \"Ред\",\n        \"unknown\": \"Непознато\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Тражено\",\n        \"queued\": \"На чекању\",\n        \"artists\": \"Извођачи\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Тражено\",\n        \"queued\": \"На чекању\",\n        \"books\": \"Књиге\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Недостајеће епизоде\",\n        \"missingMovies\": \"Недостајећи филмови\"\n    },\n    \"ombi\": {\n        \"pending\": \"На чекању\",\n        \"approved\": \"Одобрено\",\n        \"available\": \"Доступно\"\n    },\n    \"seerr\": {\n        \"pending\": \"На чекању\",\n        \"approved\": \"Одобрено\",\n        \"available\": \"Доступно\",\n        \"completed\": \"Завршено\",\n        \"processing\": \"Обрада\",\n        \"issues\": \"Отворених питања\"\n    },\n    \"netalertx\": {\n        \"total\": \"Укупно\",\n        \"connected\": \"Повезано\",\n        \"new_devices\": \"Нови уређаји\",\n        \"down_alerts\": \"Упозорења о паду\"\n    },\n    \"pihole\": {\n        \"queries\": \"Упити\",\n        \"blocked\": \"Блокирано\",\n        \"blocked_percent\": \"Блокирано %\",\n        \"gravity\": \"Gravity\"\n    },\n    \"adguard\": {\n        \"queries\": \"Упити\",\n        \"blocked\": \"Блокирано\",\n        \"filtered\": \"Филтрирано\",\n        \"latency\": \"Кашњење\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Слање\",\n        \"download\": \"Преузимање\",\n        \"ping\": \"Пинг\"\n    },\n    \"portainer\": {\n        \"running\": \"Покренуто\",\n        \"stopped\": \"Заустављено\",\n        \"total\": \"Укупно\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Преузето\",\n        \"nondownload\": \"Непреузето\",\n        \"read\": \"Прочитано\",\n        \"unread\": \"Непрочитано\",\n        \"downloadedread\": \"Преузето и прочитано\",\n        \"downloadedunread\": \"Преузето и непрочитано\",\n        \"nondownloadedread\": \"Непреузето и прочитано\",\n        \"nondownloadedunread\": \"Непреузето и непрочитано\"\n    },\n    \"tailscale\": {\n        \"address\": \"Адреса\",\n        \"expires\": \"Истиче\",\n        \"never\": \"Никада\",\n        \"last_seen\": \"Последње виђен\",\n        \"now\": \"Сада\",\n        \"years\": \"{{number}}г\",\n        \"weeks\": \"{{number}}н\",\n        \"days\": \"{{number}}д\",\n        \"hours\": \"{{number}}ч\",\n        \"minutes\": \"{{number}}м\",\n        \"seconds\": \"{{number}}с\",\n        \"ago\": \"Пре {{value}}\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Упити\",\n        \"totalNoError\": \"Успешно\",\n        \"totalServerFailure\": \"Неуспешно\",\n        \"totalNxDomain\": \"NX домени\",\n        \"totalRefused\": \"Одбијено\",\n        \"totalAuthoritative\": \"Ауторитативно\",\n        \"totalRecursive\": \"Рекурзивно\",\n        \"totalCached\": \"Кеширано\",\n        \"totalBlocked\": \"Блокирано\",\n        \"totalDropped\": \"Испуштено\",\n        \"totalClients\": \"Клијенти\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Ред\",\n        \"processed\": \"Обрада\",\n        \"errored\": \"Грешке\",\n        \"saved\": \"Сачувано\"\n    },\n    \"traefik\": {\n        \"routers\": \"Рутери\",\n        \"services\": \"Сервиси\",\n        \"middleware\": \"Мидлвер\"\n    },\n    \"trilium\": {\n        \"version\": \"Верзија\",\n        \"notesCount\": \"Белешке\",\n        \"dbSize\": \"Величина базе података\",\n        \"unknown\": \"Непознато\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"Нема активних стримова\",\n        \"please_wait\": \"Молим сачекајте\"\n    },\n    \"npm\": {\n        \"enabled\": \"Омогућено\",\n        \"disabled\": \"Онемогућено\",\n        \"total\": \"Укупно\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Конфигуришите једну или више криптовалута за праћење\",\n        \"1hour\": \"1 сат\",\n        \"1day\": \"1 дан\",\n        \"7days\": \"7 дана\",\n        \"30days\": \"30 дана\"\n    },\n    \"gotify\": {\n        \"apps\": \"Апликација\",\n        \"clients\": \"Клијенти\",\n        \"messages\": \"Поруке\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Индексери\",\n        \"numberOfGrabs\": \"Број грабова\",\n        \"numberOfQueries\": \"Упити\",\n        \"numberOfFailGrabs\": \"Неуспешни грабови\",\n        \"numberOfFailQueries\": \"Неуспешни упити\"\n    },\n    \"jackett\": {\n        \"configured\": \"Подешено\",\n        \"errored\": \"Грешке\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Сесије\",\n        \"numConnections\": \"Повезивање\",\n        \"dataRelayed\": \"Пренето\",\n        \"transferRate\": \"Стопа\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Корисника\",\n        \"status_count\": \"Објаве\",\n        \"domain_count\": \"Домени\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Тражено\",\n        \"queued\": \"На чекању\",\n        \"series\": \"Серије\"\n    },\n    \"minecraft\": {\n        \"players\": \"Играчи\",\n        \"version\": \"Верзија\",\n        \"status\": \"Стање\",\n        \"up\": \"На мрежи\",\n        \"down\": \"Није на мрежи\"\n    },\n    \"miniflux\": {\n        \"read\": \"Прочитано\",\n        \"unread\": \"Непрочитано\"\n    },\n    \"authentik\": {\n        \"users\": \"Корисника\",\n        \"loginsLast24H\": \"Пријаве (24ч)\",\n        \"failedLoginsLast24H\": \"Неуспешне пријаве (24ч)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"Меморија\",\n        \"cpu\": \"Процесор\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"glances\": {\n        \"cpu\": \"Процесор\",\n        \"load\": \"Учитавање\",\n        \"wait\": \"Молим сачекајте\",\n        \"temp\": \"Температура\",\n        \"_temp\": \"Темп.\",\n        \"warn\": \"Упоз.\",\n        \"uptime\": \"Активно\",\n        \"total\": \"Укупно\",\n        \"free\": \"Слободно\",\n        \"used\": \"У употреби\",\n        \"days\": \"д\",\n        \"hours\": \"ч\",\n        \"crit\": \"Крит.\",\n        \"read\": \"Прочитано\",\n        \"write\": \"Уписа\",\n        \"gpu\": \"Граф.\",\n        \"mem\": \"Мем.\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Обележивачи\",\n        \"service\": \"Услуга\",\n        \"search\": \"Претрага\",\n        \"custom\": \"Прилагођено\",\n        \"visit\": \"Посети\",\n        \"url\": \"УРЛ адреса\",\n        \"searchsuggestion\": \"Предлози\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Сунчано\",\n        \"0-night\": \"Ведро\",\n        \"1-day\": \"Прережно сунчано\",\n        \"1-night\": \"Претежно ведро\",\n        \"2-day\": \"Делимично облачно\",\n        \"2-night\": \"Делимично облачно\",\n        \"3-day\": \"Облачно\",\n        \"3-night\": \"Облачно\",\n        \"45-day\": \"Магловито\",\n        \"45-night\": \"Магловито\",\n        \"48-day\": \"Магловито\",\n        \"48-night\": \"Магловито\",\n        \"51-day\": \"Слаба киша\",\n        \"51-night\": \"Слаба киша\",\n        \"53-day\": \"Слаба киша\",\n        \"53-night\": \"Слаба киша\",\n        \"55-day\": \"Јака киша\",\n        \"55-night\": \"Јака киша\",\n        \"56-day\": \"Слаба ледена киша\",\n        \"56-night\": \"Слаба ледена киша\",\n        \"57-day\": \"Ледена киша\",\n        \"57-night\": \"Ледена киша\",\n        \"61-day\": \"Слаба киша\",\n        \"61-night\": \"Слаба киша\",\n        \"63-day\": \"Киша\",\n        \"63-night\": \"Киша\",\n        \"65-day\": \"Јака киша\",\n        \"65-night\": \"Јака киша\",\n        \"66-day\": \"Ледена киша\",\n        \"66-night\": \"Ледена киша\",\n        \"67-day\": \"Ледена киша\",\n        \"67-night\": \"Ледена киша\",\n        \"71-day\": \"Слаб снег\",\n        \"71-night\": \"Слаб снег\",\n        \"73-day\": \"Снег\",\n        \"73-night\": \"Снег\",\n        \"75-day\": \"Јак снег\",\n        \"75-night\": \"Јак снег\",\n        \"77-day\": \"Снежна зрна\",\n        \"77-night\": \"Снежна зрна\",\n        \"80-day\": \"Слаби пљускови\",\n        \"80-night\": \"Слаби пљускови\",\n        \"81-day\": \"Пљускови\",\n        \"81-night\": \"Пљускови\",\n        \"82-day\": \"Јаки пљускови\",\n        \"82-night\": \"Јаки пљускови\",\n        \"85-day\": \"Снежне падавине\",\n        \"85-night\": \"Снежне падавине\",\n        \"86-day\": \"Снежне падавине\",\n        \"86-night\": \"Снежне падавине\",\n        \"95-day\": \"Грмљавина\",\n        \"95-night\": \"Грмљавина\",\n        \"96-day\": \"Грмљавина са градом\",\n        \"96-night\": \"Грмљавина са градом\",\n        \"99-day\": \"Грмљавина са градом\",\n        \"99-night\": \"Грмљавина са градом\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Систем\",\n        \"updates\": \"Ажурирања\",\n        \"update_available\": \"Доступно ажурирање\",\n        \"up_to_date\": \"Ажурирано\",\n        \"child_bridges\": \"Мостови потомака\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Горе\",\n        \"pending\": \"На чекању\",\n        \"down\": \"Доле\",\n        \"ok\": \"Ок\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Сада\",\n        \"up\": \"Горе\",\n        \"grace\": \"У грејс периоду\",\n        \"down\": \"Доле\",\n        \"paused\": \"Паузирано\",\n        \"status\": \"Стање\",\n        \"last_ping\": \"Последњи пинг\",\n        \"never\": \"Још без пинга\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Скенирано\",\n        \"containers_updated\": \"Ажурирано\",\n        \"containers_failed\": \"Неуспешно\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Одобрено\",\n        \"rejectedPushes\": \"Одбијено\",\n        \"filters\": \"Филтери\",\n        \"indexers\": \"Индексери\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Ред\",\n        \"videos\": \"Видеи\",\n        \"channels\": \"Канали\",\n        \"playlists\": \"Плејлисте\"\n    },\n    \"truenas\": {\n        \"load\": \"Заузеће система\",\n        \"uptime\": \"Време рада\",\n        \"alerts\": \"Упозорења\"\n    },\n    \"pyload\": {\n        \"speed\": \"Брзина\",\n        \"active\": \"Активно\",\n        \"queue\": \"Ред\",\n        \"total\": \"Укупно\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Јавна IP адреса\",\n        \"region\": \"Регион\",\n        \"country\": \"Држава\",\n        \"port_forwarded\": \"Порт прослеђен\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Канали\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Тјунери\",\n        \"channelNumber\": \"Канал\",\n        \"channelNetwork\": \"Мрежа\",\n        \"signalStrength\": \"Јачина\",\n        \"signalQuality\": \"Количина\",\n        \"symbolQuality\": \"Количина\",\n        \"networkRate\": \"Проток\",\n        \"clientIP\": \"Клијент\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Пређено\",\n        \"failed\": \"Неуспешно\",\n        \"unknown\": \"Непознато\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Примљено\",\n        \"total\": \"Укупно\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Организације\",\n        \"sites\": \"Сајтови\",\n        \"resources\": \"Ресурси\",\n        \"targets\": \"Циљеви\",\n        \"traffic\": \"Саобраћај\",\n        \"in\": \"Улазак\",\n        \"out\": \"Излазак\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Напуњеност батерије\",\n        \"ups_load\": \"Оптерећење УПС-а\",\n        \"ups_status\": \"Статус УПС-а\",\n        \"online\": \"На мрежи\",\n        \"on_battery\": \"На батерији\",\n        \"low_battery\": \"Низак ниво батерије\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Молим сачекајте\",\n        \"no_devices\": \"Нису примљени подаци са уређаја\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"Заузеће процесора\",\n        \"memoryUsed\": \"Заузеће меморије\",\n        \"uptime\": \"Време рада\",\n        \"numberOfLeases\": \"Закупи\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Сви стримови\",\n        \"streams_active\": \"Активно\",\n        \"streams_xepg\": \"XEPG канали\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Данас\",\n        \"absolutePower\": \"Енергија\",\n        \"relativePower\": \"% Енергије\",\n        \"limit\": \"Лимитер\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"Заузеће процесора\",\n        \"memory\": \"Активна меморија\",\n        \"wanUpload\": \"WAN слање\",\n        \"wanDownload\": \"WAN примање\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Стање штампача\",\n        \"print_status\": \"Статус штампања\",\n        \"print_progress\": \"Напредак\",\n        \"layers\": \"Слојеви\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Стање\",\n        \"temp_tool\": \"Температура алата\",\n        \"temp_bed\": \"Температура постоља\",\n        \"job_completion\": \"Завршетак\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Порекло IP адресе\",\n        \"status\": \"Стање\"\n    },\n    \"pfsense\": {\n        \"load\": \"Просечно оптерећење\",\n        \"memory\": \"Заузеће меморије\",\n        \"wanStatus\": \"WAN статус\",\n        \"up\": \"Горе\",\n        \"down\": \"Доле\",\n        \"temp\": \"Темп.\",\n        \"disk\": \"Коришћење диска\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Складиште података\",\n        \"failed_tasks_24h\": \"Неуспешни задаци 24ч\",\n        \"cpu_usage\": \"Процесор\",\n        \"memory_usage\": \"Меморија\"\n    },\n    \"immich\": {\n        \"users\": \"Корисника\",\n        \"photos\": \"Фотографије\",\n        \"videos\": \"Видеи\",\n        \"storage\": \"Складиште\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Локација активно\",\n        \"down\": \"Локација неактивно\",\n        \"uptime\": \"Време рада\",\n        \"incident\": \"Инцидент\",\n        \"m\": \"м\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Серије\",\n        \"archives\": \"Архиве\",\n        \"chapters\": \"Поглавља\",\n        \"categories\": \"Категорије\"\n    },\n    \"komga\": {\n        \"libraries\": \"Библиотеке\",\n        \"series\": \"Серије\",\n        \"books\": \"Књиге\"\n    },\n    \"diskstation\": {\n        \"days\": \"Дана\",\n        \"uptime\": \"Време рада\",\n        \"volumeAvailable\": \"Доступно\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Канали\",\n        \"streams\": \"Стримови\"\n    },\n    \"mylar\": {\n        \"series\": \"Серије\",\n        \"issues\": \"Издања\",\n        \"wanted\": \"Тражено\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Албуми\",\n        \"photos\": \"Фотографије\",\n        \"videos\": \"Видеи\",\n        \"people\": \"Особе\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Ред\",\n        \"processing\": \"Обрада\",\n        \"processed\": \"Обрада\",\n        \"time\": \"Време\"\n    },\n    \"firefly\": {\n        \"networth\": \"Нето вредност\",\n        \"budget\": \"Буџет\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Командна табла\",\n        \"datasources\": \"Извори података\",\n        \"totalalerts\": \"Укупно обавешења\",\n        \"alertstriggered\": \"Покренута упозорења\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Заузеће процесора\",\n        \"memoryusage\": \"Заузеће меморије\",\n        \"freespace\": \"Слободан простор\",\n        \"activeusers\": \"Активни корисници\",\n        \"numfiles\": \"Датотеке\",\n        \"numshares\": \"Ставке подељене\"\n    },\n    \"kopia\": {\n        \"status\": \"Стање\",\n        \"size\": \"Величина\",\n        \"lastrun\": \"Последње покретање\",\n        \"nextrun\": \"Счедеће покретање\",\n        \"failed\": \"Неуспешно\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Активни радници\",\n        \"total_workers\": \"Укупно радника\",\n        \"records_total\": \"Дужина реда\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Сервери\",\n        \"nodes\": \"Чворови\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Циљева активно\",\n        \"targets_down\": \"Циљева неактивно\",\n        \"targets_total\": \"Укупно циљева\"\n    },\n    \"gatus\": {\n        \"up\": \"Локација активно\",\n        \"down\": \"Локација неактивно\",\n        \"uptime\": \"Време рада\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Данас\",\n        \"gross_percent_1y\": \"Једна година\",\n        \"gross_percent_max\": \"Све време\",\n        \"net_worth\": \"Нето вредност\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Подкасти\",\n        \"books\": \"Књиге\",\n        \"podcastsDuration\": \"Трајање\",\n        \"booksDuration\": \"Трајање\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Дом људи\",\n        \"lights_on\": \"Укључена светла\",\n        \"switches_on\": \"Укључени прекидачи\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Надгледање\",\n        \"updates\": \"Ажурирања\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Књиге\",\n        \"authors\": \"Аутори\",\n        \"categories\": \"Категорије\",\n        \"series\": \"Серије\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Библиотеке\",\n        \"books\": \"Књиге\",\n        \"reading\": \"Читање\",\n        \"finished\": \"Завршено\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Ред\",\n        \"downloadBytesRemaining\": \"Преостало\",\n        \"downloadTotalBytes\": \"Величина\",\n        \"downloadSpeed\": \"Брзина\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Серије\",\n        \"totalFiles\": \"Датотеке\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Резултат\",\n        \"status\": \"Стање\",\n        \"buildId\": \"ИД верзије\",\n        \"succeeded\": \"Успело\",\n        \"notStarted\": \"Није започето\",\n        \"failed\": \"Неуспешно\",\n        \"canceled\": \"Отказано\",\n        \"inProgress\": \"У току\",\n        \"totalPrs\": \"Укупно ПР\",\n        \"myPrs\": \"Моји ПР\",\n        \"approved\": \"Одобрено\"\n    },\n    \"gamedig\": {\n        \"status\": \"Стање\",\n        \"online\": \"На мрежи\",\n        \"offline\": \"Није на мрежи\",\n        \"name\": \"Назив\",\n        \"map\": \"Мапа\",\n        \"currentPlayers\": \"Тренутни играчи\",\n        \"players\": \"Играчи\",\n        \"maxPlayers\": \"Максимално играча\",\n        \"bots\": \"Ботови\",\n        \"ping\": \"Пинг\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ок\",\n        \"errored\": \"Грешке\",\n        \"noRecent\": \"Застарели\",\n        \"totalUsed\": \"Искоришћени простор\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Рецепти\",\n        \"users\": \"Корисника\",\n        \"categories\": \"Категорије\",\n        \"tags\": \"Ознаке\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Преузимање\",\n        \"total\": \"Укупно\",\n        \"running\": \"Покренуто\",\n        \"stopped\": \"Заустављено\",\n        \"passed\": \"Пређено\",\n        \"failed\": \"Неуспешно\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Време рада\",\n        \"cpuLoad\": \"Просечно оптерећење процесора (5 мин)\",\n        \"up\": \"Горе\",\n        \"down\": \"Доле\",\n        \"bytesTx\": \"Пренесено\",\n        \"bytesRx\": \"Примљено\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Стање\",\n        \"uptime\": \"Време рада\",\n        \"lastDown\": \"Последњи прекид рада\",\n        \"downDuration\": \"Трајање прекида рада\",\n        \"sitesUp\": \"Локација активно\",\n        \"sitesDown\": \"Локација неактивно\",\n        \"paused\": \"Паузирано\",\n        \"notyetchecked\": \"Још није проверено\",\n        \"up\": \"Горе\",\n        \"seemsdown\": \"Делује неактивно\",\n        \"down\": \"Доле\",\n        \"unknown\": \"Непознато\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"У биоскопима\",\n        \"physicalRelease\": \"Физичко издање\",\n        \"digitalRelease\": \"Дигитално иѕдање\",\n        \"noEventsToday\": \"Нема догађаја за данас!\",\n        \"noEventsFound\": \"Није пронађен ниједан догађај\",\n        \"errorWhenLoadingData\": \"Грешка при учитавању података календара\"\n    },\n    \"romm\": {\n        \"platforms\": \"Платформе\",\n        \"totalRoms\": \"Игре\",\n        \"saves\": \"Сачувано\",\n        \"states\": \"Стања\",\n        \"screenshots\": \"Снимци екрана\",\n        \"totalfilesize\": \"Укупна величина\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Домени\",\n        \"mailboxes\": \"Сандучићи\",\n        \"mails\": \"Пошта\",\n        \"storage\": \"Складиште\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Упозорења\",\n        \"criticals\": \"Критично\"\n    },\n    \"plantit\": {\n        \"events\": \"Догађаји\",\n        \"plants\": \"Биљке\",\n        \"photos\": \"Фотографије\",\n        \"species\": \"Врсте\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Обавештења\",\n        \"issues\": \"Издања\",\n        \"pulls\": \"Захтеви за спајање\",\n        \"repositories\": \"Ризнице\"\n    },\n    \"stash\": {\n        \"scenes\": \"Сцене\",\n        \"scenesPlayed\": \"Одигране сцене\",\n        \"playCount\": \"Укупан број репродукција\",\n        \"playDuration\": \"Време гледања\",\n        \"sceneSize\": \"Величина сцена\",\n        \"sceneDuration\": \"Трајање сцена\",\n        \"images\": \"Слике\",\n        \"imageSize\": \"Величина слика\",\n        \"galleries\": \"Галерије\",\n        \"performers\": \"Извођачи\",\n        \"studios\": \"Студији\",\n        \"movies\": \"Филмови\",\n        \"tags\": \"Ознаке\",\n        \"oCount\": \"О број\"\n    },\n    \"tandoor\": {\n        \"users\": \"Корисника\",\n        \"recipes\": \"Рецепти\",\n        \"keywords\": \"Кључне речи\"\n    },\n    \"homebox\": {\n        \"items\": \"Ставке\",\n        \"totalWithWarranty\": \"Са гаранцијом\",\n        \"locations\": \"Локације\",\n        \"labels\": \"Ознаке\",\n        \"users\": \"Корисника\",\n        \"totalValue\": \"Укупна вредност\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Упозорења\",\n        \"bans\": \"Забране\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Повезано\",\n        \"enabled\": \"Омогућено\",\n        \"disabled\": \"Онемогућено\",\n        \"total\": \"Укупно\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Проксирано\",\n        \"auth\": \"Са ауторизацијом\",\n        \"outdated\": \"Застарело\",\n        \"banned\": \"Бановано\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Пинг\",\n        \"download\": \"Преузимање\",\n        \"upload\": \"Слање\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Акције\",\n        \"loading\": \"Учитавање\",\n        \"open\": \"Отворено - Америчко тржиште\",\n        \"closed\": \"Затворено - Америчко тржиште\",\n        \"invalidConfiguration\": \"Неважећа конфигурација\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Камере\",\n        \"uptime\": \"Време рада\",\n        \"version\": \"Верзија\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Везе\",\n        \"collections\": \"Колекције\",\n        \"tags\": \"Ознаке\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Није класификовано\",\n        \"information\": \"Информација\",\n        \"warning\": \"Упозорење\",\n        \"average\": \"Просечно\",\n        \"high\": \"Високо\",\n        \"disaster\": \"Катастрофа\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Возило\",\n        \"vehicles\": \"Возила\",\n        \"serviceRecords\": \"Сервисни записи\",\n        \"reminders\": \"Подсетници\",\n        \"nextReminder\": \"Следећи подсетник\",\n        \"none\": \"Без\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Активни пројекти\",\n        \"tasks7d\": \"Задаци за ову недељу\",\n        \"tasksOverdue\": \"Закаснели задаци\",\n        \"tasksInProgress\": \"Задаци у току\"\n    },\n    \"headscale\": {\n        \"name\": \"Назив\",\n        \"address\": \"Адреса\",\n        \"last_seen\": \"Последње виђен\",\n        \"status\": \"Стање\",\n        \"online\": \"На мрежи\",\n        \"offline\": \"Није на мрежи\"\n    },\n    \"beszel\": {\n        \"name\": \"Назив\",\n        \"systems\": \"Системи\",\n        \"up\": \"Горе\",\n        \"down\": \"Доле\",\n        \"paused\": \"Паузирано\",\n        \"pending\": \"На чекању\",\n        \"status\": \"Стање\",\n        \"updated\": \"Ажурирано\",\n        \"cpu\": \"Процесор\",\n        \"memory\": \"Меморија\",\n        \"disk\": \"Диск\",\n        \"network\": \"Мрежа\"\n    },\n    \"argocd\": {\n        \"apps\": \"Апликације\",\n        \"synced\": \"Синхронизовано\",\n        \"outOfSync\": \"Ван синхронизације\",\n        \"healthy\": \"Здравих\",\n        \"degraded\": \"Деградирано\",\n        \"progressing\": \"Напредак\",\n        \"missing\": \"Недостаје\",\n        \"suspended\": \"Суспендовано\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Учитавање\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Групе\",\n        \"issues\": \"Издања\",\n        \"merges\": \"Захтеви за спајање\",\n        \"projects\": \"Пројекти\"\n    },\n    \"apcups\": {\n        \"status\": \"Стање\",\n        \"load\": \"Учитавање\",\n        \"bcharge\": \"Напуњеност батерије\",\n        \"timeleft\": \"Преостало време\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Обележивачи\",\n        \"favorites\": \"Омиљено\",\n        \"archived\": \"Архивирано\",\n        \"highlights\": \"Истакнуто\",\n        \"lists\": \"Листе\",\n        \"tags\": \"Ознаке\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Мрежа\",\n        \"connected\": \"Повезано\",\n        \"disconnected\": \"Прекинуто\",\n        \"updateStatus\": \"Ажурирај\",\n        \"update_yes\": \"Доступно\",\n        \"update_no\": \"Ажурирано\",\n        \"downloads\": \"Преузимање\",\n        \"uploads\": \"Слање\",\n        \"sharedFiles\": \"Датотеке\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Песме\",\n        \"movies\": \"Филмови\",\n        \"episodes\": \"Епизоде\",\n        \"other\": \"Остало\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Проблеми са услугом\",\n        \"hostErrors\": \"Проблеми са хостом\"\n    },\n    \"komodo\": {\n        \"total\": \"Укупно\",\n        \"running\": \"Покренуто\",\n        \"stopped\": \"Заустављено\",\n        \"down\": \"Доле\",\n        \"unhealthy\": \"Нездравих\",\n        \"unknown\": \"Непознато\",\n        \"servers\": \"Сервери\",\n        \"stacks\": \"Стекови\",\n        \"containers\": \"Контејнера\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Доступно\",\n        \"used\": \"У употреби\",\n        \"total\": \"Укупно\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Претплате\",\n        \"thisMonthlyCost\": \"Овај мјесец\",\n        \"nextMonthlyCost\": \"Следећи месец\",\n        \"previousMonthlyCost\": \"Претходни месец\",\n        \"nextRenewingSubscription\": \"Следећа уплата\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Покренуто\",\n        \"STOPPED\": \"Заустављено\",\n        \"NEW_ARRAY\": \"Нови Array\",\n        \"RECON_DISK\": \"Реконструкција диска\",\n        \"DISABLE_DISK\": \"Диск је онемогућен\",\n        \"SWAP_DSBL\": \"Swap је онемогућен\",\n        \"INVALID_EXPANSION\": \"Неважеће проширење\",\n        \"PARITY_NOT_BIGGEST\": \"Паритет није највећи\",\n        \"TOO_MANY_MISSING_DISKS\": \"Превише недостајућих дискова\",\n        \"NEW_DISK_TOO_SMALL\": \"Нови диск је премали\",\n        \"NO_DATA_DISKS\": \"Нема дискова са подацима\",\n        \"notifications\": \"Обавештења\",\n        \"status\": \"Статус\",\n        \"cpu\": \"Процесор\",\n        \"memoryUsed\": \"Искоришћена меморија\",\n        \"memoryAvailable\": \"Доступна меморија\",\n        \"arrayUsed\": \"Коришћени Array\",\n        \"arrayFree\": \"Слободан Array\",\n        \"poolUsed\": \"{{pool}} коришћено\",\n        \"poolFree\": \"{{pool}} слободно\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Планови\",\n        \"num_success_30\": \"Успешно\",\n        \"num_failure_30\": \"Неуспешно\",\n        \"num_success_latest\": \"Успевајући\",\n        \"num_failure_latest\": \"Неуспешно\",\n        \"bytes_added_30\": \"Додати бајтови\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Песме\",\n        \"time\": \"Време\",\n        \"artists\": \"Извођачи\"\n    },\n    \"arcane\": {\n        \"containers\": \"Контејнера\",\n        \"images\": \"Слике\",\n        \"image_updates\": \"Ажурирања слика\",\n        \"images_unused\": \"Неискоришћено\",\n        \"environment_required\": \"ИД окружења је обавезан\"\n    },\n    \"dockhand\": {\n        \"running\": \"Покренуто\",\n        \"stopped\": \"Заустављено\",\n        \"cpu\": \"Процесор\",\n        \"memory\": \"Меморија\",\n        \"images\": \"Слике\",\n        \"volumes\": \"Јачине звука\",\n        \"events_today\": \"Данашњи догађаји\",\n        \"pending_updates\": \"Ажурирања на чекању\",\n        \"stacks\": \"Стекови\",\n        \"paused\": \"Паузирано\",\n        \"total\": \"Укупно\",\n        \"environment_not_found\": \"Окружење није пронађено\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/sv/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"mån\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Saknar Widget-typ: {{type}}\",\n        \"api_error\": \"API-fel\",\n        \"information\": \"Information\",\n        \"status\": \"Status\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Raw Error\",\n        \"response_data\": \"Response Data\"\n    },\n    \"weather\": {\n        \"current\": \"Nuvarande plats\",\n        \"allow\": \"Klicka för att tillåta\",\n        \"updating\": \"Uppdaterar\",\n        \"wait\": \"Vänligen vänta\"\n    },\n    \"search\": {\n        \"placeholder\": \"Sök…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"MEM\",\n        \"total\": \"Total\",\n        \"free\": \"Ledigt\",\n        \"used\": \"Använt\",\n        \"load\": \"Laddar\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Max\",\n        \"uptime\": \"UP\"\n    },\n    \"unifi\": {\n        \"users\": \"Användare\",\n        \"uptime\": \"Uptime\",\n        \"days\": \"Dagar\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Enheter\",\n        \"lan_devices\": \"LAN Devices\",\n        \"wlan_devices\": \"WLAN Devices\",\n        \"lan_users\": \"LAN-användare\",\n        \"wlan_users\": \"WLAN-användare\",\n        \"up\": \"UP\",\n        \"down\": \"MOTTAGIT\",\n        \"wait\": \"Please wait\",\n        \"empty_data\": \"Subsystem status unknown\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Running\",\n        \"offline\": \"Offline\",\n        \"error\": \"Error\",\n        \"unknown\": \"Unknown\",\n        \"healthy\": \"Healthy\",\n        \"starting\": \"Starting\",\n        \"unhealthy\": \"Unhealthy\",\n        \"not_found\": \"Not Found\",\n        \"exited\": \"Exited\",\n        \"partial\": \"Partial\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"Ping\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP status\",\n        \"error\": \"Error\",\n        \"response\": \"Response\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"emby\": {\n        \"playing\": \"Spelar\",\n        \"transcoding\": \"Omkodning\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"Inga aktiva strömmar\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Avsnitt\",\n        \"songs\": \"Songs\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Online\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Production\",\n        \"battery_soc\": \"Battery\",\n        \"grid_power\": \"Grid\",\n        \"home_power\": \"Consumption\",\n        \"charge_power\": \"Charger\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Subscriptions\",\n        \"unread\": \"Unread\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Unconfigured\",\n        \"connectionStatusConnecting\": \"Connecting\",\n        \"connectionStatusAuthenticating\": \"Authenticating\",\n        \"connectionStatusPendingDisconnect\": \"Pending Disconnect\",\n        \"connectionStatusDisconnecting\": \"Disconnecting\",\n        \"connectionStatusDisconnected\": \"Disconnected\",\n        \"connectionStatusConnected\": \"Connected\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"Max. Down\",\n        \"maxUp\": \"Max. Up\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"Received\",\n        \"sent\": \"Sent\",\n        \"externalIPAddress\": \"Ext. IP\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Current requests\",\n        \"requests_failed\": \"Failed requests\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Total Observed\",\n        \"diffsDetected\": \"Diffs Detected\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Shows\",\n        \"recordings\": \"Recordings\",\n        \"scheduled\": \"Scheduled\",\n        \"passes\": \"Passes\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"Check Plex Connection\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Connected APs\",\n        \"activeUser\": \"Active devices\",\n        \"alerts\": \"Alerts\",\n        \"connectedGateways\": \"Connected gateways\",\n        \"connectedSwitches\": \"Connected switches\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Hastighet\",\n        \"remaining\": \"Återstående\",\n        \"downloaded\": \"Nedladdat\"\n    },\n    \"plex\": {\n        \"streams\": \"Aktiva strömmar\",\n        \"albums\": \"Albums\",\n        \"movies\": \"Movies\",\n        \"tv\": \"TV-serier\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"Kö\",\n        \"timeleft\": \"Tid kvar\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Aktiva\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU Usage\",\n        \"memUsage\": \"MEM Usage\",\n        \"systemTempC\": \"System Temp\",\n        \"poolUsage\": \"Pool Usage\",\n        \"volumeUsage\": \"Volume Usage\",\n        \"invalid\": \"Invalid\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Eftersöker\",\n        \"queued\": \"I kö\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"Missing\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"Artists\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"Böcker\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Saknade program\",\n        \"missingMovies\": \"Saknade filmer\"\n    },\n    \"ombi\": {\n        \"pending\": \"Avvaktar\",\n        \"approved\": \"Godkända\",\n        \"available\": \"Tillgänglig\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"New Devices\",\n        \"down_alerts\": \"Down Alerts\"\n    },\n    \"pihole\": {\n        \"queries\": \"Förfrågningar\",\n        \"blocked\": \"Blockerad\",\n        \"blocked_percent\": \"Blocked %\",\n        \"gravity\": \"Gravity\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"Filtrerad\",\n        \"latency\": \"Svarstid\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stoppade\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Non-Downloaded\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Downloaded & Read\",\n        \"downloadedunread\": \"Downloaded & Unread\",\n        \"nondownloadedread\": \"Non-Downloaded & Read\",\n        \"nondownloadedunread\": \"Non-Downloaded & Unread\"\n    },\n    \"tailscale\": {\n        \"address\": \"Address\",\n        \"expires\": \"Expires\",\n        \"never\": \"Never\",\n        \"last_seen\": \"Last Seen\",\n        \"now\": \"Now\",\n        \"years\": \"{{number}}y\",\n        \"weeks\": \"{{number}}w\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Ago\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"Success\",\n        \"totalServerFailure\": \"Failures\",\n        \"totalNxDomain\": \"NX Domains\",\n        \"totalRefused\": \"Refused\",\n        \"totalAuthoritative\": \"Authoritative\",\n        \"totalRecursive\": \"Recursive\",\n        \"totalCached\": \"Cached\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"Dropped\",\n        \"totalClients\": \"Klienter\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Processed\",\n        \"errored\": \"Errored\",\n        \"saved\": \"Saved\"\n    },\n    \"traefik\": {\n        \"routers\": \"Routers\",\n        \"services\": \"Tjänster\",\n        \"middleware\": \"Middleware\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Please Wait\"\n    },\n    \"npm\": {\n        \"enabled\": \"Aktiverad\",\n        \"disabled\": \"Inaktiverad\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Konfigurera en eller flera kryptovalutor att följa\",\n        \"1hour\": \"1 timme\",\n        \"1day\": \"1 dag\",\n        \"7days\": \"7 dagar\",\n        \"30days\": \"30 dagar\"\n    },\n    \"gotify\": {\n        \"apps\": \"Program\",\n        \"clients\": \"Clients\",\n        \"messages\": \"Meddelande\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indexerare\",\n        \"numberOfGrabs\": \"Hämtningar\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"Misslyckade hämtningar\",\n        \"numberOfFailQueries\": \"Misslyckade hämtningar\"\n    },\n    \"jackett\": {\n        \"configured\": \"Konfigurerade\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sessioner\",\n        \"numConnections\": \"Anslutningar\",\n        \"dataRelayed\": \"Relayed\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"Posts\",\n        \"domain_count\": \"Domains\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Players\",\n        \"version\": \"Version\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Read\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"Inloggningar (24h)\",\n        \"failedLoginsLast24H\": \"Misslyckade inloggningar (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temp\",\n        \"warn\": \"Warn\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Crit\",\n        \"read\": \"Read\",\n        \"write\": \"Write\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Bookmark\",\n        \"service\": \"Service\",\n        \"search\": \"Sök\",\n        \"custom\": \"Custom\",\n        \"visit\": \"Visit\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Suggestion\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Sunny\",\n        \"0-night\": \"Clear\",\n        \"1-day\": \"Mainly Sunny\",\n        \"1-night\": \"Mainly Clear\",\n        \"2-day\": \"Partly Cloudy\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"Cloudy\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"Foggy\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"Light Drizzle\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"Drizzle\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"Heavy Drizzle\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"Light Freezing Drizzle\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"Freezing Drizzle\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"Light Rain\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"Rain\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Heavy Rain\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Freezing Rain\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Light Snow\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Snow\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Heavy Snow\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Snow Grains\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Light Showers\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Showers\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Heavy Showers\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Snow Showers\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"Thunderstorm\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Thunderstorm With Hail\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"System\",\n        \"updates\": \"Updates\",\n        \"update_available\": \"Update Available\",\n        \"up_to_date\": \"Up to Date\",\n        \"child_bridges\": \"Child Bridges\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"New\",\n        \"up\": \"Up\",\n        \"grace\": \"In Grace Period\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Last Ping\",\n        \"never\": \"No pings yet\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Scanned\",\n        \"containers_updated\": \"Updated\",\n        \"containers_failed\": \"Failed\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Rejected\",\n        \"filters\": \"Filters\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Videos\",\n        \"channels\": \"Channels\",\n        \"playlists\": \"Playlists\"\n    },\n    \"truenas\": {\n        \"load\": \"System Load\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Speed\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Public IP\",\n        \"region\": \"Region\",\n        \"country\": \"Country\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tuners\",\n        \"channelNumber\": \"Channel\",\n        \"channelNetwork\": \"Network\",\n        \"signalStrength\": \"Strength\",\n        \"signalQuality\": \"Quality\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Client\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Inbox\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Battery Charge\",\n        \"ups_load\": \"UPS Load\",\n        \"ups_status\": \"UPS Status\",\n        \"online\": \"Online\",\n        \"on_battery\": \"On Battery\",\n        \"low_battery\": \"Low Battery\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"No Device Data Received\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"CPU Load\",\n        \"memoryUsed\": \"Memory Used\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Leases\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"All Streams\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG Channels\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Today\",\n        \"absolutePower\": \"Power\",\n        \"relativePower\": \"Power %\",\n        \"limit\": \"Limit\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Active Memory\",\n        \"wanUpload\": \"WAN Upload\",\n        \"wanDownload\": \"WAN Download\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Printer State\",\n        \"print_status\": \"Print Status\",\n        \"print_progress\": \"Progress\",\n        \"layers\": \"Layers\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Tool temp\",\n        \"temp_bed\": \"Bed temp\",\n        \"job_completion\": \"Completion\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Origin IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Load Avg\",\n        \"memory\": \"Mem Usage\",\n        \"wanStatus\": \"WAN Status\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Disk Usage\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datastore\",\n        \"failed_tasks_24h\": \"Failed Tasks 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memory\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Storage\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Incident\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Archives\",\n        \"chapters\": \"Chapters\",\n        \"categories\": \"Categories\"\n    },\n    \"komga\": {\n        \"libraries\": \"Libraries\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Issues\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"People\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Time\"\n    },\n    \"firefly\": {\n        \"networth\": \"Net Worth\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Dashboards\",\n        \"datasources\": \"Data Sources\",\n        \"totalalerts\": \"Total Alerts\",\n        \"alertstriggered\": \"Alerts Triggered\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Cpu Load\",\n        \"memoryusage\": \"Memory Usage\",\n        \"freespace\": \"Free Space\",\n        \"activeusers\": \"Active Users\",\n        \"numfiles\": \"Files\",\n        \"numshares\": \"Shared Items\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Size\",\n        \"lastrun\": \"Last Run\",\n        \"nextrun\": \"Next Run\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Active Workers\",\n        \"total_workers\": \"Total Workers\",\n        \"records_total\": \"Queue Length\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servers\",\n        \"nodes\": \"Nodes\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Targets Up\",\n        \"targets_down\": \"Targets Down\",\n        \"targets_total\": \"Total Targets\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"One year\",\n        \"gross_percent_max\": \"All time\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasts\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Duration\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"People Home\",\n        \"lights_on\": \"Lights On\",\n        \"switches_on\": \"Switches On\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Monitoring\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Authors\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Result\",\n        \"status\": \"Status\",\n        \"buildId\": \"Build ID\",\n        \"succeeded\": \"Succeeded\",\n        \"notStarted\": \"Not Started\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Canceled\",\n        \"inProgress\": \"In Progress\",\n        \"totalPrs\": \"Total PRs\",\n        \"myPrs\": \"My PRs\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Name\",\n        \"map\": \"Map\",\n        \"currentPlayers\": \"Current players\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Max players\",\n        \"bots\": \"Bots\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Errors\",\n        \"noRecent\": \"Out of Date\",\n        \"totalUsed\": \"Used Storage\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Recipes\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Tags\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Downloading\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"CPU Load Avg (5m)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Transmitted\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Last Downtime\",\n        \"downDuration\": \"Downtime Duration\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Not Yet Checked\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Seems Down\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"In cinemas\",\n        \"physicalRelease\": \"Physical release\",\n        \"digitalRelease\": \"Digital release\",\n        \"noEventsToday\": \"No events for today!\",\n        \"noEventsFound\": \"No events found\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platforms\",\n        \"totalRoms\": \"Games\",\n        \"saves\": \"Saves\",\n        \"states\": \"States\",\n        \"screenshots\": \"Screenshots\",\n        \"totalfilesize\": \"Total Size\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Mailboxes\",\n        \"mails\": \"Mails\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Warnings\",\n        \"criticals\": \"Criticals\"\n    },\n    \"plantit\": {\n        \"events\": \"Events\",\n        \"plants\": \"Plants\",\n        \"photos\": \"Photos\",\n        \"species\": \"Species\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Notifications\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Pull Requests\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Scenes\",\n        \"scenesPlayed\": \"Scenes Played\",\n        \"playCount\": \"Total Plays\",\n        \"playDuration\": \"Time Watched\",\n        \"sceneSize\": \"Scenes Size\",\n        \"sceneDuration\": \"Scenes Duration\",\n        \"images\": \"Images\",\n        \"imageSize\": \"Images Size\",\n        \"galleries\": \"Galleries\",\n        \"performers\": \"Performers\",\n        \"studios\": \"Studios\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O Count\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Keywords\"\n    },\n    \"homebox\": {\n        \"items\": \"Items\",\n        \"totalWithWarranty\": \"With Warranty\",\n        \"locations\": \"Locations\",\n        \"labels\": \"Labels\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Total Value\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Bans\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxied\",\n        \"auth\": \"With Auth\",\n        \"outdated\": \"Outdated\",\n        \"banned\": \"Banned\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Stocks\",\n        \"loading\": \"Loading\",\n        \"open\": \"Open - US Market\",\n        \"closed\": \"Closed - US Market\",\n        \"invalidConfiguration\": \"Invalid Configuration\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Cameras\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Länkar\",\n        \"collections\": \"Samlingar\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Not classified\",\n        \"information\": \"Information\",\n        \"warning\": \"Warning\",\n        \"average\": \"Average\",\n        \"high\": \"High\",\n        \"disaster\": \"Disaster\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vehicle\",\n        \"vehicles\": \"Vehicles\",\n        \"serviceRecords\": \"Service Records\",\n        \"reminders\": \"Reminders\",\n        \"nextReminder\": \"Next Reminder\",\n        \"none\": \"None\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Active Projects\",\n        \"tasks7d\": \"Tasks Due This Week\",\n        \"tasksOverdue\": \"Overdue Tasks\",\n        \"tasksInProgress\": \"Tasks In Progress\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Systems\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Disk\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Apps\",\n        \"synced\": \"Synced\",\n        \"outOfSync\": \"Out Of Sync\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Degraded\",\n        \"progressing\": \"Progressing\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Suspended\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Groups\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Projects\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bookmarks\",\n        \"favorites\": \"Favorites\",\n        \"archived\": \"Archived\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Lists\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Other\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/te/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"mo\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"విడ్జెట్ లేదు: {{type}}\",\n        \"api_error\": \"API లోపం\",\n        \"information\": \"Information\",\n        \"status\": \"హోదా\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Raw Error\",\n        \"response_data\": \"Response Data\"\n    },\n    \"weather\": {\n        \"current\": \"ప్రస్తుత స్తలం\",\n        \"allow\": \"అనుమతించడానికి క్లిక్ చేయండి\",\n        \"updating\": \"నవీకరిస్తోంది\",\n        \"wait\": \"దయచేసి వేచి ఉండండి\"\n    },\n    \"search\": {\n        \"placeholder\": \"వెతకండి…\"\n    },\n    \"resources\": {\n        \"cpu\": \"సీపియూ\",\n        \"mem\": \"MEM\",\n        \"total\": \"మొత్తం\",\n        \"free\": \"మిగిలింది\",\n        \"used\": \"ఉపయోగించబడిన\",\n        \"load\": \"లోడ్\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Max\",\n        \"uptime\": \"UP\"\n    },\n    \"unifi\": {\n        \"users\": \"వినియోగదారులు\",\n        \"uptime\": \"Uptime\",\n        \"days\": \"రోజులు\",\n        \"wan\": \"WAN\",\n        \"lan\": \"లాన్\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"పరికరాలు\",\n        \"lan_devices\": \"LAN పరికరాలు\",\n        \"wlan_devices\": \"WLAN పరికరాలు\",\n        \"lan_users\": \"LAN వినియోగదారులు\",\n        \"wlan_users\": \"WLAN వినియోగదారులు\",\n        \"up\": \"UP\",\n        \"down\": \"డౌన్\",\n        \"wait\": \"Please wait\",\n        \"empty_data\": \"Subsystem status unknown\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Running\",\n        \"offline\": \"ఆఫ్‌లైన్\",\n        \"error\": \"Error\",\n        \"unknown\": \"Unknown\",\n        \"healthy\": \"Healthy\",\n        \"starting\": \"Starting\",\n        \"unhealthy\": \"Unhealthy\",\n        \"not_found\": \"Not Found\",\n        \"exited\": \"Exited\",\n        \"partial\": \"Partial\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"Ping\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP status\",\n        \"error\": \"Error\",\n        \"response\": \"Response\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"emby\": {\n        \"playing\": \"ఆడుతున్నారు\",\n        \"transcoding\": \"ట్రాన్స్‌కోడింగ్\",\n        \"bitrate\": \"బిట్రేట్\",\n        \"no_active\": \"యాక్టివ్ స్ట్రీమ్‌లు లేవు\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Online\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Production\",\n        \"battery_soc\": \"Battery\",\n        \"grid_power\": \"Grid\",\n        \"home_power\": \"Consumption\",\n        \"charge_power\": \"Charger\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Subscriptions\",\n        \"unread\": \"Unread\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"Unconfigured\",\n        \"connectionStatusConnecting\": \"Connecting\",\n        \"connectionStatusAuthenticating\": \"Authenticating\",\n        \"connectionStatusPendingDisconnect\": \"Pending Disconnect\",\n        \"connectionStatusDisconnecting\": \"Disconnecting\",\n        \"connectionStatusDisconnected\": \"Disconnected\",\n        \"connectionStatusConnected\": \"Connected\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"Max. Down\",\n        \"maxUp\": \"Max. Up\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"Received\",\n        \"sent\": \"Sent\",\n        \"externalIPAddress\": \"Ext. IP\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Current requests\",\n        \"requests_failed\": \"Failed requests\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"మొత్తం గమనించబడింది\",\n        \"diffsDetected\": \"తేడాలు గుర్తించబడ్డాయి\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Shows\",\n        \"recordings\": \"Recordings\",\n        \"scheduled\": \"Scheduled\",\n        \"passes\": \"Passes\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"Check Plex Connection\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Connected APs\",\n        \"activeUser\": \"Active devices\",\n        \"alerts\": \"Alerts\",\n        \"connectedGateways\": \"Connected gateways\",\n        \"connectedSwitches\": \"Connected switches\"\n    },\n    \"nzbget\": {\n        \"rate\": \"రేట్\",\n        \"remaining\": \"మిగిలింది\",\n        \"downloaded\": \"డౌన్‌లోడ్ చేయబడింది\"\n    },\n    \"plex\": {\n        \"streams\": \"యాక్టివ్ స్ట్రీమ్‌లు\",\n        \"albums\": \"Albums\",\n        \"movies\": \"Movies\",\n        \"tv\": \"దూరదర్శిని కార్యక్రమాలు\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"వరుస\",\n        \"timeleft\": \"మిగిలి వున్న సమయం\"\n    },\n    \"rutorrent\": {\n        \"active\": \"చురుకుగా\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU Usage\",\n        \"memUsage\": \"MEM Usage\",\n        \"systemTempC\": \"System Temp\",\n        \"poolUsage\": \"Pool Usage\",\n        \"volumeUsage\": \"Volume Usage\",\n        \"invalid\": \"Invalid\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"కావలెను\",\n        \"queued\": \"క్యూయూఎడ్\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"మిస్సింగ్\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"Artists\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"పుస్తకాలు\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"ఎపిసోడ్‌లు లేవు\",\n        \"missingMovies\": \"సినిమాలు లేవు\"\n    },\n    \"ombi\": {\n        \"pending\": \"పెండింగ్\",\n        \"approved\": \"ఆమోదించబడింది\",\n        \"available\": \"అందుబాటులో వున్నవి\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"New Devices\",\n        \"down_alerts\": \"Down Alerts\"\n    },\n    \"pihole\": {\n        \"queries\": \"ప్రశ్నలు\",\n        \"blocked\": \"నిరోధించబడింది\",\n        \"blocked_percent\": \"Blocked %\",\n        \"gravity\": \"గురుత్వాకర్షణ\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"ఫిల్టర్ చేయబడింది\",\n        \"latency\": \"జాప్యం\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"ఆగిపోయినవి\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Non-Downloaded\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Downloaded & Read\",\n        \"downloadedunread\": \"Downloaded & Unread\",\n        \"nondownloadedread\": \"Non-Downloaded & Read\",\n        \"nondownloadedunread\": \"Non-Downloaded & Unread\"\n    },\n    \"tailscale\": {\n        \"address\": \"Address\",\n        \"expires\": \"Expires\",\n        \"never\": \"Never\",\n        \"last_seen\": \"Last Seen\",\n        \"now\": \"Now\",\n        \"years\": \"{{number}}y\",\n        \"weeks\": \"{{number}}w\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Ago\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"Success\",\n        \"totalServerFailure\": \"Failures\",\n        \"totalNxDomain\": \"NX Domains\",\n        \"totalRefused\": \"Refused\",\n        \"totalAuthoritative\": \"Authoritative\",\n        \"totalRecursive\": \"Recursive\",\n        \"totalCached\": \"Cached\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"Dropped\",\n        \"totalClients\": \"ఖాతాదారులు\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Processed\",\n        \"errored\": \"Errored\",\n        \"saved\": \"Saved\"\n    },\n    \"traefik\": {\n        \"routers\": \"రౌటర్లు\",\n        \"services\": \"సేవలు\",\n        \"middleware\": \"మిడిల్వేర్\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Please Wait\"\n    },\n    \"npm\": {\n        \"enabled\": \"ప్రారంభించబడింది\",\n        \"disabled\": \"డిసేబ్లెడ్\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"ట్రాక్ చేయడానికి ఒకటి లేదా అంతకంటే ఎక్కువ క్రిప్టో కరెన్సీలను కాన్ఫిగర్ చేయండి\",\n        \"1hour\": \"1 గంట\",\n        \"1day\": \"1 రోజు\",\n        \"7days\": \"7 రోజులు\",\n        \"30days\": \"30 రోజులు\"\n    },\n    \"gotify\": {\n        \"apps\": \"అప్లికేషన్లు\",\n        \"clients\": \"Clients\",\n        \"messages\": \"సందేశాలు\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"సూచికలు\",\n        \"numberOfGrabs\": \"గ్రాబ్స్\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"ఫెయిల్ గ్రాబ్స్\",\n        \"numberOfFailQueries\": \"విఫలమైన ప్రశ్నలు\"\n    },\n    \"jackett\": {\n        \"configured\": \"కాన్ఫిగర్ చేయబడింది\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"సెషన్స్\",\n        \"numConnections\": \"కనెక్షన్లు\",\n        \"dataRelayed\": \"రెలయెడఁ\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"పోస్ట్‌లు\",\n        \"domain_count\": \"డొమైన్‌లు\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Players\",\n        \"version\": \"Version\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Read\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"లాగిన్లు (24గం)\",\n        \"failedLoginsLast24H\": \"విఫలమైన లాగిన్‌లు (24గం)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"విఎంలు\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temp\",\n        \"warn\": \"Warn\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Crit\",\n        \"read\": \"Read\",\n        \"write\": \"Write\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"బుక్మార్క్\",\n        \"service\": \"సేవ\",\n        \"search\": \"Search\",\n        \"custom\": \"Custom\",\n        \"visit\": \"Visit\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Suggestion\"\n    },\n    \"wmo\": {\n        \"0-day\": \"సన్నీ\",\n        \"0-night\": \"స్పష్టమైన\",\n        \"1-day\": \"ప్రధానంగా ఎండ\",\n        \"1-night\": \"ప్రధానంగా స్పష్టంగా\",\n        \"2-day\": \"పాక్షికంగా మేఘావృతమై ఉంటుంది\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"మేఘావృతం\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"పొగమంచు\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"తేలికపాటి చినుకులు\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"చినుకులు\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"భారీ చినుకులు\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"తేలికపాటి గడ్డకట్టే చినుకులు\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"గడ్డకట్టే చినుకులు\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"తేలికపాటి వర్షం\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"వర్షం\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"భారీవర్షం\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"గడ్డకట్టే వర్షం\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"తేలికపాటి మంచు\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"మంచు\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"భారీ మంచు\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"మంచు గింజలు\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"తేలికపాటి జల్లులు\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"జల్లులు\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"భారీ వర్షాలు\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"మంచు జల్లులు\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"ఉరుము\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"వడగళ్లతో కూడిన ఉరుములతో కూడిన వర్షం\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"వ్యవస్థ\",\n        \"updates\": \"నవీకరణలు\",\n        \"update_available\": \"అందుబాటులో నవీకరణ\",\n        \"up_to_date\": \"తాజాగా\",\n        \"child_bridges\": \"పిల్ల వంతెనలు\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"New\",\n        \"up\": \"Up\",\n        \"grace\": \"In Grace Period\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Last Ping\",\n        \"never\": \"No pings yet\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"స్కాన్ చేశారు\",\n        \"containers_updated\": \"నవీకరించబడింది\",\n        \"containers_failed\": \"విఫలమయ్యారు\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"తిరస్కరించారు\",\n        \"filters\": \"ఫిల్టర్లు\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Videos\",\n        \"channels\": \"Channels\",\n        \"playlists\": \"Playlists\"\n    },\n    \"truenas\": {\n        \"load\": \"System Load\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Speed\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Public IP\",\n        \"region\": \"Region\",\n        \"country\": \"Country\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tuners\",\n        \"channelNumber\": \"Channel\",\n        \"channelNetwork\": \"Network\",\n        \"signalStrength\": \"Strength\",\n        \"signalQuality\": \"Quality\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Client\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Inbox\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Battery Charge\",\n        \"ups_load\": \"UPS Load\",\n        \"ups_status\": \"UPS Status\",\n        \"online\": \"Online\",\n        \"on_battery\": \"On Battery\",\n        \"low_battery\": \"Low Battery\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"No Device Data Received\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"CPU Load\",\n        \"memoryUsed\": \"Memory Used\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Leases\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"All Streams\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG Channels\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Today\",\n        \"absolutePower\": \"Power\",\n        \"relativePower\": \"Power %\",\n        \"limit\": \"Limit\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Active Memory\",\n        \"wanUpload\": \"WAN Upload\",\n        \"wanDownload\": \"WAN Download\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Printer State\",\n        \"print_status\": \"Print Status\",\n        \"print_progress\": \"Progress\",\n        \"layers\": \"Layers\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Tool temp\",\n        \"temp_bed\": \"Bed temp\",\n        \"job_completion\": \"Completion\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Origin IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Load Avg\",\n        \"memory\": \"Mem Usage\",\n        \"wanStatus\": \"WAN Status\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Disk Usage\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datastore\",\n        \"failed_tasks_24h\": \"Failed Tasks 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memory\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Storage\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Incident\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Archives\",\n        \"chapters\": \"Chapters\",\n        \"categories\": \"Categories\"\n    },\n    \"komga\": {\n        \"libraries\": \"Libraries\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Issues\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"People\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Time\"\n    },\n    \"firefly\": {\n        \"networth\": \"Net Worth\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Dashboards\",\n        \"datasources\": \"Data Sources\",\n        \"totalalerts\": \"Total Alerts\",\n        \"alertstriggered\": \"Alerts Triggered\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Cpu Load\",\n        \"memoryusage\": \"Memory Usage\",\n        \"freespace\": \"Free Space\",\n        \"activeusers\": \"Active Users\",\n        \"numfiles\": \"Files\",\n        \"numshares\": \"Shared Items\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Size\",\n        \"lastrun\": \"Last Run\",\n        \"nextrun\": \"Next Run\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Active Workers\",\n        \"total_workers\": \"Total Workers\",\n        \"records_total\": \"Queue Length\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servers\",\n        \"nodes\": \"Nodes\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Targets Up\",\n        \"targets_down\": \"Targets Down\",\n        \"targets_total\": \"Total Targets\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"One year\",\n        \"gross_percent_max\": \"All time\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasts\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Duration\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"People Home\",\n        \"lights_on\": \"Lights On\",\n        \"switches_on\": \"Switches On\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Monitoring\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Authors\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Result\",\n        \"status\": \"Status\",\n        \"buildId\": \"Build ID\",\n        \"succeeded\": \"Succeeded\",\n        \"notStarted\": \"Not Started\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Canceled\",\n        \"inProgress\": \"In Progress\",\n        \"totalPrs\": \"Total PRs\",\n        \"myPrs\": \"My PRs\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Name\",\n        \"map\": \"Map\",\n        \"currentPlayers\": \"Current players\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Max players\",\n        \"bots\": \"Bots\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Errors\",\n        \"noRecent\": \"Out of Date\",\n        \"totalUsed\": \"Used Storage\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Recipes\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Tags\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Downloading\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"CPU Load Avg (5m)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Transmitted\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Last Downtime\",\n        \"downDuration\": \"Downtime Duration\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Not Yet Checked\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Seems Down\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"In cinemas\",\n        \"physicalRelease\": \"Physical release\",\n        \"digitalRelease\": \"Digital release\",\n        \"noEventsToday\": \"No events for today!\",\n        \"noEventsFound\": \"No events found\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platforms\",\n        \"totalRoms\": \"Games\",\n        \"saves\": \"Saves\",\n        \"states\": \"States\",\n        \"screenshots\": \"Screenshots\",\n        \"totalfilesize\": \"Total Size\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Mailboxes\",\n        \"mails\": \"Mails\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Warnings\",\n        \"criticals\": \"Criticals\"\n    },\n    \"plantit\": {\n        \"events\": \"Events\",\n        \"plants\": \"Plants\",\n        \"photos\": \"Photos\",\n        \"species\": \"Species\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Notifications\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Pull Requests\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Scenes\",\n        \"scenesPlayed\": \"Scenes Played\",\n        \"playCount\": \"Total Plays\",\n        \"playDuration\": \"Time Watched\",\n        \"sceneSize\": \"Scenes Size\",\n        \"sceneDuration\": \"Scenes Duration\",\n        \"images\": \"Images\",\n        \"imageSize\": \"Images Size\",\n        \"galleries\": \"Galleries\",\n        \"performers\": \"Performers\",\n        \"studios\": \"Studios\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O Count\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Keywords\"\n    },\n    \"homebox\": {\n        \"items\": \"Items\",\n        \"totalWithWarranty\": \"With Warranty\",\n        \"locations\": \"Locations\",\n        \"labels\": \"Labels\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Total Value\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Bans\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxied\",\n        \"auth\": \"With Auth\",\n        \"outdated\": \"Outdated\",\n        \"banned\": \"Banned\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Stocks\",\n        \"loading\": \"Loading\",\n        \"open\": \"Open - US Market\",\n        \"closed\": \"Closed - US Market\",\n        \"invalidConfiguration\": \"Invalid Configuration\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Cameras\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Links\",\n        \"collections\": \"Collections\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Not classified\",\n        \"information\": \"Information\",\n        \"warning\": \"Warning\",\n        \"average\": \"Average\",\n        \"high\": \"High\",\n        \"disaster\": \"Disaster\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vehicle\",\n        \"vehicles\": \"Vehicles\",\n        \"serviceRecords\": \"Service Records\",\n        \"reminders\": \"Reminders\",\n        \"nextReminder\": \"Next Reminder\",\n        \"none\": \"None\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Active Projects\",\n        \"tasks7d\": \"Tasks Due This Week\",\n        \"tasksOverdue\": \"Overdue Tasks\",\n        \"tasksInProgress\": \"Tasks In Progress\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Systems\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Disk\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Apps\",\n        \"synced\": \"Synced\",\n        \"outOfSync\": \"Out Of Sync\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Degraded\",\n        \"progressing\": \"Progressing\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Suspended\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Groups\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Projects\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bookmarks\",\n        \"favorites\": \"Favorites\",\n        \"archived\": \"Archived\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Lists\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Other\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/th/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"mo\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"minutes\": \"m\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"ประเภทวิดเจ็ตหาย: {{type}}\",\n        \"api_error\": \"API มีข้อผิดพลาด\",\n        \"information\": \"ข้อมูล\",\n        \"status\": \"สถานะ\",\n        \"url\": \"URL\",\n        \"raw_error\": \"ข้อมูลต้นฉบับผิดพลาด\",\n        \"response_data\": \"ข้อมูลการตอบกลับ\"\n    },\n    \"weather\": {\n        \"current\": \"สถานที่ปัจจุบัน\",\n        \"allow\": \"คลิกเพื่ออนุญาต\",\n        \"updating\": \"กำลังปรับปรุง\",\n        \"wait\": \"โปรดรอ\"\n    },\n    \"search\": {\n        \"placeholder\": \"ค้นหา…\"\n    },\n    \"resources\": {\n        \"cpu\": \"ซีพียู\",\n        \"mem\": \"เมม\",\n        \"total\": \"ทั้งหมด\",\n        \"free\": \"ฟรี\",\n        \"used\": \"ใช้แล้ว\",\n        \"load\": \"โหลด\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Max\",\n        \"uptime\": \"UP\"\n    },\n    \"unifi\": {\n        \"users\": \"ผู้ใช้\",\n        \"uptime\": \"Uptime\",\n        \"days\": \"วัน\",\n        \"wan\": \"WAN\",\n        \"lan\": \"แลน\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"อุปกรณ์\",\n        \"lan_devices\": \"อุปกรณ์แลน\",\n        \"wlan_devices\": \"WLAN Devices\",\n        \"lan_users\": \"LAN Users\",\n        \"wlan_users\": \"WLAN Users\",\n        \"up\": \"UP\",\n        \"down\": \"DOWN\",\n        \"wait\": \"Please wait\",\n        \"empty_data\": \"Subsystem status unknown\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Running\",\n        \"offline\": \"ออฟไลน์\",\n        \"error\": \"ข้อผิดพลาด\",\n        \"unknown\": \"ไม่ทราบ\",\n        \"healthy\": \"Healthy\",\n        \"starting\": \"Starting\",\n        \"unhealthy\": \"Unhealthy\",\n        \"not_found\": \"Not Found\",\n        \"exited\": \"Exited\",\n        \"partial\": \"Partial\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"ปิง\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP status\",\n        \"error\": \"Error\",\n        \"response\": \"Response\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"emby\": {\n        \"playing\": \"กำลังเล่น\",\n        \"transcoding\": \"การแปลงรหัส\",\n        \"bitrate\": \"อัตราบิต\",\n        \"no_active\": \"ไม่มีสตรีมที่ใช้งานอยู่\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"ออนไลน์\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Production\",\n        \"battery_soc\": \"Battery\",\n        \"grid_power\": \"Grid\",\n        \"home_power\": \"Consumption\",\n        \"charge_power\": \"Charger\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"ดาวน์โหลด\",\n        \"upload\": \"อัพโหลด\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Subscriptions\",\n        \"unread\": \"ยังไม่ได้อ่าน\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"ยังไม่ได้กำหนดค่า\",\n        \"connectionStatusConnecting\": \"กำลังเชื่อมต่อ\",\n        \"connectionStatusAuthenticating\": \"Authenticating\",\n        \"connectionStatusPendingDisconnect\": \"Pending Disconnect\",\n        \"connectionStatusDisconnecting\": \"Disconnecting\",\n        \"connectionStatusDisconnected\": \"Disconnected\",\n        \"connectionStatusConnected\": \"Connected\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"Max. Down\",\n        \"maxUp\": \"Max. Up\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"Received\",\n        \"sent\": \"Sent\",\n        \"externalIPAddress\": \"Ext. IP\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Current requests\",\n        \"requests_failed\": \"Failed requests\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Total Observed\",\n        \"diffsDetected\": \"Diffs Detected\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Shows\",\n        \"recordings\": \"Recordings\",\n        \"scheduled\": \"Scheduled\",\n        \"passes\": \"Passes\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"Check Plex Connection\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Connected APs\",\n        \"activeUser\": \"Active devices\",\n        \"alerts\": \"Alerts\",\n        \"connectedGateways\": \"Connected gateways\",\n        \"connectedSwitches\": \"Connected switches\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Rate\",\n        \"remaining\": \"Remaining\",\n        \"downloaded\": \"Downloaded\"\n    },\n    \"plex\": {\n        \"streams\": \"Active Streams\",\n        \"albums\": \"Albums\",\n        \"movies\": \"Movies\",\n        \"tv\": \"TV Shows\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"Queue\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Active\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU Usage\",\n        \"memUsage\": \"MEM Usage\",\n        \"systemTempC\": \"System Temp\",\n        \"poolUsage\": \"Pool Usage\",\n        \"volumeUsage\": \"Volume Usage\",\n        \"invalid\": \"Invalid\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"หายไป\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"Artists\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"Books\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Missing Episodes\",\n        \"missingMovies\": \"Missing Movies\"\n    },\n    \"ombi\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"New Devices\",\n        \"down_alerts\": \"Down Alerts\"\n    },\n    \"pihole\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"blocked_percent\": \"Blocked %\",\n        \"gravity\": \"Gravity\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"Filtered\",\n        \"latency\": \"Latency\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Non-Downloaded\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Downloaded & Read\",\n        \"downloadedunread\": \"Downloaded & Unread\",\n        \"nondownloadedread\": \"Non-Downloaded & Read\",\n        \"nondownloadedunread\": \"Non-Downloaded & Unread\"\n    },\n    \"tailscale\": {\n        \"address\": \"Address\",\n        \"expires\": \"Expires\",\n        \"never\": \"Never\",\n        \"last_seen\": \"Last Seen\",\n        \"now\": \"Now\",\n        \"years\": \"{{number}}y\",\n        \"weeks\": \"{{number}}w\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Ago\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"Success\",\n        \"totalServerFailure\": \"Failures\",\n        \"totalNxDomain\": \"NX Domains\",\n        \"totalRefused\": \"Refused\",\n        \"totalAuthoritative\": \"Authoritative\",\n        \"totalRecursive\": \"Recursive\",\n        \"totalCached\": \"Cached\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"Dropped\",\n        \"totalClients\": \"Clients\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Processed\",\n        \"errored\": \"Errored\",\n        \"saved\": \"Saved\"\n    },\n    \"traefik\": {\n        \"routers\": \"Routers\",\n        \"services\": \"Services\",\n        \"middleware\": \"Middleware\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Please Wait\"\n    },\n    \"npm\": {\n        \"enabled\": \"เปิด\",\n        \"disabled\": \"ปิด\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Configure one or more crypto currencies to track\",\n        \"1hour\": \"1 Hour\",\n        \"1day\": \"1 Day\",\n        \"7days\": \"7 Days\",\n        \"30days\": \"30 Days\"\n    },\n    \"gotify\": {\n        \"apps\": \"Applications\",\n        \"clients\": \"Clients\",\n        \"messages\": \"Messages\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indexers\",\n        \"numberOfGrabs\": \"Grabs\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"Fail Grabs\",\n        \"numberOfFailQueries\": \"Fail Queries\"\n    },\n    \"jackett\": {\n        \"configured\": \"Configured\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sessions\",\n        \"numConnections\": \"Connections\",\n        \"dataRelayed\": \"Relayed\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"Posts\",\n        \"domain_count\": \"Domains\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Players\",\n        \"version\": \"เวอร์ชั่น\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Read\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"Logins (24h)\",\n        \"failedLoginsLast24H\": \"Failed Logins (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temp\",\n        \"warn\": \"Warn\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Crit\",\n        \"read\": \"Read\",\n        \"write\": \"Write\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Bookmark\",\n        \"service\": \"Service\",\n        \"search\": \"Search\",\n        \"custom\": \"Custom\",\n        \"visit\": \"Visit\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Suggestion\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Sunny\",\n        \"0-night\": \"Clear\",\n        \"1-day\": \"Mainly Sunny\",\n        \"1-night\": \"Mainly Clear\",\n        \"2-day\": \"Partly Cloudy\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"Cloudy\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"Foggy\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"Light Drizzle\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"Drizzle\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"Heavy Drizzle\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"Light Freezing Drizzle\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"Freezing Drizzle\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"Light Rain\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"Rain\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Heavy Rain\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Freezing Rain\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Light Snow\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Snow\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Heavy Snow\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Snow Grains\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Light Showers\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Showers\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Heavy Showers\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Snow Showers\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"Thunderstorm\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Thunderstorm With Hail\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"System\",\n        \"updates\": \"Updates\",\n        \"update_available\": \"Update Available\",\n        \"up_to_date\": \"Up to Date\",\n        \"child_bridges\": \"Child Bridges\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"New\",\n        \"up\": \"Up\",\n        \"grace\": \"In Grace Period\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Last Ping\",\n        \"never\": \"No pings yet\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Scanned\",\n        \"containers_updated\": \"Updated\",\n        \"containers_failed\": \"Failed\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Rejected\",\n        \"filters\": \"Filters\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Videos\",\n        \"channels\": \"Channels\",\n        \"playlists\": \"Playlists\"\n    },\n    \"truenas\": {\n        \"load\": \"System Load\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Speed\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Public IP\",\n        \"region\": \"Region\",\n        \"country\": \"Country\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tuners\",\n        \"channelNumber\": \"Channel\",\n        \"channelNetwork\": \"Network\",\n        \"signalStrength\": \"Strength\",\n        \"signalQuality\": \"Quality\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Client\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Inbox\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Battery Charge\",\n        \"ups_load\": \"UPS Load\",\n        \"ups_status\": \"UPS Status\",\n        \"online\": \"Online\",\n        \"on_battery\": \"On Battery\",\n        \"low_battery\": \"Low Battery\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"No Device Data Received\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"CPU Load\",\n        \"memoryUsed\": \"Memory Used\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Leases\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"All Streams\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG Channels\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Today\",\n        \"absolutePower\": \"Power\",\n        \"relativePower\": \"Power %\",\n        \"limit\": \"Limit\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Active Memory\",\n        \"wanUpload\": \"WAN Upload\",\n        \"wanDownload\": \"WAN Download\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Printer State\",\n        \"print_status\": \"Print Status\",\n        \"print_progress\": \"Progress\",\n        \"layers\": \"Layers\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Tool temp\",\n        \"temp_bed\": \"Bed temp\",\n        \"job_completion\": \"Completion\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Origin IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Load Avg\",\n        \"memory\": \"Mem Usage\",\n        \"wanStatus\": \"WAN Status\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Disk Usage\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datastore\",\n        \"failed_tasks_24h\": \"Failed Tasks 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memory\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Storage\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"เว็บไซต์ ล่ม\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Incident\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Archives\",\n        \"chapters\": \"Chapters\",\n        \"categories\": \"Categories\"\n    },\n    \"komga\": {\n        \"libraries\": \"Libraries\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Issues\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"People\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Time\"\n    },\n    \"firefly\": {\n        \"networth\": \"Net Worth\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Dashboards\",\n        \"datasources\": \"Data Sources\",\n        \"totalalerts\": \"Total Alerts\",\n        \"alertstriggered\": \"Alerts Triggered\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Cpu Load\",\n        \"memoryusage\": \"Memory Usage\",\n        \"freespace\": \"Free Space\",\n        \"activeusers\": \"Active Users\",\n        \"numfiles\": \"Files\",\n        \"numshares\": \"Shared Items\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Size\",\n        \"lastrun\": \"Last Run\",\n        \"nextrun\": \"Next Run\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Active Workers\",\n        \"total_workers\": \"Total Workers\",\n        \"records_total\": \"Queue Length\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servers\",\n        \"nodes\": \"Nodes\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Targets Up\",\n        \"targets_down\": \"Targets Down\",\n        \"targets_total\": \"Total Targets\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"One year\",\n        \"gross_percent_max\": \"All time\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasts\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Duration\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"People Home\",\n        \"lights_on\": \"Lights On\",\n        \"switches_on\": \"Switches On\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Monitoring\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Authors\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Result\",\n        \"status\": \"Status\",\n        \"buildId\": \"Build ID\",\n        \"succeeded\": \"Succeeded\",\n        \"notStarted\": \"Not Started\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Canceled\",\n        \"inProgress\": \"In Progress\",\n        \"totalPrs\": \"Total PRs\",\n        \"myPrs\": \"My PRs\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Name\",\n        \"map\": \"Map\",\n        \"currentPlayers\": \"Current players\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Max players\",\n        \"bots\": \"Bots\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Errors\",\n        \"noRecent\": \"Out of Date\",\n        \"totalUsed\": \"Used Storage\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Recipes\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Tags\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Downloading\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"CPU Load Avg (5m)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Transmitted\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Last Downtime\",\n        \"downDuration\": \"Downtime Duration\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Not Yet Checked\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Seems Down\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"In cinemas\",\n        \"physicalRelease\": \"Physical release\",\n        \"digitalRelease\": \"Digital release\",\n        \"noEventsToday\": \"No events for today!\",\n        \"noEventsFound\": \"No events found\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platforms\",\n        \"totalRoms\": \"Games\",\n        \"saves\": \"Saves\",\n        \"states\": \"States\",\n        \"screenshots\": \"Screenshots\",\n        \"totalfilesize\": \"Total Size\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Mailboxes\",\n        \"mails\": \"Mails\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Warnings\",\n        \"criticals\": \"Criticals\"\n    },\n    \"plantit\": {\n        \"events\": \"Events\",\n        \"plants\": \"Plants\",\n        \"photos\": \"Photos\",\n        \"species\": \"Species\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Notifications\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Pull Requests\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Scenes\",\n        \"scenesPlayed\": \"Scenes Played\",\n        \"playCount\": \"Total Plays\",\n        \"playDuration\": \"Time Watched\",\n        \"sceneSize\": \"Scenes Size\",\n        \"sceneDuration\": \"Scenes Duration\",\n        \"images\": \"Images\",\n        \"imageSize\": \"Images Size\",\n        \"galleries\": \"Galleries\",\n        \"performers\": \"Performers\",\n        \"studios\": \"Studios\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O Count\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Keywords\"\n    },\n    \"homebox\": {\n        \"items\": \"Items\",\n        \"totalWithWarranty\": \"With Warranty\",\n        \"locations\": \"Locations\",\n        \"labels\": \"Labels\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Total Value\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Bans\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxied\",\n        \"auth\": \"With Auth\",\n        \"outdated\": \"Outdated\",\n        \"banned\": \"Banned\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Stocks\",\n        \"loading\": \"Loading\",\n        \"open\": \"Open - US Market\",\n        \"closed\": \"Closed - US Market\",\n        \"invalidConfiguration\": \"Invalid Configuration\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Cameras\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Links\",\n        \"collections\": \"Collections\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Not classified\",\n        \"information\": \"Information\",\n        \"warning\": \"Warning\",\n        \"average\": \"Average\",\n        \"high\": \"High\",\n        \"disaster\": \"Disaster\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vehicle\",\n        \"vehicles\": \"Vehicles\",\n        \"serviceRecords\": \"Service Records\",\n        \"reminders\": \"Reminders\",\n        \"nextReminder\": \"Next Reminder\",\n        \"none\": \"None\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Active Projects\",\n        \"tasks7d\": \"Tasks Due This Week\",\n        \"tasksOverdue\": \"Overdue Tasks\",\n        \"tasksInProgress\": \"Tasks In Progress\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Systems\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Disk\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Apps\",\n        \"synced\": \"Synced\",\n        \"outOfSync\": \"Out Of Sync\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Degraded\",\n        \"progressing\": \"Progressing\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Suspended\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Groups\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Merge Requests\",\n        \"projects\": \"Projects\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Bookmarks\",\n        \"favorites\": \"Favorites\",\n        \"archived\": \"Archived\",\n        \"highlights\": \"Highlights\",\n        \"lists\": \"Lists\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Update\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Downloads\",\n        \"uploads\": \"Uploads\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Other\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/tr/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"ay\",\n        \"days\": \"g\",\n        \"hours\": \"sa\",\n        \"minutes\": \"dk\",\n        \"seconds\": \"s\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Eksik Araç Türü: {{type}}\",\n        \"api_error\": \"API Hatası\",\n        \"information\": \"Bilgi\",\n        \"status\": \"Durum\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Ham Hata\",\n        \"response_data\": \"Yanıt Verisi\"\n    },\n    \"weather\": {\n        \"current\": \"Mevcut Konum\",\n        \"allow\": \"İzin vermek için tıklayın\",\n        \"updating\": \"Güncelleniyor\",\n        \"wait\": \"Lütfen bekleyin\"\n    },\n    \"search\": {\n        \"placeholder\": \"Ara…\"\n    },\n    \"resources\": {\n        \"cpu\": \"İşlemci\",\n        \"mem\": \"Bellek\",\n        \"total\": \"Toplam\",\n        \"free\": \"Boş\",\n        \"used\": \"Kullanımda\",\n        \"load\": \"Yük\",\n        \"temp\": \"Sıcaklık\",\n        \"max\": \"En Yüksek\",\n        \"uptime\": \"Çalışıyor\"\n    },\n    \"unifi\": {\n        \"users\": \"Kullanıcılar\",\n        \"uptime\": \"Çalışma Süresi\",\n        \"days\": \"Günler\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Aygıtlar\",\n        \"lan_devices\": \"LAN Aygıtları\",\n        \"wlan_devices\": \"WLAN Aygıtları\",\n        \"lan_users\": \"LAN Kullanıcıları\",\n        \"wlan_users\": \"WLAN Kullanıcıları\",\n        \"up\": \"ÇALIŞIYOR\",\n        \"down\": \"Aşağı\",\n        \"wait\": \"Lütfen bekleyin\",\n        \"empty_data\": \"Alt sistem durumu bilinmiyor\"\n    },\n    \"docker\": {\n        \"rx\": \"Gelen Veri\",\n        \"tx\": \"Giden Veri\",\n        \"mem\": \"Bellek\",\n        \"cpu\": \"İşlemci\",\n        \"running\": \"Çalışıyor\",\n        \"offline\": \"Çevrimdışı\",\n        \"error\": \"Hata\",\n        \"unknown\": \"Bilinmiyor\",\n        \"healthy\": \"Sağlıklı\",\n        \"starting\": \"Başlatılıyor\",\n        \"unhealthy\": \"Sağlıksız\",\n        \"not_found\": \"Bulunamadı\",\n        \"exited\": \"Kapandı\",\n        \"partial\": \"Kısmi\"\n    },\n    \"ping\": {\n        \"error\": \"Hata\",\n        \"ping\": \"Gecikme\",\n        \"down\": \"İndirme\",\n        \"up\": \"Yükleme\",\n        \"not_available\": \"Uygun değil\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTPS durumu\",\n        \"error\": \"Hata\",\n        \"response\": \"Yanıt\",\n        \"down\": \"İndirme\",\n        \"up\": \"Çalışıyor\",\n        \"not_available\": \"Uygun değil\"\n    },\n    \"emby\": {\n        \"playing\": \"Oynatılıyor\",\n        \"transcoding\": \"Dönüştürülüyor\",\n        \"bitrate\": \"Bit Hızı\",\n        \"no_active\": \"Etkin akış yok\",\n        \"movies\": \"Film\",\n        \"series\": \"Dizi\",\n        \"episodes\": \"Bölüm\",\n        \"songs\": \"Şarkı\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Oynatılıyor\",\n        \"transcoding\": \"Dönüştürülüyor\",\n        \"bitrate\": \"Bit Hızı\",\n        \"no_active\": \"Aktif Yayın Yok\",\n        \"movies\": \"Film\",\n        \"series\": \"Dizi\",\n        \"episodes\": \"Bölüm\",\n        \"songs\": \"Şarkı\"\n    },\n    \"esphome\": {\n        \"offline\": \"Çevrimdışı\",\n        \"offline_alt\": \"Çevrimdışı\",\n        \"online\": \"Çevrimiçi\",\n        \"total\": \"Toplam\",\n        \"unknown\": \"Bilinmiyor\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Üretim\",\n        \"battery_soc\": \"Batarya\",\n        \"grid_power\": \"Güç\",\n        \"home_power\": \"Tüketim\",\n        \"charge_power\": \"Şarj\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"İndirme\",\n        \"upload\": \"Yükleme\",\n        \"leech\": \"İndirilen\",\n        \"seed\": \"Gönderilen\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Abonelikler\",\n        \"unread\": \"Okunmamış\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Durum\",\n        \"connectionStatusUnconfigured\": \"Yapılandırılmamış\",\n        \"connectionStatusConnecting\": \"Bağlanıyor\",\n        \"connectionStatusAuthenticating\": \"Kimlik doğrulanıyor\",\n        \"connectionStatusPendingDisconnect\": \"Bağlantının Kesilmesi Bekleniyor\",\n        \"connectionStatusDisconnecting\": \"Bağlantı kesiliyor...\",\n        \"connectionStatusDisconnected\": \"Bağlı değil\",\n        \"connectionStatusConnected\": \"Bağlı\",\n        \"uptime\": \"Çalışma Süresi\",\n        \"maxDown\": \"Maks. İndirme\",\n        \"maxUp\": \"Maks. Gönderme\",\n        \"down\": \"İndirme\",\n        \"up\": \"Yükleme\",\n        \"received\": \"Alınan\",\n        \"sent\": \"Gönderilen\",\n        \"externalIPAddress\": \"Harici IP\",\n        \"externalIPv6Address\": \"Dış IPv6\",\n        \"externalIPv6Prefix\": \"Dış IPv6-Önek\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Akış\",\n        \"requests\": \"Anlık İstekler\",\n        \"requests_failed\": \"Başarısız İstekler\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Toplam Gözlenen\",\n        \"diffsDetected\": \"Farklar Algılandı\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Diziler\",\n        \"recordings\": \"Kayıtlar\",\n        \"scheduled\": \"Planlanmış\",\n        \"passes\": \"Geçilenler\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Oynatılıyor\",\n        \"transcoding\": \"Dönüştürülüyor\",\n        \"bitrate\": \"Bit Hızı\",\n        \"no_active\": \"Etkin akış yok\",\n        \"plex_connection_error\": \"Plex Bağlantısı Kontrol Ediliyor\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Bağlı AP'ler\",\n        \"activeUser\": \"Etkin aygıtlar\",\n        \"alerts\": \"Alarmlar\",\n        \"connectedGateways\": \"Bağlı ağ geçitleri\",\n        \"connectedSwitches\": \"Bağlı anahtarlar\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Oran\",\n        \"remaining\": \"Kalan\",\n        \"downloaded\": \"İndirilen\"\n    },\n    \"plex\": {\n        \"streams\": \"Etkin akış\",\n        \"albums\": \"Albümler\",\n        \"movies\": \"Film\",\n        \"tv\": \"TV Showları\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Oran\",\n        \"queue\": \"Kuyruk\",\n        \"timeleft\": \"Kalan Zaman\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Etkin\",\n        \"upload\": \"Gönderme\",\n        \"download\": \"İndirme\"\n    },\n    \"transmission\": {\n        \"download\": \"İndirme\",\n        \"upload\": \"Gönderme\",\n        \"leech\": \"İndirilen\",\n        \"seed\": \"Gönderilen\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"İndirme\",\n        \"upload\": \"Gönderme\",\n        \"leech\": \"İndirilen\",\n        \"seed\": \"Gönderilen\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"İşlemci Kullanımı\",\n        \"memUsage\": \"Bellek Kullanımı\",\n        \"systemTempC\": \"Sistem Sıcaklığı\",\n        \"poolUsage\": \"Havuz Kullanımı\",\n        \"volumeUsage\": \"Alan Kullanımı\",\n        \"invalid\": \"Geçersiz\"\n    },\n    \"deluge\": {\n        \"download\": \"İndirme\",\n        \"upload\": \"Gönderme\",\n        \"leech\": \"İndirilen\",\n        \"seed\": \"Gönderilen\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Önbellek İsabetli Byte\",\n        \"cachemissbytes\": \"Önbellek Kaçırılan Byte\"\n    },\n    \"downloadstation\": {\n        \"download\": \"İndirme\",\n        \"upload\": \"Gönderme\",\n        \"leech\": \"İndirilen\",\n        \"seed\": \"Gönderilen\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"İstendi\",\n        \"queued\": \"Kuyrukta\",\n        \"series\": \"Diziler\",\n        \"queue\": \"Kuyruk\",\n        \"unknown\": \"Bilinmeyen\"\n    },\n    \"radarr\": {\n        \"wanted\": \"İstendi\",\n        \"missing\": \"Eksik\",\n        \"queued\": \"Kuyrukta\",\n        \"movies\": \"Film\",\n        \"queue\": \"Kuyruk\",\n        \"unknown\": \"Bilinmeyen\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"İstendi\",\n        \"queued\": \"Kuyrukta\",\n        \"artists\": \"Sanatçılar\"\n    },\n    \"readarr\": {\n        \"wanted\": \"İstendi\",\n        \"queued\": \"Kuyrukta\",\n        \"books\": \"Kitaplar\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Eksik Bölümler\",\n        \"missingMovies\": \"Eksik Filmler\"\n    },\n    \"ombi\": {\n        \"pending\": \"Bekleyen\",\n        \"approved\": \"Onaylı\",\n        \"available\": \"Kullanılabilir\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Toplam\",\n        \"connected\": \"Bağlı\",\n        \"new_devices\": \"Yeni Cihazlar\",\n        \"down_alerts\": \"Hata Uyarıları\"\n    },\n    \"pihole\": {\n        \"queries\": \"Sorgular\",\n        \"blocked\": \"Engellenen\",\n        \"blocked_percent\": \"Engellenen %\",\n        \"gravity\": \"Gravity\"\n    },\n    \"adguard\": {\n        \"queries\": \"Sorgular\",\n        \"blocked\": \"Engellenen\",\n        \"filtered\": \"Filtrelendi\",\n        \"latency\": \"Gecikme\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Yükleme\",\n        \"download\": \"İndirme\",\n        \"ping\": \"Gecikme\"\n    },\n    \"portainer\": {\n        \"running\": \"Çalışıyor\",\n        \"stopped\": \"Durdu\",\n        \"total\": \"Toplam\"\n    },\n    \"suwayomi\": {\n        \"download\": \"İndirilen\",\n        \"nondownload\": \"İndirilmemiş\",\n        \"read\": \"Okunan\",\n        \"unread\": \"Okunmamış\",\n        \"downloadedread\": \"İndirildi ve okundu\",\n        \"downloadedunread\": \"İndirildi ve okunmadı\",\n        \"nondownloadedread\": \"İndirilmedi ve okundu\",\n        \"nondownloadedunread\": \"İndirilmedi ve okunmadı\"\n    },\n    \"tailscale\": {\n        \"address\": \"Adres\",\n        \"expires\": \"Geciken\",\n        \"never\": \"Asla\",\n        \"last_seen\": \"Son Görülme\",\n        \"now\": \"Şimdi\",\n        \"years\": \"{{number}} Yıl\",\n        \"weeks\": \"{{number}} Hafta\",\n        \"days\": \"{{number}} Gün\",\n        \"hours\": \"{{number}} Saat\",\n        \"minutes\": \"{{number}} Dakika\",\n        \"seconds\": \"{{number}} Saniye\",\n        \"ago\": \"{{value}} Önce\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Sorgular\",\n        \"totalNoError\": \"Başarılı\",\n        \"totalServerFailure\": \"Başarısızlıklar\",\n        \"totalNxDomain\": \"NX Alan Adları\",\n        \"totalRefused\": \"Reddedildi\",\n        \"totalAuthoritative\": \"Yetkili\",\n        \"totalRecursive\": \"Tekrarlamalı\",\n        \"totalCached\": \"Önbelleğe alındı\",\n        \"totalBlocked\": \"Engellenen\",\n        \"totalDropped\": \"Bırakıldı\",\n        \"totalClients\": \"Alıcılar\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Kuyruk\",\n        \"processed\": \"İşlendi\",\n        \"errored\": \"Hatalı\",\n        \"saved\": \"Kaydedildi\"\n    },\n    \"traefik\": {\n        \"routers\": \"Yönlendiriciler\",\n        \"services\": \"Hizmetler\",\n        \"middleware\": \"Ara Katman\"\n    },\n    \"trilium\": {\n        \"version\": \"Sürüm\",\n        \"notesCount\": \"Notlar\",\n        \"dbSize\": \"Veritabanı Boyutu\",\n        \"unknown\": \"Bilinmeyen\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"Etkin akış yok\",\n        \"please_wait\": \"Lütfen Bekleyin\"\n    },\n    \"npm\": {\n        \"enabled\": \"Etkin\",\n        \"disabled\": \"Devre dışı\",\n        \"total\": \"Toplam\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"İzleme için bir veya daha fazla kripto para birimi yapılandırın\",\n        \"1hour\": \"1 Saat\",\n        \"1day\": \"1 Gün\",\n        \"7days\": \"7 Gün\",\n        \"30days\": \"30 Gün\"\n    },\n    \"gotify\": {\n        \"apps\": \"Uygulamalar\",\n        \"clients\": \"İstemciler\",\n        \"messages\": \"İletiler\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"İndeksleyici\",\n        \"numberOfGrabs\": \"Yakalamalar\",\n        \"numberOfQueries\": \"Sorgular\",\n        \"numberOfFailGrabs\": \"Başarısız Yakalamalar\",\n        \"numberOfFailQueries\": \"Başarısız Sorgular\"\n    },\n    \"jackett\": {\n        \"configured\": \"Yapılandırılmış\",\n        \"errored\": \"Hatalı\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Oturumlar\",\n        \"numConnections\": \"Bağlantı Sayısı\",\n        \"dataRelayed\": \"Aktarılan\",\n        \"transferRate\": \"Oran\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Kullanıcılar\",\n        \"status_count\": \"Gönderiler\",\n        \"domain_count\": \"Etki Alanları\"\n    },\n    \"medusa\": {\n        \"wanted\": \"İstendi\",\n        \"queued\": \"Kuyrukta\",\n        \"series\": \"Diziler\"\n    },\n    \"minecraft\": {\n        \"players\": \"Oyuncular\",\n        \"version\": \"Sürüm\",\n        \"status\": \"Durum\",\n        \"up\": \"Çevrimiçi\",\n        \"down\": \"Çevrimdışı\"\n    },\n    \"miniflux\": {\n        \"read\": \"Okunmuş\",\n        \"unread\": \"Okunmamış\"\n    },\n    \"authentik\": {\n        \"users\": \"Kullanıcılar\",\n        \"loginsLast24H\": \"Girişler (24 Saat)\",\n        \"failedLoginsLast24H\": \"Başarısız Girişler (24 Saat)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"Bellek\",\n        \"cpu\": \"İşlemci\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"Sanal Makineler\"\n    },\n    \"glances\": {\n        \"cpu\": \"İşlemci\",\n        \"load\": \"Yük\",\n        \"wait\": \"Lütfen bekleyin\",\n        \"temp\": \"Sıcaklık\",\n        \"_temp\": \"Sıcaklık\",\n        \"warn\": \"Uyarı\",\n        \"uptime\": \"ÇALIŞIYOR\",\n        \"total\": \"Toplam\",\n        \"free\": \"Boş\",\n        \"used\": \"Kullanılıyor\",\n        \"days\": \"g.\",\n        \"hours\": \"s.\",\n        \"crit\": \"Kritik\",\n        \"read\": \"Okundu\",\n        \"write\": \"Yazma\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Bellek\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Yer imi\",\n        \"service\": \"Hizmet\",\n        \"search\": \"Ara\",\n        \"custom\": \"Özel\",\n        \"visit\": \"Ziyaret\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Öneri\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Güneşli\",\n        \"0-night\": \"Açık\",\n        \"1-day\": \"Çoğunlukla Güneşli\",\n        \"1-night\": \"Çoğunlukla Açık\",\n        \"2-day\": \"Parçalı Bulutlu\",\n        \"2-night\": \"Parçalı Bulutlu\",\n        \"3-day\": \"Bulutlu\",\n        \"3-night\": \"Bulutlu\",\n        \"45-day\": \"Sisli\",\n        \"45-night\": \"Sisli\",\n        \"48-day\": \"Sisli\",\n        \"48-night\": \"Sisli\",\n        \"51-day\": \"Az Çiseleyen Yağmur\",\n        \"51-night\": \"Hafif Çiseleme\",\n        \"53-day\": \"Çiseleyen Yağmur\",\n        \"53-night\": \"Çiseleme\",\n        \"55-day\": \"Çok Çiseleyen Yağmur\",\n        \"55-night\": \"Yoğun Çiseleme\",\n        \"56-day\": \"Soğuk Az Çiseleyen Yağmur\",\n        \"56-night\": \"Hafif Dondurucu Çiseleme\",\n        \"57-day\": \"Soğuk Çiseleyen Yağmur\",\n        \"57-night\": \"Dondurucu Çiseleme\",\n        \"61-day\": \"Hafif Yağmur\",\n        \"61-night\": \"Hafif Yağmur\",\n        \"63-day\": \"Yağmur\",\n        \"63-night\": \"Yağmur\",\n        \"65-day\": \"Çok Yağmur\",\n        \"65-night\": \"Şiddetli Yağmur\",\n        \"66-day\": \"Dondurucu Yağmur\",\n        \"66-night\": \"Dondurucu Yağmur\",\n        \"67-day\": \"Dondurucu Yağmur\",\n        \"67-night\": \"Dondurucu Yağmur\",\n        \"71-day\": \"Hafif Kar\",\n        \"71-night\": \"Hafif Kar\",\n        \"73-day\": \"Kar\",\n        \"73-night\": \"Kar\",\n        \"75-day\": \"Çok Kar\",\n        \"75-night\": \"Yoğun Kar\",\n        \"77-day\": \"Kar Taneleri\",\n        \"77-night\": \"Kar Taneleri\",\n        \"80-day\": \"Hafif Sağanak\",\n        \"80-night\": \"Hafif Sağanak\",\n        \"81-day\": \"Sağanak\",\n        \"81-night\": \"Sağanak\",\n        \"82-day\": \"Yoğun Sağanak\",\n        \"82-night\": \"Yoğun Sağanak\",\n        \"85-day\": \"Karlı Sağanak\",\n        \"85-night\": \"Karlı Sağanak\",\n        \"86-day\": \"Karlı Sağanak\",\n        \"86-night\": \"Karlı Sağanak\",\n        \"95-day\": \"Gök Gürültülü Fırtına\",\n        \"95-night\": \"Fırtına\",\n        \"96-day\": \"Dolu İle Gök Gürültülü Fırtına\",\n        \"96-night\": \"Dolu Yağışlı Fırtına\",\n        \"99-day\": \"Dolu Yağışlı Fırtına\",\n        \"99-night\": \"Dolu Yağışlı Fırtına\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Sistem\",\n        \"updates\": \"Güncellemeler\",\n        \"update_available\": \"Güncelleme Kullanılabilir\",\n        \"up_to_date\": \"Güncel\",\n        \"child_bridges\": \"Alt Köprüler\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Çalışıyor\",\n        \"pending\": \"Bekleyen\",\n        \"down\": \"Çalışmayan\",\n        \"ok\": \"Tamam\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Yeni\",\n        \"up\": \"Çalışıyor\",\n        \"grace\": \"Tolerans Döneminde\",\n        \"down\": \"Çalışmayan\",\n        \"paused\": \"Durduruldu\",\n        \"status\": \"Durum\",\n        \"last_ping\": \"Son gecikme\",\n        \"never\": \"Henüz gecikme yok\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Tarandı\",\n        \"containers_updated\": \"Güncellendi\",\n        \"containers_failed\": \"Başarısız\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Onaylı\",\n        \"rejectedPushes\": \"Reddedildi\",\n        \"filters\": \"Süzgeçler\",\n        \"indexers\": \"İndeksleyici\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Kuyruk\",\n        \"videos\": \"Video\",\n        \"channels\": \"Kanallar\",\n        \"playlists\": \"Oynatma Listeleri\"\n    },\n    \"truenas\": {\n        \"load\": \"Sistem Yükü\",\n        \"uptime\": \"Çalışma Süresi\",\n        \"alerts\": \"Alarmlar\"\n    },\n    \"pyload\": {\n        \"speed\": \"Hız\",\n        \"active\": \"Etkin\",\n        \"queue\": \"Kuyruk\",\n        \"total\": \"Toplam\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Açık IP\",\n        \"region\": \"Bölge\",\n        \"country\": \"Ülke\",\n        \"port_forwarded\": \"Yönlendirilen Port\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Kanallar\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Ayarlayıcılar\",\n        \"channelNumber\": \"Kanal\",\n        \"channelNetwork\": \"Ağ\",\n        \"signalStrength\": \"Sağlamlık\",\n        \"signalQuality\": \"Kalite\",\n        \"symbolQuality\": \"Kalite\",\n        \"networkRate\": \"Bit Hızı\",\n        \"clientIP\": \"Alıcı\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Geçti\",\n        \"failed\": \"Başarısız\",\n        \"unknown\": \"Bilinmeyen\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Gelen Kutusu\",\n        \"total\": \"Toplam\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Kuruluşlar\",\n        \"sites\": \"Siteler\",\n        \"resources\": \"Kaynaklar\",\n        \"targets\": \"Hedefler\",\n        \"traffic\": \"Trafik\",\n        \"in\": \"Gelen\",\n        \"out\": \"Giden\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Pil Yüzdesi\",\n        \"ups_load\": \"UPS Yükü\",\n        \"ups_status\": \"UPS Durumu\",\n        \"online\": \"Çevrimiçi\",\n        \"on_battery\": \"Pilde\",\n        \"low_battery\": \"Düşük Pil\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Lütfen Bekleyin\",\n        \"no_devices\": \"Cihaz Verisi Alınamadı\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"İşlemci yükü\",\n        \"memoryUsed\": \"Bellek Kullanımı\",\n        \"uptime\": \"Çalışma süresi\",\n        \"numberOfLeases\": \"Kiralama\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Tüm Akışlar\",\n        \"streams_active\": \"Etkin akışlar\",\n        \"streams_xepg\": \"XEPG Kanalları\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Bugün\",\n        \"absolutePower\": \"Güç\",\n        \"relativePower\": \"Güç %\",\n        \"limit\": \"Limit\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"İşlemci yükü\",\n        \"memory\": \"Aktif Bellek\",\n        \"wanUpload\": \"WAN Yükleme\",\n        \"wanDownload\": \"WAN İndirme\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Yazıcı Durumu\",\n        \"print_status\": \"Yazıcı Durumu\",\n        \"print_progress\": \"İlerleme\",\n        \"layers\": \"Katmanlar\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Durum\",\n        \"temp_tool\": \"Araç sıcaklığı\",\n        \"temp_bed\": \"Yatak sıcaklığı\",\n        \"job_completion\": \"Tamamlanma\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Gerçek IP\",\n        \"status\": \"Durum\"\n    },\n    \"pfsense\": {\n        \"load\": \"Ort. Yükleme\",\n        \"memory\": \"Bellek Kullanımı\",\n        \"wanStatus\": \"WAN Durumu\",\n        \"up\": \"Çalışıyor\",\n        \"down\": \"Çalışmayan\",\n        \"temp\": \"Sıcaklık\",\n        \"disk\": \"Disk Kullanımı\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Veri deposu\",\n        \"failed_tasks_24h\": \"Başarısız Görevler 24h\",\n        \"cpu_usage\": \"İşlemci\",\n        \"memory_usage\": \"Bellek\"\n    },\n    \"immich\": {\n        \"users\": \"Kullanıcılar\",\n        \"photos\": \"Fotoğraf\",\n        \"videos\": \"Video\",\n        \"storage\": \"Depolama\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Site çalışıyor\",\n        \"down\": \"Çalışmayan site\",\n        \"uptime\": \"Çalışma süresi\",\n        \"incident\": \"Olay\",\n        \"m\": \"dk\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Diziler\",\n        \"archives\": \"Arşivler\",\n        \"chapters\": \"Bölümler\",\n        \"categories\": \"Kategoriler\"\n    },\n    \"komga\": {\n        \"libraries\": \"Kütüphane\",\n        \"series\": \"Seriler\",\n        \"books\": \"Kitap\"\n    },\n    \"diskstation\": {\n        \"days\": \"Gün\",\n        \"uptime\": \"Çalışma süresi\",\n        \"volumeAvailable\": \"Uygun\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Kanallar\",\n        \"streams\": \"Akışlar\"\n    },\n    \"mylar\": {\n        \"series\": \"Diziler\",\n        \"issues\": \"Sorunlar\",\n        \"wanted\": \"İstendi\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albümler\",\n        \"photos\": \"Fotoğraf\",\n        \"videos\": \"Video\",\n        \"people\": \"İnsan\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Kuyruk\",\n        \"processing\": \"İşleniyor\",\n        \"processed\": \"İşlendi\",\n        \"time\": \"Zaman\"\n    },\n    \"firefly\": {\n        \"networth\": \"Net değer\",\n        \"budget\": \"Bütçe\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Kontrol Paneli\",\n        \"datasources\": \"Veri Kaynakları\",\n        \"totalalerts\": \"Toplam Uyarılar\",\n        \"alertstriggered\": \"Uyarılar Tetiklendi\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"İşlemci yükü\",\n        \"memoryusage\": \"Bellek Kullanımı\",\n        \"freespace\": \"Boş Alan\",\n        \"activeusers\": \"Etkin kullanıcılar\",\n        \"numfiles\": \"Dosyalar\",\n        \"numshares\": \"Paylaşılan Öğeler\"\n    },\n    \"kopia\": {\n        \"status\": \"Durum\",\n        \"size\": \"Boyut\",\n        \"lastrun\": \"Son Çalışma\",\n        \"nextrun\": \"Sonraki Çalışma\",\n        \"failed\": \"Başarısız\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Etkin kullanıcılar\",\n        \"total_workers\": \"Toplam Kullanıcılar\",\n        \"records_total\": \"Sıra Uzunluğu\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Sunucular\",\n        \"nodes\": \"Düğümler\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Çalışan Hedef\",\n        \"targets_down\": \"Çalışmayan hedef\",\n        \"targets_total\": \"Toplam Hedef\"\n    },\n    \"gatus\": {\n        \"up\": \"Çalışan Siteler\",\n        \"down\": \"Çalışmayan site\",\n        \"uptime\": \"Çalışma süresi\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Bugün\",\n        \"gross_percent_1y\": \"Bir yıl\",\n        \"gross_percent_max\": \"Tüm zaman\",\n        \"net_worth\": \"Net Değer\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcast\",\n        \"books\": \"Kitap\",\n        \"podcastsDuration\": \"Süre\",\n        \"booksDuration\": \"Süre\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Evdeki İnsanlar\",\n        \"lights_on\": \"Işıklar Açık\",\n        \"switches_on\": \"Aç\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"İzleme\",\n        \"updates\": \"Güncellemeler\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Kitaplar\",\n        \"authors\": \"Yazarlar\",\n        \"categories\": \"Kategoriler\",\n        \"series\": \"Diziler\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Kütüphaneler\",\n        \"books\": \"Kitaplar\",\n        \"reading\": \"Okunuyor\",\n        \"finished\": \"Bitti\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Kuyruk\",\n        \"downloadBytesRemaining\": \"Kalan\",\n        \"downloadTotalBytes\": \"Boyut\",\n        \"downloadSpeed\": \"Hız\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Diziler\",\n        \"totalFiles\": \"Dosyalar\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Sonuç\",\n        \"status\": \"Durum\",\n        \"buildId\": \"Yapı Kimliği\",\n        \"succeeded\": \"Başarılı\",\n        \"notStarted\": \"Henüz Başlamadı\",\n        \"failed\": \"Başarısız\",\n        \"canceled\": \"İptal edildi\",\n        \"inProgress\": \"Sürüyor\",\n        \"totalPrs\": \"Toplam Çekme İstekleri\",\n        \"myPrs\": \"Benim Çekme İsteklerim\",\n        \"approved\": \"Onaylı\"\n    },\n    \"gamedig\": {\n        \"status\": \"Durum\",\n        \"online\": \"Çevrimiçi\",\n        \"offline\": \"Çevrimdışı\",\n        \"name\": \"Ad\",\n        \"map\": \"Harita\",\n        \"currentPlayers\": \"Mevcut oyuncular\",\n        \"players\": \"Oyuncular\",\n        \"maxPlayers\": \"Maks. oyuncu\",\n        \"bots\": \"Botlar\",\n        \"ping\": \"Gecikme\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Tamam\",\n        \"errored\": \"Hatalar\",\n        \"noRecent\": \"Tarihi geçmiş\",\n        \"totalUsed\": \"Kullanılan depolama alanı\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Tarifler\",\n        \"users\": \"Kullanıcılar\",\n        \"categories\": \"Kategoriler\",\n        \"tags\": \"Etiketler\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"İndiriliyor\",\n        \"total\": \"Toplam\",\n        \"running\": \"Çalışıyor\",\n        \"stopped\": \"Durdu\",\n        \"passed\": \"Başarılı\",\n        \"failed\": \"Başarısız\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Çalışma süresi\",\n        \"cpuLoad\": \"İşlemci yükü ortalaması (5dk)\",\n        \"up\": \"Çalışıyor\",\n        \"down\": \"Çalışmayan\",\n        \"bytesTx\": \"İletilen\",\n        \"bytesRx\": \"Alınan\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Durum\",\n        \"uptime\": \"Çalışma süresi\",\n        \"lastDown\": \"Son Kesinti\",\n        \"downDuration\": \"Kesinti Süresi\",\n        \"sitesUp\": \"Site çalışıyor\",\n        \"sitesDown\": \"Çalışmayan site\",\n        \"paused\": \"Durduruldu\",\n        \"notyetchecked\": \"Henüz Kontrol Edilmedi\",\n        \"up\": \"Çalışıyor\",\n        \"seemsdown\": \"Kapalı görünüyor\",\n        \"down\": \"Çalışmayan\",\n        \"unknown\": \"Bilinmeyen\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"Sinemalarda\",\n        \"physicalRelease\": \"Fiziksel Yayınlanan\",\n        \"digitalRelease\": \"Dijitalde Yayınlandı\",\n        \"noEventsToday\": \"Bugün için etkinlik yok!\",\n        \"noEventsFound\": \"Etkinlik bulunamadı\",\n        \"errorWhenLoadingData\": \"Takvim verileri yüklenirken hata\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platformlar\",\n        \"totalRoms\": \"Oyunlar\",\n        \"saves\": \"Kayıtlar\",\n        \"states\": \"Durumlar\",\n        \"screenshots\": \"Ekran görüntüleri\",\n        \"totalfilesize\": \"Toplam Kapasite\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Alan Adları\",\n        \"mailboxes\": \"Posta kutuları\",\n        \"mails\": \"Postalar\",\n        \"storage\": \"Depolama\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Uyarılar\",\n        \"criticals\": \"Kritik\"\n    },\n    \"plantit\": {\n        \"events\": \"Etkinlikler\",\n        \"plants\": \"Bitkiler\",\n        \"photos\": \"Fotoğraf\",\n        \"species\": \"Türler\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Bildirimler\",\n        \"issues\": \"Sorunlar\",\n        \"pulls\": \"Değişiklik İstekleri\",\n        \"repositories\": \"Depolar\"\n    },\n    \"stash\": {\n        \"scenes\": \"Sahneler\",\n        \"scenesPlayed\": \"Oynanan Sahneler\",\n        \"playCount\": \"Toplam Oynatma\",\n        \"playDuration\": \"İzlenen Süre\",\n        \"sceneSize\": \"Sahne Boyutu\",\n        \"sceneDuration\": \"Sahne Süresi\",\n        \"images\": \"Görseller\",\n        \"imageSize\": \"Görsel Boyutu\",\n        \"galleries\": \"Galeriler\",\n        \"performers\": \"Oyuncu\",\n        \"studios\": \"Stüdyolar\",\n        \"movies\": \"Filmler\",\n        \"tags\": \"Etiketler\",\n        \"oCount\": \"O Sayısı\"\n    },\n    \"tandoor\": {\n        \"users\": \"Kullanıcılar\",\n        \"recipes\": \"Tarifler\",\n        \"keywords\": \"Anahtar Sözcükler\"\n    },\n    \"homebox\": {\n        \"items\": \"Ögeler\",\n        \"totalWithWarranty\": \"Garantili\",\n        \"locations\": \"Konum\",\n        \"labels\": \"Etiketler\",\n        \"users\": \"Kullanıcılar\",\n        \"totalValue\": \"Toplam Değer\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Uyarılar\",\n        \"bans\": \"Yasaklar\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Bağlı\",\n        \"enabled\": \"Etkin\",\n        \"disabled\": \"Devre dışı\",\n        \"total\": \"Toplam\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxy Üzerinden\",\n        \"auth\": \"Kimlik Doğrulamalı\",\n        \"outdated\": \"Eskimiş\",\n        \"banned\": \"Yasaklı\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Gecikme\",\n        \"download\": \"İndirme\",\n        \"upload\": \"Yükleme\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Hisse Senetleri\",\n        \"loading\": \"Yükleniyor\",\n        \"open\": \"Açık - ABD Pazarı\",\n        \"closed\": \"Kapalı - ABD Pazarı\",\n        \"invalidConfiguration\": \"Geçersiz Yapılandırma\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Kameralar\",\n        \"uptime\": \"Çalışma süresi\",\n        \"version\": \"Sürüm\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Bağlantılar\",\n        \"collections\": \"Koleksiyonlar\",\n        \"tags\": \"Etiketler\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Sınıflandırılmamış\",\n        \"information\": \"Bilgi\",\n        \"warning\": \"Uyarı\",\n        \"average\": \"Ortalama\",\n        \"high\": \"Yüksek\",\n        \"disaster\": \"Felaket\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Taşıt\",\n        \"vehicles\": \"Taşıtlar\",\n        \"serviceRecords\": \"Servis Kayıtları\",\n        \"reminders\": \"Hatırlatıcılar\",\n        \"nextReminder\": \"Sonraki hatırlatıcı\",\n        \"none\": \"Hiçbiri\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Etkin projeler\",\n        \"tasks7d\": \"Bitişi Bu Hafta Olan Görevler\",\n        \"tasksOverdue\": \"Gecikmiş Görevler\",\n        \"tasksInProgress\": \"Devam Eden Görevler\"\n    },\n    \"headscale\": {\n        \"name\": \"Ad\",\n        \"address\": \"Adres\",\n        \"last_seen\": \"Son Görülme\",\n        \"status\": \"Durum\",\n        \"online\": \"Çevrimiçi\",\n        \"offline\": \"Çevrimdışı\"\n    },\n    \"beszel\": {\n        \"name\": \"Ad\",\n        \"systems\": \"Sistemler\",\n        \"up\": \"Çalışıyor\",\n        \"down\": \"Çalışmayan\",\n        \"paused\": \"Durduruldu\",\n        \"pending\": \"Beklemede\",\n        \"status\": \"Durum\",\n        \"updated\": \"Güncellendi\",\n        \"cpu\": \"İşlemci\",\n        \"memory\": \"Bellek\",\n        \"disk\": \"Depolama\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Uygulamalar\",\n        \"synced\": \"Senkron\",\n        \"outOfSync\": \"Senkron Değil\",\n        \"healthy\": \"Sağlıklı\",\n        \"degraded\": \"Sorunlu\",\n        \"progressing\": \"Uygulanıyor\",\n        \"missing\": \"Eksik\",\n        \"suspended\": \"Askıya Alındı\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Yükleniyor\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Gruplar\",\n        \"issues\": \"Sorunlar\",\n        \"merges\": \"Birleştirme İstekleri\",\n        \"projects\": \"Projeler\"\n    },\n    \"apcups\": {\n        \"status\": \"Durum\",\n        \"load\": \"Yük\",\n        \"bcharge\": \"Pil Yüzdesi\",\n        \"timeleft\": \"Kalan zaman\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Yer imleri\",\n        \"favorites\": \"Gözdeler\",\n        \"archived\": \"Arşivlenen\",\n        \"highlights\": \"Öne Çıkanlar\",\n        \"lists\": \"Listeler\",\n        \"tags\": \"Etiketler\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Ağ\",\n        \"connected\": \"Bağlı\",\n        \"disconnected\": \"Bağlı değil\",\n        \"updateStatus\": \"Güncelleme\",\n        \"update_yes\": \"Uygun\",\n        \"update_no\": \"Güncel\",\n        \"downloads\": \"İndirmeler\",\n        \"uploads\": \"Yüklemeler\",\n        \"sharedFiles\": \"Dosyalar\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Şarkı\",\n        \"movies\": \"Film\",\n        \"episodes\": \"Bölüm\",\n        \"other\": \"Diğer\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Hizmet Sorunları\",\n        \"hostErrors\": \"Sunucu Sorunları\"\n    },\n    \"komodo\": {\n        \"total\": \"Toplam\",\n        \"running\": \"Çalışıyor\",\n        \"stopped\": \"Durdu\",\n        \"down\": \"Çalışmayan\",\n        \"unhealthy\": \"Sağlıksız\",\n        \"unknown\": \"Bilinmeyen\",\n        \"servers\": \"Sunucular\",\n        \"stacks\": \"Yığınlar\",\n        \"containers\": \"Konteynerler\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Uygun\",\n        \"used\": \"Kullanılıyor\",\n        \"total\": \"Toplam\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Abonelikler\",\n        \"thisMonthlyCost\": \"Bu Ay\",\n        \"nextMonthlyCost\": \"Sonraki Ay\",\n        \"previousMonthlyCost\": \"Önceki Ay\",\n        \"nextRenewingSubscription\": \"Sonraki Ödeme\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Başladı\",\n        \"STOPPED\": \"Durdu\",\n        \"NEW_ARRAY\": \"Yeni dizi\",\n        \"RECON_DISK\": \"Disk Yeniden Oluşturuluyor\",\n        \"DISABLE_DISK\": \"Disk devre dışı\",\n        \"SWAP_DSBL\": \"Swap devre dışı\",\n        \"INVALID_EXPANSION\": \"Geçersiz Genişletme\",\n        \"PARITY_NOT_BIGGEST\": \"Parity En Büyük Disk Değil\",\n        \"TOO_MANY_MISSING_DISKS\": \"Çok fazla disk eksik\",\n        \"NEW_DISK_TOO_SMALL\": \"Yeni disk çok küçük\",\n        \"NO_DATA_DISKS\": \"Veri diski yok\",\n        \"notifications\": \"Bildirimler\",\n        \"status\": \"Durum\",\n        \"cpu\": \"İşlemci\",\n        \"memoryUsed\": \"Bellek kullanılıyor\",\n        \"memoryAvailable\": \"Bellek uygun\",\n        \"arrayUsed\": \"Kullanılan dizi\",\n        \"arrayFree\": \"Uygun dizi\",\n        \"poolUsed\": \"{{pool}} kullanılıyor\",\n        \"poolFree\": \"{{pool}} boş\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Planlar\",\n        \"num_success_30\": \"Başarılılar\",\n        \"num_failure_30\": \"Başarısızlıklar\",\n        \"num_success_latest\": \"Başarılı\",\n        \"num_failure_latest\": \"Başarısız\",\n        \"bytes_added_30\": \"Eklenen Veri\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Şarkılar\",\n        \"time\": \"Zaman\",\n        \"artists\": \"Sanatçılar\"\n    },\n    \"arcane\": {\n        \"containers\": \"Konteynerler\",\n        \"images\": \"İmajlar\",\n        \"image_updates\": \"İmaj Güncellemeleri\",\n        \"images_unused\": \"Kullanılmayan İmajlar\",\n        \"environment_required\": \"Ortam Kimliği Gerekli\"\n    },\n    \"dockhand\": {\n        \"running\": \"Çalışan\",\n        \"stopped\": \"Durdurulan\",\n        \"cpu\": \"İşlemci\",\n        \"memory\": \"Bellek\",\n        \"images\": \"İmajlar\",\n        \"volumes\": \"Birimler\",\n        \"events_today\": \"Bugünkü Olaylar\",\n        \"pending_updates\": \"Bekleyen Güncellemeler\",\n        \"stacks\": \"Yığınlar\",\n        \"paused\": \"Duraklatılan\",\n        \"total\": \"Toplam\",\n        \"environment_not_found\": \"Ortam Bulunamadı\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/uk/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"міс\",\n        \"days\": \"днів\",\n        \"hours\": \"год\",\n        \"minutes\": \"хв\",\n        \"seconds\": \"с\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Відсутній тип віджета: {{type}}\",\n        \"api_error\": \"Помилка API\",\n        \"information\": \"Інформація\",\n        \"status\": \"Стан\",\n        \"url\": \"URL-адреса\",\n        \"raw_error\": \"Помилка Raw\",\n        \"response_data\": \"Дані відповіді\"\n    },\n    \"weather\": {\n        \"current\": \"Поточне розташування\",\n        \"allow\": \"Натисніть, щоб дозволити\",\n        \"updating\": \"Оновлення\",\n        \"wait\": \"Будь ласка, зачекайте\"\n    },\n    \"search\": {\n        \"placeholder\": \"Пошук…\"\n    },\n    \"resources\": {\n        \"cpu\": \"ЦП\",\n        \"mem\": \"ОЗП\",\n        \"total\": \"Усього\",\n        \"free\": \"Вільно\",\n        \"used\": \"Використано\",\n        \"load\": \"Завантаження\",\n        \"temp\": \"Температура\",\n        \"max\": \"Макс.\",\n        \"uptime\": \"Онлайн\"\n    },\n    \"unifi\": {\n        \"users\": \"Користувачі\",\n        \"uptime\": \"Час роботи\",\n        \"days\": \"Днів\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Пристрої\",\n        \"lan_devices\": \"LAN пристрої\",\n        \"wlan_devices\": \"WLAN пристрої\",\n        \"lan_users\": \"LAN користувачі\",\n        \"wlan_users\": \"WLAN користувачі\",\n        \"up\": \"UP\",\n        \"down\": \"Завантаження\",\n        \"wait\": \"Будь ласка, зачекайте\",\n        \"empty_data\": \"Статус підсистеми невідомий\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"Пам'ять\",\n        \"cpu\": \"Процесор\",\n        \"running\": \"Запущено\",\n        \"offline\": \"Офлайн\",\n        \"error\": \"Помилка\",\n        \"unknown\": \"Невідомий\",\n        \"healthy\": \"Здоровий\",\n        \"starting\": \"Запуск\",\n        \"unhealthy\": \"Нездоровий\",\n        \"not_found\": \"Не знайдено\",\n        \"exited\": \"Вийшов\",\n        \"partial\": \"Частковий\"\n    },\n    \"ping\": {\n        \"error\": \"Помилка\",\n        \"ping\": \"Пінг\",\n        \"down\": \"Офлайн\",\n        \"up\": \"Онлайн\",\n        \"not_available\": \"Не доступний\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP статус\",\n        \"error\": \"Помилка\",\n        \"response\": \"Відповідь\",\n        \"down\": \"Офлайн\",\n        \"up\": \"Онлайн\",\n        \"not_available\": \"Не доступний\"\n    },\n    \"emby\": {\n        \"playing\": \"Відтворення\",\n        \"transcoding\": \"Транскодування\",\n        \"bitrate\": \"Бітрейт\",\n        \"no_active\": \"Немає активних потоків\",\n        \"movies\": \"Фільми\",\n        \"series\": \"Серії\",\n        \"episodes\": \"Епізоди\",\n        \"songs\": \"Пісні\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Офлайн\",\n        \"offline_alt\": \"Офлайн\",\n        \"online\": \"Онлайн\",\n        \"total\": \"Усього\",\n        \"unknown\": \"Невідомо\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Виробництво\",\n        \"battery_soc\": \"Батарея\",\n        \"grid_power\": \"Сітка\",\n        \"home_power\": \"Споживання\",\n        \"charge_power\": \"Зарядний пристрій\",\n        \"kilowatt\": \"кВт\"\n    },\n    \"flood\": {\n        \"download\": \"Завантажено\",\n        \"upload\": \"Відправлено\",\n        \"leech\": \"Ліч\",\n        \"seed\": \"Сід\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Передплата\",\n        \"unread\": \"Не прочитано\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Стан\",\n        \"connectionStatusUnconfigured\": \"Не налаштовано\",\n        \"connectionStatusConnecting\": \"Підключення\",\n        \"connectionStatusAuthenticating\": \"Автентифікація\",\n        \"connectionStatusPendingDisconnect\": \"Очікує відключення\",\n        \"connectionStatusDisconnecting\": \"Відключення\",\n        \"connectionStatusDisconnected\": \"Відключено\",\n        \"connectionStatusConnected\": \"З'єднано\",\n        \"uptime\": \"Час роботи\",\n        \"maxDown\": \"Макс. завантаження\",\n        \"maxUp\": \"Макс. віддача\",\n        \"down\": \"Офлайн\",\n        \"up\": \"Онлайн\",\n        \"received\": \"Отримано\",\n        \"sent\": \"Надіслано\",\n        \"externalIPAddress\": \"Зовнішній IP\",\n        \"externalIPv6Address\": \"Зовнішній IPv6\",\n        \"externalIPv6Prefix\": \"Зовнішній Префікс IPv6-\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Потоки\",\n        \"requests\": \"Поточні запити\",\n        \"requests_failed\": \"Невдалі запити\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Всього спостережень\",\n        \"diffsDetected\": \"Виявлено відмінності\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Вистави\",\n        \"recordings\": \"Записи\",\n        \"scheduled\": \"Заплановано\",\n        \"passes\": \"Пропуски\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Грає\",\n        \"transcoding\": \"Транскодування\",\n        \"bitrate\": \"Бітрейт\",\n        \"no_active\": \"Немає активних потоків\",\n        \"plex_connection_error\": \"Перевірте з'єднання Plex\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Підключені точки доступу\",\n        \"activeUser\": \"Активні пристрої\",\n        \"alerts\": \"Оповіщення\",\n        \"connectedGateways\": \"Підключені шлюзи\",\n        \"connectedSwitches\": \"Підключені перемикачі\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Швидкість\",\n        \"remaining\": \"Залишилося\",\n        \"downloaded\": \"Завантажено\"\n    },\n    \"plex\": {\n        \"streams\": \"Активні потоки\",\n        \"albums\": \"Альбоми\",\n        \"movies\": \"Фільми\",\n        \"tv\": \"TБ шоу\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Швидкість\",\n        \"queue\": \"Черга\",\n        \"timeleft\": \"Залишилось\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Активний\",\n        \"upload\": \"Вивантаж.\",\n        \"download\": \"Завантажено\"\n    },\n    \"transmission\": {\n        \"download\": \"Завантажено\",\n        \"upload\": \"Вивантаж.\",\n        \"leech\": \"Ліч\",\n        \"seed\": \"Сід\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Завантажити\",\n        \"upload\": \"Вивантаж.\",\n        \"leech\": \"Ліч\",\n        \"seed\": \"Сід\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"Використання CPU\",\n        \"memUsage\": \"Використання пам'яті\",\n        \"systemTempC\": \"Температура системи\",\n        \"poolUsage\": \"Використання пулу\",\n        \"volumeUsage\": \"Гучність\",\n        \"invalid\": \"Недійсний\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Ліч\",\n        \"seed\": \"Сід\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Кеш-хіт байт\",\n        \"cachemissbytes\": \"Кеш-міс байт\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Вивантаж.\",\n        \"leech\": \"Ліч\",\n        \"seed\": \"Сід\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Розшукується\",\n        \"queued\": \"У черзі\",\n        \"series\": \"Серіали\",\n        \"queue\": \"Черга\",\n        \"unknown\": \"Невідомо\"\n    },\n    \"radarr\": {\n        \"wanted\": \"У бажаних\",\n        \"missing\": \"Відсутній\",\n        \"queued\": \"У черзі\",\n        \"movies\": \"Фільми\",\n        \"queue\": \"Черга\",\n        \"unknown\": \"Невідомо\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"У бажаних\",\n        \"queued\": \"У черзі\",\n        \"artists\": \"Виконавці\"\n    },\n    \"readarr\": {\n        \"wanted\": \"У бажаних\",\n        \"queued\": \"У черзі\",\n        \"books\": \"Книжки\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Відсутні епізоди\",\n        \"missingMovies\": \"Відсутні фільми\"\n    },\n    \"ombi\": {\n        \"pending\": \"В очікуванні\",\n        \"approved\": \"Затверджено\",\n        \"available\": \"Доступно\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Усього\",\n        \"connected\": \"З'єднано\",\n        \"new_devices\": \"Нові пристрої\",\n        \"down_alerts\": \"Сповіщення про падіння\"\n    },\n    \"pihole\": {\n        \"queries\": \"Запити\",\n        \"blocked\": \"Заблоковано\",\n        \"blocked_percent\": \"Заблоковано %\",\n        \"gravity\": \"Доменів в списку\"\n    },\n    \"adguard\": {\n        \"queries\": \"Запити\",\n        \"blocked\": \"Заблоковано\",\n        \"filtered\": \"Відфільтровано\",\n        \"latency\": \"Затримка\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Вивантаж.\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Зупинено\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Не завантажено\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Завантажено та Прочитано\",\n        \"downloadedunread\": \"Завантажено та Непрочитано\",\n        \"nondownloadedread\": \"Не завантажено та Прочитано\",\n        \"nondownloadedunread\": \"Не завантажено та Не прочитано\"\n    },\n    \"tailscale\": {\n        \"address\": \"Адреса\",\n        \"expires\": \"Дійсний до\",\n        \"never\": \"Ніколи\",\n        \"last_seen\": \"Востаннє у мережі\",\n        \"now\": \"Зараз\",\n        \"years\": \"{{number}}р\",\n        \"weeks\": \"{{number}}тиж\",\n        \"days\": \"{{number}}д\",\n        \"hours\": \"{{number}}год\",\n        \"minutes\": \"{{number}}хв\",\n        \"seconds\": \"{{number}}с\",\n        \"ago\": \"{{value}} тому\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"Успішно\",\n        \"totalServerFailure\": \"Помилки\",\n        \"totalNxDomain\": \"NX Домени\",\n        \"totalRefused\": \"Відмовлено\",\n        \"totalAuthoritative\": \"Авторитетні\",\n        \"totalRecursive\": \"Рекурсивні\",\n        \"totalCached\": \"Кешовані\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"Видалені\",\n        \"totalClients\": \"Клієнти\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Обробка\",\n        \"errored\": \"Помилка\",\n        \"saved\": \"Збережено\"\n    },\n    \"traefik\": {\n        \"routers\": \"Роутери\",\n        \"services\": \"Сервіси\",\n        \"middleware\": \"Проміжне програмне забезпечення\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Будь ласка, зачекайте\"\n    },\n    \"npm\": {\n        \"enabled\": \"Увімкнено\",\n        \"disabled\": \"Вимкнено\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Налаштуйте одну або кілька криптовалют для відстеження\",\n        \"1hour\": \"1 година\",\n        \"1day\": \"1 день\",\n        \"7days\": \"7 днів\",\n        \"30days\": \"30 днів\"\n    },\n    \"gotify\": {\n        \"apps\": \"Застосунки\",\n        \"clients\": \"Clients\",\n        \"messages\": \"Повідомлення\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Індексатори\",\n        \"numberOfGrabs\": \"Захоплення\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"Невдалі захоплення\",\n        \"numberOfFailQueries\": \"Невдалі запити\"\n    },\n    \"jackett\": {\n        \"configured\": \"Налаштовано\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Сесії\",\n        \"numConnections\": \"Підключення\",\n        \"dataRelayed\": \"Ретрансльовано\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"Пости\",\n        \"domain_count\": \"Домени\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Гравці\",\n        \"version\": \"Версія\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Прочитано\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"Вхід (протягом доби)\",\n        \"failedLoginsLast24H\": \"Невдалі входи (протягом доби)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"Контейнери Linux\",\n        \"vms\": \"Віртуальні машини\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Температура\",\n        \"warn\": \"Увага\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Крит\",\n        \"read\": \"Read\",\n        \"write\": \"Написати\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Пам'ять\",\n        \"swap\": \"Обмін\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Закладка\",\n        \"service\": \"Сервіс\",\n        \"search\": \"Пошук\",\n        \"custom\": \"Користувацький\",\n        \"visit\": \"Відвідайте\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Пропозиція\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Сонячно\",\n        \"0-night\": \"Ясно\",\n        \"1-day\": \"Переважно сонячно\",\n        \"1-night\": \"Переважно ясно\",\n        \"2-day\": \"Частково хмарно\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"Хмарно\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"Туманно\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"Легка мряка\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"Мряка\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"Сильна мряка\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"Невеликий морозний дощ\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"Морозний дощ\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"Невеликий дощ\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"Дощ\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Сильний дощ\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Холодний дощ\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Невеликий сніг\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Сніг\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Снігопад\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Снігові зерна\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Невелика злива\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Злива\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Сильна злива\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Дощ зі снігом\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"Гроза\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Гроза з градом\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"Система\",\n        \"updates\": \"Оновлення\",\n        \"update_available\": \"Доступне оновлення\",\n        \"up_to_date\": \"Актуально\",\n        \"child_bridges\": \"Дитячі мости\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"Новий\",\n        \"up\": \"Up\",\n        \"grace\": \"У пільговий період\",\n        \"down\": \"Down\",\n        \"paused\": \"Призупинено\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Останній пінг\",\n        \"never\": \"Пінгів ще немає\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Відскановано\",\n        \"containers_updated\": \"Оновлено\",\n        \"containers_failed\": \"Невдача\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Відхилено\",\n        \"filters\": \"Фільтри\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Відео\",\n        \"channels\": \"Канали\",\n        \"playlists\": \"Плейлисти\"\n    },\n    \"truenas\": {\n        \"load\": \"Завантаження системи\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Швидкість\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Публічний IP\",\n        \"region\": \"Регіон\",\n        \"country\": \"Країна\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Тюнери\",\n        \"channelNumber\": \"Канал\",\n        \"channelNetwork\": \"Мережа\",\n        \"signalStrength\": \"Сила\",\n        \"signalQuality\": \"Якість\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Клієнт\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Пройшов\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Вхідні\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Заряд батареї\",\n        \"ups_load\": \"UPS завантаження\",\n        \"ups_status\": \"Статус UPS\",\n        \"online\": \"Online\",\n        \"on_battery\": \"Від батареї\",\n        \"low_battery\": \"Низький заряд\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"Дані про пристрій не отримано\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"Завантаження CPU\",\n        \"memoryUsed\": \"Використана пам'ять\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Оренди\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"Всі потоки\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"Канали XEPG\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Сьогодні\",\n        \"absolutePower\": \"Потужність\",\n        \"relativePower\": \"Заряд %\",\n        \"limit\": \"Ліміт\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Активна пам'ять\",\n        \"wanUpload\": \"Вивантаження WAN\",\n        \"wanDownload\": \"Завантаження WAN\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Стан принтера\",\n        \"print_status\": \"Статус друку\",\n        \"print_progress\": \"Прогрес\",\n        \"layers\": \"Шари\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Температура інструменту\",\n        \"temp_bed\": \"Температура ліжка\",\n        \"job_completion\": \"Завершення\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Походження IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Середнє завантаження\",\n        \"memory\": \"Використання пам'яті\",\n        \"wanStatus\": \"Статус WAN\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Використання диска\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Сховище даних\",\n        \"failed_tasks_24h\": \"Невиконані завдання за останню добу\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Пам'ять\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Фотографії\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Сховище\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Активні сайти\",\n        \"down\": \"Неактивні сайти\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Інцидент\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Архіви\",\n        \"chapters\": \"Глави\",\n        \"categories\": \"Категорії\"\n    },\n    \"komga\": {\n        \"libraries\": \"Бібліотеки\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Питання\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"Люди\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Час\"\n    },\n    \"firefly\": {\n        \"networth\": \"Чисті Активи\",\n        \"budget\": \"Бюджет\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Інформаційні панелі\",\n        \"datasources\": \"Джерела даних\",\n        \"totalalerts\": \"Всього сповіщень\",\n        \"alertstriggered\": \"Спрацювали сповіщення\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Навантаження Cpu\",\n        \"memoryusage\": \"Використання пам'яті\",\n        \"freespace\": \"Вільного місця\",\n        \"activeusers\": \"Активні користувачі\",\n        \"numfiles\": \"Файли\",\n        \"numshares\": \"Спільні елементи\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Розмір\",\n        \"lastrun\": \"Останній запуск\",\n        \"nextrun\": \"Наступний запуск\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Активні працівники\",\n        \"total_workers\": \"Всього робітників\",\n        \"records_total\": \"Довжина черги\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Сервери\",\n        \"nodes\": \"Вузли\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Цілі вгору\",\n        \"targets_down\": \"Цілі вниз\",\n        \"targets_total\": \"Всього цілей\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"Один рік\",\n        \"gross_percent_max\": \"Весь час\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Подкасти\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Тривалість\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"Люди вдома\",\n        \"lights_on\": \"Світло ввімкнено\",\n        \"switches_on\": \"Вмикається\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Спостереження\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Автори\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Результат\",\n        \"status\": \"Status\",\n        \"buildId\": \"ID збірки\",\n        \"succeeded\": \"Успішно\",\n        \"notStarted\": \"Не розпочато\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Скасовано\",\n        \"inProgress\": \"В процесі\",\n        \"totalPrs\": \"Всього PR\",\n        \"myPrs\": \"Мій PR\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Назва\",\n        \"map\": \"Мапа\",\n        \"currentPlayers\": \"Поточні гравці\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Максимум гравців\",\n        \"bots\": \"Ботів\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Добре\",\n        \"errored\": \"Помилки\",\n        \"noRecent\": \"Застарілий\",\n        \"totalUsed\": \"Використовувана пам'ять\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Отримувачі\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Теги\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Завантаження\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"Сер. навантаження ЦП (\\\"5\\\" хв)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Передано\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Останній час простою\",\n        \"downDuration\": \"Тривалість простою\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Ще не перевірено\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Вірогідно в простої\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"У кінотеатрах\",\n        \"physicalRelease\": \"Фізичний реліз\",\n        \"digitalRelease\": \"Цифровий реліз\",\n        \"noEventsToday\": \"Події на сьогодні відсутні!\",\n        \"noEventsFound\": \"Події не знайдено\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Платформи\",\n        \"totalRoms\": \"Ігри\",\n        \"saves\": \"Збереження\",\n        \"states\": \"Стани\",\n        \"screenshots\": \"Знімки екрану\",\n        \"totalfilesize\": \"Загальний обсяг\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Пошта\",\n        \"mails\": \"Листи\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Попередження\",\n        \"criticals\": \"Критичні\"\n    },\n    \"plantit\": {\n        \"events\": \"Події\",\n        \"plants\": \"Рослини\",\n        \"photos\": \"Photos\",\n        \"species\": \"Види\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Сповіщення\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Pull-запити\",\n        \"repositories\": \"Репозиторії\"\n    },\n    \"stash\": {\n        \"scenes\": \"Сцени\",\n        \"scenesPlayed\": \"Зіграні сцени\",\n        \"playCount\": \"Всього п'єс\",\n        \"playDuration\": \"Переглянуто\",\n        \"sceneSize\": \"Розміри сцен\",\n        \"sceneDuration\": \"Тривалість сцен\",\n        \"images\": \"Зображення\",\n        \"imageSize\": \"Розміри зображень\",\n        \"galleries\": \"Галереї\",\n        \"performers\": \"Виконавці\",\n        \"studios\": \"Студії\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"Кількість O\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Ключові слова\"\n    },\n    \"homebox\": {\n        \"items\": \"Речі\",\n        \"totalWithWarranty\": \"З гарантією\",\n        \"locations\": \"Місцезнаходження\",\n        \"labels\": \"Мітки\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Загальне значення\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Блокування\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Пропущено через проксі\",\n        \"auth\": \"З аутентифікацією\",\n        \"outdated\": \"Застаріле\",\n        \"banned\": \"Заблоковано\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Акції\",\n        \"loading\": \"Завантажую\",\n        \"open\": \"Відкрито - ринок США\",\n        \"closed\": \"Закрито - ринок США\",\n        \"invalidConfiguration\": \"Неприпустима конфігурація\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Камери\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Посилання\",\n        \"collections\": \"Колекції\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Не визначено\",\n        \"information\": \"Information\",\n        \"warning\": \"Попередження\",\n        \"average\": \"Середнє\",\n        \"high\": \"Високе\",\n        \"disaster\": \"Катастрофа\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Транспортний засіб\",\n        \"vehicles\": \"Транспортні засоби\",\n        \"serviceRecords\": \"Записи служб\",\n        \"reminders\": \"Нагадування\",\n        \"nextReminder\": \"Наступне нагадування\",\n        \"none\": \"Жодного\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Активні проекти\",\n        \"tasks7d\": \"Завдання цього тижня\",\n        \"tasksOverdue\": \"Прострочені завдання\",\n        \"tasksInProgress\": \"Завдання в процесі\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Системи\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Диск\",\n        \"network\": \"МЕРЕЖА\"\n    },\n    \"argocd\": {\n        \"apps\": \"Додатки\",\n        \"synced\": \"Синхронізовано\",\n        \"outOfSync\": \"Не синхронізовано\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"Деградує\",\n        \"progressing\": \"Прогрес\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"Призупинено\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Групи\",\n        \"issues\": \"Issues\",\n        \"merges\": \"Запити на злиття\",\n        \"projects\": \"Проєкти\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Закладки\",\n        \"favorites\": \"Обране\",\n        \"archived\": \"Заархівовані\",\n        \"highlights\": \"Основні моменти\",\n        \"lists\": \"Списки\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"Оновити\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"Завантаження\",\n        \"uploads\": \"Вивантаження\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Songs\",\n        \"movies\": \"Movies\",\n        \"episodes\": \"Episodes\",\n        \"other\": \"Інше\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/vi/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"tháng\",\n        \"days\": \"ngày\",\n        \"hours\": \"giờ\",\n        \"minutes\": \"phút\",\n        \"seconds\": \"giây\"\n    },\n    \"widget\": {\n        \"missing_type\": \"Thiếu loại Widget: {{type}}\",\n        \"api_error\": \"Lỗi API\",\n        \"information\": \"Thông tin\",\n        \"status\": \"Trạng thái\",\n        \"url\": \"URL\",\n        \"raw_error\": \"Lỗi thô\",\n        \"response_data\": \"Dữ liệu phản hồi\"\n    },\n    \"weather\": {\n        \"current\": \"Vị trí hiện tại\",\n        \"allow\": \"Bấm để đồng ý\",\n        \"updating\": \"Đang cập nhật\",\n        \"wait\": \"Vui lòng chờ\"\n    },\n    \"search\": {\n        \"placeholder\": \"Tìm kiếm…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"MEM\",\n        \"total\": \"Tổng\",\n        \"free\": \"Dư\",\n        \"used\": \"Đã dùng\",\n        \"load\": \"\\n\",\n        \"temp\": \"TEMP\",\n        \"max\": \"Tối đa\",\n        \"uptime\": \"UP\"\n    },\n    \"unifi\": {\n        \"users\": \"Người dùng\",\n        \"uptime\": \"Thời gian hoạt động\",\n        \"days\": \"Ngày\",\n        \"wan\": \"WAN\",\n        \"lan\": \"LAN\",\n        \"wlan\": \"WLAN\",\n        \"devices\": \"Thiết bị\",\n        \"lan_devices\": \"Thiết bị trong mạng LAN\",\n        \"wlan_devices\": \"Thiết bị trong mạng WLAN\",\n        \"lan_users\": \"Người dùng mạng LAN\",\n        \"wlan_users\": \"Người dùng mạng WLAN\",\n        \"up\": \"UP\",\n        \"down\": \"DOWN\",\n        \"wait\": \"Vui lòng chờ\",\n        \"empty_data\": \"Trạng thái hệ thống phụ không xác định\"\n    },\n    \"docker\": {\n        \"rx\": \"RX\",\n        \"tx\": \"TX\",\n        \"mem\": \"Bộ nhớ\",\n        \"cpu\": \"CPU\",\n        \"running\": \"Đang hoạt động\",\n        \"offline\": \"Ngoại tuyến\",\n        \"error\": \"Lỗi\",\n        \"unknown\": \"Không xác định\",\n        \"healthy\": \"Ổn định\",\n        \"starting\": \"Đang bắt đầu\",\n        \"unhealthy\": \"Bất thường\",\n        \"not_found\": \"Không tìm thấy\",\n        \"exited\": \"Exited\",\n        \"partial\": \"Partial\"\n    },\n    \"ping\": {\n        \"error\": \"Lỗi\",\n        \"ping\": \"Ping\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Không khả dụng\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"Trạng thái HTTP\",\n        \"error\": \"Lỗi\",\n        \"response\": \"Phản hồi\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Không có sẵn\"\n    },\n    \"emby\": {\n        \"playing\": \"Đang chơi\",\n        \"transcoding\": \"Chuyển định dạng\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Phim ảnh\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Bài hát\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"movies\": \"Movies\",\n        \"series\": \"Series\",\n        \"episodes\": \"Episodes\",\n        \"songs\": \"Songs\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"Online\",\n        \"total\": \"Total\",\n        \"unknown\": \"Không xác định\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"Production\",\n        \"battery_soc\": \"Pin\",\n        \"grid_power\": \"Lưới\",\n        \"home_power\": \"Consumption\",\n        \"charge_power\": \"Bộ sạc\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"Tải xuống\",\n        \"upload\": \"Tải lên\",\n        \"leech\": \"\",\n        \"seed\": \"Seed\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"Đăng ký\",\n        \"unread\": \"Chưa đọc\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Trạng thái\",\n        \"connectionStatusUnconfigured\": \"Chưa được cấu hình\",\n        \"connectionStatusConnecting\": \"Đang kết nối\",\n        \"connectionStatusAuthenticating\": \"Đang uỷ quyền\",\n        \"connectionStatusPendingDisconnect\": \"Đang chờ ngắt kết nối\",\n        \"connectionStatusDisconnecting\": \"Đang ngắt kết nối\",\n        \"connectionStatusDisconnected\": \"Đã ngắt kết nối\",\n        \"connectionStatusConnected\": \"Đã kết nối\",\n        \"uptime\": \"Thời gian hoạt động\",\n        \"maxDown\": \"Tải xuống tối đa\",\n        \"maxUp\": \"Tải lên tối đa\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"Received\",\n        \"sent\": \"Sent\",\n        \"externalIPAddress\": \"Ext. IP\",\n        \"externalIPv6Address\": \"Ext. IPv6\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"Upstreams\",\n        \"requests\": \"Current requests\",\n        \"requests_failed\": \"Failed requests\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"Total Observed\",\n        \"diffsDetected\": \"Diffs Detected\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"Shows\",\n        \"recordings\": \"Recordings\",\n        \"scheduled\": \"Scheduled\",\n        \"passes\": \"Passes\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"Check Plex Connection\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"Connected APs\",\n        \"activeUser\": \"Active devices\",\n        \"alerts\": \"Alerts\",\n        \"connectedGateways\": \"Connected gateways\",\n        \"connectedSwitches\": \"Connected switches\"\n    },\n    \"nzbget\": {\n        \"rate\": \"Rate\",\n        \"remaining\": \"Remaining\",\n        \"downloaded\": \"Đã tải\"\n    },\n    \"plex\": {\n        \"streams\": \"Active Streams\",\n        \"albums\": \"Albums\",\n        \"movies\": \"Movies\",\n        \"tv\": \"TV Shows\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"Hàng chờ\",\n        \"timeleft\": \"Thời gian còn lại\"\n    },\n    \"rutorrent\": {\n        \"active\": \"Hoạt động\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU Usage\",\n        \"memUsage\": \"MEM Usage\",\n        \"systemTempC\": \"System Temp\",\n        \"poolUsage\": \"Pool Usage\",\n        \"volumeUsage\": \"Volume Usage\",\n        \"invalid\": \"Invalid\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"Cache Hit Bytes\",\n        \"cachemissbytes\": \"Cache Miss Bytes\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"Missing\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"Artists\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"Sách\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"Missing Episodes\",\n        \"missingMovies\": \"Missing Movies\"\n    },\n    \"ombi\": {\n        \"pending\": \"Đang xử lý\",\n        \"approved\": \"Đã duyệt\",\n        \"available\": \"Available\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"New Devices\",\n        \"down_alerts\": \"Down Alerts\"\n    },\n    \"pihole\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"blocked_percent\": \"Blocked %\",\n        \"gravity\": \"Gravity\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"Filtered\",\n        \"latency\": \"Latency\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"total\": \"Total\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"Non-Downloaded\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"Downloaded & Read\",\n        \"downloadedunread\": \"Downloaded & Unread\",\n        \"nondownloadedread\": \"Non-Downloaded & Read\",\n        \"nondownloadedunread\": \"Non-Downloaded & Unread\"\n    },\n    \"tailscale\": {\n        \"address\": \"Address\",\n        \"expires\": \"Expires\",\n        \"never\": \"Never\",\n        \"last_seen\": \"Last Seen\",\n        \"now\": \"Now\",\n        \"years\": \"{{number}}y\",\n        \"weeks\": \"{{number}}w\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} Ago\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"Success\",\n        \"totalServerFailure\": \"Failures\",\n        \"totalNxDomain\": \"NX Domains\",\n        \"totalRefused\": \"Refused\",\n        \"totalAuthoritative\": \"Authoritative\",\n        \"totalRecursive\": \"Recursive\",\n        \"totalCached\": \"Cached\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"Dropped\",\n        \"totalClients\": \"Clients\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"Processed\",\n        \"errored\": \"Errored\",\n        \"saved\": \"Saved\"\n    },\n    \"traefik\": {\n        \"routers\": \"Routers\",\n        \"services\": \"Services\",\n        \"middleware\": \"Middleware\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"Please Wait\"\n    },\n    \"npm\": {\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"Configure one or more crypto currencies to track\",\n        \"1hour\": \"1 Hour\",\n        \"1day\": \"1 Day\",\n        \"7days\": \"7 Days\",\n        \"30days\": \"30 Days\"\n    },\n    \"gotify\": {\n        \"apps\": \"Applications\",\n        \"clients\": \"Clients\",\n        \"messages\": \"Messages\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"Indexers\",\n        \"numberOfGrabs\": \"Grabs\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"Fail Grabs\",\n        \"numberOfFailQueries\": \"Fail Queries\"\n    },\n    \"jackett\": {\n        \"configured\": \"Configured\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"Sessions\",\n        \"numConnections\": \"Connections\",\n        \"dataRelayed\": \"Relayed\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"Posts\",\n        \"domain_count\": \"Domains\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"Players\",\n        \"version\": \"Version\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"Read\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"Logins (24h)\",\n        \"failedLoginsLast24H\": \"Failed Logins (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"LXC\",\n        \"vms\": \"VMs\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"Load\",\n        \"wait\": \"Please wait\",\n        \"temp\": \"TEMP\",\n        \"_temp\": \"Temp\",\n        \"warn\": \"Warn\",\n        \"uptime\": \"UP\",\n        \"total\": \"Total\",\n        \"free\": \"Free\",\n        \"used\": \"Used\",\n        \"days\": \"d\",\n        \"hours\": \"h\",\n        \"crit\": \"Crit\",\n        \"read\": \"Read\",\n        \"write\": \"Write\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"Bookmark\",\n        \"service\": \"Service\",\n        \"search\": \"Search\",\n        \"custom\": \"Custom\",\n        \"visit\": \"Visit\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"Suggestion\"\n    },\n    \"wmo\": {\n        \"0-day\": \"Sunny\",\n        \"0-night\": \"Clear\",\n        \"1-day\": \"Mainly Sunny\",\n        \"1-night\": \"Mainly Clear\",\n        \"2-day\": \"Partly Cloudy\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"Cloudy\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"Foggy\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"Light Drizzle\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"Drizzle\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"Heavy Drizzle\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"Light Freezing Drizzle\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"Freezing Drizzle\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"Light Rain\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"Rain\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"Heavy Rain\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"Freezing Rain\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"Light Snow\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"Snow\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"Heavy Snow\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"Snow Grains\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"Light Showers\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"Showers\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"Heavy Showers\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"Snow Showers\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"Thunderstorm\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"Thunderstorm With Hail\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"System\",\n        \"updates\": \"Updates\",\n        \"update_available\": \"Update Available\",\n        \"up_to_date\": \"Up to Date\",\n        \"child_bridges\": \"Child Bridges\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"New\",\n        \"up\": \"Up\",\n        \"grace\": \"In Grace Period\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"status\": \"Status\",\n        \"last_ping\": \"Last Ping\",\n        \"never\": \"No pings yet\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"Scanned\",\n        \"containers_updated\": \"Updated\",\n        \"containers_failed\": \"Failed\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"Rejected\",\n        \"filters\": \"Filters\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"Videos\",\n        \"channels\": \"Channels\",\n        \"playlists\": \"Playlists\"\n    },\n    \"truenas\": {\n        \"load\": \"System Load\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"Speed\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"Public IP\",\n        \"region\": \"Region\",\n        \"country\": \"Country\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"Tuners\",\n        \"channelNumber\": \"Channel\",\n        \"channelNetwork\": \"Network\",\n        \"signalStrength\": \"Strength\",\n        \"signalQuality\": \"Quality\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"Client\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"Inbox\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"Battery Charge\",\n        \"ups_load\": \"UPS Load\",\n        \"ups_status\": \"UPS Status\",\n        \"online\": \"Online\",\n        \"on_battery\": \"On Battery\",\n        \"low_battery\": \"Low Battery\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"No Device Data Received\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"CPU Load\",\n        \"memoryUsed\": \"Memory Used\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"Leases\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"All Streams\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG Channels\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Today\",\n        \"absolutePower\": \"Power\",\n        \"relativePower\": \"Power %\",\n        \"limit\": \"Limit\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"Active Memory\",\n        \"wanUpload\": \"WAN Upload\",\n        \"wanDownload\": \"WAN Download\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"Printer State\",\n        \"print_status\": \"Print Status\",\n        \"print_progress\": \"Progress\",\n        \"layers\": \"Layers\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"Tool temp\",\n        \"temp_bed\": \"Bed temp\",\n        \"job_completion\": \"Completion\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"Origin IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"Load Avg\",\n        \"memory\": \"Mem Usage\",\n        \"wanStatus\": \"WAN Status\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"Disk Usage\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"Datastore\",\n        \"failed_tasks_24h\": \"Failed Tasks 24h\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"Memory\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"storage\": \"Storage\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"Incident\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Archives\",\n        \"chapters\": \"Chapters\",\n        \"categories\": \"Categories\"\n    },\n    \"komga\": {\n        \"libraries\": \"Libraries\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"Issues\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"People\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"Time\"\n    },\n    \"firefly\": {\n        \"networth\": \"Net Worth\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"Dashboards\",\n        \"datasources\": \"Data Sources\",\n        \"totalalerts\": \"Total Alerts\",\n        \"alertstriggered\": \"Alerts Triggered\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"Cpu Load\",\n        \"memoryusage\": \"Memory Usage\",\n        \"freespace\": \"Free Space\",\n        \"activeusers\": \"Active Users\",\n        \"numfiles\": \"Files\",\n        \"numshares\": \"Shared Items\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"Size\",\n        \"lastrun\": \"Last Run\",\n        \"nextrun\": \"Next Run\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"Active Workers\",\n        \"total_workers\": \"Total Workers\",\n        \"records_total\": \"Queue Length\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"Servers\",\n        \"nodes\": \"Nodes\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"Targets Up\",\n        \"targets_down\": \"Targets Down\",\n        \"targets_total\": \"Total Targets\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"One year\",\n        \"gross_percent_max\": \"All time\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasts\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"Duration\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"People Home\",\n        \"lights_on\": \"Lights On\",\n        \"switches_on\": \"Switches On\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"Monitoring\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"Authors\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Result\",\n        \"status\": \"Status\",\n        \"buildId\": \"Build ID\",\n        \"succeeded\": \"Succeeded\",\n        \"notStarted\": \"Not Started\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"Canceled\",\n        \"inProgress\": \"In Progress\",\n        \"totalPrs\": \"Total PRs\",\n        \"myPrs\": \"My PRs\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Name\",\n        \"map\": \"Map\",\n        \"currentPlayers\": \"Current players\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Max players\",\n        \"bots\": \"Bots\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Errors\",\n        \"noRecent\": \"Out of Date\",\n        \"totalUsed\": \"Used Storage\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Recipes\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Tags\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Downloading\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"CPU Load Avg (5m)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"Transmitted\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Last Downtime\",\n        \"downDuration\": \"Downtime Duration\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Not Yet Checked\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Seems Down\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"In cinemas\",\n        \"physicalRelease\": \"Physical release\",\n        \"digitalRelease\": \"Digital release\",\n        \"noEventsToday\": \"No events for today!\",\n        \"noEventsFound\": \"No events found\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"Platforms\",\n        \"totalRoms\": \"Games\",\n        \"saves\": \"Saves\",\n        \"states\": \"States\",\n        \"screenshots\": \"Screenshots\",\n        \"totalfilesize\": \"Total Size\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"Mailboxes\",\n        \"mails\": \"Mails\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"Warnings\",\n        \"criticals\": \"Criticals\"\n    },\n    \"plantit\": {\n        \"events\": \"Events\",\n        \"plants\": \"Plants\",\n        \"photos\": \"Photos\",\n        \"species\": \"Species\"\n    },\n    \"gitea\": {\n        \"notifications\": \"Notifications\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"Pull Requests\",\n        \"repositories\": \"Repositories\"\n    },\n    \"stash\": {\n        \"scenes\": \"Scenes\",\n        \"scenesPlayed\": \"Scenes Played\",\n        \"playCount\": \"Total Plays\",\n        \"playDuration\": \"Time Watched\",\n        \"sceneSize\": \"Scenes Size\",\n        \"sceneDuration\": \"Scenes Duration\",\n        \"images\": \"Images\",\n        \"imageSize\": \"Images Size\",\n        \"galleries\": \"Galleries\",\n        \"performers\": \"Performers\",\n        \"studios\": \"Studios\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O Count\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"Keywords\"\n    },\n    \"homebox\": {\n        \"items\": \"Items\",\n        \"totalWithWarranty\": \"With Warranty\",\n        \"locations\": \"Locations\",\n        \"labels\": \"Labels\",\n        \"users\": \"Users\",\n        \"totalValue\": \"Total Value\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"Bans\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"Proxied\",\n        \"auth\": \"With Auth\",\n        \"outdated\": \"Outdated\",\n        \"banned\": \"Banned\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"Stocks\",\n        \"loading\": \"Loading\",\n        \"open\": \"Open - US Market\",\n        \"closed\": \"Closed - US Market\",\n        \"invalidConfiguration\": \"Invalid Configuration\"\n    },\n    \"frigate\": {\n        \"cameras\": \"Cameras\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"Links\",\n        \"collections\": \"Collections\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"Not classified\",\n        \"information\": \"Information\",\n        \"warning\": \"Warning\",\n        \"average\": \"Average\",\n        \"high\": \"High\",\n        \"disaster\": \"Disaster\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"Vehicle\",\n        \"vehicles\": \"Vehicles\",\n        \"serviceRecords\": \"Service Records\",\n        \"reminders\": \"Reminders\",\n        \"nextReminder\": \"Next Reminder\",\n        \"none\": \"None\"\n    },\n    \"vikunja\": {\n        \"projects\": \"Active Projects\",\n        \"tasks7d\": \"Tasks Due This Week\",\n        \"tasksOverdue\": \"Overdue Tasks\",\n        \"tasksInProgress\": \"Tasks In Progress\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"Systems\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Đã tạm dừng\",\n        \"pending\": \"Đang xử lý\",\n        \"status\": \"Trạng thái\",\n        \"updated\": \"Đã cập nhật\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"Ổ đĩa\",\n        \"network\": \"NET\"\n    },\n    \"argocd\": {\n        \"apps\": \"Ứng dụng\",\n        \"synced\": \"Synced\",\n        \"outOfSync\": \"Out Of Sync\",\n        \"healthy\": \"Ổn định\",\n        \"degraded\": \"Degraded\",\n        \"progressing\": \"Progressing\",\n        \"missing\": \"Bị thiếu\",\n        \"suspended\": \"Suspended\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Đang tải\"\n    },\n    \"gitlab\": {\n        \"groups\": \"Nhóm\",\n        \"issues\": \"Vấn đề\",\n        \"merges\": \"Yêu cầu Hợp nhất\",\n        \"projects\": \"Dự án\"\n    },\n    \"apcups\": {\n        \"status\": \"Trạng thái\",\n        \"load\": \"Đang tải\\n\",\n        \"bcharge\": \"Sạc pin\",\n        \"timeleft\": \"Thời gian còn lại\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"Dấu trang\",\n        \"favorites\": \"Mục yêu thích\",\n        \"archived\": \"Đã lưu trữ\",\n        \"highlights\": \"Tâm điểm\",\n        \"lists\": \"Danh sách\",\n        \"tags\": \"Thẻ\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Mạng\",\n        \"connected\": \"Đã kết nối\",\n        \"disconnected\": \"Mất kết nối\",\n        \"updateStatus\": \"Cập nhật\",\n        \"update_yes\": \"Khả dụng\",\n        \"update_no\": \"Đã cập nhật\",\n        \"downloads\": \"Tải xuống\",\n        \"uploads\": \"Tải lên\",\n        \"sharedFiles\": \"Tập tin\"\n    },\n    \"jellystat\": {\n        \"songs\": \"Bài hát\",\n        \"movies\": \"Phim ảnh\",\n        \"episodes\": \"Tập\",\n        \"other\": \"Khác\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Không ổn định\",\n        \"unknown\": \"Không xác định\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Tổng\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Đăng ký\",\n        \"thisMonthlyCost\": \"Tháng này\",\n        \"nextMonthlyCost\": \"Tháng sau\",\n        \"previousMonthlyCost\": \"Tháng trước\",\n        \"nextRenewingSubscription\": \"Lần thanh toán kế tiếp\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Đã bắt đầu\",\n        \"STOPPED\": \"Đã dừng\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"Không có dữ liệu ổ đĩa\",\n        \"notifications\": \"Thông báo\",\n        \"status\": \"Trạng thái\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Các kế hoạch\",\n        \"num_success_30\": \"Thành công\",\n        \"num_failure_30\": \"Thất bại\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Bài hát\",\n        \"time\": \"Thời gian\",\n        \"artists\": \"Nghệ sĩ\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/yue/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"月\",\n        \"days\": \"天\",\n        \"hours\": \"小時\",\n        \"minutes\": \"分\",\n        \"seconds\": \"秒\"\n    },\n    \"widget\": {\n        \"missing_type\": \"缺少小部件類型：{{type}}\",\n        \"api_error\": \"API 錯誤\",\n        \"information\": \"資訊\",\n        \"status\": \"狀況\",\n        \"url\": \"網址\",\n        \"raw_error\": \"原始錯誤\",\n        \"response_data\": \"回應資料\"\n    },\n    \"weather\": {\n        \"current\": \"依家位置\",\n        \"allow\": \"點擊允許\",\n        \"updating\": \"更新緊\",\n        \"wait\": \"請稍後\"\n    },\n    \"search\": {\n        \"placeholder\": \"搜索緊…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"記憶體\",\n        \"total\": \"全部\",\n        \"free\": \"剩餘\",\n        \"used\": \"用咗\",\n        \"load\": \"負荷\",\n        \"temp\": \"溫度\",\n        \"max\": \"最大\",\n        \"uptime\": \"運作時間\"\n    },\n    \"unifi\": {\n        \"users\": \"使用者\",\n        \"uptime\": \"運行時間\",\n        \"days\": \"天\",\n        \"wan\": \"WAN\",\n        \"lan\": \"區域網路\",\n        \"wlan\": \"無線區域網路\",\n        \"devices\": \"設備\",\n        \"lan_devices\": \"有線設備\",\n        \"wlan_devices\": \"無線設備\",\n        \"lan_users\": \"有線使用者\",\n        \"wlan_users\": \"無線使用者\",\n        \"up\": \"UP\",\n        \"down\": \"離線\",\n        \"wait\": \"Please wait\",\n        \"empty_data\": \"子系統狀態未知\"\n    },\n    \"docker\": {\n        \"rx\": \"接收\",\n        \"tx\": \"發送\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"執行中\",\n        \"offline\": \"離線\",\n        \"error\": \"錯誤\",\n        \"unknown\": \"未知\",\n        \"healthy\": \"健康\",\n        \"starting\": \"啟動中\",\n        \"unhealthy\": \"不健康的\",\n        \"not_found\": \"未找到\",\n        \"exited\": \"已退出\",\n        \"partial\": \"部分\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"延遲\",\n        \"down\": \"離線\",\n        \"up\": \"上線\",\n        \"not_available\": \"不可用\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP 狀態\",\n        \"error\": \"Error\",\n        \"response\": \"回應\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"emby\": {\n        \"playing\": \"播放緊\",\n        \"transcoding\": \"轉碼緊\",\n        \"bitrate\": \"比特率\",\n        \"no_active\": \"無任何活動\",\n        \"movies\": \"電影\",\n        \"series\": \"影集\",\n        \"episodes\": \"集\",\n        \"songs\": \"曲目\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"正在播放\",\n        \"transcoding\": \"轉碼\",\n        \"bitrate\": \"位元率\",\n        \"no_active\": \"無播放活動\",\n        \"movies\": \"電影\",\n        \"series\": \"系列\",\n        \"episodes\": \"劇集\",\n        \"songs\": \"曲目\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"在線\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"正式環境\",\n        \"battery_soc\": \"電池\",\n        \"grid_power\": \"電網\",\n        \"home_power\": \"電源使用率\",\n        \"charge_power\": \"充電\",\n        \"kilowatt\": \"千瓦\"\n    },\n    \"flood\": {\n        \"download\": \"下載速率\",\n        \"upload\": \"上傳速率\",\n        \"leech\": \"未完成下載\",\n        \"seed\": \"已完成下載\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"訂閱\",\n        \"unread\": \"未讀\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"未設定\",\n        \"connectionStatusConnecting\": \"連線中\",\n        \"connectionStatusAuthenticating\": \"身份驗證中\",\n        \"connectionStatusPendingDisconnect\": \"待中斷連線\",\n        \"connectionStatusDisconnecting\": \"正在中斷連線\",\n        \"connectionStatusDisconnected\": \"連線已中斷\",\n        \"connectionStatusConnected\": \"已連線\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"最大下載速率\",\n        \"maxUp\": \"最大上傳速率\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"已接收\",\n        \"sent\": \"送出\",\n        \"externalIPAddress\": \"外部 IP\",\n        \"externalIPv6Address\": \"外部 IPv6\",\n        \"externalIPv6Prefix\": \"外部 IPv6前綴\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"上行\",\n        \"requests\": \"目前請求數\",\n        \"requests_failed\": \"失敗請求\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"總監測數\",\n        \"diffsDetected\": \"偵測到的變更\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"節目\",\n        \"recordings\": \"錄影\",\n        \"scheduled\": \"已排定\",\n        \"passes\": \"通行證\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"檢查Plex的連接狀態\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"已連接的存取點\",\n        \"activeUser\": \"在線裝置\",\n        \"alerts\": \"警示\",\n        \"connectedGateways\": \"已連線的閘道\",\n        \"connectedSwitches\": \"已連接的交換器\"\n    },\n    \"nzbget\": {\n        \"rate\": \"速度\",\n        \"remaining\": \"剩餘\",\n        \"downloaded\": \"下載咗\"\n    },\n    \"plex\": {\n        \"streams\": \"正在播放\",\n        \"albums\": \"專輯\",\n        \"movies\": \"Movies\",\n        \"tv\": \"影集\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"隊列\",\n        \"timeleft\": \"用時\"\n    },\n    \"rutorrent\": {\n        \"active\": \"激活\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU 使用率\",\n        \"memUsage\": \"記憶體使用率\",\n        \"systemTempC\": \"系統溫度\",\n        \"poolUsage\": \"儲存池使用率\",\n        \"volumeUsage\": \"儲存區用量\",\n        \"invalid\": \"無效的\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"快取未命中位元組\",\n        \"cachemissbytes\": \"快取未命中位元組\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"想睇\",\n        \"queued\": \"排緊隊\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"缺少\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"創作者\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"書\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"缺少嘅劇集\",\n        \"missingMovies\": \"缺少電影\"\n    },\n    \"ombi\": {\n        \"pending\": \"待定\",\n        \"approved\": \"批准\",\n        \"available\": \"可用\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"新裝置\",\n        \"down_alerts\": \"離線警告\"\n    },\n    \"pihole\": {\n        \"queries\": \"查詢\",\n        \"blocked\": \"封鎖\",\n        \"blocked_percent\": \"已封鎖 %\",\n        \"gravity\": \"重力\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"過濾\",\n        \"latency\": \"延遲\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"執行中\",\n        \"stopped\": \"暫停\",\n        \"total\": \"全部\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"已下載\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"已下載且已閱讀\",\n        \"downloadedunread\": \"已下載且未閱讀\",\n        \"nondownloadedread\": \"未下載但已閱讀\",\n        \"nondownloadedunread\": \"未下載且未閱讀\"\n    },\n    \"tailscale\": {\n        \"address\": \"位址\",\n        \"expires\": \"已失效\",\n        \"never\": \"未曾\",\n        \"last_seen\": \"上次連線\",\n        \"now\": \"現在\",\n        \"years\": \"{{number}} 年\",\n        \"weeks\": \"{{number}} 週\",\n        \"days\": \"{{number}} 天\",\n        \"hours\": \"{{number}} 小時\",\n        \"minutes\": \"{{number}} 分鐘\",\n        \"seconds\": \"{{number}} 秒\",\n        \"ago\": \"{{value}} 前\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"成功\",\n        \"totalServerFailure\": \"失敗\",\n        \"totalNxDomain\": \"網域\",\n        \"totalRefused\": \"被拒絕\",\n        \"totalAuthoritative\": \"權威的\",\n        \"totalRecursive\": \"遞迴\",\n        \"totalCached\": \"快取\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"丟棄\",\n        \"totalClients\": \"客戶端\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"已處理\",\n        \"errored\": \"發生錯誤\",\n        \"saved\": \"已儲存\"\n    },\n    \"traefik\": {\n        \"routers\": \"路由器\",\n        \"services\": \"服務項\",\n        \"middleware\": \"中間件\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"請稍後\"\n    },\n    \"npm\": {\n        \"enabled\": \"啟用\",\n        \"disabled\": \"停用咗\",\n        \"total\": \"全部\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"配置一個或多個加密貨幣以進行跟蹤\",\n        \"1hour\": \"1 個鐘\",\n        \"1day\": \"1 日\",\n        \"7days\": \"7 日\",\n        \"30days\": \"30日\"\n    },\n    \"gotify\": {\n        \"apps\": \"應用\",\n        \"clients\": \"Clients\",\n        \"messages\": \"消息\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"索引\",\n        \"numberOfGrabs\": \"抓住\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"失敗抓取\",\n        \"numberOfFailQueries\": \"查詢失敗\"\n    },\n    \"jackett\": {\n        \"configured\": \"配置\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"會話\",\n        \"numConnections\": \"連接\",\n        \"dataRelayed\": \"傳遞\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"職位\",\n        \"domain_count\": \"域\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"玩家\",\n        \"version\": \"版本\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"已讀\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"登錄（ 24小时）\",\n        \"failedLoginsLast24H\": \"登錄失敗（ 24鐘頭）\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"Linux 容器\",\n        \"vms\": \"虛擬機\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"負載\",\n        \"wait\": \"請稍候\",\n        \"temp\": \"溫度\",\n        \"_temp\": \"溫度\",\n        \"warn\": \"警告\",\n        \"uptime\": \"運作時間\",\n        \"total\": \"全部\",\n        \"free\": \"剩餘\",\n        \"used\": \"已使用\",\n        \"days\": \"日\",\n        \"hours\": \"時\",\n        \"crit\": \"重大的\",\n        \"read\": \"已讀\",\n        \"write\": \"寫入\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"記憶體\",\n        \"swap\": \"交換空間\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"書籤\",\n        \"service\": \"服務\",\n        \"search\": \"搜尋\",\n        \"custom\": \"自訂\",\n        \"visit\": \"造訪\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"建議\"\n    },\n    \"wmo\": {\n        \"0-day\": \"晴天\",\n        \"0-night\": \"晴朗\",\n        \"1-day\": \"晴時多雲\",\n        \"1-night\": \"晴時多雲\",\n        \"2-day\": \"多雲時陰\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"陰天\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"有霧\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"小毛雨\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"毛雨\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"大毛雨\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"小凍毛雨\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"凍毛雨\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"小雨\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"雨\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"大雨\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"凍雨\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"小雪\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"雪\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"大雪\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"雪粒\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"微陣雨\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"陣雨\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"強陣雨\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"陣雪\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"雷雨\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"雷雨伴隨冰雹\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"系統\",\n        \"updates\": \"更新\",\n        \"update_available\": \"有可用的更新\",\n        \"up_to_date\": \"已更新至最新\",\n        \"child_bridges\": \"子網橋接\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"新建立\",\n        \"up\": \"Up\",\n        \"grace\": \"延緩中\",\n        \"down\": \"Down\",\n        \"paused\": \"擱置中\",\n        \"status\": \"Status\",\n        \"last_ping\": \"上次檢查\",\n        \"never\": \"尚未檢查\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"已掃描\",\n        \"containers_updated\": \"已更新\",\n        \"containers_failed\": \"失敗\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"拒絕\",\n        \"filters\": \"篩選器\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"影片\",\n        \"channels\": \"頻道\",\n        \"playlists\": \"播放清單\"\n    },\n    \"truenas\": {\n        \"load\": \"系統負載\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"速度\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"公用IP\",\n        \"region\": \"地區\",\n        \"country\": \"國家\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"高畫質\",\n        \"tunerCount\": \"調諧器\",\n        \"channelNumber\": \"頻道\",\n        \"channelNetwork\": \"網絡\",\n        \"signalStrength\": \"強度\",\n        \"signalQuality\": \"品質\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"用戶端\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"通過\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"收件箱\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"充電\",\n        \"ups_load\": \"備用電源負載\",\n        \"ups_status\": \"備用電源狀態\",\n        \"online\": \"Online\",\n        \"on_battery\": \"電池供電\",\n        \"low_battery\": \"低電量\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"未收到裝置資料\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"CPU負載\",\n        \"memoryUsed\": \"已使用的記憶體\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"租約\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"所有播放活動\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG頻道\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"今天\",\n        \"absolutePower\": \"功率\",\n        \"relativePower\": \"功率百分比\",\n        \"limit\": \"上限\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"記憶體\",\n        \"wanUpload\": \"WAN上傳\",\n        \"wanDownload\": \"WAN下載\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"列印機狀態\",\n        \"print_status\": \"列印狀態\",\n        \"print_progress\": \"進度\",\n        \"layers\": \"層\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"噴頭溫度\",\n        \"temp_bed\": \"平台溫度\",\n        \"job_completion\": \"完成度\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"源頭IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"平均負載量\",\n        \"memory\": \"記憶體使用率\",\n        \"wanStatus\": \"網際網路狀態\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"硬碟使用率\",\n        \"wanIP\": \"網際網路 IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"數據存儲\",\n        \"failed_tasks_24h\": \"24小時內失敗任務\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"記憶體\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"照片\",\n        \"videos\": \"Videos\",\n        \"storage\": \"儲存空間\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"在線網站\",\n        \"down\": \"離線網站\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"事件\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"檔案\",\n        \"chapters\": \"章節\",\n        \"categories\": \"類別\"\n    },\n    \"komga\": {\n        \"libraries\": \"文庫\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"出版\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"人物\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"時間\"\n    },\n    \"firefly\": {\n        \"networth\": \"淨值\",\n        \"budget\": \"預算\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"控制面板\",\n        \"datasources\": \"數據來源\",\n        \"totalalerts\": \"警報總數\",\n        \"alertstriggered\": \"觸發的警報\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"處理器負載\",\n        \"memoryusage\": \"記憶體用量\",\n        \"freespace\": \"可用空間\",\n        \"activeusers\": \"活躍用戶\",\n        \"numfiles\": \"檔案\",\n        \"numshares\": \"已分享\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"檔案大小\",\n        \"lastrun\": \"上次執行\",\n        \"nextrun\": \"下次執行\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"在線工作程序\",\n        \"total_workers\": \"總工作程序\",\n        \"records_total\": \"佇列長度\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"伺服器\",\n        \"nodes\": \"節點\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"目標上線\",\n        \"targets_down\": \"目標離線\",\n        \"targets_total\": \"目標總數\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"一年\",\n        \"gross_percent_max\": \"所有時間\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"播客\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"歷時\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"在家人數\",\n        \"lights_on\": \"燈亮著\",\n        \"switches_on\": \"開關開著\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"監測中\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"作者\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"結果\",\n        \"status\": \"Status\",\n        \"buildId\": \"組建編號\",\n        \"succeeded\": \"成功\",\n        \"notStarted\": \"尚未啟用\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"取消\",\n        \"inProgress\": \"執行中\",\n        \"totalPrs\": \"總提取要求\",\n        \"myPrs\": \"我的提取要求\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"名稱\",\n        \"map\": \"地圖\",\n        \"currentPlayers\": \"當前玩家數\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"玩家數上限\",\n        \"bots\": \"機器人\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"確定\",\n        \"errored\": \"錯誤\",\n        \"noRecent\": \"已過時\",\n        \"totalUsed\": \"已使用空間\"\n    },\n    \"mealie\": {\n        \"recipes\": \"食譜\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"標籤\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"下載中\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"處理器平均負載(5分鐘)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"已傳送\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"近一次停機時間\",\n        \"downDuration\": \"歷時停機時間\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"尚未檢查\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"似乎離線\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"上映中\",\n        \"physicalRelease\": \"實體發行\",\n        \"digitalRelease\": \"數位發行\",\n        \"noEventsToday\": \"今天沒有事件！\",\n        \"noEventsFound\": \"找不到事件\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"平台\",\n        \"totalRoms\": \"遊戲\",\n        \"saves\": \"已儲存\",\n        \"states\": \"州\",\n        \"screenshots\": \"螢幕截圖\",\n        \"totalfilesize\": \"大小總計\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"信箱\",\n        \"mails\": \"郵件數\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"警告\",\n        \"criticals\": \"嚴重\"\n    },\n    \"plantit\": {\n        \"events\": \"事件\",\n        \"plants\": \"植物\",\n        \"photos\": \"Photos\",\n        \"species\": \"物種\"\n    },\n    \"gitea\": {\n        \"notifications\": \"通知\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"提取請求\",\n        \"repositories\": \"套件來源\"\n    },\n    \"stash\": {\n        \"scenes\": \"場景\",\n        \"scenesPlayed\": \"已播放場景\",\n        \"playCount\": \"總播放次數\",\n        \"playDuration\": \"觀看時數\",\n        \"sceneSize\": \"場景大小\",\n        \"sceneDuration\": \"場景為期\",\n        \"images\": \"圖片\",\n        \"imageSize\": \"圖片大小\",\n        \"galleries\": \"畫廊\",\n        \"performers\": \"表演者\",\n        \"studios\": \"工作室\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"0 個\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"關鍵字\"\n    },\n    \"homebox\": {\n        \"items\": \"項目\",\n        \"totalWithWarranty\": \"有保證\",\n        \"locations\": \"位置\",\n        \"labels\": \"標籤\",\n        \"users\": \"Users\",\n        \"totalValue\": \"總共\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"禁止\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"已代理\",\n        \"auth\": \"已授權\",\n        \"outdated\": \"已過時\",\n        \"banned\": \"已封鎖\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"股票\",\n        \"loading\": \"載入中\",\n        \"open\": \"美國市場已開放\",\n        \"closed\": \"美國市場已關閉\",\n        \"invalidConfiguration\": \"無效的設定\"\n    },\n    \"frigate\": {\n        \"cameras\": \"相機\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"連結\",\n        \"collections\": \"收藏庫\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"未分類\",\n        \"information\": \"Information\",\n        \"warning\": \"警告\",\n        \"average\": \"平均\",\n        \"high\": \"高優先權\",\n        \"disaster\": \"災難\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"車輛\",\n        \"vehicles\": \"車輛\",\n        \"serviceRecords\": \"保養記錄\",\n        \"reminders\": \"提醒\",\n        \"nextReminder\": \"下一個提醒\",\n        \"none\": \"沒有\"\n    },\n    \"vikunja\": {\n        \"projects\": \"活躍專案\",\n        \"tasks7d\": \"本週到期任務\",\n        \"tasksOverdue\": \"逾期處理\",\n        \"tasksInProgress\": \"正在執行的任務\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"系統\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"儲存空間\",\n        \"network\": \"網路\"\n    },\n    \"argocd\": {\n        \"apps\": \"應用程式\",\n        \"synced\": \"已同步\",\n        \"outOfSync\": \"不同步\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"已降級\",\n        \"progressing\": \"進度\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"暫停\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"群組\",\n        \"issues\": \"Issues\",\n        \"merges\": \"合併請求\",\n        \"projects\": \"專案\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"書籤\",\n        \"favorites\": \"我的最愛\",\n        \"archived\": \"已存檔\",\n        \"highlights\": \"標記\",\n        \"lists\": \"列表\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"更新\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"下載\",\n        \"uploads\": \"上傳\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"曲目\",\n        \"movies\": \"電影\",\n        \"episodes\": \"劇集\",\n        \"other\": \"其它\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/zh-Hans/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, rate(bits: false; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, rate(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"月\",\n        \"days\": \"日\",\n        \"hours\": \"时\",\n        \"minutes\": \"分\",\n        \"seconds\": \"秒\"\n    },\n    \"widget\": {\n        \"missing_type\": \"缺失的组件类型: {{type}}\",\n        \"api_error\": \"API 错误\",\n        \"information\": \"信息\",\n        \"status\": \"状态\",\n        \"url\": \"URL\",\n        \"raw_error\": \"原始信息错误\",\n        \"response_data\": \"响应数据\"\n    },\n    \"weather\": {\n        \"current\": \"当前位置\",\n        \"allow\": \"点击以允许\",\n        \"updating\": \"更新中\",\n        \"wait\": \"请稍候\"\n    },\n    \"search\": {\n        \"placeholder\": \"搜索…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"内存\",\n        \"total\": \"总计\",\n        \"free\": \"空闲\",\n        \"used\": \"已使用\",\n        \"load\": \"负载\",\n        \"temp\": \"温度\",\n        \"max\": \"最大\",\n        \"uptime\": \"运行时间\"\n    },\n    \"unifi\": {\n        \"users\": \"用户\",\n        \"uptime\": \"运行时间\",\n        \"days\": \"天\",\n        \"wan\": \"WAN\",\n        \"lan\": \"局域网\",\n        \"wlan\": \"无线局域网\",\n        \"devices\": \"设备\",\n        \"lan_devices\": \"LAN 设备\",\n        \"wlan_devices\": \"无线局域网设备\",\n        \"lan_users\": \"局域网用户\",\n        \"wlan_users\": \"无线局域网用户\",\n        \"up\": \"在线\",\n        \"down\": \"离线\",\n        \"wait\": \"请稍候\",\n        \"empty_data\": \"子系统状态未知\"\n    },\n    \"docker\": {\n        \"rx\": \"接收\",\n        \"tx\": \"发送\",\n        \"mem\": \"内存\",\n        \"cpu\": \"CPU\",\n        \"running\": \"运行中\",\n        \"offline\": \"离线\",\n        \"error\": \"错误\",\n        \"unknown\": \"未知\",\n        \"healthy\": \"健康\",\n        \"starting\": \"启动中\",\n        \"unhealthy\": \"不健康\",\n        \"not_found\": \"未找到\",\n        \"exited\": \"已退出\",\n        \"partial\": \"部分\"\n    },\n    \"ping\": {\n        \"error\": \"错误\",\n        \"ping\": \"延迟\",\n        \"down\": \"离线\",\n        \"up\": \"在线\",\n        \"not_available\": \"不可用\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP 状态\",\n        \"error\": \"错误\",\n        \"response\": \"响应\",\n        \"down\": \"离线\",\n        \"up\": \"在线\",\n        \"not_available\": \"不可用\"\n    },\n    \"emby\": {\n        \"playing\": \"播放中\",\n        \"transcoding\": \"转码\",\n        \"bitrate\": \"比特率\",\n        \"no_active\": \"暂无播放\",\n        \"movies\": \"电影\",\n        \"series\": \"系列\",\n        \"episodes\": \"剧集\",\n        \"songs\": \"歌曲\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"播放中\",\n        \"transcoding\": \"转码\",\n        \"bitrate\": \"比特率\",\n        \"no_active\": \"暂无播放\",\n        \"movies\": \"电影\",\n        \"series\": \"系列\",\n        \"episodes\": \"剧集\",\n        \"songs\": \"歌曲\"\n    },\n    \"esphome\": {\n        \"offline\": \"离线\",\n        \"offline_alt\": \"离线\",\n        \"online\": \"在线的\",\n        \"total\": \"总计\",\n        \"unknown\": \"未知\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"正式环境\",\n        \"battery_soc\": \"电量\",\n        \"grid_power\": \"Grid\",\n        \"home_power\": \"Consumption\",\n        \"charge_power\": \"Charger\",\n        \"kilowatt\": \"kW\"\n    },\n    \"flood\": {\n        \"download\": \"下载\",\n        \"upload\": \"上传速率\",\n        \"leech\": \"下载中\",\n        \"seed\": \"做种\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"订阅\",\n        \"unread\": \"未读\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"状态\",\n        \"connectionStatusUnconfigured\": \"未配置\",\n        \"connectionStatusConnecting\": \"连接中\",\n        \"connectionStatusAuthenticating\": \"认证中\",\n        \"connectionStatusPendingDisconnect\": \"等待断开连接\",\n        \"connectionStatusDisconnecting\": \"正在断开连接\",\n        \"connectionStatusDisconnected\": \"未连接\",\n        \"connectionStatusConnected\": \"已连接\",\n        \"uptime\": \"运行时间\",\n        \"maxDown\": \"最大下载速度\",\n        \"maxUp\": \"最大上传速度\",\n        \"down\": \"离线\",\n        \"up\": \"在线\",\n        \"received\": \"已接收\",\n        \"sent\": \"已发送\",\n        \"externalIPAddress\": \"外部IP\",\n        \"externalIPv6Address\": \"\",\n        \"externalIPv6Prefix\": \"Ext. IPv6-Prefix\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"上游\",\n        \"requests\": \"当前请求\",\n        \"requests_failed\": \"失败请求\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"观察到的总数\",\n        \"diffsDetected\": \"检测到差异\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"节目\",\n        \"recordings\": \"录像\",\n        \"scheduled\": \"已计划的\",\n        \"passes\": \"通行证\"\n    },\n    \"tautulli\": {\n        \"playing\": \"播放中\",\n        \"transcoding\": \"转码\",\n        \"bitrate\": \"比特率\",\n        \"no_active\": \"暂无播放\",\n        \"plex_connection_error\": \"Check Plex Connection\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"暂无播放\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"比特率\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"连接中的AP\",\n        \"activeUser\": \"活跃设备\",\n        \"alerts\": \"警报\",\n        \"connectedGateways\": \"已连接网关\",\n        \"connectedSwitches\": \"已连接开关\"\n    },\n    \"nzbget\": {\n        \"rate\": \"速率\",\n        \"remaining\": \"剩余\",\n        \"downloaded\": \"下载\"\n    },\n    \"plex\": {\n        \"streams\": \"活动流\",\n        \"albums\": \"专辑\",\n        \"movies\": \"电影\",\n        \"tv\": \"电视节目\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"速率\",\n        \"queue\": \"队列\",\n        \"timeleft\": \"剩余时间\"\n    },\n    \"rutorrent\": {\n        \"active\": \"活动中\",\n        \"upload\": \"上传\",\n        \"download\": \"下载\"\n    },\n    \"transmission\": {\n        \"download\": \"下载\",\n        \"upload\": \"\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"下载速率\",\n        \"upload\": \"上传速率\",\n        \"leech\": \"下载中\",\n        \"seed\": \"做种\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"处理器\",\n        \"memUsage\": \"内存使用\",\n        \"systemTempC\": \"系统温度\",\n        \"poolUsage\": \"存储池\",\n        \"volumeUsage\": \"Volume Usage\",\n        \"invalid\": \"Invalid\"\n    },\n    \"deluge\": {\n        \"download\": \"下载\",\n        \"upload\": \"上传\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"缓存命中字节\",\n        \"cachemissbytes\": \"缓存Bytes失败\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"\",\n        \"seed\": \"做种\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"想看\",\n        \"queued\": \"排队\",\n        \"series\": \"系列\",\n        \"queue\": \"队列\",\n        \"unknown\": \"未知\"\n    },\n    \"radarr\": {\n        \"wanted\": \"想看\",\n        \"missing\": \"丢失\",\n        \"queued\": \"队列中\",\n        \"movies\": \"电影\",\n        \"queue\": \"队列\",\n        \"unknown\": \"未知\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"想看\",\n        \"queued\": \"队列中\",\n        \"artists\": \"Artists\"\n    },\n    \"readarr\": {\n        \"wanted\": \"想看\",\n        \"queued\": \"队列中\",\n        \"books\": \"书籍\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"缺少的剧集\",\n        \"missingMovies\": \"缺少的电影\"\n    },\n    \"ombi\": {\n        \"pending\": \"待办的\",\n        \"approved\": \"已批准\",\n        \"available\": \"可用\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"新设备\",\n        \"down_alerts\": \"离线警报\"\n    },\n    \"pihole\": {\n        \"queries\": \"查询\",\n        \"blocked\": \"阻止\",\n        \"blocked_percent\": \"拦截 %\",\n        \"gravity\": \"屏蔽列表\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"过滤\",\n        \"latency\": \"延迟\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"运行中\",\n        \"stopped\": \"停止\",\n        \"total\": \"总计\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"未下载\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"已下载 & 已读\",\n        \"downloadedunread\": \"已下载 & 未读\",\n        \"nondownloadedread\": \"未下载 & 已读\",\n        \"nondownloadedunread\": \"未下载 & 未读\"\n    },\n    \"tailscale\": {\n        \"address\": \"地址\",\n        \"expires\": \"失效\",\n        \"never\": \"从不\",\n        \"last_seen\": \"最后上线\",\n        \"now\": \"现在\",\n        \"years\": \"{{number}}年\",\n        \"weeks\": \"{{number}}周\",\n        \"days\": \"{{number}}d\",\n        \"hours\": \"{{number}}h\",\n        \"minutes\": \"{{number}}m\",\n        \"seconds\": \"{{number}}s\",\n        \"ago\": \"{{value}} 以前\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"成功\",\n        \"totalServerFailure\": \"失败\",\n        \"totalNxDomain\": \"域\",\n        \"totalRefused\": \"已拒绝\",\n        \"totalAuthoritative\": \"权威\",\n        \"totalRecursive\": \"递归\",\n        \"totalCached\": \"缓存\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"丢弃\",\n        \"totalClients\": \"客户端\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"已处理\",\n        \"errored\": \"出错\",\n        \"saved\": \"已保存\"\n    },\n    \"traefik\": {\n        \"routers\": \"路由器\",\n        \"services\": \"服务\",\n        \"middleware\": \"中间件\"\n    },\n    \"trilium\": {\n        \"version\": \"版本\",\n        \"notesCount\": \"笔记\",\n        \"dbSize\": \"数据库大小\",\n        \"unknown\": \"未知\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"暂无播放\",\n        \"please_wait\": \"请等待\"\n    },\n    \"npm\": {\n        \"enabled\": \"已启用\",\n        \"disabled\": \"禁用\",\n        \"total\": \"总计\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"配置一个或多个需要追踪的加密\",\n        \"1hour\": \"1小时\",\n        \"1day\": \"1天\",\n        \"7days\": \"7天\",\n        \"30days\": \"30天\"\n    },\n    \"gotify\": {\n        \"apps\": \"应用\",\n        \"clients\": \"Clients\",\n        \"messages\": \"信息\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"索引器\",\n        \"numberOfGrabs\": \"抓取\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"抓取失败\",\n        \"numberOfFailQueries\": \"查询失败\"\n    },\n    \"jackett\": {\n        \"configured\": \"已配置\",\n        \"errored\": \"出错\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"会话\",\n        \"numConnections\": \"连接\",\n        \"dataRelayed\": \"中继\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"帖子\",\n        \"domain_count\": \"域\"\n    },\n    \"medusa\": {\n        \"wanted\": \"\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"玩家\",\n        \"version\": \"版本\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"已读\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"登录 (24h)\",\n        \"failedLoginsLast24H\": \"登录失败 (24h)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"内存\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"容器\",\n        \"vms\": \"虚拟机\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"负载\",\n        \"wait\": \"请稍候\",\n        \"temp\": \"转速\",\n        \"_temp\": \"Temp\",\n        \"warn\": \"Warn\",\n        \"uptime\": \"运行时间\",\n        \"total\": \"总计\",\n        \"free\": \"空闲\",\n        \"used\": \"已使用\",\n        \"days\": \"日\",\n        \"hours\": \"时\",\n        \"crit\": \"Crit\",\n        \"read\": \"读取\",\n        \"write\": \"写入\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"Mem\",\n        \"swap\": \"Swap\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"书签\",\n        \"service\": \"服务\",\n        \"search\": \"搜索\",\n        \"custom\": \"自定\",\n        \"visit\": \"访问\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"建议\"\n    },\n    \"wmo\": {\n        \"0-day\": \"晴天\",\n        \"0-night\": \"晴朗\",\n        \"1-day\": \"主要是晴天\",\n        \"1-night\": \"大部晴朗\",\n        \"2-day\": \"多云\",\n        \"2-night\": \"多云\",\n        \"3-day\": \"阴天\",\n        \"3-night\": \"阴天\",\n        \"45-day\": \"有雾\",\n        \"45-night\": \"雾\",\n        \"48-day\": \"雾\",\n        \"48-night\": \"雾\",\n        \"51-day\": \"小雨\",\n        \"51-night\": \"小细雨\",\n        \"53-day\": \"小雨\",\n        \"53-night\": \"细雨\",\n        \"55-day\": \"毛毛雨\",\n        \"55-night\": \"大细雨\",\n        \"56-day\": \"小冻毛雨\",\n        \"56-night\": \"小冻毛雨\",\n        \"57-day\": \"冻毛雨\",\n        \"57-night\": \"冻毛雨\",\n        \"61-day\": \"小雨\",\n        \"61-night\": \"小雨\",\n        \"63-day\": \"雨\",\n        \"63-night\": \"雨天\",\n        \"65-day\": \"大雨\",\n        \"65-night\": \"大雨\",\n        \"66-day\": \"冻雨\",\n        \"66-night\": \"冻雨\",\n        \"67-day\": \"冻雨\",\n        \"67-night\": \"冻雨\",\n        \"71-day\": \"小雪\",\n        \"71-night\": \"小雪\",\n        \"73-day\": \"中雪\",\n        \"73-night\": \"中雪\",\n        \"75-day\": \"大雪\",\n        \"75-night\": \"大雪\",\n        \"77-day\": \"雪粒\",\n        \"77-night\": \"雪粒\",\n        \"80-day\": \"微阵雨\",\n        \"80-night\": \"小阵雨\",\n        \"81-day\": \"阵雨\",\n        \"81-night\": \"阵雨\",\n        \"82-day\": \"强阵雨\",\n        \"82-night\": \"强阵雨\",\n        \"85-day\": \"阵雪\",\n        \"85-night\": \"阵雪\",\n        \"86-day\": \"阵雪\",\n        \"86-night\": \"阵雪\",\n        \"95-day\": \"雷雨\",\n        \"95-night\": \"雷雨\",\n        \"96-day\": \"雷雨伴随冰雹\",\n        \"96-night\": \"雷雨伴随冰雹\",\n        \"99-day\": \"雷雨伴随冰雹\",\n        \"99-night\": \"雷雨伴随冰雹\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"System\",\n        \"updates\": \"更新\",\n        \"update_available\": \"有可用的更新\",\n        \"up_to_date\": \"Up to Date\",\n        \"child_bridges\": \"子网桥\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"新建立\",\n        \"up\": \"Up\",\n        \"grace\": \"延缓中\",\n        \"down\": \"Down\",\n        \"paused\": \"暂停\",\n        \"status\": \"Status\",\n        \"last_ping\": \"上次检查\",\n        \"never\": \"尚未检查\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"已扫描\",\n        \"containers_updated\": \"已升级\",\n        \"containers_failed\": \"失败\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"拒绝\",\n        \"filters\": \"Filters\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"影片\",\n        \"channels\": \"频道\",\n        \"playlists\": \"播放清单\"\n    },\n    \"truenas\": {\n        \"load\": \"系统负载\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"速度\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"公网 IP\",\n        \"region\": \"区域\",\n        \"country\": \"国家\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"HD\",\n        \"tunerCount\": \"电台数\",\n        \"channelNumber\": \"频道数\",\n        \"channelNetwork\": \"网络\",\n        \"signalStrength\": \"强度\",\n        \"signalQuality\": \"质量\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"客户端\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"通过\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"收件箱\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"充电中\",\n        \"ups_load\": \"UPS 负载\",\n        \"ups_status\": \"UPS 状态\",\n        \"online\": \"Online\",\n        \"on_battery\": \"电池供电\",\n        \"low_battery\": \"电量低\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"没有接收到设备数据\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"处理器\",\n        \"memoryUsed\": \"内存\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"租约\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"所有播放活动\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG 频道\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"Today\",\n        \"absolutePower\": \"Power\",\n        \"relativePower\": \"Power %\",\n        \"limit\": \"Limit\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"内存\",\n        \"wanUpload\": \"WAN上传\",\n        \"wanDownload\": \"WAN下载\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"打印机状态\",\n        \"print_status\": \"打印状态\",\n        \"print_progress\": \"打印进程\",\n        \"layers\": \"层\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"喷头温度\",\n        \"temp_bed\": \"平台温度\",\n        \"job_completion\": \"完成度\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"源IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"平均负载\",\n        \"memory\": \"内存\",\n        \"wanStatus\": \"WAN 状态\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"磁盘\",\n        \"wanIP\": \"WAN IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"数据存储\",\n        \"failed_tasks_24h\": \"24h失败任务\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"内存\"\n    },\n    \"immich\": {\n        \"users\": \"用户\",\n        \"photos\": \"照片\",\n        \"videos\": \"影片\",\n        \"storage\": \"储存空间\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"在线网站\",\n        \"down\": \"离线网站\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"严重事件\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"Archives\",\n        \"chapters\": \"Chapters\",\n        \"categories\": \"Categories\"\n    },\n    \"komga\": {\n        \"libraries\": \"书库\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"问题\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"人物\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"时间\"\n    },\n    \"firefly\": {\n        \"networth\": \"净值\",\n        \"budget\": \"Budget\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"控制面板\",\n        \"datasources\": \"数据来源\",\n        \"totalalerts\": \"警报总数\",\n        \"alertstriggered\": \"触发的警报\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"CPU 负载\",\n        \"memoryusage\": \"内存\",\n        \"freespace\": \"剩余空间\",\n        \"activeusers\": \"活跃用户\",\n        \"numfiles\": \"文件\",\n        \"numshares\": \"共享项目\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"大小\",\n        \"lastrun\": \"最后运行\",\n        \"nextrun\": \"下次运行\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"在线工作节点\",\n        \"total_workers\": \"工作节点总数\",\n        \"records_total\": \"队列长度\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"服务器\",\n        \"nodes\": \"节点\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"目标上线\",\n        \"targets_down\": \"目标离线\",\n        \"targets_total\": \"总目标\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"一年\",\n        \"gross_percent_max\": \"所有时间\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"播客\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"持续时间\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"在家人数\",\n        \"lights_on\": \"照明开\",\n        \"switches_on\": \"开关开\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"监测中\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"书籍\",\n        \"authors\": \"作者\",\n        \"categories\": \"分类\",\n        \"series\": \"系列\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"队列\",\n        \"downloadBytesRemaining\": \"剩余\",\n        \"downloadTotalBytes\": \"大小\",\n        \"downloadSpeed\": \"速度\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"系列\",\n        \"totalFiles\": \"文件\"\n    },\n    \"azuredevops\": {\n        \"result\": \"Result\",\n        \"status\": \"Status\",\n        \"buildId\": \"Build ID\",\n        \"succeeded\": \"Succeeded\",\n        \"notStarted\": \"Not Started\",\n        \"failed\": \"失败\",\n        \"canceled\": \"Canceled\",\n        \"inProgress\": \"In Progress\",\n        \"totalPrs\": \"Total PRs\",\n        \"myPrs\": \"My PRs\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"Name\",\n        \"map\": \"Map\",\n        \"currentPlayers\": \"Current players\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"Max players\",\n        \"bots\": \"Bots\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"Ok\",\n        \"errored\": \"Errors\",\n        \"noRecent\": \"Out of Date\",\n        \"totalUsed\": \"Used Storage\"\n    },\n    \"mealie\": {\n        \"recipes\": \"Recipes\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"Tags\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"Downloading\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"CPU 负载平均值(5m)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"已传输\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"Last Downtime\",\n        \"downDuration\": \"Downtime Duration\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"Not Yet Checked\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"Seems Down\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"In cinemas\",\n        \"physicalRelease\": \"Physical release\",\n        \"digitalRelease\": \"Digital release\",\n        \"noEventsToday\": \"今天没有活动！\",\n        \"noEventsFound\": \"未找到事件\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"平台\",\n        \"totalRoms\": \"游戏数\",\n        \"saves\": \"已保存\",\n        \"states\": \"状态\",\n        \"screenshots\": \"屏幕截图\",\n        \"totalfilesize\": \"总大小\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"邮箱\",\n        \"mails\": \"邮件\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"警告\",\n        \"criticals\": \"严重\"\n    },\n    \"plantit\": {\n        \"events\": \"事件\",\n        \"plants\": \"植物\",\n        \"photos\": \"Photos\",\n        \"species\": \"物种\"\n    },\n    \"gitea\": {\n        \"notifications\": \"通知\",\n        \"issues\": \"议题\",\n        \"pulls\": \"PR\",\n        \"repositories\": \"代码仓库\"\n    },\n    \"stash\": {\n        \"scenes\": \"场景\",\n        \"scenesPlayed\": \"已播放场景\",\n        \"playCount\": \"播放总数\",\n        \"playDuration\": \"播放时间\",\n        \"sceneSize\": \"场景大小\",\n        \"sceneDuration\": \"场景时长\",\n        \"images\": \"图片\",\n        \"imageSize\": \"图像大小\",\n        \"galleries\": \"图库\",\n        \"performers\": \"演员\",\n        \"studios\": \"工作室\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"O 个\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"关键词\"\n    },\n    \"homebox\": {\n        \"items\": \"条目\",\n        \"totalWithWarranty\": \"有保证\",\n        \"locations\": \"位置\",\n        \"labels\": \"标签\",\n        \"users\": \"Users\",\n        \"totalValue\": \"总计\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"禁用\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"禁用\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"已代理\",\n        \"auth\": \"使用认证\",\n        \"outdated\": \"已过期\",\n        \"banned\": \"已禁止\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"库存\",\n        \"loading\": \"正在加载\",\n        \"open\": \"打開-美国商店\",\n        \"closed\": \"关闭-美国市场\",\n        \"invalidConfiguration\": \"无效配置\"\n    },\n    \"frigate\": {\n        \"cameras\": \"摄像头\",\n        \"uptime\": \"运行时间\",\n        \"version\": \"版本\"\n    },\n    \"linkwarden\": {\n        \"links\": \"链接\",\n        \"collections\": \"收藏\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"未分类\",\n        \"information\": \"Information\",\n        \"warning\": \"警告\",\n        \"average\": \"平均红包\",\n        \"high\": \"高\",\n        \"disaster\": \"灾难\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"载具\",\n        \"vehicles\": \"交通工具\",\n        \"serviceRecords\": \"保养记录\",\n        \"reminders\": \"提示\",\n        \"nextReminder\": \"下次提醒\",\n        \"none\": \"空\"\n    },\n    \"vikunja\": {\n        \"projects\": \"积极的项目\",\n        \"tasks7d\": \"本周到期的任务\",\n        \"tasksOverdue\": \"过期的任务\",\n        \"tasksInProgress\": \"正在处理的任务\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"系统\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"内存\",\n        \"disk\": \"磁盘\",\n        \"network\": \"网络\"\n    },\n    \"argocd\": {\n        \"apps\": \"应用程序\",\n        \"synced\": \"已同步\",\n        \"outOfSync\": \"未同步\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"已降级\",\n        \"progressing\": \"进行中\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"已停用\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"群组\",\n        \"issues\": \"议题\",\n        \"merges\": \"合并请求\",\n        \"projects\": \"项目\"\n    },\n    \"apcups\": {\n        \"status\": \"状态\",\n        \"load\": \"负载\",\n        \"bcharge\": \"电池电量\",\n        \"timeleft\": \"剩余供电时间\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"书签\",\n        \"favorites\": \"我的最爱\",\n        \"archived\": \"已归档\",\n        \"highlights\": \"标记\",\n        \"lists\": \"列表\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"更新\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"下载\",\n        \"uploads\": \"上传\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"歌曲\",\n        \"movies\": \"电影\",\n        \"episodes\": \"剧集\",\n        \"other\": \"其他\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"已停止\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"已用内存\",\n        \"memoryAvailable\": \"可用内存\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"运行中\",\n        \"stopped\": \"停止\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"内存\",\n        \"images\": \"图片\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "public/locales/zh-Hant/common.json",
    "content": "{\n    \"common\": {\n        \"bytes\": \"{{value, bytes}}\",\n        \"bits\": \"{{value, bytes(bits: true)}}\",\n        \"bbytes\": \"{{value, bytes(binary: true)}}\",\n        \"bbits\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"byterate\": \"{{value, rate(bits: false)}}\",\n        \"bibyterate\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"bitrate\": \"{{value, rate(bits: true)}}\",\n        \"bibitrate\": \"{{value, bytes(bits: true; binary: true)}}\",\n        \"percent\": \"{{value, percent}}\",\n        \"number\": \"{{value, number}}\",\n        \"ms\": \"{{value, number}}\",\n        \"date\": \"{{value, date}}\",\n        \"relativeDate\": \"{{value, relativeDate}}\",\n        \"duration\": \"{{value, duration}}\",\n        \"months\": \"月\",\n        \"days\": \"天\",\n        \"hours\": \"小時\",\n        \"minutes\": \"分\",\n        \"seconds\": \"秒\"\n    },\n    \"widget\": {\n        \"missing_type\": \"遺失小工具的類型: {{type}}\",\n        \"api_error\": \"API 錯誤\",\n        \"information\": \"資訊\",\n        \"status\": \"狀態\",\n        \"url\": \"網址\",\n        \"raw_error\": \"原始錯誤\",\n        \"response_data\": \"回應資料\"\n    },\n    \"weather\": {\n        \"current\": \"目前位置\",\n        \"allow\": \"點擊以允許\",\n        \"updating\": \"正在更新\",\n        \"wait\": \"請稍候\"\n    },\n    \"search\": {\n        \"placeholder\": \"搜尋…\"\n    },\n    \"resources\": {\n        \"cpu\": \"CPU\",\n        \"mem\": \"記憶體\",\n        \"total\": \"全部\",\n        \"free\": \"剩餘\",\n        \"used\": \"已使用\",\n        \"load\": \"負載\",\n        \"temp\": \"溫度\",\n        \"max\": \"最大\",\n        \"uptime\": \"運作時間\"\n    },\n    \"unifi\": {\n        \"users\": \"用戶\",\n        \"uptime\": \"運行時間\",\n        \"days\": \"天\",\n        \"wan\": \"WAN\",\n        \"lan\": \"區域網路\",\n        \"wlan\": \"無線區域網路\",\n        \"devices\": \"設備\",\n        \"lan_devices\": \"有線設備\",\n        \"wlan_devices\": \"無線設備\",\n        \"lan_users\": \"有線使用者\",\n        \"wlan_users\": \"無線使用者\",\n        \"up\": \"UP\",\n        \"down\": \"離線\",\n        \"wait\": \"Please wait\",\n        \"empty_data\": \"子系統狀態未知\"\n    },\n    \"docker\": {\n        \"rx\": \"接收\",\n        \"tx\": \"傳送\",\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"running\": \"執行中\",\n        \"offline\": \"離線\",\n        \"error\": \"錯誤\",\n        \"unknown\": \"未知\",\n        \"healthy\": \"健康\",\n        \"starting\": \"啟動中\",\n        \"unhealthy\": \"不健康的\",\n        \"not_found\": \"找不到\",\n        \"exited\": \"已退出\",\n        \"partial\": \"部分\"\n    },\n    \"ping\": {\n        \"error\": \"Error\",\n        \"ping\": \"延遲\",\n        \"down\": \"離線\",\n        \"up\": \"上線\",\n        \"not_available\": \"不可用\"\n    },\n    \"siteMonitor\": {\n        \"http_status\": \"HTTP 狀態\",\n        \"error\": \"Error\",\n        \"response\": \"回應\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"not_available\": \"Not Available\"\n    },\n    \"emby\": {\n        \"playing\": \"正在播放\",\n        \"transcoding\": \"轉碼\",\n        \"bitrate\": \"位元率\",\n        \"no_active\": \"無播放活動\",\n        \"movies\": \"電影\",\n        \"series\": \"影集\",\n        \"episodes\": \"集\",\n        \"songs\": \"曲目\"\n    },\n    \"jellyfin\": {\n        \"playing\": \"正在播放\",\n        \"transcoding\": \"轉碼\",\n        \"bitrate\": \"位元率\",\n        \"no_active\": \"無播放活動\",\n        \"movies\": \"電影\",\n        \"series\": \"系列\",\n        \"episodes\": \"劇集\",\n        \"songs\": \"曲目\"\n    },\n    \"esphome\": {\n        \"offline\": \"Offline\",\n        \"offline_alt\": \"Offline\",\n        \"online\": \"上線\",\n        \"total\": \"Total\",\n        \"unknown\": \"Unknown\"\n    },\n    \"evcc\": {\n        \"pv_power\": \"正式環境\",\n        \"battery_soc\": \"電池\",\n        \"grid_power\": \"電網\",\n        \"home_power\": \"電源使用率\",\n        \"charge_power\": \"充電\",\n        \"kilowatt\": \"千瓦\"\n    },\n    \"flood\": {\n        \"download\": \"下載速率\",\n        \"upload\": \"上傳速率\",\n        \"leech\": \"未完成下載\",\n        \"seed\": \"已完成下載\"\n    },\n    \"freshrss\": {\n        \"subscriptions\": \"訂閱\",\n        \"unread\": \"未讀\"\n    },\n    \"fritzbox\": {\n        \"connectionStatus\": \"Status\",\n        \"connectionStatusUnconfigured\": \"未設定\",\n        \"connectionStatusConnecting\": \"連線中\",\n        \"connectionStatusAuthenticating\": \"身份驗證中\",\n        \"connectionStatusPendingDisconnect\": \"待中斷連線\",\n        \"connectionStatusDisconnecting\": \"正在中斷連線\",\n        \"connectionStatusDisconnected\": \"連線已中斷\",\n        \"connectionStatusConnected\": \"已連線\",\n        \"uptime\": \"Uptime\",\n        \"maxDown\": \"最大下載速率\",\n        \"maxUp\": \"最大上傳速率\",\n        \"down\": \"Down\",\n        \"up\": \"Up\",\n        \"received\": \"已接收\",\n        \"sent\": \"送出\",\n        \"externalIPAddress\": \"外部 IP\",\n        \"externalIPv6Address\": \"外部 IPv6\",\n        \"externalIPv6Prefix\": \"外部 IPv6前綴\"\n    },\n    \"caddy\": {\n        \"upstreams\": \"上游\",\n        \"requests\": \"目前請求數\",\n        \"requests_failed\": \"失敗請求\"\n    },\n    \"changedetectionio\": {\n        \"totalObserved\": \"總監測數\",\n        \"diffsDetected\": \"偵測到的變更\"\n    },\n    \"channelsdvrserver\": {\n        \"shows\": \"節目\",\n        \"recordings\": \"錄影\",\n        \"scheduled\": \"已排定\",\n        \"passes\": \"通行證\"\n    },\n    \"tautulli\": {\n        \"playing\": \"Playing\",\n        \"transcoding\": \"Transcoding\",\n        \"bitrate\": \"Bitrate\",\n        \"no_active\": \"No Active Streams\",\n        \"plex_connection_error\": \"檢查Plex的連線狀態\"\n    },\n    \"tracearr\": {\n        \"no_active\": \"No Active Streams\",\n        \"streams\": \"Streams\",\n        \"transcodes\": \"Transcodes\",\n        \"directplay\": \"Direct Play\",\n        \"bitrate\": \"Bitrate\"\n    },\n    \"omada\": {\n        \"connectedAp\": \"已連線的無線網路\",\n        \"activeUser\": \"上線裝置\",\n        \"alerts\": \"警示\",\n        \"connectedGateways\": \"已連線的閘道\",\n        \"connectedSwitches\": \"已連線的交換器\"\n    },\n    \"nzbget\": {\n        \"rate\": \"速率\",\n        \"remaining\": \"剩餘\",\n        \"downloaded\": \"已下載\"\n    },\n    \"plex\": {\n        \"streams\": \"正在播放\",\n        \"albums\": \"專輯\",\n        \"movies\": \"Movies\",\n        \"tv\": \"影集\"\n    },\n    \"sabnzbd\": {\n        \"rate\": \"Rate\",\n        \"queue\": \"佇列\",\n        \"timeleft\": \"剩餘時間\"\n    },\n    \"rutorrent\": {\n        \"active\": \"活動中\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\"\n    },\n    \"transmission\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qbittorrent\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"qnap\": {\n        \"cpuUsage\": \"CPU 使用率\",\n        \"memUsage\": \"記憶體使用率\",\n        \"systemTempC\": \"系統溫度\",\n        \"poolUsage\": \"儲存池使用率\",\n        \"volumeUsage\": \"儲存區用量\",\n        \"invalid\": \"無效的\"\n    },\n    \"deluge\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"develancacheui\": {\n        \"cachehitbytes\": \"快取未命中位元組\",\n        \"cachemissbytes\": \"快取未命中位元組\"\n    },\n    \"downloadstation\": {\n        \"download\": \"Download\",\n        \"upload\": \"Upload\",\n        \"leech\": \"Leech\",\n        \"seed\": \"Seed\"\n    },\n    \"sonarr\": {\n        \"wanted\": \"關注中\",\n        \"queued\": \"已加入佇列\",\n        \"series\": \"Series\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"radarr\": {\n        \"wanted\": \"Wanted\",\n        \"missing\": \"缺少\",\n        \"queued\": \"Queued\",\n        \"movies\": \"Movies\",\n        \"queue\": \"Queue\",\n        \"unknown\": \"Unknown\"\n    },\n    \"lidarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"artists\": \"創作者\"\n    },\n    \"readarr\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"books\": \"叢書\"\n    },\n    \"bazarr\": {\n        \"missingEpisodes\": \"缺少的劇集\",\n        \"missingMovies\": \"缺少的電影\"\n    },\n    \"ombi\": {\n        \"pending\": \"待下載\",\n        \"approved\": \"已核准\",\n        \"available\": \"可觀看\"\n    },\n    \"seerr\": {\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"available\": \"Available\",\n        \"completed\": \"Completed\",\n        \"processing\": \"Processing\",\n        \"issues\": \"Open Issues\"\n    },\n    \"netalertx\": {\n        \"total\": \"Total\",\n        \"connected\": \"Connected\",\n        \"new_devices\": \"新裝置\",\n        \"down_alerts\": \"離線警告\"\n    },\n    \"pihole\": {\n        \"queries\": \"查詢\",\n        \"blocked\": \"已阻擋\",\n        \"blocked_percent\": \"已封鎖 %\",\n        \"gravity\": \"阻擋清單\"\n    },\n    \"adguard\": {\n        \"queries\": \"Queries\",\n        \"blocked\": \"Blocked\",\n        \"filtered\": \"已過濾\",\n        \"latency\": \"延遲\"\n    },\n    \"speedtest\": {\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"ping\": \"Ping\"\n    },\n    \"portainer\": {\n        \"running\": \"執行中\",\n        \"stopped\": \"已停止\",\n        \"total\": \"全部\"\n    },\n    \"suwayomi\": {\n        \"download\": \"Downloaded\",\n        \"nondownload\": \"已下載\",\n        \"read\": \"Read\",\n        \"unread\": \"Unread\",\n        \"downloadedread\": \"已下載且已閱讀\",\n        \"downloadedunread\": \"已下載且未閱讀\",\n        \"nondownloadedread\": \"未下載但已閱讀\",\n        \"nondownloadedunread\": \"未下載且未閱讀\"\n    },\n    \"tailscale\": {\n        \"address\": \"位址\",\n        \"expires\": \"已過期\",\n        \"never\": \"未曾\",\n        \"last_seen\": \"上次連線\",\n        \"now\": \"現在\",\n        \"years\": \"{{number}} 年\",\n        \"weeks\": \"{{number}} 週\",\n        \"days\": \"{{number}} 天\",\n        \"hours\": \"{{number}} 小時\",\n        \"minutes\": \"{{number}} 分鐘\",\n        \"seconds\": \"{{number}} 秒\",\n        \"ago\": \"{{value}} 前\"\n    },\n    \"technitium\": {\n        \"totalQueries\": \"Queries\",\n        \"totalNoError\": \"成功\",\n        \"totalServerFailure\": \"失敗\",\n        \"totalNxDomain\": \"網域\",\n        \"totalRefused\": \"被拒絕\",\n        \"totalAuthoritative\": \"權威的\",\n        \"totalRecursive\": \"遞迴\",\n        \"totalCached\": \"快取\",\n        \"totalBlocked\": \"Blocked\",\n        \"totalDropped\": \"丟棄\",\n        \"totalClients\": \"客戶端\"\n    },\n    \"tdarr\": {\n        \"queue\": \"Queue\",\n        \"processed\": \"已處理\",\n        \"errored\": \"發生錯誤\",\n        \"saved\": \"已儲存\"\n    },\n    \"traefik\": {\n        \"routers\": \"路由器\",\n        \"services\": \"服務\",\n        \"middleware\": \"中介軟體\"\n    },\n    \"trilium\": {\n        \"version\": \"Version\",\n        \"notesCount\": \"Notes\",\n        \"dbSize\": \"Database Size\",\n        \"unknown\": \"Unknown\"\n    },\n    \"navidrome\": {\n        \"nothing_streaming\": \"No Active Streams\",\n        \"please_wait\": \"請稍後\"\n    },\n    \"npm\": {\n        \"enabled\": \"已啟用\",\n        \"disabled\": \"已停用\",\n        \"total\": \"全部\"\n    },\n    \"coinmarketcap\": {\n        \"configure\": \"請設定一個或多個欲追蹤的加密貨幣\",\n        \"1hour\": \"1小時\",\n        \"1day\": \"1天\",\n        \"7days\": \"7天\",\n        \"30days\": \"30天\"\n    },\n    \"gotify\": {\n        \"apps\": \"應用程式\",\n        \"clients\": \"Clients\",\n        \"messages\": \"訊息\"\n    },\n    \"prowlarr\": {\n        \"enableIndexers\": \"索引器\",\n        \"numberOfGrabs\": \"抓取\",\n        \"numberOfQueries\": \"Queries\",\n        \"numberOfFailGrabs\": \"抓取失敗\",\n        \"numberOfFailQueries\": \"查詢失敗\"\n    },\n    \"jackett\": {\n        \"configured\": \"已設定\",\n        \"errored\": \"Errored\"\n    },\n    \"strelaysrv\": {\n        \"numActiveSessions\": \"工作階段\",\n        \"numConnections\": \"連線\",\n        \"dataRelayed\": \"中繼\",\n        \"transferRate\": \"Rate\"\n    },\n    \"mastodon\": {\n        \"user_count\": \"Users\",\n        \"status_count\": \"文章\",\n        \"domain_count\": \"網域\"\n    },\n    \"medusa\": {\n        \"wanted\": \"Wanted\",\n        \"queued\": \"Queued\",\n        \"series\": \"Series\"\n    },\n    \"minecraft\": {\n        \"players\": \"玩家\",\n        \"version\": \"版本\",\n        \"status\": \"Status\",\n        \"up\": \"Online\",\n        \"down\": \"Offline\"\n    },\n    \"miniflux\": {\n        \"read\": \"已讀\",\n        \"unread\": \"Unread\"\n    },\n    \"authentik\": {\n        \"users\": \"Users\",\n        \"loginsLast24H\": \"登入 (過去 24 小時)\",\n        \"failedLoginsLast24H\": \"登入失敗 (過去 24 小時)\"\n    },\n    \"proxmox\": {\n        \"mem\": \"MEM\",\n        \"cpu\": \"CPU\",\n        \"lxc\": \"Linux 容器\",\n        \"vms\": \"虛擬機\"\n    },\n    \"glances\": {\n        \"cpu\": \"CPU\",\n        \"load\": \"負載\",\n        \"wait\": \"請稍候\",\n        \"temp\": \"溫度\",\n        \"_temp\": \"溫度\",\n        \"warn\": \"警告\",\n        \"uptime\": \"運作時間\",\n        \"total\": \"全部\",\n        \"free\": \"剩餘\",\n        \"used\": \"已使用\",\n        \"days\": \"日\",\n        \"hours\": \"時\",\n        \"crit\": \"重大的\",\n        \"read\": \"已讀\",\n        \"write\": \"寫入\",\n        \"gpu\": \"GPU\",\n        \"mem\": \"記憶體\",\n        \"swap\": \"交換空間\"\n    },\n    \"quicklaunch\": {\n        \"bookmark\": \"書籤\",\n        \"service\": \"服務\",\n        \"search\": \"搜尋\",\n        \"custom\": \"自訂\",\n        \"visit\": \"造訪\",\n        \"url\": \"URL\",\n        \"searchsuggestion\": \"建議\"\n    },\n    \"wmo\": {\n        \"0-day\": \"晴天\",\n        \"0-night\": \"晴朗\",\n        \"1-day\": \"晴時多雲\",\n        \"1-night\": \"晴時多雲\",\n        \"2-day\": \"多雲時陰\",\n        \"2-night\": \"Partly Cloudy\",\n        \"3-day\": \"陰天\",\n        \"3-night\": \"Cloudy\",\n        \"45-day\": \"有霧\",\n        \"45-night\": \"Foggy\",\n        \"48-day\": \"Foggy\",\n        \"48-night\": \"Foggy\",\n        \"51-day\": \"小毛雨\",\n        \"51-night\": \"Light Drizzle\",\n        \"53-day\": \"毛雨\",\n        \"53-night\": \"Drizzle\",\n        \"55-day\": \"大毛雨\",\n        \"55-night\": \"Heavy Drizzle\",\n        \"56-day\": \"小凍毛雨\",\n        \"56-night\": \"Light Freezing Drizzle\",\n        \"57-day\": \"凍毛雨\",\n        \"57-night\": \"Freezing Drizzle\",\n        \"61-day\": \"小雨\",\n        \"61-night\": \"Light Rain\",\n        \"63-day\": \"雨\",\n        \"63-night\": \"Rain\",\n        \"65-day\": \"大雨\",\n        \"65-night\": \"Heavy Rain\",\n        \"66-day\": \"凍雨\",\n        \"66-night\": \"Freezing Rain\",\n        \"67-day\": \"Freezing Rain\",\n        \"67-night\": \"Freezing Rain\",\n        \"71-day\": \"小雪\",\n        \"71-night\": \"Light Snow\",\n        \"73-day\": \"雪\",\n        \"73-night\": \"Snow\",\n        \"75-day\": \"大雪\",\n        \"75-night\": \"Heavy Snow\",\n        \"77-day\": \"雪粒\",\n        \"77-night\": \"Snow Grains\",\n        \"80-day\": \"微陣雨\",\n        \"80-night\": \"Light Showers\",\n        \"81-day\": \"陣雨\",\n        \"81-night\": \"Showers\",\n        \"82-day\": \"強陣雨\",\n        \"82-night\": \"Heavy Showers\",\n        \"85-day\": \"陣雪\",\n        \"85-night\": \"Snow Showers\",\n        \"86-day\": \"Snow Showers\",\n        \"86-night\": \"Snow Showers\",\n        \"95-day\": \"雷雨\",\n        \"95-night\": \"Thunderstorm\",\n        \"96-day\": \"雷雨伴隨冰雹\",\n        \"96-night\": \"Thunderstorm With Hail\",\n        \"99-day\": \"Thunderstorm With Hail\",\n        \"99-night\": \"Thunderstorm With Hail\"\n    },\n    \"homebridge\": {\n        \"available_update\": \"系統\",\n        \"updates\": \"更新\",\n        \"update_available\": \"有可用的更新\",\n        \"up_to_date\": \"已更新至最新\",\n        \"child_bridges\": \"子網橋接\",\n        \"child_bridges_status\": \"{{ok}}/{{total}}\",\n        \"up\": \"Up\",\n        \"pending\": \"Pending\",\n        \"down\": \"Down\",\n        \"ok\": \"Ok\"\n    },\n    \"healthchecks\": {\n        \"new\": \"新建\",\n        \"up\": \"Up\",\n        \"grace\": \"延緩中\",\n        \"down\": \"Down\",\n        \"paused\": \"已暫停\",\n        \"status\": \"Status\",\n        \"last_ping\": \"上次檢查\",\n        \"never\": \"尚未檢查\"\n    },\n    \"watchtower\": {\n        \"containers_scanned\": \"已掃描\",\n        \"containers_updated\": \"已更新\",\n        \"containers_failed\": \"失敗\"\n    },\n    \"autobrr\": {\n        \"approvedPushes\": \"Approved\",\n        \"rejectedPushes\": \"拒絕\",\n        \"filters\": \"篩選器\",\n        \"indexers\": \"Indexers\"\n    },\n    \"tubearchivist\": {\n        \"downloads\": \"Queue\",\n        \"videos\": \"影片\",\n        \"channels\": \"頻道\",\n        \"playlists\": \"播放清單\"\n    },\n    \"truenas\": {\n        \"load\": \"系統負載\",\n        \"uptime\": \"Uptime\",\n        \"alerts\": \"Alerts\"\n    },\n    \"pyload\": {\n        \"speed\": \"速度\",\n        \"active\": \"Active\",\n        \"queue\": \"Queue\",\n        \"total\": \"Total\"\n    },\n    \"gluetun\": {\n        \"public_ip\": \"公有IP\",\n        \"region\": \"地區\",\n        \"country\": \"國家\",\n        \"port_forwarded\": \"Port Forwarded\"\n    },\n    \"hdhomerun\": {\n        \"channels\": \"Channels\",\n        \"hd\": \"高畫質\",\n        \"tunerCount\": \"調諧器\",\n        \"channelNumber\": \"頻道\",\n        \"channelNetwork\": \"網絡\",\n        \"signalStrength\": \"強度\",\n        \"signalQuality\": \"品質\",\n        \"symbolQuality\": \"Quality\",\n        \"networkRate\": \"Bitrate\",\n        \"clientIP\": \"用戶端\"\n    },\n    \"scrutiny\": {\n        \"passed\": \"通過\",\n        \"failed\": \"Failed\",\n        \"unknown\": \"Unknown\"\n    },\n    \"paperlessngx\": {\n        \"inbox\": \"收件箱\",\n        \"total\": \"Total\"\n    },\n    \"pangolin\": {\n        \"orgs\": \"Orgs\",\n        \"sites\": \"Sites\",\n        \"resources\": \"Resources\",\n        \"targets\": \"Targets\",\n        \"traffic\": \"Traffic\",\n        \"in\": \"In\",\n        \"out\": \"Out\"\n    },\n    \"peanut\": {\n        \"battery_charge\": \"充電\",\n        \"ups_load\": \"備用電源負載\",\n        \"ups_status\": \"備用電源狀態\",\n        \"online\": \"Online\",\n        \"on_battery\": \"電池供電\",\n        \"low_battery\": \"低電量\"\n    },\n    \"nextdns\": {\n        \"wait\": \"Please Wait\",\n        \"no_devices\": \"未收到裝置資料\"\n    },\n    \"mikrotik\": {\n        \"cpuLoad\": \"CPU負載\",\n        \"memoryUsed\": \"已使用的記憶體\",\n        \"uptime\": \"Uptime\",\n        \"numberOfLeases\": \"租約\"\n    },\n    \"xteve\": {\n        \"streams_all\": \"所有播放活動\",\n        \"streams_active\": \"Active Streams\",\n        \"streams_xepg\": \"XEPG頻道\"\n    },\n    \"opendtu\": {\n        \"yieldDay\": \"今天\",\n        \"absolutePower\": \"功率\",\n        \"relativePower\": \"功率百分比\",\n        \"limit\": \"上限\"\n    },\n    \"opnsense\": {\n        \"cpu\": \"CPU Load\",\n        \"memory\": \"記憶體\",\n        \"wanUpload\": \"WAN上傳\",\n        \"wanDownload\": \"WAN下載\"\n    },\n    \"moonraker\": {\n        \"printer_state\": \"列印機狀態\",\n        \"print_status\": \"列印狀態\",\n        \"print_progress\": \"進度\",\n        \"layers\": \"層\"\n    },\n    \"octoprint\": {\n        \"printer_state\": \"Status\",\n        \"temp_tool\": \"噴頭溫度\",\n        \"temp_bed\": \"平台溫度\",\n        \"job_completion\": \"完成度\"\n    },\n    \"cloudflared\": {\n        \"origin_ip\": \"源始IP\",\n        \"status\": \"Status\"\n    },\n    \"pfsense\": {\n        \"load\": \"平均負載量\",\n        \"memory\": \"記憶體使用率\",\n        \"wanStatus\": \"網際網路狀態\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"temp\": \"Temp\",\n        \"disk\": \"硬碟使用率\",\n        \"wanIP\": \"網際網路 IP\"\n    },\n    \"proxmoxbackupserver\": {\n        \"datastore_usage\": \"資料存儲\",\n        \"failed_tasks_24h\": \"24小時內失敗任務\",\n        \"cpu_usage\": \"CPU\",\n        \"memory_usage\": \"記憶體\"\n    },\n    \"immich\": {\n        \"users\": \"Users\",\n        \"photos\": \"照片\",\n        \"videos\": \"Videos\",\n        \"storage\": \"儲存空間\"\n    },\n    \"uptimekuma\": {\n        \"up\": \"網站上線\",\n        \"down\": \"網站離線\",\n        \"uptime\": \"Uptime\",\n        \"incident\": \"事件\",\n        \"m\": \"m\"\n    },\n    \"atsumeru\": {\n        \"series\": \"Series\",\n        \"archives\": \"檔案\",\n        \"chapters\": \"章節\",\n        \"categories\": \"類別\"\n    },\n    \"komga\": {\n        \"libraries\": \"文庫\",\n        \"series\": \"Series\",\n        \"books\": \"Books\"\n    },\n    \"diskstation\": {\n        \"days\": \"Days\",\n        \"uptime\": \"Uptime\",\n        \"volumeAvailable\": \"Available\"\n    },\n    \"dispatcharr\": {\n        \"channels\": \"Channels\",\n        \"streams\": \"Streams\"\n    },\n    \"mylar\": {\n        \"series\": \"Series\",\n        \"issues\": \"問題\",\n        \"wanted\": \"Wanted\"\n    },\n    \"photoprism\": {\n        \"albums\": \"Albums\",\n        \"photos\": \"Photos\",\n        \"videos\": \"Videos\",\n        \"people\": \"人物\"\n    },\n    \"fileflows\": {\n        \"queue\": \"Queue\",\n        \"processing\": \"Processing\",\n        \"processed\": \"Processed\",\n        \"time\": \"時間\"\n    },\n    \"firefly\": {\n        \"networth\": \"淨值\",\n        \"budget\": \"預算\"\n    },\n    \"grafana\": {\n        \"dashboards\": \"儀表板\",\n        \"datasources\": \"資料來源\",\n        \"totalalerts\": \"警報總數\",\n        \"alertstriggered\": \"觸發的警報\"\n    },\n    \"nextcloud\": {\n        \"cpuload\": \"處理器負載\",\n        \"memoryusage\": \"記憶體用量\",\n        \"freespace\": \"可用空間\",\n        \"activeusers\": \"活躍使用者\",\n        \"numfiles\": \"檔案\",\n        \"numshares\": \"已分享\"\n    },\n    \"kopia\": {\n        \"status\": \"Status\",\n        \"size\": \"檔案大小\",\n        \"lastrun\": \"上次執行\",\n        \"nextrun\": \"下次執行\",\n        \"failed\": \"Failed\"\n    },\n    \"unmanic\": {\n        \"active_workers\": \"線上工作程序\",\n        \"total_workers\": \"總工作程序\",\n        \"records_total\": \"佇列長度\"\n    },\n    \"pterodactyl\": {\n        \"servers\": \"伺服器\",\n        \"nodes\": \"節點\"\n    },\n    \"prometheus\": {\n        \"targets_up\": \"目標上線\",\n        \"targets_down\": \"目標離線\",\n        \"targets_total\": \"目標總數\"\n    },\n    \"gatus\": {\n        \"up\": \"Sites Up\",\n        \"down\": \"Sites Down\",\n        \"uptime\": \"Uptime\"\n    },\n    \"ghostfolio\": {\n        \"gross_percent_today\": \"Today\",\n        \"gross_percent_1y\": \"一年\",\n        \"gross_percent_max\": \"所有時間\",\n        \"net_worth\": \"Net Worth\"\n    },\n    \"audiobookshelf\": {\n        \"podcasts\": \"Podcasts\",\n        \"books\": \"Books\",\n        \"podcastsDuration\": \"歷時\",\n        \"booksDuration\": \"Duration\"\n    },\n    \"homeassistant\": {\n        \"people_home\": \"在家人數\",\n        \"lights_on\": \"燈光開啟\",\n        \"switches_on\": \"開關開啟\"\n    },\n    \"whatsupdocker\": {\n        \"monitoring\": \"監測中\",\n        \"updates\": \"Updates\"\n    },\n    \"calibreweb\": {\n        \"books\": \"Books\",\n        \"authors\": \"作者\",\n        \"categories\": \"Categories\",\n        \"series\": \"Series\"\n    },\n    \"booklore\": {\n        \"libraries\": \"Libraries\",\n        \"books\": \"Books\",\n        \"reading\": \"Reading\",\n        \"finished\": \"Finished\"\n    },\n    \"jdownloader\": {\n        \"downloadCount\": \"Queue\",\n        \"downloadBytesRemaining\": \"Remaining\",\n        \"downloadTotalBytes\": \"Size\",\n        \"downloadSpeed\": \"Speed\"\n    },\n    \"kavita\": {\n        \"seriesCount\": \"Series\",\n        \"totalFiles\": \"Files\"\n    },\n    \"azuredevops\": {\n        \"result\": \"結果\",\n        \"status\": \"Status\",\n        \"buildId\": \"組建編號\",\n        \"succeeded\": \"成功\",\n        \"notStarted\": \"尚未啟動\",\n        \"failed\": \"Failed\",\n        \"canceled\": \"取消\",\n        \"inProgress\": \"執行中\",\n        \"totalPrs\": \"總合併請求\",\n        \"myPrs\": \"我的合併請求\",\n        \"approved\": \"Approved\"\n    },\n    \"gamedig\": {\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\",\n        \"name\": \"名稱\",\n        \"map\": \"地圖\",\n        \"currentPlayers\": \"目前玩家數\",\n        \"players\": \"Players\",\n        \"maxPlayers\": \"玩家數上限\",\n        \"bots\": \"機器人\",\n        \"ping\": \"Ping\"\n    },\n    \"urbackup\": {\n        \"ok\": \"確定\",\n        \"errored\": \"錯誤\",\n        \"noRecent\": \"已過時\",\n        \"totalUsed\": \"已使用儲存空間\"\n    },\n    \"mealie\": {\n        \"recipes\": \"食譜\",\n        \"users\": \"Users\",\n        \"categories\": \"Categories\",\n        \"tags\": \"標籤\"\n    },\n    \"openmediavault\": {\n        \"downloading\": \"下載中\",\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"passed\": \"Passed\",\n        \"failed\": \"Failed\"\n    },\n    \"openwrt\": {\n        \"uptime\": \"Uptime\",\n        \"cpuLoad\": \"處理器平均負載(5分鐘)\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"bytesTx\": \"已傳送\",\n        \"bytesRx\": \"Received\"\n    },\n    \"uptimerobot\": {\n        \"status\": \"Status\",\n        \"uptime\": \"Uptime\",\n        \"lastDown\": \"上一次停擺時間\",\n        \"downDuration\": \"停擺時間\",\n        \"sitesUp\": \"Sites Up\",\n        \"sitesDown\": \"Sites Down\",\n        \"paused\": \"Paused\",\n        \"notyetchecked\": \"尚未檢查\",\n        \"up\": \"Up\",\n        \"seemsdown\": \"似乎離線\",\n        \"down\": \"Down\",\n        \"unknown\": \"Unknown\"\n    },\n    \"calendar\": {\n        \"inCinemas\": \"上映中\",\n        \"physicalRelease\": \"實體發行\",\n        \"digitalRelease\": \"數位發行\",\n        \"noEventsToday\": \"今天沒有事件！\",\n        \"noEventsFound\": \"找不到事件\",\n        \"errorWhenLoadingData\": \"Error when loading calendar data\"\n    },\n    \"romm\": {\n        \"platforms\": \"平台\",\n        \"totalRoms\": \"遊戲\",\n        \"saves\": \"已儲存\",\n        \"states\": \"州\",\n        \"screenshots\": \"螢幕截圖\",\n        \"totalfilesize\": \"大小總計\"\n    },\n    \"mailcow\": {\n        \"domains\": \"Domains\",\n        \"mailboxes\": \"信箱\",\n        \"mails\": \"郵件數\",\n        \"storage\": \"Storage\"\n    },\n    \"netdata\": {\n        \"warnings\": \"警告\",\n        \"criticals\": \"嚴重\"\n    },\n    \"plantit\": {\n        \"events\": \"事件\",\n        \"plants\": \"植物\",\n        \"photos\": \"Photos\",\n        \"species\": \"物種\"\n    },\n    \"gitea\": {\n        \"notifications\": \"通知\",\n        \"issues\": \"Issues\",\n        \"pulls\": \"提取請求\",\n        \"repositories\": \"套件來源\"\n    },\n    \"stash\": {\n        \"scenes\": \"場景\",\n        \"scenesPlayed\": \"已播放場景\",\n        \"playCount\": \"總播放次數\",\n        \"playDuration\": \"觀看時數\",\n        \"sceneSize\": \"場景大小\",\n        \"sceneDuration\": \"場景為期\",\n        \"images\": \"圖片\",\n        \"imageSize\": \"圖片大小\",\n        \"galleries\": \"畫廊\",\n        \"performers\": \"表演者\",\n        \"studios\": \"工作室\",\n        \"movies\": \"Movies\",\n        \"tags\": \"Tags\",\n        \"oCount\": \"0 個\"\n    },\n    \"tandoor\": {\n        \"users\": \"Users\",\n        \"recipes\": \"Recipes\",\n        \"keywords\": \"關鍵字\"\n    },\n    \"homebox\": {\n        \"items\": \"項目\",\n        \"totalWithWarranty\": \"有保證\",\n        \"locations\": \"位置\",\n        \"labels\": \"標籤\",\n        \"users\": \"Users\",\n        \"totalValue\": \"總共\"\n    },\n    \"crowdsec\": {\n        \"alerts\": \"Alerts\",\n        \"bans\": \"禁止\"\n    },\n    \"wgeasy\": {\n        \"connected\": \"Connected\",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\",\n        \"total\": \"Total\"\n    },\n    \"swagdashboard\": {\n        \"proxied\": \"已代理\",\n        \"auth\": \"已授權\",\n        \"outdated\": \"已過時\",\n        \"banned\": \"已封鎖\"\n    },\n    \"myspeed\": {\n        \"ping\": \"Ping\",\n        \"download\": \"Download\",\n        \"upload\": \"Upload\"\n    },\n    \"stocks\": {\n        \"stocks\": \"股票\",\n        \"loading\": \"載入中\",\n        \"open\": \"美國市場已開放\",\n        \"closed\": \"美國市場已關閉\",\n        \"invalidConfiguration\": \"無效的設定\"\n    },\n    \"frigate\": {\n        \"cameras\": \"相機\",\n        \"uptime\": \"Uptime\",\n        \"version\": \"Version\"\n    },\n    \"linkwarden\": {\n        \"links\": \"連結\",\n        \"collections\": \"收藏庫\",\n        \"tags\": \"Tags\"\n    },\n    \"zabbix\": {\n        \"unclassified\": \"未分類\",\n        \"information\": \"Information\",\n        \"warning\": \"警告\",\n        \"average\": \"平均\",\n        \"high\": \"高優先權\",\n        \"disaster\": \"災難\"\n    },\n    \"lubelogger\": {\n        \"vehicle\": \"車輛\",\n        \"vehicles\": \"車輛\",\n        \"serviceRecords\": \"保養記錄\",\n        \"reminders\": \"提醒\",\n        \"nextReminder\": \"下一個提醒\",\n        \"none\": \"沒有\"\n    },\n    \"vikunja\": {\n        \"projects\": \"活躍專案\",\n        \"tasks7d\": \"本週到期任務\",\n        \"tasksOverdue\": \"逾期處理\",\n        \"tasksInProgress\": \"正在執行的任務\"\n    },\n    \"headscale\": {\n        \"name\": \"Name\",\n        \"address\": \"Address\",\n        \"last_seen\": \"Last Seen\",\n        \"status\": \"Status\",\n        \"online\": \"Online\",\n        \"offline\": \"Offline\"\n    },\n    \"beszel\": {\n        \"name\": \"Name\",\n        \"systems\": \"系統\",\n        \"up\": \"Up\",\n        \"down\": \"Down\",\n        \"paused\": \"Paused\",\n        \"pending\": \"Pending\",\n        \"status\": \"Status\",\n        \"updated\": \"Updated\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"MEM\",\n        \"disk\": \"儲存空間\",\n        \"network\": \"網路\"\n    },\n    \"argocd\": {\n        \"apps\": \"應用程式\",\n        \"synced\": \"已同步\",\n        \"outOfSync\": \"不同步\",\n        \"healthy\": \"Healthy\",\n        \"degraded\": \"已降級\",\n        \"progressing\": \"進度\",\n        \"missing\": \"Missing\",\n        \"suspended\": \"暫停\"\n    },\n    \"spoolman\": {\n        \"loading\": \"Loading\"\n    },\n    \"gitlab\": {\n        \"groups\": \"群組\",\n        \"issues\": \"Issues\",\n        \"merges\": \"合併請求\",\n        \"projects\": \"專案\"\n    },\n    \"apcups\": {\n        \"status\": \"Status\",\n        \"load\": \"Load\",\n        \"bcharge\": \"Battery Charge\",\n        \"timeleft\": \"Time Left\"\n    },\n    \"karakeep\": {\n        \"bookmarks\": \"書籤\",\n        \"favorites\": \"我的最愛\",\n        \"archived\": \"已存檔\",\n        \"highlights\": \"標記\",\n        \"lists\": \"列表\",\n        \"tags\": \"Tags\"\n    },\n    \"slskd\": {\n        \"slskStatus\": \"Network\",\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"updateStatus\": \"更新\",\n        \"update_yes\": \"Available\",\n        \"update_no\": \"Up to Date\",\n        \"downloads\": \"下載\",\n        \"uploads\": \"上傳\",\n        \"sharedFiles\": \"Files\"\n    },\n    \"jellystat\": {\n        \"songs\": \"曲目\",\n        \"movies\": \"電影\",\n        \"episodes\": \"劇集\",\n        \"other\": \"其它\"\n    },\n    \"checkmk\": {\n        \"serviceErrors\": \"Service issues\",\n        \"hostErrors\": \"Host issues\"\n    },\n    \"komodo\": {\n        \"total\": \"Total\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"down\": \"Down\",\n        \"unhealthy\": \"Unhealthy\",\n        \"unknown\": \"Unknown\",\n        \"servers\": \"Servers\",\n        \"stacks\": \"Stacks\",\n        \"containers\": \"Containers\"\n    },\n    \"filebrowser\": {\n        \"available\": \"Available\",\n        \"used\": \"Used\",\n        \"total\": \"Total\"\n    },\n    \"wallos\": {\n        \"activeSubscriptions\": \"Subscriptions\",\n        \"thisMonthlyCost\": \"This Month\",\n        \"nextMonthlyCost\": \"Next Month\",\n        \"previousMonthlyCost\": \"Prev. Month\",\n        \"nextRenewingSubscription\": \"Next Payment\"\n    },\n    \"unraid\": {\n        \"STARTED\": \"Started\",\n        \"STOPPED\": \"Stopped\",\n        \"NEW_ARRAY\": \"New Array\",\n        \"RECON_DISK\": \"Reconstructing Disk\",\n        \"DISABLE_DISK\": \"Disk Disabled\",\n        \"SWAP_DSBL\": \"Swap Disable\",\n        \"INVALID_EXPANSION\": \"Invalid Expansion\",\n        \"PARITY_NOT_BIGGEST\": \"Parity Not Biggest\",\n        \"TOO_MANY_MISSING_DISKS\": \"Too Many Missing Disks\",\n        \"NEW_DISK_TOO_SMALL\": \"New Disk Too Small\",\n        \"NO_DATA_DISKS\": \"No Data Disks\",\n        \"notifications\": \"Notifications\",\n        \"status\": \"Status\",\n        \"cpu\": \"CPU\",\n        \"memoryUsed\": \"Memory Used\",\n        \"memoryAvailable\": \"Memory Available\",\n        \"arrayUsed\": \"Array Used\",\n        \"arrayFree\": \"Array Free\",\n        \"poolUsed\": \"{{pool}} Used\",\n        \"poolFree\": \"{{pool}} Free\"\n    },\n    \"backrest\": {\n        \"num_plans\": \"Plans\",\n        \"num_success_30\": \"Successes\",\n        \"num_failure_30\": \"Failures\",\n        \"num_success_latest\": \"Succeeding\",\n        \"num_failure_latest\": \"Failing\",\n        \"bytes_added_30\": \"Bytes Added\"\n    },\n    \"yourspotify\": {\n        \"songs\": \"Songs\",\n        \"time\": \"Time\",\n        \"artists\": \"Artists\"\n    },\n    \"arcane\": {\n        \"containers\": \"Containers\",\n        \"images\": \"Images\",\n        \"image_updates\": \"Image Updates\",\n        \"images_unused\": \"Unused\",\n        \"environment_required\": \"Environment ID Required\"\n    },\n    \"dockhand\": {\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\",\n        \"cpu\": \"CPU\",\n        \"memory\": \"Memory\",\n        \"images\": \"Images\",\n        \"volumes\": \"Volumes\",\n        \"events_today\": \"Events Today\",\n        \"pending_updates\": \"Pending Updates\",\n        \"stacks\": \"Stacks\",\n        \"paused\": \"Paused\",\n        \"total\": \"Total\",\n        \"environment_not_found\": \"Environment Not Found\"\n    },\n    \"sparkyfitness\": {\n        \"eaten\": \"Eaten\",\n        \"burned\": \"Burned\",\n        \"remaining\": \"Remaining\",\n        \"steps\": \"Steps\"\n    }\n}\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"homepage-docs\"\nversion = \"1.0.0\"\ndescription = \"Documentation for the Homepage project\"\nrequires-python = \">=3.13\"\ndependencies = [\n    \"zensical>=0.0.21\",\n]\n"
  },
  {
    "path": "src/__tests__/pages/_app.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\n// Next's Head implementation relies on internal Next contexts; stub it for unit tests.\nvi.mock(\"next/head\", () => ({\n  default: ({ children }) => <>{children}</>,\n}));\n\nvi.mock(\"utils/contexts/color\", () => ({\n  ColorProvider: ({ children }) => <>{children}</>,\n}));\nvi.mock(\"utils/contexts/theme\", () => ({\n  ThemeProvider: ({ children }) => <>{children}</>,\n}));\nvi.mock(\"utils/contexts/settings\", () => ({\n  SettingsProvider: ({ children }) => <>{children}</>,\n}));\nvi.mock(\"utils/contexts/tab\", () => ({\n  TabProvider: ({ children }) => <>{children}</>,\n}));\n\nimport App from \"pages/_app.jsx\";\n\ndescribe(\"pages/_app\", () => {\n  it(\"renders the active page component with pageProps\", () => {\n    function Page({ message }) {\n      return <div>msg:{message}</div>;\n    }\n\n    render(<App Component={Page} pageProps={{ message: \"hello\" }} />);\n\n    expect(screen.getByText(\"msg:hello\")).toBeInTheDocument();\n    expect(document.querySelector('meta[name=\"viewport\"]')).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/_document.test.jsx",
    "content": "import { renderToStaticMarkup } from \"react-dom/server\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nvi.mock(\"next/document\", () => ({\n  Html: ({ children }) => <div data-testid=\"html\">{children}</div>,\n  Head: ({ children }) => <div data-testid=\"head\">{children}</div>,\n  Main: () => <main data-testid=\"main\" />,\n  NextScript: () => <script data-testid=\"nextscript\" />,\n}));\n\nimport Document from \"pages/_document.jsx\";\n\ndescribe(\"pages/_document\", () => {\n  it(\"renders the PWA meta + custom css links\", () => {\n    const html = renderToStaticMarkup(<Document />);\n\n    expect(html).toContain('meta name=\"mobile-web-app-capable\" content=\"yes\"');\n    expect(html).toContain('link rel=\"manifest\" href=\"/site.webmanifest?v=4\"');\n    expect(html).toContain('link rel=\"preload\" href=\"/api/config/custom.css\" as=\"style\"');\n    expect(html).toContain('link rel=\"stylesheet\" href=\"/api/config/custom.css\"');\n    expect(html).toContain('data-testid=\"main\"');\n    expect(html).toContain('data-testid=\"nextscript\"');\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/bookmarks.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { bookmarksResponse } = vi.hoisted(() => ({\n  bookmarksResponse: vi.fn(),\n}));\n\nvi.mock(\"utils/config/api-response\", () => ({\n  bookmarksResponse,\n}));\n\nimport handler from \"pages/api/bookmarks\";\n\ndescribe(\"pages/api/bookmarks\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"returns bookmarksResponse()\", async () => {\n    bookmarksResponse.mockResolvedValueOnce({ ok: true });\n\n    const req = { query: {} };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.body).toEqual({ ok: true });\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/config/[path].test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { fs, config, logger } = vi.hoisted(() => ({\n  fs: {\n    existsSync: vi.fn(),\n    readFileSync: vi.fn(),\n  },\n  config: {\n    CONF_DIR: \"/conf\",\n  },\n  logger: {\n    error: vi.fn(),\n  },\n}));\n\nvi.mock(\"fs\", () => ({\n  default: fs,\n  ...fs,\n}));\n\nvi.mock(\"utils/config/config\", () => config);\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nimport handler from \"pages/api/config/[path]\";\n\ndescribe(\"pages/api/config/[path]\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"returns 422 for unsupported files\", async () => {\n    const req = { query: { path: \"not-supported.txt\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(422);\n  });\n\n  it(\"returns empty content when the file doesn't exist\", async () => {\n    fs.existsSync.mockReturnValueOnce(false);\n\n    const req = { query: { path: \"custom.css\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.headers[\"Content-Type\"]).toBe(\"text/css\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toBe(\"\");\n  });\n\n  it(\"returns file content when the file exists\", async () => {\n    fs.existsSync.mockReturnValueOnce(true);\n    fs.readFileSync.mockReturnValueOnce(\"body{}\");\n\n    const req = { query: { path: \"custom.js\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.headers[\"Content-Type\"]).toBe(\"text/javascript\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toBe(\"body{}\");\n  });\n\n  it(\"logs and returns 500 when reading the file throws\", async () => {\n    fs.existsSync.mockReturnValueOnce(true);\n    fs.readFileSync.mockImplementationOnce(() => {\n      throw new Error(\"boom\");\n    });\n\n    const req = { query: { path: \"custom.css\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toBe(\"Internal Server Error\");\n    expect(logger.error).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/docker/stats/[...service].test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { state, DockerCtor, getDockerArguments, logger } = vi.hoisted(() => {\n  const state = {\n    docker: null,\n    dockerArgs: { conn: { socketPath: \"/var/run/docker.sock\" }, swarm: false },\n  };\n\n  function DockerCtor() {\n    return state.docker;\n  }\n\n  return {\n    state,\n    DockerCtor,\n    getDockerArguments: vi.fn(() => state.dockerArgs),\n    logger: { error: vi.fn() },\n  };\n});\n\nvi.mock(\"dockerode\", () => ({\n  default: DockerCtor,\n}));\n\nvi.mock(\"utils/config/docker\", () => ({\n  default: getDockerArguments,\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nimport handler from \"pages/api/docker/stats/[...service]\";\n\ndescribe(\"pages/api/docker/stats/[...service]\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    state.dockerArgs = { conn: { socketPath: \"/var/run/docker.sock\" }, swarm: false };\n    state.docker = {\n      listContainers: vi.fn(),\n      getContainer: vi.fn(),\n      listTasks: vi.fn(),\n    };\n  });\n\n  it(\"returns 400 when container name/server params are missing\", async () => {\n    const req = { query: { service: [] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"docker query parameters are required\" });\n  });\n\n  it(\"returns 500 when docker returns a non-array containers payload\", async () => {\n    state.docker.listContainers.mockResolvedValue(Buffer.from(\"bad\"));\n\n    const req = { query: { service: [\"c\", \"local\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: \"query failed\" });\n  });\n\n  it(\"returns stats for an existing container\", async () => {\n    state.docker.listContainers.mockResolvedValue([{ Names: [\"/myapp\"], Id: \"cid1\" }]);\n    const containerStats = { cpu_stats: { cpu_usage: { total_usage: 1 } } };\n    state.docker.getContainer.mockReturnValue({\n      stats: vi.fn().mockResolvedValue(containerStats),\n    });\n\n    const req = { query: { service: [\"myapp\", \"local\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ stats: containerStats });\n  });\n\n  it(\"uses swarm tasks to locate a container and reports a friendly error when stats cannot be retrieved\", async () => {\n    state.dockerArgs.swarm = true;\n    state.docker.listContainers.mockResolvedValue([{ Names: [\"/other\"], Id: \"local1\" }]);\n    state.docker.listTasks.mockResolvedValue([\n      { Status: { ContainerStatus: { ContainerID: \"local1\" } } },\n      { Status: { ContainerStatus: { ContainerID: \"remote1\" } } },\n    ]);\n    state.docker.getContainer.mockReturnValue({\n      stats: vi.fn().mockRejectedValue(new Error(\"nope\")),\n    });\n\n    const req = { query: { service: [\"svc\", \"local\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ error: \"Unable to retrieve stats\" });\n  });\n\n  it(\"returns stats for a swarm task container when present locally\", async () => {\n    state.dockerArgs.swarm = true;\n    state.docker.listContainers.mockResolvedValue([{ Names: [\"/other\"], Id: \"local1\" }]);\n    state.docker.listTasks.mockResolvedValue([{ Status: { ContainerStatus: { ContainerID: \"local1\" } } }]);\n\n    const containerStats = { cpu_stats: { cpu_usage: { total_usage: 2 } } };\n    state.docker.getContainer.mockReturnValue({\n      stats: vi.fn().mockResolvedValue(containerStats),\n    });\n\n    const req = { query: { service: [\"svc\", \"local\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ stats: containerStats });\n  });\n\n  it(\"returns 404 when no container or swarm task is found\", async () => {\n    state.dockerArgs.swarm = true;\n    state.docker.listContainers.mockResolvedValue([{ Names: [\"/other\"], Id: \"local1\" }]);\n    state.docker.listTasks.mockResolvedValue([]);\n\n    const req = { query: { service: [\"missing\", \"local\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(404);\n    expect(res.body).toEqual({ error: \"not found\" });\n  });\n\n  it(\"logs and returns 500 when the docker query throws\", async () => {\n    getDockerArguments.mockImplementationOnce(() => {\n      throw new Error(\"boom\");\n    });\n\n    const req = { query: { service: [\"myapp\", \"local\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: { message: \"boom\" } });\n    expect(logger.error).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/docker/status/[...service].test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { state, DockerCtor, getDockerArguments, logger } = vi.hoisted(() => {\n  const state = {\n    docker: null,\n    dockerCtorArgs: [],\n    dockerArgs: { conn: { socketPath: \"/var/run/docker.sock\" }, swarm: false },\n  };\n\n  function DockerCtor(conn) {\n    state.dockerCtorArgs.push(conn);\n    return state.docker;\n  }\n\n  return {\n    state,\n    DockerCtor,\n    getDockerArguments: vi.fn(() => state.dockerArgs),\n    logger: { error: vi.fn() },\n  };\n});\n\nvi.mock(\"dockerode\", () => ({\n  default: DockerCtor,\n}));\n\nvi.mock(\"utils/config/docker\", () => ({\n  default: getDockerArguments,\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nimport handler from \"pages/api/docker/status/[...service]\";\n\ndescribe(\"pages/api/docker/status/[...service]\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    state.dockerCtorArgs.length = 0;\n    state.dockerArgs = { conn: { socketPath: \"/var/run/docker.sock\" }, swarm: false };\n    state.docker = {\n      listContainers: vi.fn(),\n      getContainer: vi.fn(),\n      getService: vi.fn(),\n      listTasks: vi.fn(),\n    };\n  });\n\n  it(\"returns 400 when container name/server params are missing\", async () => {\n    const req = { query: { service: [] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"docker query parameters are required\" });\n  });\n\n  it(\"returns 500 when docker returns a non-array containers payload\", async () => {\n    state.docker.listContainers.mockResolvedValue(Buffer.from(\"bad\"));\n\n    const req = { query: { service: [\"c\", \"local\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: \"query failed\" });\n  });\n\n  it(\"inspects an existing container and returns status + health\", async () => {\n    state.docker.listContainers.mockResolvedValue([{ Names: [\"/myapp\"], Id: \"cid1\" }]);\n    state.docker.getContainer.mockReturnValue({\n      inspect: vi.fn().mockResolvedValue({ State: { Status: \"running\", Health: { Status: \"healthy\" } } }),\n    });\n\n    const req = { query: { service: [\"myapp\", \"local\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(getDockerArguments).toHaveBeenCalledWith(\"local\");\n    expect(state.dockerCtorArgs).toHaveLength(1);\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ status: \"running\", health: \"healthy\" });\n  });\n\n  it(\"returns 404 when container does not exist and swarm is disabled\", async () => {\n    state.docker.listContainers.mockResolvedValue([{ Names: [\"/other\"], Id: \"cid1\" }]);\n\n    const req = { query: { service: [\"missing\", \"local\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(404);\n    expect(res.body).toEqual({ status: \"not found\" });\n  });\n\n  it(\"reports replicated swarm service status based on desired replicas\", async () => {\n    state.dockerArgs.swarm = true;\n    state.docker.listContainers.mockResolvedValue([{ Names: [\"/other\"], Id: \"cid1\" }]);\n    state.docker.getService.mockReturnValue({\n      inspect: vi.fn().mockResolvedValue({ Spec: { Mode: { Replicated: { Replicas: \"2\" } } } }),\n    });\n    state.docker.listTasks.mockResolvedValue([{ Status: {} }, { Status: {} }]);\n\n    const req = { query: { service: [\"svc\", \"local\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ status: \"running 2/2\" });\n  });\n\n  it(\"reports partial status for replicated services with fewer running tasks\", async () => {\n    state.dockerArgs.swarm = true;\n    state.docker.listContainers.mockResolvedValue([{ Names: [\"/other\"], Id: \"cid1\" }]);\n    state.docker.getService.mockReturnValue({\n      inspect: vi.fn().mockResolvedValue({ Spec: { Mode: { Replicated: { Replicas: \"3\" } } } }),\n    });\n    state.docker.listTasks.mockResolvedValue([{ Status: {} }]);\n\n    const req = { query: { service: [\"svc\", \"local\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ status: \"partial 1/3\" });\n  });\n\n  it(\"handles global services by inspecting a local task container when possible\", async () => {\n    state.dockerArgs.swarm = true;\n    state.docker.listContainers.mockResolvedValue([{ Names: [\"/other\"], Id: \"local1\" }]);\n    state.docker.getService.mockReturnValue({\n      inspect: vi.fn().mockResolvedValue({ Spec: { Mode: { Global: {} } } }),\n    });\n    state.docker.listTasks.mockResolvedValue([\n      { Status: { ContainerStatus: { ContainerID: \"local1\" }, State: \"running\" } },\n    ]);\n    state.docker.getContainer.mockReturnValue({\n      inspect: vi.fn().mockResolvedValue({ State: { Status: \"running\", Health: { Status: \"unhealthy\" } } }),\n    });\n\n    const req = { query: { service: [\"svc\", \"local\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ status: \"running\", health: \"unhealthy\" });\n  });\n\n  it(\"falls back to task status when global service container inspect fails\", async () => {\n    state.dockerArgs.swarm = true;\n    state.docker.listContainers.mockResolvedValue([{ Names: [\"/other\"], Id: \"local1\" }]);\n    state.docker.getService.mockReturnValue({\n      inspect: vi.fn().mockResolvedValue({ Spec: { Mode: { Global: {} } } }),\n    });\n    state.docker.listTasks.mockResolvedValue([\n      { Status: { ContainerStatus: { ContainerID: \"local1\" }, State: \"pending\" } },\n    ]);\n    state.docker.getContainer.mockReturnValue({\n      inspect: vi.fn().mockRejectedValue(new Error(\"nope\")),\n    });\n\n    const req = { query: { service: [\"svc\", \"local\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ status: \"pending\" });\n  });\n\n  it(\"returns 404 when swarm is enabled but the service does not exist\", async () => {\n    state.dockerArgs.swarm = true;\n    state.docker.listContainers.mockResolvedValue([{ Names: [\"/other\"], Id: \"cid1\" }]);\n    state.docker.getService.mockReturnValue({\n      inspect: vi.fn().mockRejectedValue(new Error(\"not found\")),\n    });\n\n    const req = { query: { service: [\"svc\", \"local\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(404);\n    expect(res.body).toEqual({ status: \"not found\" });\n  });\n\n  it(\"logs and returns 500 when the docker query throws\", async () => {\n    getDockerArguments.mockImplementationOnce(() => {\n      throw new Error(\"boom\");\n    });\n\n    const req = { query: { service: [\"svc\", \"local\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: { message: \"boom\" } });\n    expect(logger.error).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/hash.test.js",
    "content": "import { createHash } from \"crypto\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nfunction sha256(input) {\n  return createHash(\"sha256\").update(input).digest(\"hex\");\n}\n\nconst { readFileSync, checkAndCopyConfig, CONF_DIR } = vi.hoisted(() => ({\n  readFileSync: vi.fn(),\n  checkAndCopyConfig: vi.fn(),\n  CONF_DIR: \"/conf\",\n}));\n\nvi.mock(\"fs\", () => ({\n  readFileSync,\n}));\n\nvi.mock(\"utils/config/config\", () => ({\n  default: checkAndCopyConfig,\n  CONF_DIR,\n}));\n\nimport handler from \"pages/api/hash\";\n\ndescribe(\"pages/api/hash\", () => {\n  const originalBuildTime = process.env.HOMEPAGE_BUILDTIME;\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    process.env.HOMEPAGE_BUILDTIME = originalBuildTime;\n  });\n\n  it(\"returns a combined sha256 hash of known config files and build time\", async () => {\n    process.env.HOMEPAGE_BUILDTIME = \"build-1\";\n\n    // Return deterministic contents based on file name.\n    readFileSync.mockImplementation((filePath) => {\n      const name = filePath.split(\"/\").pop();\n      return `content:${name}`;\n    });\n\n    const req = { query: {} };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    const configs = [\n      \"docker.yaml\",\n      \"settings.yaml\",\n      \"services.yaml\",\n      \"bookmarks.yaml\",\n      \"widgets.yaml\",\n      \"custom.css\",\n      \"custom.js\",\n    ];\n    const hashes = configs.map((c) => sha256(`content:${c}`));\n    const expected = sha256(hashes.join(\"\") + \"build-1\");\n\n    expect(checkAndCopyConfig).toHaveBeenCalled();\n    expect(res.body).toEqual({ hash: expected });\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/healthcheck.test.js",
    "content": "import { describe, expect, it } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nimport handler from \"pages/api/healthcheck\";\n\ndescribe(\"pages/api/healthcheck\", () => {\n  it(\"returns 'up'\", () => {\n    const req = {};\n    const res = createMockRes();\n\n    handler(req, res);\n\n    expect(res.body).toBe(\"up\");\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/kubernetes/stats/[...service].test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { getKubeConfig, coreApi, metricsApi, MetricsCtor, logger } = vi.hoisted(() => {\n  const metricsApi = {\n    getPodMetrics: vi.fn(),\n  };\n\n  function MetricsCtor() {\n    return metricsApi;\n  }\n\n  return {\n    getKubeConfig: vi.fn(),\n    coreApi: { listNamespacedPod: vi.fn() },\n    metricsApi,\n    MetricsCtor,\n    logger: { error: vi.fn() },\n  };\n});\n\nvi.mock(\"@kubernetes/client-node\", () => ({\n  CoreV1Api: function CoreV1Api() {},\n  Metrics: MetricsCtor,\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/kubernetes\", () => ({\n  getKubeConfig,\n}));\n\nimport handler from \"pages/api/kubernetes/stats/[...service]\";\n\ndescribe(\"pages/api/kubernetes/stats/[...service]\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    getKubeConfig.mockReturnValue({\n      makeApiClient: () => coreApi,\n    });\n  });\n\n  it(\"returns 400 when namespace/appName params are missing\", async () => {\n    const req = { query: { service: [] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"kubernetes query parameters are required\" });\n  });\n\n  it(\"returns 500 when kubernetes is not configured\", async () => {\n    getKubeConfig.mockReturnValue(null);\n\n    const req = { query: { service: [\"default\", \"app\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: \"No kubernetes configuration\" });\n  });\n\n  it(\"returns 500 when listNamespacedPod fails\", async () => {\n    coreApi.listNamespacedPod.mockRejectedValue({ statusCode: 500, body: \"nope\", response: \"nope\" });\n\n    const req = { query: { service: [\"default\", \"app\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: \"Error communicating with kubernetes\" });\n  });\n\n  it(\"returns 404 when no pods match the selector\", async () => {\n    coreApi.listNamespacedPod.mockResolvedValue({ items: [] });\n\n    const req = { query: { service: [\"default\", \"app\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(404);\n    expect(res.body).toEqual({\n      error: \"no pods found with namespace=default and labelSelector=app.kubernetes.io/name=app\",\n    });\n  });\n\n  it(\"computes limits even when metrics are missing (404 from metrics server)\", async () => {\n    coreApi.listNamespacedPod.mockResolvedValue({\n      items: [\n        {\n          metadata: { name: \"pod-a\" },\n          spec: {\n            containers: [\n              { resources: { limits: { cpu: \"500m\", memory: \"1Gi\" } } },\n              { resources: { limits: { cpu: \"250m\" } } },\n            ],\n          },\n        },\n      ],\n    });\n\n    metricsApi.getPodMetrics.mockRejectedValue({ statusCode: 404, body: \"no metrics\", response: \"no metrics\" });\n\n    const req = { query: { service: [\"default\", \"app\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({\n      stats: {\n        mem: 0,\n        cpu: 0,\n        cpuLimit: 0.75,\n        memLimit: 1000000000,\n        cpuUsage: 0,\n        memUsage: 0,\n      },\n    });\n  });\n\n  it(\"logs when metrics lookup fails with a non-404 error and still returns computed limits\", async () => {\n    coreApi.listNamespacedPod.mockResolvedValue({\n      items: [\n        {\n          metadata: { name: \"pod-a\" },\n          spec: {\n            containers: [{ resources: { limits: { cpu: \"500m\", memory: \"1Gi\" } } }],\n          },\n        },\n      ],\n    });\n\n    metricsApi.getPodMetrics.mockRejectedValue({ statusCode: 500, body: \"boom\", response: \"boom\" });\n\n    const req = { query: { service: [\"default\", \"app\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(logger.error).toHaveBeenCalled();\n    expect(res.statusCode).toBe(200);\n    expect(res.body.stats.cpuLimit).toBe(0.5);\n    expect(res.body.stats.memLimit).toBe(1000000000);\n    expect(res.body.stats.cpu).toBe(0);\n    expect(res.body.stats.mem).toBe(0);\n  });\n\n  it(\"aggregates usage for matched pods and reports percent usage\", async () => {\n    coreApi.listNamespacedPod.mockResolvedValue({\n      items: [\n        {\n          metadata: { name: \"pod-a\" },\n          spec: { containers: [{ resources: { limits: { cpu: \"1000m\", memory: \"2Gi\" } } }] },\n        },\n        {\n          metadata: { name: \"pod-b\" },\n          spec: { containers: [{ resources: { limits: { cpu: \"500m\", memory: \"1Gi\" } } }] },\n        },\n      ],\n    });\n\n    metricsApi.getPodMetrics.mockResolvedValue({\n      items: [\n        // includes a non-selected pod, should be ignored\n        { metadata: { name: \"other\" }, containers: [{ usage: { cpu: \"100m\", memory: \"10Mi\" } }] },\n        {\n          metadata: { name: \"pod-a\" },\n          containers: [{ usage: { cpu: \"250m\", memory: \"100Mi\" } }, { usage: { cpu: \"250m\", memory: \"100Mi\" } }],\n        },\n        { metadata: { name: \"pod-b\" }, containers: [{ usage: { cpu: \"500m\", memory: \"1Gi\" } }] },\n      ],\n    });\n\n    const req = { query: { service: [\"default\", \"app\"], podSelector: \"app=test\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    const { stats } = res.body;\n    expect(stats.cpuLimit).toBe(1.5);\n    expect(stats.memLimit).toBe(3000000000);\n    expect(stats.cpu).toBeCloseTo(1.0, 5);\n    expect(stats.mem).toBe(1200000000);\n    expect(stats.cpuUsage).toBeCloseTo((100 * 1.0) / 1.5, 5);\n    expect(stats.memUsage).toBeCloseTo((100 * 1200000000) / 3000000000, 5);\n  });\n\n  it(\"returns 500 when an unexpected error is thrown\", async () => {\n    getKubeConfig.mockImplementationOnce(() => {\n      throw new Error(\"boom\");\n    });\n\n    const req = { query: { service: [\"default\", \"app\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: \"unknown error\" });\n    expect(logger.error).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/kubernetes/status/[...service].test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { getKubeConfig, coreApi, logger } = vi.hoisted(() => ({\n  getKubeConfig: vi.fn(),\n  coreApi: { listNamespacedPod: vi.fn() },\n  logger: { error: vi.fn() },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/kubernetes\", () => ({\n  getKubeConfig,\n}));\n\nimport handler from \"pages/api/kubernetes/status/[...service]\";\n\ndescribe(\"pages/api/kubernetes/status/[...service]\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    getKubeConfig.mockReturnValue({\n      makeApiClient: () => coreApi,\n    });\n  });\n\n  it(\"returns 400 when namespace/appName params are missing\", async () => {\n    const req = { query: { service: [] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"kubernetes query parameters are required\" });\n  });\n\n  it(\"returns 500 when kubernetes is not configured\", async () => {\n    getKubeConfig.mockReturnValue(null);\n\n    const req = { query: { service: [\"default\", \"app\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: \"No kubernetes configuration\" });\n  });\n\n  it(\"returns 500 when listNamespacedPod fails\", async () => {\n    coreApi.listNamespacedPod.mockRejectedValue({ statusCode: 500, body: \"nope\", response: \"nope\" });\n\n    const req = { query: { service: [\"default\", \"app\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: \"Error communicating with kubernetes\" });\n  });\n\n  it(\"returns 404 when no pods match the selector\", async () => {\n    coreApi.listNamespacedPod.mockResolvedValue({ items: [] });\n\n    const req = { query: { service: [\"default\", \"app\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(404);\n    expect(res.body).toEqual({ status: \"not found\" });\n  });\n\n  it(\"returns partial when some pods are ready but not all\", async () => {\n    coreApi.listNamespacedPod.mockResolvedValue({\n      items: [{ status: { phase: \"Running\" } }, { status: { phase: \"Pending\" } }],\n    });\n\n    const req = { query: { service: [\"default\", \"app\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ status: \"partial\" });\n  });\n\n  it(\"returns running when all pods are ready\", async () => {\n    coreApi.listNamespacedPod.mockResolvedValue({\n      items: [{ status: { phase: \"Running\" } }, { status: { phase: \"Succeeded\" } }],\n    });\n\n    const req = { query: { service: [\"default\", \"app\"], podSelector: \"app=test\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(coreApi.listNamespacedPod).toHaveBeenCalledWith({\n      namespace: \"default\",\n      labelSelector: \"app=test\",\n    });\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ status: \"running\" });\n  });\n\n  it(\"returns 500 when an unexpected error is thrown\", async () => {\n    getKubeConfig.mockImplementationOnce(() => {\n      throw new Error(\"boom\");\n    });\n\n    const req = { query: { service: [\"default\", \"app\"] } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: \"unknown error\" });\n    expect(logger.error).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/ping.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { getServiceItem, ping, logger } = vi.hoisted(() => ({\n  getServiceItem: vi.fn(),\n  ping: { probe: vi.fn() },\n  logger: { debug: vi.fn() },\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  getServiceItem,\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"ping\", () => ({\n  promise: ping,\n}));\n\nimport handler from \"pages/api/ping\";\n\ndescribe(\"pages/api/ping\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"returns 400 when service item isn't found\", async () => {\n    getServiceItem.mockResolvedValueOnce(null);\n\n    const req = { query: { groupName: \"g\", serviceName: \"s\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body.error).toContain(\"Unable to find service\");\n  });\n\n  it(\"returns 400 when ping host isn't configured\", async () => {\n    getServiceItem.mockResolvedValueOnce({ ping: \"\" });\n\n    const req = { query: { groupName: \"g\", serviceName: \"s\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body.error).toBe(\"No ping host given\");\n  });\n\n  it(\"pings the hostname extracted from a URL\", async () => {\n    getServiceItem.mockResolvedValueOnce({ ping: \"http://example.com:1234/path\" });\n    ping.probe.mockResolvedValueOnce({ alive: true });\n\n    const req = { query: { groupName: \"g\", serviceName: \"s\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(ping.probe).toHaveBeenCalledWith(\"example.com\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ alive: true });\n  });\n\n  it(\"returns 400 when ping throws\", async () => {\n    getServiceItem.mockResolvedValueOnce({ ping: \"example.com\" });\n    ping.probe.mockRejectedValueOnce(new Error(\"nope\"));\n\n    const req = { query: { groupName: \"g\", serviceName: \"s\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body.error).toContain(\"Error attempting ping\");\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/proxmox/stats/[...service].test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { getProxmoxConfig, httpProxy, logger } = vi.hoisted(() => ({\n  getProxmoxConfig: vi.fn(),\n  httpProxy: vi.fn(),\n  logger: { error: vi.fn() },\n}));\n\nvi.mock(\"utils/config/proxmox\", () => ({\n  getProxmoxConfig,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nimport handler from \"pages/api/proxmox/stats/[...service]\";\n\ndescribe(\"pages/api/proxmox/stats/[...service]\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"returns 400 when node param is missing\", async () => {\n    const req = { query: { service: [], type: \"qemu\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Proxmox node parameter is required\" });\n  });\n\n  it(\"returns 500 when proxmox config is missing\", async () => {\n    getProxmoxConfig.mockReturnValue(null);\n\n    const req = { query: { service: [\"pve\", \"100\"], type: \"qemu\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: \"Proxmox server configuration not found\" });\n  });\n\n  it(\"returns 400 when node config is missing and legacy credentials are not present\", async () => {\n    getProxmoxConfig.mockReturnValue({ other: { url: \"http://x\", token: \"t\", secret: \"s\" } });\n\n    const req = { query: { service: [\"pve\", \"100\"], type: \"qemu\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual(\n      expect.objectContaining({\n        error: expect.stringContaining(\"Proxmox config not found for the specified node\"),\n      }),\n    );\n  });\n\n  it(\"returns status/cpu/mem for a successful Proxmox response using per-node credentials\", async () => {\n    getProxmoxConfig.mockReturnValue({\n      pve: { url: \"http://pve\", token: \"tok\", secret: \"sec\" },\n    });\n    httpProxy.mockResolvedValueOnce([\n      200,\n      \"application/json\",\n      Buffer.from(JSON.stringify({ data: { status: \"running\", cpu: 0.2, mem: 123 } })),\n    ]);\n\n    const req = { query: { service: [\"pve\", \"100\"], type: \"qemu\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledWith(\"http://pve/api2/json/nodes/pve/qemu/100/status/current\", {\n      method: \"GET\",\n      headers: { Authorization: \"PVEAPIToken=tok=sec\" },\n    });\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ status: \"running\", cpu: 0.2, mem: 123 });\n  });\n\n  it(\"falls back to legacy top-level credentials when no node block exists\", async () => {\n    getProxmoxConfig.mockReturnValue({ url: \"http://pve\", token: \"tok\", secret: \"sec\" });\n    httpProxy.mockResolvedValueOnce([\n      200,\n      \"application/json\",\n      Buffer.from(JSON.stringify({ data: { cpu: 0.1, mem: 1 } })),\n    ]);\n\n    const req = { query: { service: [\"pve\", \"100\"], type: \"lxc\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledWith(\"http://pve/api2/json/nodes/pve/lxc/100/status/current\", expect.any(Object));\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ status: \"unknown\", cpu: 0.1, mem: 1 });\n  });\n\n  it(\"returns a non-200 status when Proxmox responds with an error\", async () => {\n    getProxmoxConfig.mockReturnValue({ url: \"http://pve\", token: \"tok\", secret: \"sec\" });\n    httpProxy.mockResolvedValueOnce([401, \"application/json\", Buffer.from(JSON.stringify({ error: \"no\" }))]);\n\n    const req = { query: { service: [\"pve\", \"100\"], type: \"qemu\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(401);\n    expect(res.body).toEqual({ error: \"Failed to fetch Proxmox qemu status\" });\n  });\n\n  it(\"returns 500 when the Proxmox response is missing expected data\", async () => {\n    getProxmoxConfig.mockReturnValue({ url: \"http://pve\", token: \"tok\", secret: \"sec\" });\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({}))]);\n\n    const req = { query: { service: [\"pve\", \"100\"], type: \"qemu\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: \"Invalid response from Proxmox API\" });\n  });\n\n  it(\"logs and returns 500 when an unexpected error occurs\", async () => {\n    getProxmoxConfig.mockReturnValue({ url: \"http://pve\", token: \"tok\", secret: \"sec\" });\n    httpProxy.mockRejectedValueOnce(new Error(\"boom\"));\n\n    const req = { query: { service: [\"pve\", \"100\"], type: \"qemu\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(logger.error).toHaveBeenCalled();\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: \"Failed to fetch Proxmox status\" });\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/releases.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { cachedRequest, logger } = vi.hoisted(() => ({\n  cachedRequest: vi.fn(),\n  logger: { error: vi.fn() },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  cachedRequest,\n}));\n\nimport handler from \"pages/api/releases\";\n\ndescribe(\"pages/api/releases\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"returns cached GitHub releases\", async () => {\n    cachedRequest.mockResolvedValueOnce([{ tag_name: \"v1\" }]);\n\n    const req = {};\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.body).toEqual([{ tag_name: \"v1\" }]);\n  });\n\n  it(\"returns [] when cachedRequest throws\", async () => {\n    cachedRequest.mockRejectedValueOnce(new Error(\"nope\"));\n\n    const req = {};\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.body).toEqual([]);\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/revalidate.test.js",
    "content": "import { describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nimport handler from \"pages/api/revalidate\";\n\ndescribe(\"pages/api/revalidate\", () => {\n  it(\"revalidates and returns {revalidated:true}\", async () => {\n    const req = {};\n    const res = createMockRes();\n    res.revalidate = vi.fn().mockResolvedValueOnce(undefined);\n\n    await handler(req, res);\n\n    expect(res.revalidate).toHaveBeenCalledWith(\"/\");\n    expect(res.body).toEqual({ revalidated: true });\n  });\n\n  it(\"returns 500 when revalidate throws\", async () => {\n    const req = {};\n    const res = createMockRes();\n    res.revalidate = vi.fn().mockRejectedValueOnce(new Error(\"nope\"));\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toBe(\"Error revalidating\");\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/search/searchSuggestion.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { providers, getSettings, widgetsFromConfig, cachedRequest } = vi.hoisted(() => ({\n  providers: {\n    custom: { name: \"Custom\", url: false, suggestionUrl: null },\n    google: { name: \"Google\", url: \"https://google?q=\", suggestionUrl: \"https://google/suggest?q=\" },\n    empty: { name: \"NoSuggest\", url: \"x\", suggestionUrl: null },\n  },\n  getSettings: vi.fn(),\n  widgetsFromConfig: vi.fn(),\n  cachedRequest: vi.fn(),\n}));\n\nvi.mock(\"components/widgets/search/search\", () => ({\n  searchProviders: {\n    custom: providers.custom,\n    google: providers.google,\n    empty: providers.empty,\n  },\n}));\n\nvi.mock(\"utils/config/config\", () => ({\n  getSettings,\n}));\n\nvi.mock(\"utils/config/widget-helpers\", () => ({\n  widgetsFromConfig,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  cachedRequest,\n}));\n\nimport handler from \"pages/api/search/searchSuggestion\";\n\ndescribe(\"pages/api/search/searchSuggestion\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n\n    // Reset provider objects since handler mutates the Custom provider.\n    providers.custom.url = false;\n    providers.custom.suggestionUrl = null;\n  });\n\n  it(\"returns empty suggestions when providerName is unknown\", async () => {\n    const req = { query: { query: \"hello\", providerName: \"Unknown\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.body).toEqual([\"hello\", []]);\n  });\n\n  it(\"returns empty suggestions when provider has no suggestionUrl\", async () => {\n    const req = { query: { query: \"hello\", providerName: \"NoSuggest\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.body).toEqual([\"hello\", []]);\n  });\n\n  it(\"calls cachedRequest for a standard provider\", async () => {\n    cachedRequest.mockResolvedValueOnce([\"q\", [\"a\"]]);\n\n    const req = { query: { query: \"hello world\", providerName: \"Google\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(cachedRequest).toHaveBeenCalledWith(\"https://google/suggest?q=hello%20world\", 5, \"Mozilla/5.0\");\n    expect(res.body).toEqual([\"q\", [\"a\"]]);\n  });\n\n  it(\"resolves Custom provider suggestionUrl from widgets.yaml when present\", async () => {\n    widgetsFromConfig.mockResolvedValueOnce([\n      { type: \"search\", options: { url: \"https://custom?q=\", suggestionUrl: \"https://custom/suggest?q=\" } },\n    ]);\n    cachedRequest.mockResolvedValueOnce([\"q\", [\"x\"]]);\n\n    const req = { query: { query: \"hello\", providerName: \"Custom\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(cachedRequest).toHaveBeenCalledWith(\"https://custom/suggest?q=hello\", 5, \"Mozilla/5.0\");\n    expect(res.body).toEqual([\"q\", [\"x\"]]);\n  });\n\n  it(\"falls back to quicklaunch custom settings when no search widget is configured\", async () => {\n    widgetsFromConfig.mockResolvedValueOnce([]);\n    getSettings.mockReturnValueOnce({\n      quicklaunch: { provider: \"custom\", url: \"https://ql?q=\", suggestionUrl: \"https://ql/suggest?q=\" },\n    });\n    cachedRequest.mockResolvedValueOnce([\"q\", [\"y\"]]);\n\n    const req = { query: { query: \"hello\", providerName: \"Custom\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(cachedRequest).toHaveBeenCalledWith(\"https://ql/suggest?q=hello\", 5, \"Mozilla/5.0\");\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/services/index.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { servicesResponse } = vi.hoisted(() => ({\n  servicesResponse: vi.fn(),\n}));\n\nvi.mock(\"utils/config/api-response\", () => ({\n  servicesResponse,\n}));\n\nimport handler from \"pages/api/services/index\";\n\ndescribe(\"pages/api/services/index\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"returns servicesResponse()\", async () => {\n    servicesResponse.mockResolvedValueOnce({ services: [] });\n\n    const req = {};\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.body).toEqual({ services: [] });\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/services/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { state, getServiceWidget, calendarProxy } = vi.hoisted(() => ({\n  state: {\n    genericResult: { ok: true },\n  },\n  getServiceWidget: vi.fn(),\n  calendarProxy: vi.fn(),\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => ({ debug: vi.fn(), error: vi.fn() }),\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({ default: getServiceWidget }));\n\nconst handlerFn = vi.hoisted(() => ({ handler: vi.fn() }));\nvi.mock(\"utils/proxy/handlers/generic\", () => ({ default: handlerFn.handler }));\n\n// Calendar proxy is only used for an exception; keep it stubbed.\nvi.mock(\"widgets/calendar/proxy\", () => ({ default: calendarProxy }));\n\n// Provide a minimal widget registry for mapping tests.\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    linkwarden: {\n      api: \"{url}/api/v1/{endpoint}\",\n      mappings: {\n        collections: { endpoint: \"collections\" },\n      },\n    },\n    segments: {\n      api: \"{url}/{endpoint}\",\n      mappings: {\n        item: { endpoint: \"items/{id}\", segments: [\"id\"] },\n      },\n    },\n    queryparams: {\n      api: \"{url}/{endpoint}\",\n      mappings: {\n        list: { endpoint: \"list\", params: [\"limit\"], optionalParams: [\"q\"] },\n      },\n    },\n    endpointproxy: {\n      api: \"{url}/{endpoint}\",\n      mappings: {\n        list: { endpoint: \"list\", proxyHandler: handlerFn.handler, headers: { \"X-Test\": \"1\" } },\n      },\n    },\n    regex: {\n      api: \"{url}/{endpoint}\",\n      allowedEndpoints: /^ok\\//,\n    },\n    ical: {\n      api: \"{url}/{endpoint}\",\n      proxyHandler: calendarProxy,\n    },\n    unifi_console: {\n      api: \"{url}/{endpoint}\",\n      proxyHandler: handlerFn.handler,\n    },\n  },\n}));\n\nimport servicesProxy from \"pages/api/services/proxy\";\n\nfunction createMockRes() {\n  const res = {\n    statusCode: undefined,\n    body: undefined,\n    status: (code) => {\n      res.statusCode = code;\n      return res;\n    },\n    json: (data) => {\n      res.body = data;\n      return res;\n    },\n    send: (data) => {\n      res.body = data;\n      return res;\n    },\n    end: () => res,\n    setHeader: vi.fn(),\n  };\n  return res;\n}\n\ndescribe(\"pages/api/services/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"maps opaque endpoints using widget.mappings and calls the handler\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"linkwarden\" });\n    handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint }));\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", index: \"0\", endpoint: \"collections\" } };\n    const res = createMockRes();\n\n    await servicesProxy(req, res);\n\n    expect(handlerFn.handler).toHaveBeenCalled();\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ endpoint: \"collections\" });\n  });\n\n  it(\"returns 403 for unsupported endpoint mapping\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"linkwarden\" });\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", index: \"0\", endpoint: \"nope\" } };\n    const res = createMockRes();\n\n    await servicesProxy(req, res);\n\n    expect(res.statusCode).toBe(403);\n    expect(res.body).toEqual({ error: \"Unsupported service endpoint\" });\n  });\n\n  it(\"returns 403 for unknown widget types\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"does_not_exist\" });\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", index: \"0\", endpoint: \"collections\" } };\n    const res = createMockRes();\n\n    await servicesProxy(req, res);\n\n    expect(res.statusCode).toBe(403);\n    expect(res.body).toEqual({ error: \"Unknown proxy service type\" });\n  });\n\n  it(\"quick-returns the proxy handler when no endpoint is provided\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"linkwarden\" });\n    handlerFn.handler.mockImplementation(async (_req, res) => res.status(200).json(state.genericResult));\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", index: \"0\" } };\n    const res = createMockRes();\n\n    await servicesProxy(req, res);\n\n    expect(handlerFn.handler).toHaveBeenCalledTimes(1);\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ ok: true });\n  });\n\n  it(\"applies the calendar exception and always delegates to calendarProxyHandler\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"calendar\" });\n    calendarProxy.mockImplementation(async (_req, res) => res.status(200).json({ ok: \"calendar\" }));\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", index: \"0\", endpoint: \"events\" } };\n    const res = createMockRes();\n\n    await servicesProxy(req, res);\n\n    expect(calendarProxy).toHaveBeenCalledTimes(1);\n    expect(handlerFn.handler).not.toHaveBeenCalled();\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ ok: \"calendar\" });\n  });\n\n  it(\"applies the unifi_console exception when service and group are unifi_console\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"something_else\" });\n    handlerFn.handler.mockImplementation(async (_req, res) => res.status(200).json({ ok: \"unifi\" }));\n\n    const req = {\n      method: \"GET\",\n      query: { group: \"unifi_console\", service: \"unifi_console\", index: \"0\" },\n    };\n    const res = createMockRes();\n\n    await servicesProxy(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ ok: \"unifi\" });\n  });\n\n  it(\"rejects unsupported mapping methods\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"linkwarden\" });\n\n    // Inject a mapping with a method requirement through the mocked registry.\n    const widgets = (await import(\"widgets/widgets\")).default;\n    const originalMethod = widgets.linkwarden.mappings.collections.method;\n    widgets.linkwarden.mappings.collections.method = \"POST\";\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", index: \"0\", endpoint: \"collections\" } };\n    const res = createMockRes();\n\n    await servicesProxy(req, res);\n\n    expect(res.statusCode).toBe(403);\n    expect(res.body).toEqual({ error: \"Unsupported method\" });\n\n    widgets.linkwarden.mappings.collections.method = originalMethod;\n  });\n\n  it(\"replaces endpoint segments and rejects unsupported segment keys/values\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"segments\" });\n    handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint }));\n\n    const res1 = createMockRes();\n    await servicesProxy(\n      {\n        method: \"GET\",\n        query: { group: \"g\", service: \"s\", index: \"0\", endpoint: \"item\", segments: JSON.stringify({ id: \"123\" }) },\n      },\n      res1,\n    );\n    expect(res1.statusCode).toBe(200);\n    expect(res1.body).toEqual({ endpoint: \"items/123\" });\n\n    const res2 = createMockRes();\n    await servicesProxy(\n      {\n        method: \"GET\",\n        query: { group: \"g\", service: \"s\", index: \"0\", endpoint: \"item\", segments: JSON.stringify({ nope: \"123\" }) },\n      },\n      res2,\n    );\n    expect(res2.statusCode).toBe(403);\n    expect(res2.body).toEqual({ error: \"Unsupported segment\" });\n\n    const res3 = createMockRes();\n    await servicesProxy(\n      {\n        method: \"GET\",\n        query: { group: \"g\", service: \"s\", index: \"0\", endpoint: \"item\", segments: JSON.stringify({ id: \"../123\" }) },\n      },\n      res3,\n    );\n    expect(res3.statusCode).toBe(403);\n    expect(res3.body).toEqual({ error: \"Unsupported segment\" });\n  });\n\n  it(\"adds query params based on mapping params + optionalParams\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"queryparams\" });\n    handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint }));\n\n    const req = {\n      method: \"GET\",\n      query: {\n        group: \"g\",\n        service: \"s\",\n        index: \"0\",\n        endpoint: \"list\",\n        query: JSON.stringify({ limit: 10, q: \"test\" }),\n      },\n    };\n    const res = createMockRes();\n\n    await servicesProxy(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body.endpoint).toBe(\"list?limit=10&q=test\");\n  });\n\n  it(\"passes mapping headers via req.extraHeaders and uses mapping.proxyHandler when provided\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"endpointproxy\" });\n    handlerFn.handler.mockImplementation(async (req, res) =>\n      res.status(200).json({ headers: req.extraHeaders ?? null }),\n    );\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", index: \"0\", endpoint: \"list\" } };\n    const res = createMockRes();\n\n    await servicesProxy(req, res);\n\n    expect(handlerFn.handler).toHaveBeenCalledTimes(1);\n    expect(res.statusCode).toBe(200);\n    expect(res.body.headers).toEqual({ \"X-Test\": \"1\" });\n  });\n\n  it(\"allows regex endpoints when widget.allowedEndpoints matches\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"regex\" });\n    handlerFn.handler.mockImplementation(async (_req, res) => res.status(200).json({ ok: true }));\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", index: \"0\", endpoint: \"ok/test\" } };\n    const res = createMockRes();\n\n    await servicesProxy(req, res);\n\n    expect(res.statusCode).toBe(200);\n  });\n\n  it(\"rejects unmapped proxy requests when no mapping and regex does not match\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"regex\" });\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", index: \"0\", endpoint: \"nope\" } };\n    const res = createMockRes();\n\n    await servicesProxy(req, res);\n\n    expect(res.statusCode).toBe(403);\n    expect(res.body).toEqual({ error: \"Unmapped proxy request.\" });\n  });\n\n  it(\"falls back to the service proxy handler when mapping.proxyHandler is not a function\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"mapbroken\" });\n    handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint }));\n\n    const widgets = (await import(\"widgets/widgets\")).default;\n    widgets.mapbroken = {\n      api: \"{url}/{endpoint}\",\n      mappings: {\n        x: { endpoint: \"ok\", proxyHandler: \"nope\" },\n      },\n    };\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", index: \"0\", endpoint: \"x\" } };\n    const res = createMockRes();\n\n    await servicesProxy(req, res);\n\n    expect(handlerFn.handler).toHaveBeenCalled();\n    expect(res.statusCode).toBe(200);\n    expect(res.body.endpoint).toBe(\"ok\");\n  });\n\n  it(\"returns 403 when a widget defines a non-function proxyHandler\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"brokenhandler\" });\n\n    const widgets = (await import(\"widgets/widgets\")).default;\n    widgets.brokenhandler = {\n      api: \"{url}/{endpoint}\",\n      proxyHandler: \"nope\",\n    };\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", index: \"0\", endpoint: \"any\" } };\n    const res = createMockRes();\n\n    await servicesProxy(req, res);\n\n    expect(res.statusCode).toBe(403);\n    expect(res.body).toEqual({ error: \"Unknown proxy service type\" });\n  });\n\n  it(\"returns 500 on unexpected errors\", async () => {\n    getServiceWidget.mockRejectedValueOnce(new Error(\"boom\"));\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", index: \"0\", endpoint: \"collections\" } };\n    const res = createMockRes();\n\n    await servicesProxy(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: \"Unexpected error\" });\n  });\n\n  it(\"returns 500 when an async proxy handler throws\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"linkwarden\" });\n    handlerFn.handler.mockRejectedValueOnce(new Error(\"proxy boom\"));\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", index: \"0\" } };\n    const res = createMockRes();\n\n    await servicesProxy(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: \"Unexpected error\" });\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/siteMonitor.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { getServiceItem, httpProxy, perf, logger } = vi.hoisted(() => ({\n  getServiceItem: vi.fn(),\n  httpProxy: vi.fn(),\n  perf: { now: vi.fn() },\n  logger: { debug: vi.fn() },\n}));\n\nvi.mock(\"perf_hooks\", () => ({\n  performance: perf,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  getServiceItem,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nimport handler from \"pages/api/siteMonitor\";\n\ndescribe(\"pages/api/siteMonitor\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"returns 400 when the service item is missing\", async () => {\n    getServiceItem.mockResolvedValueOnce(null);\n\n    const req = { query: { groupName: \"g\", serviceName: \"s\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body.error).toContain(\"Unable to find service\");\n  });\n\n  it(\"returns 400 when the monitor URL is missing\", async () => {\n    getServiceItem.mockResolvedValueOnce({ siteMonitor: \"\" });\n\n    const req = { query: { groupName: \"g\", serviceName: \"s\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body.error).toBe(\"No http monitor URL given\");\n  });\n\n  it(\"uses HEAD and returns status + latency when the response is OK\", async () => {\n    getServiceItem.mockResolvedValueOnce({ siteMonitor: \"http://example.com\" });\n    perf.now.mockReturnValueOnce(1).mockReturnValueOnce(11);\n    httpProxy.mockResolvedValueOnce([200]);\n\n    const req = { query: { groupName: \"g\", serviceName: \"s\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledWith(\"http://example.com\", { method: \"HEAD\" });\n    expect(res.statusCode).toBe(200);\n    expect(res.body.status).toBe(200);\n    expect(res.body.latency).toBe(10);\n  });\n\n  it(\"falls back to GET when HEAD is rejected\", async () => {\n    getServiceItem.mockResolvedValueOnce({ siteMonitor: \"http://example.com\" });\n    perf.now.mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValueOnce(5).mockReturnValueOnce(15);\n    httpProxy.mockResolvedValueOnce([500]).mockResolvedValueOnce([200]);\n\n    const req = { query: { groupName: \"g\", serviceName: \"s\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(httpProxy).toHaveBeenNthCalledWith(1, \"http://example.com\", { method: \"HEAD\" });\n    expect(httpProxy).toHaveBeenNthCalledWith(2, \"http://example.com\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ status: 200, latency: 10 });\n  });\n\n  it(\"returns 400 when httpProxy throws\", async () => {\n    getServiceItem.mockResolvedValueOnce({ siteMonitor: \"http://example.com\" });\n    httpProxy.mockRejectedValueOnce(new Error(\"nope\"));\n\n    const req = { query: { groupName: \"g\", serviceName: \"s\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body.error).toContain(\"Error attempting http monitor\");\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/theme.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { checkAndCopyConfig, getSettings } = vi.hoisted(() => ({\n  checkAndCopyConfig: vi.fn(),\n  getSettings: vi.fn(),\n}));\n\nvi.mock(\"utils/config/config\", () => ({\n  default: checkAndCopyConfig,\n  getSettings,\n}));\n\nimport handler from \"pages/api/theme\";\n\ndescribe(\"pages/api/theme\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"returns defaults when settings are missing\", () => {\n    getSettings.mockReturnValueOnce({});\n\n    const res = createMockRes();\n    handler({ res });\n\n    expect(checkAndCopyConfig).toHaveBeenCalledWith(\"settings.yaml\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ color: \"slate\", theme: \"dark\" });\n  });\n\n  it(\"returns configured color + theme when present\", () => {\n    getSettings.mockReturnValueOnce({ color: \"red\", theme: \"light\" });\n\n    const res = createMockRes();\n    handler({ res });\n\n    expect(res.body).toEqual({ color: \"red\", theme: \"light\" });\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/validate.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { checkAndCopyConfig } = vi.hoisted(() => ({\n  checkAndCopyConfig: vi.fn(),\n}));\n\nvi.mock(\"utils/config/config\", () => ({\n  default: checkAndCopyConfig,\n}));\n\nimport handler from \"pages/api/validate\";\n\ndescribe(\"pages/api/validate\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"returns errors for any configs that don't validate\", async () => {\n    checkAndCopyConfig.mockReturnValueOnce(true).mockReturnValueOnce(\"settings bad\").mockReturnValue(true);\n\n    const req = {};\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.body).toEqual([\"settings bad\"]);\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/widgets/glances.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { getPrivateWidgetOptions, httpProxy, logger } = vi.hoisted(() => ({\n  getPrivateWidgetOptions: vi.fn(),\n  httpProxy: vi.fn(),\n  logger: { error: vi.fn() },\n}));\n\nvi.mock(\"utils/config/widget-helpers\", () => ({\n  getPrivateWidgetOptions,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nimport handler from \"pages/api/widgets/glances\";\n\ndescribe(\"pages/api/widgets/glances\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"returns 400 when the widget URL is missing\", async () => {\n    getPrivateWidgetOptions.mockResolvedValueOnce({});\n\n    const req = { query: { index: \"0\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body.error).toBe(\"Missing Glances URL\");\n  });\n\n  it(\"returns cpu/load/mem and includes optional endpoints when requested\", async () => {\n    getPrivateWidgetOptions.mockResolvedValueOnce({ url: \"http://glances\", username: \"u\", password: \"p\" });\n\n    httpProxy\n      .mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ total: 1 }))]) // cpu\n      .mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ avg: 2 }))]) // load\n      .mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ available: 3 }))]) // mem\n      .mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify(\"1 days\"))]) // uptime\n      .mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify([{ label: \"cpu_thermal\", value: 50 }]))]) // sensors\n      .mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify([{ mnt_point: \"/\", percent: 1 }]))]); // fs\n\n    const req = { query: { index: \"0\", uptime: \"1\", cputemp: \"1\", disk: \"1\", version: \"4\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledWith(\n      \"http://glances/api/4/cpu\",\n      expect.objectContaining({\n        method: \"GET\",\n        headers: expect.objectContaining({ Authorization: expect.any(String) }),\n      }),\n    );\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({\n      cpu: { total: 1 },\n      load: { avg: 2 },\n      mem: { available: 3 },\n      uptime: \"1 days\",\n      sensors: [{ label: \"cpu_thermal\", value: 50 }],\n      fs: [{ mnt_point: \"/\", percent: 1 }],\n    });\n  });\n\n  it(\"does not call optional endpoints unless requested\", async () => {\n    getPrivateWidgetOptions.mockResolvedValueOnce({ url: \"http://glances\" });\n\n    httpProxy\n      .mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ total: 1 }))]) // cpu\n      .mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ avg: 2 }))]) // load\n      .mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ available: 3 }))]); // mem\n\n    const req = { query: { index: \"0\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(3);\n    expect(httpProxy.mock.calls[0][1].headers.Authorization).toBeUndefined();\n    expect(res.statusCode).toBe(200);\n  });\n\n  it(\"returns 400 when glances returns 401\", async () => {\n    getPrivateWidgetOptions.mockResolvedValueOnce({ url: \"http://glances\" });\n    httpProxy.mockResolvedValueOnce([401, null, Buffer.from(\"nope\")]);\n\n    const req = { query: { index: \"0\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual(expect.objectContaining({ error: expect.stringContaining(\"Authorization failure\") }));\n  });\n\n  it(\"returns 400 when glances returns a non-200 status for a downstream call\", async () => {\n    getPrivateWidgetOptions.mockResolvedValueOnce({ url: \"http://glances\" });\n\n    httpProxy\n      .mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ total: 1 }))]) // cpu\n      .mockResolvedValueOnce([500, null, Buffer.from(\"nope\")]); // load\n\n    const req = { query: { index: \"0\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual(expect.objectContaining({ error: expect.stringContaining(\"HTTP 500\") }));\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/widgets/index.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { widgetsResponse } = vi.hoisted(() => ({\n  widgetsResponse: vi.fn(),\n}));\n\nvi.mock(\"utils/config/api-response\", () => ({\n  widgetsResponse,\n}));\n\nimport handler from \"pages/api/widgets/index\";\n\ndescribe(\"pages/api/widgets/index\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"returns widgetsResponse()\", async () => {\n    widgetsResponse.mockResolvedValueOnce([{ type: \"logo\", options: {} }]);\n\n    const req = { query: {} };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.body).toEqual([{ type: \"logo\", options: {} }]);\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/widgets/kubernetes.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { kc, coreApi, metricsApi, getKubeConfig, parseCpu, parseMemory, logger } = vi.hoisted(() => {\n  const coreApi = { listNode: vi.fn() };\n  const metricsApi = { getNodeMetrics: vi.fn() };\n\n  const kc = {\n    makeApiClient: vi.fn(() => coreApi),\n  };\n\n  return {\n    kc,\n    coreApi,\n    metricsApi,\n    getKubeConfig: vi.fn(),\n    parseCpu: vi.fn(),\n    parseMemory: vi.fn(),\n    logger: { error: vi.fn(), debug: vi.fn() },\n  };\n});\n\nvi.mock(\"@kubernetes/client-node\", () => ({\n  CoreV1Api: class CoreV1Api {},\n  Metrics: class Metrics {\n    constructor() {\n      return metricsApi;\n    }\n  },\n}));\n\nvi.mock(\"utils/config/kubernetes\", () => ({\n  getKubeConfig,\n}));\n\nvi.mock(\"utils/kubernetes/utils\", () => ({\n  parseCpu,\n  parseMemory,\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nimport handler from \"pages/api/widgets/kubernetes\";\n\ndescribe(\"pages/api/widgets/kubernetes\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"returns 500 when no kube config is available\", async () => {\n    getKubeConfig.mockReturnValueOnce(null);\n\n    const req = { query: {} };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body.error).toBe(\"No kubernetes configuration\");\n  });\n\n  it(\"returns 500 when listing nodes fails\", async () => {\n    getKubeConfig.mockReturnValueOnce(kc);\n    coreApi.listNode.mockResolvedValueOnce(null);\n\n    const req = { query: {} };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body.error).toContain(\"fetching nodes\");\n  });\n\n  it(\"logs and returns 500 when listing nodes throws\", async () => {\n    getKubeConfig.mockReturnValueOnce(kc);\n    coreApi.listNode.mockRejectedValueOnce({ statusCode: 500, body: \"nope\", response: \"nope\" });\n\n    const req = { query: {} };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(logger.error).toHaveBeenCalled();\n    expect(logger.debug).toHaveBeenCalled();\n    expect(res.statusCode).toBe(500);\n    expect(res.body.error).toContain(\"fetching nodes\");\n  });\n\n  it(\"returns 500 when metrics lookup fails\", async () => {\n    getKubeConfig.mockReturnValueOnce(kc);\n    parseMemory.mockReturnValue(100);\n    coreApi.listNode.mockResolvedValueOnce({\n      items: [\n        {\n          metadata: { name: \"n1\" },\n          status: { capacity: { cpu: \"1\", memory: \"100\" }, conditions: [{ type: \"Ready\", status: \"True\" }] },\n        },\n      ],\n    });\n    metricsApi.getNodeMetrics.mockRejectedValueOnce(new Error(\"nope\"));\n\n    const req = { query: {} };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body.error).toContain(\"Error getting metrics\");\n  });\n\n  it(\"returns cluster totals and per-node usage\", async () => {\n    getKubeConfig.mockReturnValueOnce(kc);\n\n    parseMemory.mockImplementation((value) => {\n      if (value === \"100\") return 100;\n      if (value === \"50\") return 50;\n      if (value === \"30\") return 30;\n      return 0;\n    });\n    parseCpu.mockImplementation((value) => {\n      if (value === \"100m\") return 0.1;\n      if (value === \"200m\") return 0.2;\n      return 0;\n    });\n\n    coreApi.listNode.mockResolvedValueOnce({\n      items: [\n        {\n          metadata: { name: \"n1\" },\n          status: { capacity: { cpu: \"1\", memory: \"100\" }, conditions: [{ type: \"Ready\", status: \"True\" }] },\n        },\n        {\n          metadata: { name: \"n2\" },\n          status: { capacity: { cpu: \"2\", memory: \"50\" }, conditions: [{ type: \"Ready\", status: \"False\" }] },\n        },\n      ],\n    });\n\n    metricsApi.getNodeMetrics.mockResolvedValueOnce({\n      items: [\n        { metadata: { name: \"n1\" }, usage: { cpu: \"100m\", memory: \"30\" } },\n        { metadata: { name: \"n2\" }, usage: { cpu: \"200m\", memory: \"50\" } },\n      ],\n    });\n\n    const req = { query: {} };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body.cluster.cpu.total).toBe(3);\n    expect(res.body.cluster.cpu.load).toBeCloseTo(0.3);\n    expect(res.body.cluster.memory.total).toBe(150);\n    expect(res.body.nodes).toHaveLength(2);\n    expect(res.body.nodes.find((n) => n.name === \"n1\").cpu.percent).toBeCloseTo(10);\n  });\n\n  it(\"returns a metrics error when metrics contain an unexpected node name\", async () => {\n    getKubeConfig.mockReturnValueOnce(kc);\n    parseMemory.mockReturnValue(100);\n    parseCpu.mockReturnValue(0.1);\n\n    coreApi.listNode.mockResolvedValueOnce({\n      items: [\n        {\n          metadata: { name: \"n1\" },\n          status: { capacity: { cpu: \"1\", memory: \"100\" }, conditions: [{ type: \"Ready\", status: \"True\" }] },\n        },\n      ],\n    });\n    metricsApi.getNodeMetrics.mockResolvedValueOnce({\n      items: [{ metadata: { name: \"n2\" }, usage: { cpu: \"100m\", memory: \"30\" } }],\n    });\n\n    const req = { query: {} };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body.error).toContain(\"Error getting metrics\");\n    expect(logger.error).toHaveBeenCalled();\n  });\n\n  it(\"returns 500 when an unexpected error is thrown\", async () => {\n    getKubeConfig.mockImplementationOnce(() => {\n      throw new Error(\"boom\");\n    });\n\n    const req = { query: {} };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: \"unknown error\" });\n    expect(logger.error).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/widgets/longhorn.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { getSettings, httpProxy, logger } = vi.hoisted(() => ({\n  getSettings: vi.fn(),\n  httpProxy: vi.fn(),\n  logger: { error: vi.fn() },\n}));\n\nvi.mock(\"utils/config/config\", () => ({\n  getSettings,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nimport handler from \"pages/api/widgets/longhorn\";\n\ndescribe(\"pages/api/widgets/longhorn\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"returns 400 when the longhorn URL isn't configured\", async () => {\n    getSettings.mockReturnValueOnce({ providers: { longhorn: {} } });\n\n    const req = { query: {} };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body.error).toBe(\"Missing Longhorn URL\");\n  });\n\n  it(\"parses and aggregates node disk totals, including a total node\", async () => {\n    getSettings.mockReturnValueOnce({\n      providers: { longhorn: { url: \"http://lh\", username: \"u\", password: \"p\" } },\n    });\n\n    const payload = {\n      data: [\n        {\n          id: \"n1\",\n          disks: {\n            d1: { storageAvailable: 1, storageMaximum: 10, storageReserved: 2, storageScheduled: 3 },\n          },\n        },\n        {\n          id: \"n2\",\n          disks: {\n            d1: { storageAvailable: 4, storageMaximum: 20, storageReserved: 5, storageScheduled: 6 },\n            d2: { storageAvailable: 1, storageMaximum: 1, storageReserved: 1, storageScheduled: 1 },\n          },\n        },\n      ],\n    };\n\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", JSON.stringify(payload)]);\n\n    const req = { query: {} };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledWith(\n      \"http://lh/v1/nodes\",\n      expect.objectContaining({\n        method: \"GET\",\n        headers: expect.objectContaining({ Authorization: expect.any(String) }),\n      }),\n    );\n    expect(res.headers[\"Content-Type\"]).toBe(\"application/json\");\n    expect(res.statusCode).toBe(200);\n\n    const nodes = res.body.nodes;\n    expect(nodes.map((n) => n.id)).toEqual([\"n1\", \"n2\", \"total\"]);\n    expect(nodes.find((n) => n.id === \"total\")).toEqual(\n      expect.objectContaining({\n        id: \"total\",\n        available: 6,\n        maximum: 31,\n        reserved: 8,\n        scheduled: 10,\n      }),\n    );\n  });\n\n  it(\"handles nodes without disks and logs non-200 responses\", async () => {\n    getSettings.mockReturnValueOnce({ providers: { longhorn: { url: \"http://lh\" } } });\n\n    const payload = { data: [{ id: \"n1\" }] };\n    httpProxy.mockResolvedValueOnce([401, \"application/json\", JSON.stringify(payload)]);\n\n    const req = { query: {} };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(logger.error).toHaveBeenCalled();\n    expect(res.statusCode).toBe(200);\n    expect(res.body.nodes).toEqual([\n      { id: \"n1\", available: 0, maximum: 0, reserved: 0, scheduled: 0 },\n      { id: \"total\", available: 0, maximum: 0, reserved: 0, scheduled: 0 },\n    ]);\n  });\n\n  it(\"returns nodes=null when the API returns a null payload\", async () => {\n    getSettings.mockReturnValueOnce({ providers: { longhorn: { url: \"http://lh\" } } });\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", \"null\"]);\n\n    const req = { query: {} };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ nodes: null });\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/widgets/openmeteo.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { cachedRequest } = vi.hoisted(() => ({\n  cachedRequest: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  cachedRequest,\n}));\n\nimport handler from \"pages/api/widgets/openmeteo\";\n\ndescribe(\"pages/api/widgets/openmeteo\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"builds the open-meteo URL with units + timezone and calls cachedRequest\", async () => {\n    cachedRequest.mockResolvedValueOnce({ ok: true });\n\n    const req = {\n      query: { latitude: \"1\", longitude: \"2\", units: \"metric\", cache: \"5\" },\n    };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(cachedRequest).toHaveBeenCalledWith(\n      \"https://api.open-meteo.com/v1/forecast?latitude=1&longitude=2&daily=sunrise,sunset&current_weather=true&temperature_unit=celsius&timezone=auto\",\n      \"5\",\n    );\n    expect(res.body).toEqual({ ok: true });\n  });\n\n  it(\"uses the provided timezone and fahrenheit for non-metric units\", async () => {\n    cachedRequest.mockResolvedValueOnce({ ok: true });\n\n    const req = {\n      query: { latitude: \"1\", longitude: \"2\", units: \"imperial\", cache: 1, timezone: \"UTC\" },\n    };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(cachedRequest).toHaveBeenCalledWith(\n      \"https://api.open-meteo.com/v1/forecast?latitude=1&longitude=2&daily=sunrise,sunset&current_weather=true&temperature_unit=fahrenheit&timezone=UTC\",\n      1,\n    );\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/widgets/openweathermap.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { getSettings, getPrivateWidgetOptions, cachedRequest } = vi.hoisted(() => ({\n  getSettings: vi.fn(),\n  getPrivateWidgetOptions: vi.fn(),\n  cachedRequest: vi.fn(),\n}));\n\nvi.mock(\"utils/config/config\", () => ({\n  getSettings,\n}));\n\nvi.mock(\"utils/config/widget-helpers\", () => ({\n  getPrivateWidgetOptions,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  cachedRequest,\n}));\n\nimport handler from \"pages/api/widgets/openweathermap\";\n\ndescribe(\"pages/api/widgets/openweathermap\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"returns 400 when no API key and no provider are supplied\", async () => {\n    getPrivateWidgetOptions.mockResolvedValueOnce({});\n\n    const req = { query: { latitude: \"1\", longitude: \"2\", units: \"metric\", lang: \"en\", index: \"0\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Missing API key or provider\" });\n  });\n\n  it(\"returns 400 when provider doesn't match endpoint and no per-widget key exists\", async () => {\n    getPrivateWidgetOptions.mockResolvedValueOnce({});\n\n    const req = { query: { latitude: \"1\", longitude: \"2\", units: \"metric\", lang: \"en\", provider: \"weatherapi\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Invalid provider for endpoint\" });\n  });\n\n  it(\"uses key from widget options when present\", async () => {\n    getPrivateWidgetOptions.mockResolvedValueOnce({ apiKey: \"from-widget\" });\n    cachedRequest.mockResolvedValueOnce({ ok: true });\n\n    const req = {\n      query: { latitude: \"1\", longitude: \"2\", units: \"metric\", lang: \"en\", cache: \"1\", index: \"2\" },\n    };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(getPrivateWidgetOptions).toHaveBeenCalledWith(\"openweathermap\", \"2\");\n    expect(cachedRequest).toHaveBeenCalledWith(\n      \"https://api.openweathermap.org/data/2.5/weather?lat=1&lon=2&appid=from-widget&units=metric&lang=en\",\n      \"1\",\n    );\n    expect(res.body).toEqual({ ok: true });\n  });\n\n  it(\"falls back to settings provider key when provider=openweathermap\", async () => {\n    getPrivateWidgetOptions.mockResolvedValueOnce({});\n    getSettings.mockReturnValueOnce({ providers: { openweathermap: \"from-settings\" } });\n    cachedRequest.mockResolvedValueOnce({ ok: true });\n\n    const req = {\n      query: {\n        latitude: \"1\",\n        longitude: \"2\",\n        units: \"imperial\",\n        lang: \"en\",\n        provider: \"openweathermap\",\n        cache: 2,\n        index: \"0\",\n      },\n    };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(cachedRequest).toHaveBeenCalledWith(\n      \"https://api.openweathermap.org/data/2.5/weather?lat=1&lon=2&appid=from-settings&units=imperial&lang=en\",\n      2,\n    );\n    expect(res.body).toEqual({ ok: true });\n  });\n\n  it(\"returns 400 when provider=openweathermap but settings do not provide an api key\", async () => {\n    getPrivateWidgetOptions.mockResolvedValueOnce({});\n    getSettings.mockReturnValueOnce({ providers: {} });\n\n    const req = {\n      query: {\n        latitude: \"1\",\n        longitude: \"2\",\n        units: \"metric\",\n        lang: \"en\",\n        provider: \"openweathermap\",\n        cache: 1,\n        index: \"0\",\n      },\n    };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Missing API key\" });\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/widgets/resources.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { si, logger } = vi.hoisted(() => ({\n  si: {\n    currentLoad: vi.fn(),\n    fsSize: vi.fn(),\n    mem: vi.fn(),\n    cpuTemperature: vi.fn(),\n    time: vi.fn(),\n    networkStats: vi.fn(),\n    networkInterfaceDefault: vi.fn(),\n  },\n  logger: {\n    debug: vi.fn(),\n    warn: vi.fn(),\n  },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"systeminformation\", () => ({ default: si }));\n\nimport handler from \"pages/api/widgets/resources\";\n\ndescribe(\"pages/api/widgets/resources\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"returns CPU load data\", async () => {\n    si.currentLoad.mockResolvedValueOnce({ currentLoad: 12.34, avgLoad: 1.23 });\n\n    const req = { query: { type: \"cpu\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body.cpu).toEqual({ usage: 12.34, load: 1.23 });\n  });\n\n  it(\"returns 404 when requested disk target does not exist\", async () => {\n    si.fsSize.mockResolvedValueOnce([{ mount: \"/\" }]);\n\n    const req = { query: { type: \"disk\", target: \"/missing\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(404);\n    expect(res.body).toEqual({ error: \"Resource not available.\" });\n    expect(logger.warn).toHaveBeenCalled();\n  });\n\n  it(\"returns disk info for the requested mount\", async () => {\n    si.fsSize.mockResolvedValueOnce([{ mount: \"/data\", size: 1 }]);\n\n    const req = { query: { type: \"disk\", target: \"/data\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body.drive).toEqual({ mount: \"/data\", size: 1 });\n  });\n\n  it(\"returns memory, cpu temp and uptime\", async () => {\n    si.mem.mockResolvedValueOnce({ total: 10 });\n    si.cpuTemperature.mockResolvedValueOnce({ main: 50 });\n    si.time.mockResolvedValueOnce({ uptime: 123 });\n\n    const resMem = createMockRes();\n    await handler({ query: { type: \"memory\" } }, resMem);\n    expect(resMem.statusCode).toBe(200);\n    expect(resMem.body.memory).toEqual({ total: 10 });\n\n    const resTemp = createMockRes();\n    await handler({ query: { type: \"cputemp\" } }, resTemp);\n    expect(resTemp.statusCode).toBe(200);\n    expect(resTemp.body.cputemp).toEqual({ main: 50 });\n\n    const resUptime = createMockRes();\n    await handler({ query: { type: \"uptime\" } }, resUptime);\n    expect(resUptime.statusCode).toBe(200);\n    expect(resUptime.body.uptime).toBe(123);\n  });\n\n  it(\"returns 404 when requested network interface does not exist\", async () => {\n    si.networkStats.mockResolvedValueOnce([{ iface: \"en0\" }]).mockResolvedValueOnce([\n      {\n        iface: \"missing\",\n        operstate: \"unknown\",\n        rx_bytes: 0,\n        rx_dropped: 0,\n        rx_errors: 0,\n        tx_bytes: 0,\n        tx_dropped: 0,\n        tx_errors: 0,\n        rx_sec: null,\n        tx_sec: null,\n        ms: 0,\n      },\n    ]);\n\n    const req = { query: { type: \"network\", interfaceName: \"missing\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(si.networkStats).toHaveBeenNthCalledWith(1, \"*\");\n    expect(si.networkStats).toHaveBeenNthCalledWith(2, \"missing\");\n    expect(res.statusCode).toBe(404);\n    expect(res.body).toEqual({ error: \"Interface not found\" });\n  });\n\n  it(\"falls back to direct named interface query when wildcard enumeration misses it\", async () => {\n    si.networkStats.mockResolvedValueOnce([{ iface: \"eth0\", rx_bytes: 1 }]).mockResolvedValueOnce([\n      {\n        iface: \"eno1\",\n        operstate: \"up\",\n        rx_bytes: 1000,\n        rx_dropped: 0,\n        rx_errors: 0,\n        tx_bytes: 500,\n        tx_dropped: 0,\n        tx_errors: 0,\n        rx_sec: null,\n        tx_sec: null,\n        ms: 0,\n      },\n    ]);\n\n    const req = { query: { type: \"network\", interfaceName: \"eno1\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(si.networkStats).toHaveBeenNthCalledWith(1, \"*\");\n    expect(si.networkStats).toHaveBeenNthCalledWith(2, \"eno1\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body.interface).toBe(\"eno1\");\n    expect(res.body.network).toEqual({\n      iface: \"eno1\",\n      operstate: \"up\",\n      rx_bytes: 1000,\n      rx_dropped: 0,\n      rx_errors: 0,\n      tx_bytes: 500,\n      tx_dropped: 0,\n      tx_errors: 0,\n      rx_sec: null,\n      tx_sec: null,\n      ms: 0,\n    });\n  });\n\n  it(\"returns default interface network stats\", async () => {\n    si.networkStats.mockResolvedValueOnce([{ iface: \"en0\", rx_bytes: 1 }]);\n    si.networkInterfaceDefault.mockResolvedValueOnce(\"en0\");\n\n    const req = { query: { type: \"network\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body.interface).toBe(\"en0\");\n    expect(res.body.network).toEqual({ iface: \"en0\", rx_bytes: 1 });\n  });\n\n  it(\"returns 404 when the default interface cannot be found in networkStats\", async () => {\n    si.networkStats.mockResolvedValueOnce([{ iface: \"en0\", rx_bytes: 1 }]);\n    si.networkInterfaceDefault.mockResolvedValueOnce(\"en1\");\n\n    const req = { query: { type: \"network\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(404);\n    expect(res.body).toEqual({ error: \"Default interface not found\" });\n  });\n\n  it(\"returns 400 for an invalid type\", async () => {\n    const req = { query: { type: \"nope\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"invalid type\" });\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/widgets/stocks.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { getSettings, cachedRequest, logger } = vi.hoisted(() => ({\n  getSettings: vi.fn(),\n  cachedRequest: vi.fn(),\n  logger: { debug: vi.fn() },\n}));\n\nvi.mock(\"utils/config/config\", () => ({\n  getSettings,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  cachedRequest,\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nimport handler from \"pages/api/widgets/stocks\";\n\ndescribe(\"pages/api/widgets/stocks\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"validates watchlist and provider\", async () => {\n    const res1 = createMockRes();\n    await handler({ query: {} }, res1);\n    expect(res1.statusCode).toBe(400);\n\n    const res2 = createMockRes();\n    await handler({ query: { watchlist: \"null\", provider: \"finnhub\" } }, res2);\n    expect(res2.statusCode).toBe(400);\n\n    const res3 = createMockRes();\n    await handler({ query: { watchlist: \"AAPL,AAPL\", provider: \"finnhub\" } }, res3);\n    expect(res3.statusCode).toBe(400);\n    expect(res3.body.error).toContain(\"duplicates\");\n\n    const res4 = createMockRes();\n    await handler({ query: { watchlist: \"AAPL\", provider: \"nope\" } }, res4);\n    expect(res4.statusCode).toBe(400);\n    expect(res4.body.error).toContain(\"Invalid provider\");\n\n    const res5 = createMockRes();\n    await handler({ query: { watchlist: \"AAPL\" } }, res5);\n    expect(res5.statusCode).toBe(400);\n    expect(res5.body.error).toContain(\"Missing provider\");\n\n    const res6 = createMockRes();\n    await handler({ query: { watchlist: \"A,B,C,D,E,F,G,H,I\", provider: \"finnhub\" } }, res6);\n    expect(res6.statusCode).toBe(400);\n    expect(res6.body.error).toContain(\"Max items\");\n  });\n\n  it(\"returns 400 when API key isn't configured for provider\", async () => {\n    getSettings.mockReturnValueOnce({ providers: {} });\n\n    const req = { query: { watchlist: \"AAPL\", provider: \"finnhub\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body.error).toContain(\"API Key\");\n  });\n\n  it(\"tolerates missing providers config and returns a helpful error\", async () => {\n    getSettings.mockReturnValueOnce({});\n\n    const req = { query: { watchlist: \"AAPL\", provider: \"finnhub\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body.error).toContain(\"API Key\");\n  });\n\n  it(\"returns a normalized stocks response and rounds values\", async () => {\n    getSettings.mockReturnValueOnce({ providers: { finnhub: \"k\" } });\n\n    cachedRequest\n      .mockResolvedValueOnce({ c: 10.123, dp: -1.234 }) // AAPL\n      .mockResolvedValueOnce({ c: null, dp: null }); // MSFT\n\n    const req = { query: { watchlist: \"AAPL,MSFT\", provider: \"finnhub\", cache: \"1\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(cachedRequest).toHaveBeenCalledWith(\"https://finnhub.io/api/v1/quote?symbol=AAPL&token=k\", \"1\");\n    expect(res.body).toEqual({\n      stocks: [\n        { ticker: \"AAPL\", currentPrice: \"10.12\", percentChange: -1.23 },\n        { ticker: \"MSFT\", currentPrice: null, percentChange: null },\n      ],\n    });\n  });\n\n  it(\"returns null entries when the watchlist includes empty tickers\", async () => {\n    getSettings.mockReturnValueOnce({ providers: { finnhub: \"k\" } });\n    cachedRequest.mockResolvedValueOnce({ c: 1, dp: 1 });\n\n    const req = { query: { watchlist: \"AAPL,\", provider: \"finnhub\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.body.stocks[0]).toEqual({ ticker: \"AAPL\", currentPrice: \"1.00\", percentChange: 1 });\n    expect(res.body.stocks[1]).toEqual({ ticker: null, currentPrice: null, percentChange: null });\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/api/widgets/weather.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { getSettings, getPrivateWidgetOptions, cachedRequest } = vi.hoisted(() => ({\n  getSettings: vi.fn(),\n  getPrivateWidgetOptions: vi.fn(),\n  cachedRequest: vi.fn(),\n}));\n\nvi.mock(\"utils/config/config\", () => ({\n  getSettings,\n}));\n\nvi.mock(\"utils/config/widget-helpers\", () => ({\n  getPrivateWidgetOptions,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  cachedRequest,\n}));\n\nimport handler from \"pages/api/widgets/weather\";\n\ndescribe(\"pages/api/widgets/weatherapi\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"returns 400 when no API key and no provider are supplied\", async () => {\n    getPrivateWidgetOptions.mockResolvedValueOnce({});\n\n    const req = { query: { latitude: \"1\", longitude: \"2\", lang: \"en\", index: \"0\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Missing API key or provider\" });\n  });\n\n  it(\"uses key from widget options when present\", async () => {\n    getPrivateWidgetOptions.mockResolvedValueOnce({ apiKey: \"from-widget\" });\n    cachedRequest.mockResolvedValueOnce({ ok: true });\n\n    const req = { query: { latitude: \"1\", longitude: \"2\", lang: \"en\", cache: 1, index: \"0\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(cachedRequest).toHaveBeenCalledWith(\n      \"http://api.weatherapi.com/v1/current.json?q=1,2&key=from-widget&lang=en\",\n      1,\n    );\n    expect(res.body).toEqual({ ok: true });\n  });\n\n  it(\"falls back to settings provider key when provider=weatherapi\", async () => {\n    getPrivateWidgetOptions.mockResolvedValueOnce({});\n    getSettings.mockReturnValueOnce({ providers: { weatherapi: \"from-settings\" } });\n    cachedRequest.mockResolvedValueOnce({ ok: true });\n\n    const req = { query: { latitude: \"1\", longitude: \"2\", lang: \"en\", provider: \"weatherapi\", cache: \"2\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(cachedRequest).toHaveBeenCalledWith(\n      \"http://api.weatherapi.com/v1/current.json?q=1,2&key=from-settings&lang=en\",\n      \"2\",\n    );\n  });\n\n  it(\"rejects unsupported providers\", async () => {\n    getPrivateWidgetOptions.mockResolvedValueOnce({});\n\n    const req = { query: { latitude: \"1\", longitude: \"2\", lang: \"en\", provider: \"nope\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Invalid provider for endpoint\" });\n  });\n\n  it(\"returns 400 when a provider is set but no API key can be resolved\", async () => {\n    getPrivateWidgetOptions.mockResolvedValueOnce({});\n    getSettings.mockReturnValueOnce({ providers: {} });\n\n    const req = { query: { latitude: \"1\", longitude: \"2\", lang: \"en\", provider: \"weatherapi\" } };\n    const res = createMockRes();\n\n    await handler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Missing API key\" });\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/browserconfig.xml.test.js",
    "content": "import { describe, expect, it, vi } from \"vitest\";\n\nimport themes from \"utils/styles/themes\";\n\nconst { getSettings } = vi.hoisted(() => ({\n  getSettings: vi.fn(),\n}));\n\nvi.mock(\"utils/config/config\", () => ({\n  getSettings,\n}));\n\nimport BrowserConfig, { getServerSideProps } from \"pages/browserconfig.xml.jsx\";\n\nfunction createMockRes() {\n  return {\n    setHeader: vi.fn(),\n    write: vi.fn(),\n    end: vi.fn(),\n  };\n}\n\ndescribe(\"pages/browserconfig.xml\", () => {\n  it(\"writes a browserconfig xml response using the selected theme color\", async () => {\n    getSettings.mockReturnValueOnce({ color: \"slate\", theme: \"dark\" });\n    const res = createMockRes();\n\n    await getServerSideProps({ res });\n\n    expect(res.setHeader).toHaveBeenCalledWith(\"Content-Type\", \"text/xml\");\n    expect(res.end).toHaveBeenCalled();\n\n    const xml = res.write.mock.calls[0][0];\n    expect(xml).toContain('<?xml version=\"1.0\" encoding=\"utf-8\"?>');\n    expect(xml).toContain('<square150x150logo src=\"/mstile-150x150.png?v=2\"/>');\n    expect(xml).toContain(`<TileColor>${themes.slate.dark}</TileColor>`);\n  });\n\n  it(\"exports a placeholder component\", () => {\n    expect(BrowserConfig()).toBeUndefined();\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/index.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { fireEvent, render, screen, waitFor } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { ColorContext } from \"utils/contexts/color\";\nimport { SettingsContext } from \"utils/contexts/settings\";\nimport { TabContext } from \"utils/contexts/tab\";\nimport { ThemeContext } from \"utils/contexts/theme\";\n\nconst {\n  state,\n  router,\n  i18n,\n  getSettings,\n  servicesResponse,\n  bookmarksResponse,\n  widgetsResponse,\n  serverSideTranslations,\n  logger,\n  useSWR,\n  useWindowFocus,\n} = vi.hoisted(() => {\n  const state = {\n    throwIn: null,\n    validateData: [],\n    hashData: null,\n    mutateHash: vi.fn(),\n    servicesData: [],\n    bookmarksData: [],\n    widgetsData: [],\n    quickLaunchProps: null,\n    widgetCalls: [],\n    windowFocused: false,\n  };\n\n  const router = { asPath: \"/\" };\n  const i18n = { language: \"en\", changeLanguage: vi.fn() };\n\n  const getSettings = vi.fn(() => ({\n    providers: {},\n    language: \"en\",\n    title: \"Homepage\",\n  }));\n\n  const servicesResponse = vi.fn(async () => {\n    if (state.throwIn === \"services\") throw new Error(\"services failed\");\n    return [{ name: \"svc\" }];\n  });\n  const bookmarksResponse = vi.fn(async () => {\n    if (state.throwIn === \"bookmarks\") throw new Error(\"bookmarks failed\");\n    return [{ name: \"bm\" }];\n  });\n  const widgetsResponse = vi.fn(async () => {\n    if (state.throwIn === \"widgets\") throw new Error(\"widgets failed\");\n    return [{ type: \"search\" }];\n  });\n\n  const serverSideTranslations = vi.fn(async (language) => ({ _translations: language }));\n  const logger = { error: vi.fn() };\n\n  const useSWR = vi.fn((key) => {\n    if (key === \"/api/validate\") return { data: state.validateData };\n    if (key === \"/api/hash\") return { data: state.hashData, mutate: state.mutateHash };\n    if (key === \"/api/services\") return { data: state.servicesData };\n    if (key === \"/api/bookmarks\") return { data: state.bookmarksData };\n    if (key === \"/api/widgets\") return { data: state.widgetsData };\n    return { data: undefined };\n  });\n\n  const useWindowFocus = vi.fn(() => state.windowFocused);\n\n  return {\n    state,\n    router,\n    i18n,\n    getSettings,\n    servicesResponse,\n    bookmarksResponse,\n    widgetsResponse,\n    serverSideTranslations,\n    logger,\n    useSWR,\n    useWindowFocus,\n  };\n});\n\nvi.mock(\"next/dynamic\", () => ({\n  default: () => () => null,\n}));\nvi.mock(\"next/head\", () => ({ default: ({ children }) => children }));\nvi.mock(\"next/script\", () => ({ default: () => null }));\nvi.mock(\"next/router\", () => ({ useRouter: () => router }));\n\nvi.mock(\"next-i18next\", () => ({\n  useTranslation: () => ({\n    i18n,\n    t: (k) => k,\n  }),\n}));\n\nvi.mock(\"next-i18next/serverSideTranslations\", () => ({\n  serverSideTranslations,\n}));\n\nvi.mock(\"swr\", () => ({\n  default: useSWR,\n  SWRConfig: ({ children }) => children,\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/config\", () => ({\n  getSettings,\n}));\n\nvi.mock(\"utils/config/api-response\", () => ({\n  servicesResponse,\n  bookmarksResponse,\n  widgetsResponse,\n}));\n\nvi.mock(\"utils/hooks/window-focus\", () => ({\n  default: useWindowFocus,\n}));\n\nvi.mock(\"components/bookmarks/group\", () => ({\n  default: ({ bookmarks }) => <div data-testid=\"bookmarks-group\">{bookmarks?.name}</div>,\n}));\n\nvi.mock(\"components/services/group\", () => ({\n  default: ({ group }) => <div data-testid=\"services-group\">{group?.name}</div>,\n}));\n\nvi.mock(\"components/errorboundry\", () => ({\n  default: ({ children }) => <>{children}</>,\n}));\n\nvi.mock(\"components/tab\", () => ({\n  default: ({ tab }) => <li data-testid=\"tab\">{tab}</li>,\n  slugifyAndEncode: (tabName) =>\n    tabName !== undefined ? encodeURIComponent(tabName.toString().replace(/\\\\s+/g, \"-\").toLowerCase()) : \"\",\n}));\n\nvi.mock(\"components/quicklaunch\", () => ({\n  default: (props) => {\n    state.quickLaunchProps = props;\n    return (\n      <div data-testid=\"quicklaunch\">\n        {props.isOpen ? \"open\" : \"closed\"}:{props.servicesAndBookmarks?.length ?? 0}\n      </div>\n    );\n  },\n}));\n\nvi.mock(\"components/widgets/widget\", () => ({\n  default: ({ widget, style }) => {\n    state.widgetCalls.push({ widget, style });\n    return <div data-testid=\"widget\">{widget?.type}</div>;\n  },\n}));\n\nvi.mock(\"components/toggles/revalidate\", () => ({\n  default: () => null,\n}));\n\ndescribe(\"pages/index getStaticProps\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    state.throwIn = null;\n    state.validateData = [];\n    state.hashData = null;\n    state.servicesData = [];\n    state.bookmarksData = [];\n    state.widgetsData = [];\n    state.quickLaunchProps = null;\n    state.widgetCalls = [];\n    state.windowFocused = false;\n    router.asPath = \"/\";\n    i18n.changeLanguage.mockClear();\n  });\n\n  it(\"returns initial settings and api fallbacks for swr\", async () => {\n    getSettings.mockReturnValueOnce({ providers: { x: 1 }, language: \"en\", title: \"Homepage\" });\n\n    const { getStaticProps } = await import(\"pages/index.jsx\");\n    const result = await getStaticProps();\n\n    expect(result.props.initialSettings).toEqual({ language: \"en\", title: \"Homepage\" });\n    expect(result.props.fallback[\"/api/services\"]).toEqual([{ name: \"svc\" }]);\n    expect(result.props.fallback[\"/api/bookmarks\"]).toEqual([{ name: \"bm\" }]);\n    expect(result.props.fallback[\"/api/widgets\"]).toEqual([{ type: \"search\" }]);\n    expect(result.props.fallback[\"/api/hash\"]).toBe(false);\n    expect(serverSideTranslations).toHaveBeenCalledWith(\"en\");\n  });\n\n  it(\"normalizes legacy language codes before requesting translations\", async () => {\n    getSettings.mockReturnValueOnce({ providers: {}, language: \"zh-CN\" });\n\n    const { getStaticProps } = await import(\"pages/index.jsx\");\n    await getStaticProps();\n\n    expect(serverSideTranslations).toHaveBeenCalledWith(\"zh-Hans\");\n  });\n\n  it(\"falls back to empty settings and en translations on errors\", async () => {\n    getSettings.mockReturnValueOnce({ providers: {}, language: \"de\" });\n    state.throwIn = \"services\";\n\n    const { getStaticProps } = await import(\"pages/index.jsx\");\n    const result = await getStaticProps();\n\n    expect(result.props.initialSettings).toEqual({});\n    expect(result.props.fallback[\"/api/services\"]).toEqual([]);\n    expect(result.props.fallback[\"/api/bookmarks\"]).toEqual([]);\n    expect(result.props.fallback[\"/api/widgets\"]).toEqual([]);\n    expect(serverSideTranslations).toHaveBeenCalledWith(\"en\");\n    expect(logger.error).toHaveBeenCalled();\n  });\n});\n\nasync function renderIndex({\n  initialSettings = { title: \"Homepage\", layout: {} },\n  fallback = {},\n  theme = \"dark\",\n  color = \"slate\",\n  activeTab = \"\",\n  settings = initialSettings,\n} = {}) {\n  const { default: Wrapper } = await import(\"pages/index.jsx\");\n\n  const setTheme = vi.fn();\n  const setColor = vi.fn();\n  const setSettings = vi.fn();\n  const setActiveTab = vi.fn();\n\n  const renderResult = render(\n    <ThemeContext.Provider value={{ theme, setTheme }}>\n      <ColorContext.Provider value={{ color, setColor }}>\n        <SettingsContext.Provider value={{ settings, setSettings }}>\n          <TabContext.Provider value={{ activeTab, setActiveTab }}>\n            <Wrapper initialSettings={initialSettings} fallback={fallback} />\n          </TabContext.Provider>\n        </SettingsContext.Provider>\n      </ColorContext.Provider>\n    </ThemeContext.Provider>,\n  );\n\n  return { ...renderResult, setTheme, setColor, setSettings, setActiveTab };\n}\n\ndescribe(\"pages/index Wrapper\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    state.validateData = [];\n    state.hashData = null;\n    state.servicesData = [];\n    state.bookmarksData = [];\n    state.widgetsData = [];\n    state.widgetCalls = [];\n    document.documentElement.className = \"dark theme-slate\";\n  });\n\n  it(\"applies theme/color classes and renders a background overlay when configured\", async () => {\n    await renderIndex({\n      initialSettings: {\n        title: \"Homepage\",\n        color: \"slate\",\n        background: { image: \"https://example.com/bg.jpg\", opacity: 10, blur: true, saturate: 150, brightness: 125 },\n        layout: {},\n      },\n      theme: \"dark\",\n      color: \"emerald\",\n    });\n\n    await waitFor(() => {\n      expect(document.documentElement.classList.contains(\"scheme-dark\")).toBe(true);\n    });\n    expect(document.documentElement.classList.contains(\"dark\")).toBe(true);\n    expect(document.documentElement.classList.contains(\"theme-emerald\")).toBe(true);\n    expect(document.documentElement.classList.contains(\"theme-slate\")).toBe(false);\n\n    expect(document.querySelector(\"#background\")).toBeTruthy();\n    expect(document.querySelector(\"#inner_wrapper\")?.className).toContain(\"backdrop-blur\");\n    expect(document.querySelector(\"#inner_wrapper\")?.className).toContain(\"backdrop-saturate-150\");\n    expect(document.querySelector(\"#inner_wrapper\")?.className).toContain(\"backdrop-brightness-125\");\n  });\n\n  it(\"supports legacy string backgrounds in settings\", async () => {\n    await renderIndex({\n      initialSettings: {\n        title: \"Homepage\",\n        color: \"slate\",\n        background: \"https://example.com/bg.jpg\",\n        layout: {},\n      },\n      theme: \"dark\",\n      color: \"emerald\",\n    });\n\n    expect(document.querySelector(\"#background\")).toBeTruthy();\n  });\n});\n\ndescribe(\"pages/index Index routing + SWR branches\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    state.hashData = null;\n    state.mutateHash.mockClear();\n    state.servicesData = [];\n    state.bookmarksData = [];\n    state.widgetsData = [];\n  });\n\n  it(\"renders the validation error screen when /api/validate returns an error\", async () => {\n    state.validateData = { error: \"bad config\" };\n\n    await renderIndex({ initialSettings: { title: \"Homepage\", layout: {} }, settings: { layout: {} } });\n\n    expect(screen.getByText(\"Error\")).toBeInTheDocument();\n    expect(screen.getByText(\"bad config\")).toBeInTheDocument();\n  });\n\n  it(\"renders config errors when /api/validate returns a list of errors\", async () => {\n    state.validateData = [{ config: \"services.yaml\", reason: \"broken\", mark: { snippet: \"x: y\" } }];\n\n    await renderIndex({ initialSettings: { title: \"Homepage\", layout: {} }, settings: { layout: {} } });\n\n    expect(screen.getByText(\"services.yaml\")).toBeInTheDocument();\n    expect(screen.getByText(\"broken\")).toBeInTheDocument();\n    expect(screen.getByText(\"x: y\")).toBeInTheDocument();\n  });\n\n  it(\"marks the UI stale when the hash changes and triggers a revalidate reload\", async () => {\n    state.validateData = [];\n    state.hashData = { hash: \"new-hash\" };\n    localStorage.setItem(\"hash\", \"old-hash\");\n\n    const fetchSpy = vi.fn(async () => ({ ok: true }));\n\n    fetch = fetchSpy;\n\n    let reloadSpy;\n    try {\n      reloadSpy = vi.spyOn(window.location, \"reload\").mockImplementation(() => {});\n    } catch {\n      // jsdom can make window.location non-configurable in some contexts.\n      Object.defineProperty(window, \"location\", { value: { reload: vi.fn() }, writable: true });\n      reloadSpy = vi.spyOn(window.location, \"reload\").mockImplementation(() => {});\n    }\n\n    await renderIndex({ initialSettings: { title: \"Homepage\", layout: {} }, settings: { layout: {} } });\n\n    await waitFor(() => {\n      expect(fetchSpy).toHaveBeenCalledWith(\"/api/revalidate\");\n    });\n    await waitFor(() => {\n      expect(reloadSpy).toHaveBeenCalled();\n    });\n    expect(document.querySelector(\".animate-spin\")).toBeTruthy();\n  });\n\n  it(\"mutates the hash when the window regains focus\", async () => {\n    state.validateData = [];\n    state.hashData = { hash: \"h\" };\n    state.windowFocused = true;\n\n    await renderIndex({ initialSettings: { title: \"Homepage\", layout: {} }, settings: { layout: {} } });\n\n    await waitFor(() => {\n      expect(state.mutateHash).toHaveBeenCalled();\n    });\n  });\n\n  it(\"stores the initial hash in localStorage when none exists\", async () => {\n    state.validateData = [];\n    state.hashData = { hash: \"first-hash\" };\n    localStorage.removeItem(\"hash\");\n\n    await renderIndex({ initialSettings: { title: \"Homepage\", layout: {} }, settings: { layout: {} } });\n\n    await waitFor(() => {\n      expect(localStorage.getItem(\"hash\")).toBe(\"first-hash\");\n    });\n  });\n});\n\ndescribe(\"pages/index Home behavior\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    state.validateData = [];\n    state.hashData = null;\n    state.servicesData = [\n      {\n        name: \"Services\",\n        services: [{ name: \"s1\", href: \"http://svc/1\" }, { name: \"s2\" }],\n        groups: [{ name: \"Nested\", services: [{ name: \"s3\", href: \"http://svc/3\" }], groups: [] }],\n      },\n    ];\n    state.bookmarksData = [{ name: \"Bookmarks\", bookmarks: [{ name: \"b1\", href: \"http://bm/1\" }, { name: \"b2\" }] }];\n    state.widgetsData = [{ type: \"glances\" }, { type: \"search\" }];\n    state.quickLaunchProps = null;\n    state.widgetCalls = [];\n  });\n\n  it(\"passes href-bearing services and bookmarks to QuickLaunch and toggles search on keydown\", async () => {\n    await renderIndex({\n      initialSettings: { title: \"Homepage\", layout: {} },\n      settings: { title: \"Homepage\", layout: {}, language: \"en\" },\n    });\n\n    await waitFor(() => {\n      expect(state.quickLaunchProps).toBeTruthy();\n    });\n\n    expect(state.quickLaunchProps.servicesAndBookmarks.map((i) => i.name)).toEqual([\"b1\", \"s1\", \"s3\"]);\n    expect(screen.getByTestId(\"quicklaunch\")).toHaveTextContent(\"closed:3\");\n\n    fireEvent.keyDown(document.body, { key: \"a\" });\n    expect(screen.getByTestId(\"quicklaunch\")).toHaveTextContent(\"open:3\");\n\n    fireEvent.keyDown(document.body, { key: \"Escape\" });\n    expect(screen.getByTestId(\"quicklaunch\")).toHaveTextContent(\"closed:3\");\n  });\n\n  it(\"renders services and bookmark groups when present\", async () => {\n    await renderIndex({\n      initialSettings: { title: \"Homepage\", layout: {} },\n      settings: { title: \"Homepage\", layout: {}, language: \"en\" },\n    });\n\n    expect(await screen.findByTestId(\"services-group\")).toHaveTextContent(\"Services\");\n    expect(screen.getByTestId(\"bookmarks-group\")).toHaveTextContent(\"Bookmarks\");\n  });\n\n  it(\"renders tab navigation and filters groups by active tab\", async () => {\n    state.servicesData = [{ name: \"Services\", services: [], groups: [] }];\n    state.bookmarksData = [{ name: \"Bookmarks\", bookmarks: [] }];\n\n    await renderIndex({\n      initialSettings: { title: \"Homepage\", layout: { Services: { tab: \"Main\" }, Bookmarks: { tab: \"Main\" } } },\n      settings: { title: \"Homepage\", layout: { Services: { tab: \"Main\" }, Bookmarks: { tab: \"Main\" } } },\n      activeTab: \"main\",\n    });\n\n    expect(await screen.findAllByTestId(\"tab\")).toHaveLength(1);\n    expect(screen.getAllByTestId(\"services-group\")[0]).toHaveTextContent(\"Services\");\n    expect(screen.getAllByTestId(\"bookmarks-group\")[0]).toHaveTextContent(\"Bookmarks\");\n  });\n\n  it(\"waits for settings.layout to populate when it differs from initial settings\", async () => {\n    state.servicesData = [{ name: \"Services\", services: [], groups: [] }];\n    state.bookmarksData = [{ name: \"Bookmarks\", bookmarks: [] }];\n\n    await renderIndex({\n      initialSettings: { title: \"Homepage\", layout: {} },\n      // Missing layout triggers the temporary `<div />` return to avoid eager widget fetches.\n      settings: { title: \"Homepage\" },\n    });\n\n    expect(screen.queryByTestId(\"services-group\")).toBeNull();\n    expect(screen.queryByTestId(\"bookmarks-group\")).toBeNull();\n  });\n\n  it(\"applies cardBlur classes for tabs and boxed headers when configured\", async () => {\n    state.servicesData = [{ name: \"Services\", services: [], groups: [] }];\n    state.bookmarksData = [{ name: \"Bookmarks\", bookmarks: [] }];\n    state.widgetsData = [{ type: \"search\" }];\n\n    await renderIndex({\n      initialSettings: { title: \"Homepage\", layout: { Services: { tab: \"Main\" }, Bookmarks: { tab: \"Main\" } } },\n      settings: {\n        title: \"Homepage\",\n        layout: { Services: { tab: \"Main\" }, Bookmarks: { tab: \"Main\" } },\n        headerStyle: \"boxed\",\n        cardBlur: \"sm\",\n      },\n      activeTab: \"main\",\n    });\n\n    expect(document.querySelector(\"#myTab\")?.className).toContain(\"backdrop-blur-sm\");\n    expect(document.querySelector(\"#information-widgets\")?.className).toContain(\"backdrop-blur-sm\");\n  });\n\n  it(\"applies settings-driven language/theme/color updates and renders head tags\", async () => {\n    state.servicesData = [];\n    state.bookmarksData = [];\n    state.widgetsData = [];\n\n    const { setTheme, setColor, setSettings } = await renderIndex({\n      initialSettings: { title: \"Homepage\", layout: {} },\n      settings: {\n        title: \"Homepage\",\n        layout: {},\n        language: \"en\",\n        theme: \"light\",\n        color: \"emerald\",\n        disableIndexing: true,\n        base: \"/base/\",\n        favicon: \"/x.ico\",\n      },\n      theme: \"dark\",\n      color: \"slate\",\n    });\n\n    await waitFor(() => {\n      expect(setSettings).toHaveBeenCalled();\n    });\n    expect(i18n.changeLanguage).toHaveBeenCalledWith(\"en\");\n    expect(setTheme).toHaveBeenCalledWith(\"light\");\n    expect(setColor).toHaveBeenCalledWith(\"emerald\");\n\n    expect(document.querySelector('meta[name=\"robots\"][content=\"noindex, nofollow\"]')).toBeTruthy();\n    expect(document.querySelector(\"base\")?.getAttribute(\"href\")).toBe(\"/base/\");\n    expect(document.querySelector('link[rel=\"icon\"]')?.getAttribute(\"href\")).toBe(\"/x.ico\");\n  });\n\n  it(\"marks information widgets as right-aligned for known widget types\", async () => {\n    await renderIndex({\n      initialSettings: { title: \"Homepage\", layout: {} },\n      settings: { title: \"Homepage\", layout: {}, language: \"en\" },\n    });\n\n    await waitFor(() => {\n      expect(state.widgetCalls.length).toBeGreaterThan(0);\n    });\n\n    const rightAligned = state.widgetCalls.filter((c) => c.style?.isRightAligned).map((c) => c.widget.type);\n    expect(rightAligned).toEqual([\"search\"]);\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/robots.txt.test.js",
    "content": "import { describe, expect, it, vi } from \"vitest\";\n\nconst { getSettings } = vi.hoisted(() => ({\n  getSettings: vi.fn(),\n}));\n\nvi.mock(\"utils/config/config\", () => ({\n  getSettings,\n}));\n\nimport RobotsTxt, { getServerSideProps } from \"pages/robots.txt.js\";\n\nfunction createMockRes() {\n  return {\n    setHeader: vi.fn(),\n    write: vi.fn(),\n    end: vi.fn(),\n  };\n}\n\ndescribe(\"pages/robots.txt\", () => {\n  it(\"allows indexing when disableIndexing is falsey\", async () => {\n    getSettings.mockReturnValueOnce({ disableIndexing: false });\n    const res = createMockRes();\n\n    await getServerSideProps({ res });\n\n    expect(res.setHeader).toHaveBeenCalledWith(\"Content-Type\", \"text/plain\");\n    expect(res.write).toHaveBeenCalledWith(\"User-agent: *\\nAllow: /\");\n    expect(res.end).toHaveBeenCalled();\n  });\n\n  it(\"disallows indexing when disableIndexing is truthy\", async () => {\n    getSettings.mockReturnValueOnce({ disableIndexing: true });\n    const res = createMockRes();\n\n    await getServerSideProps({ res });\n\n    expect(res.write).toHaveBeenCalledWith(\"User-agent: *\\nDisallow: /\");\n  });\n\n  it(\"exports a placeholder component\", () => {\n    expect(RobotsTxt()).toBeNull();\n  });\n});\n"
  },
  {
    "path": "src/__tests__/pages/site.webmanifest.test.js",
    "content": "import { describe, expect, it, vi } from \"vitest\";\n\nimport themes from \"utils/styles/themes\";\n\nconst { checkAndCopyConfig, getSettings } = vi.hoisted(() => ({\n  checkAndCopyConfig: vi.fn(),\n  getSettings: vi.fn(),\n}));\n\nvi.mock(\"utils/config/config\", () => ({\n  default: checkAndCopyConfig,\n  getSettings,\n}));\n\nimport Webmanifest, { getServerSideProps } from \"pages/site.webmanifest.jsx\";\n\nfunction createMockRes() {\n  return {\n    setHeader: vi.fn(),\n    write: vi.fn(),\n    end: vi.fn(),\n  };\n}\n\ndescribe(\"pages/site.webmanifest\", () => {\n  it(\"writes a manifest json response and triggers a settings config check\", async () => {\n    getSettings.mockReturnValueOnce({\n      title: \"My Homepage\",\n      startUrl: \"/start\",\n      color: \"slate\",\n      theme: \"dark\",\n      pwa: {\n        icons: [{ src: \"/i.png\", sizes: \"1x1\", type: \"image/png\" }],\n        shortcuts: [{ name: \"One\", url: \"/one\" }],\n      },\n    });\n\n    const res = createMockRes();\n\n    await getServerSideProps({ res });\n\n    expect(checkAndCopyConfig).toHaveBeenCalledWith(\"settings.yaml\");\n    expect(res.setHeader).toHaveBeenCalledWith(\"Content-Type\", \"application/manifest+json\");\n    expect(res.end).toHaveBeenCalled();\n\n    const manifest = JSON.parse(res.write.mock.calls[0][0]);\n    expect(manifest.name).toBe(\"My Homepage\");\n    expect(manifest.short_name).toBe(\"My Homepage\");\n    expect(manifest.start_url).toBe(\"/start\");\n    expect(manifest.icons).toEqual([{ src: \"/i.png\", sizes: \"1x1\", type: \"image/png\" }]);\n    expect(manifest.shortcuts).toEqual([{ name: \"One\", url: \"/one\" }]);\n    expect(manifest.theme_color).toBe(themes.slate.dark);\n    expect(manifest.background_color).toBe(themes.slate.dark);\n  });\n\n  it(\"uses sensible defaults when no settings are provided\", async () => {\n    getSettings.mockReturnValueOnce({});\n\n    const res = createMockRes();\n\n    await getServerSideProps({ res });\n\n    const manifest = JSON.parse(res.write.mock.calls[0][0]);\n    expect(manifest.name).toBe(\"Homepage\");\n    expect(manifest.short_name).toBe(\"Homepage\");\n    expect(manifest.start_url).toBe(\"/\");\n    expect(manifest.display).toBe(\"standalone\");\n    expect(manifest.theme_color).toBe(themes.slate.dark);\n    expect(manifest.background_color).toBe(themes.slate.dark);\n\n    // Default icon set is used when pwa.icons is not set.\n    expect(manifest.icons).toEqual(\n      expect.arrayContaining([\n        expect.objectContaining({ src: expect.stringContaining(\"android-chrome-192x192\") }),\n        expect.objectContaining({ src: expect.stringContaining(\"android-chrome-512x512\") }),\n      ]),\n    );\n  });\n\n  it(\"respects provided pwa.icons even when it is an empty array\", async () => {\n    getSettings.mockReturnValueOnce({\n      pwa: { icons: [] },\n    });\n\n    const res = createMockRes();\n\n    await getServerSideProps({ res });\n\n    const manifest = JSON.parse(res.write.mock.calls[0][0]);\n    expect(manifest.icons).toEqual([]);\n  });\n\n  it(\"exports a placeholder component\", () => {\n    expect(Webmanifest()).toBeNull();\n  });\n});\n"
  },
  {
    "path": "src/components/bookmarks/group.jsx",
    "content": "import { Disclosure, Transition } from \"@headlessui/react\";\nimport classNames from \"classnames\";\nimport List from \"components/bookmarks/list\";\nimport ErrorBoundary from \"components/errorboundry\";\nimport ResolvedIcon from \"components/resolvedicon\";\nimport { useEffect, useRef } from \"react\";\nimport { MdKeyboardArrowDown } from \"react-icons/md\";\n\nexport default function BookmarksGroup({\n  bookmarks,\n  layout,\n  disableCollapse,\n  groupsInitiallyCollapsed,\n  bookmarksStyle,\n  maxGroupColumns,\n}) {\n  const panel = useRef();\n\n  useEffect(() => {\n    if (layout?.initiallyCollapsed ?? groupsInitiallyCollapsed) panel.current.style.height = `0`;\n  }, [layout, groupsInitiallyCollapsed]);\n\n  return (\n    <div\n      key={bookmarks.name}\n      className={classNames(\n        \"bookmark-group flex-1 overflow-hidden\",\n        layout?.style === \"row\" ? \"basis-full\" : \"basis-full md:basis-1/4 lg:basis-1/5 xl:basis-1/6\",\n        layout?.style !== \"row\" && maxGroupColumns && parseInt(maxGroupColumns, 10) > 6\n          ? `3xl:basis-1/${maxGroupColumns}`\n          : \"\",\n        layout?.header === false ? \"px-1\" : \"p-1 pb-0\",\n      )}\n    >\n      <Disclosure defaultOpen={!(layout?.initiallyCollapsed ?? groupsInitiallyCollapsed)}>\n        {({ open }) => (\n          <>\n            {layout?.header !== false && (\n              <Disclosure.Button disabled={disableCollapse} className=\"flex w-full select-none items-center group\">\n                {layout?.icon && (\n                  <div className=\"shrink-0 mr-2 w-7 h-7 bookmark-group-icon\">\n                    <ResolvedIcon icon={layout.icon} />\n                  </div>\n                )}\n                <h2 className=\"text-theme-800 dark:text-theme-300 text-xl font-medium bookmark-group-name\">\n                  {bookmarks.name}\n                </h2>\n                <MdKeyboardArrowDown\n                  className={classNames(\n                    disableCollapse ? \"hidden\" : \"\",\n                    \"transition-all opacity-0 group-hover:opacity-100 ml-auto text-theme-800 dark:text-theme-300 text-xl\",\n                    open ? \"\" : \"rotate-180\",\n                  )}\n                />\n              </Disclosure.Button>\n            )}\n            <Transition\n              // Otherwise the transition group does display: none and cancels animation\n              className=\"block!\"\n              unmount={false}\n              beforeLeave={() => {\n                panel.current.style.height = `${panel.current.scrollHeight}px`;\n                setTimeout(() => {\n                  panel.current.style.height = `0`;\n                }, 1);\n              }}\n              beforeEnter={() => {\n                panel.current.style.height = `0px`;\n                setTimeout(() => {\n                  panel.current.style.height = `${panel.current.scrollHeight}px`;\n                }, 1);\n              }}\n            >\n              <Disclosure.Panel className=\"transition-all overflow-hidden duration-300 ease-out\" ref={panel} static>\n                <ErrorBoundary>\n                  <List bookmarks={bookmarks.bookmarks} layout={layout} bookmarksStyle={bookmarksStyle} />\n                </ErrorBoundary>\n              </Disclosure.Panel>\n            </Transition>\n          </>\n        )}\n      </Disclosure>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/bookmarks/group.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen, waitFor } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nvi.mock(\"@headlessui/react\", async () => {\n  const React = await import(\"react\");\n  const { Fragment } = React;\n\n  function Transition({ as: As = Fragment, children }) {\n    if (As === Fragment) return <>{children}</>;\n    return <As>{children}</As>;\n  }\n\n  function Disclosure({ defaultOpen = true, children }) {\n    const content = typeof children === \"function\" ? children({ open: defaultOpen }) : children;\n    return <div>{content}</div>;\n  }\n\n  function DisclosureButton(props) {\n    return <button type=\"button\" {...props} />;\n  }\n\n  const DisclosurePanel = React.forwardRef(function DisclosurePanel(props, ref) {\n    // HeadlessUI uses a boolean `static` prop; avoid forwarding it to the DOM.\n    const { static: _static, ...rest } = props;\n    return <div ref={ref} data-testid=\"disclosure-panel\" {...rest} />;\n  });\n\n  Disclosure.Button = DisclosureButton;\n  Disclosure.Panel = DisclosurePanel;\n\n  return { Disclosure, Transition };\n});\n\nvi.mock(\"components/bookmarks/list\", () => ({\n  default: function BookmarksListMock({ bookmarks }) {\n    return <div data-testid=\"bookmarks-list\">count:{bookmarks?.length ?? 0}</div>;\n  },\n}));\n\nvi.mock(\"components/errorboundry\", () => ({\n  default: function ErrorBoundaryMock({ children }) {\n    return <>{children}</>;\n  },\n}));\n\nvi.mock(\"components/resolvedicon\", () => ({\n  default: function ResolvedIconMock() {\n    return <div data-testid=\"resolved-icon\" />;\n  },\n}));\n\nimport BookmarksGroup from \"./group\";\n\ndescribe(\"components/bookmarks/group\", () => {\n  it(\"renders the group header and list\", () => {\n    render(\n      <BookmarksGroup\n        bookmarks={{ name: \"Bookmarks\", bookmarks: [{ name: \"A\" }] }}\n        layout={{ icon: \"mdi:test\" }}\n        disableCollapse={false}\n        groupsInitiallyCollapsed={false}\n      />,\n    );\n\n    expect(screen.getByText(\"Bookmarks\")).toBeInTheDocument();\n    expect(screen.getByTestId(\"resolved-icon\")).toBeInTheDocument();\n    expect(screen.getByTestId(\"bookmarks-list\")).toHaveTextContent(\"count:1\");\n  });\n\n  it(\"sets the panel height to 0 when initially collapsed\", async () => {\n    render(\n      <BookmarksGroup\n        bookmarks={{ name: \"Bookmarks\", bookmarks: [] }}\n        layout={{ initiallyCollapsed: true }}\n        groupsInitiallyCollapsed={false}\n      />,\n    );\n\n    const panel = screen.getByTestId(\"disclosure-panel\");\n    await waitFor(() => {\n      expect(panel.style.height).toBe(\"0px\");\n    });\n  });\n});\n"
  },
  {
    "path": "src/components/bookmarks/group.transition.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { act, render, screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nvi.mock(\"@headlessui/react\", async () => {\n  const React = await import(\"react\");\n  const { Fragment, useEffect } = React;\n\n  function Transition({ as: As = Fragment, beforeEnter, beforeLeave, children }) {\n    useEffect(() => {\n      beforeEnter?.();\n      setTimeout(() => beforeLeave?.(), 200);\n    }, [beforeEnter, beforeLeave]);\n\n    if (As === Fragment) return <>{children}</>;\n    return <As>{children}</As>;\n  }\n\n  function Disclosure({ defaultOpen = true, children }) {\n    const content = typeof children === \"function\" ? children({ open: defaultOpen }) : children;\n    return <div>{content}</div>;\n  }\n\n  function DisclosureButton(props) {\n    return <button type=\"button\" {...props} />;\n  }\n\n  const DisclosurePanel = React.forwardRef(function DisclosurePanel(props, ref) {\n    const { static: _static, ...rest } = props;\n    return (\n      <div\n        ref={(node) => {\n          if (node) Object.defineProperty(node, \"scrollHeight\", { value: 50, configurable: true });\n          if (typeof ref === \"function\") ref(node);\n          else if (ref) ref.current = node;\n        }}\n        data-testid=\"disclosure-panel\"\n        {...rest}\n      />\n    );\n  });\n\n  Disclosure.Button = DisclosureButton;\n  Disclosure.Panel = DisclosurePanel;\n\n  return { Disclosure, Transition };\n});\n\nvi.mock(\"components/bookmarks/list\", () => ({\n  default: function BookmarksListMock() {\n    return <div data-testid=\"bookmarks-list\" />;\n  },\n}));\n\nvi.mock(\"components/errorboundry\", () => ({\n  default: function ErrorBoundaryMock({ children }) {\n    return <>{children}</>;\n  },\n}));\n\nvi.mock(\"components/resolvedicon\", () => ({\n  default: function ResolvedIconMock() {\n    return <div data-testid=\"resolved-icon\" />;\n  },\n}));\n\nimport BookmarksGroup from \"./group\";\n\ndescribe(\"components/bookmarks/group transition hooks\", () => {\n  it(\"runs the Transition beforeEnter/beforeLeave height calculations and applies maxGroupColumns\", async () => {\n    vi.useFakeTimers();\n\n    render(\n      <BookmarksGroup\n        bookmarks={{ name: \"Bookmarks\", bookmarks: [] }}\n        layout={{ initiallyCollapsed: false }}\n        groupsInitiallyCollapsed={false}\n        maxGroupColumns=\"7\"\n      />,\n    );\n\n    const wrapper = screen.getByText(\"Bookmarks\").closest(\".bookmark-group\");\n    expect(wrapper?.className).toContain(\"3xl:basis-1/7\");\n\n    const panel = screen.getByTestId(\"disclosure-panel\");\n    await act(async () => {\n      vi.runAllTimers();\n    });\n\n    expect(panel.style.height).toBe(\"0px\");\n\n    vi.useRealTimers();\n  });\n});\n"
  },
  {
    "path": "src/components/bookmarks/item.jsx",
    "content": "import classNames from \"classnames\";\nimport ResolvedIcon from \"components/resolvedicon\";\nimport { useContext } from \"react\";\nimport { SettingsContext } from \"utils/contexts/settings\";\n\nexport default function Item({ bookmark, iconOnly = false }) {\n  const description = bookmark.description ?? new URL(bookmark.href).hostname;\n  const { settings } = useContext(SettingsContext);\n\n  return (\n    <li\n      key={bookmark.name}\n      id={bookmark.id}\n      className={classNames(\"bookmark\", iconOnly && \"grid\")}\n      data-name={bookmark.name}\n    >\n      <a\n        href={bookmark.href}\n        title={bookmark.name}\n        rel=\"noreferrer\"\n        target={bookmark.target ?? settings.target ?? \"_blank\"}\n        className={classNames(\n          settings.cardBlur !== undefined && `backdrop-blur${settings.cardBlur.length ? \"-\" : \"\"}${settings.cardBlur}`,\n          \"text-left cursor-pointer transition-all rounded-md font-medium text-theme-700 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 bg-theme-100/20 hover:bg-theme-300/20 dark:bg-white/5 dark:hover:bg-white/10\",\n          iconOnly ? \"h-[60px] w-[60px] grid\" : \"block w-full mb-3\",\n        )}\n      >\n        {iconOnly ? (\n          <div className=\"flex items-center justify-center text-theme-700 hover:text-theme-700 dark:text-theme-200 text-xl font-medium rounded-md bookmark-icon py-0.5\">\n            {bookmark.icon && (\n              <div className=\"w-7 h-7\">\n                <ResolvedIcon icon={bookmark.icon} alt={bookmark.abbr} />\n              </div>\n            )}\n            {!bookmark.icon && bookmark.abbr}\n          </div>\n        ) : (\n          <div className=\"flex\">\n            <div className=\"shrink-0 flex items-center justify-center w-11 bg-theme-500/10 dark:bg-theme-900/50 text-theme-700 hover:text-theme-700 dark:text-theme-200 text-sm font-medium rounded-l-md bookmark-icon\">\n              {bookmark.icon && (\n                <div className=\"shrink-0 w-5 h-5\">\n                  <ResolvedIcon icon={bookmark.icon} alt={bookmark.abbr} />\n                </div>\n              )}\n              {!bookmark.icon && bookmark.abbr}\n            </div>\n            <div className=\"flex-1 overflow-hidden flex items-center justify-between rounded-r-md bookmark-text\">\n              <div className=\"pl-3 py-2 text-xs bookmark-name\">{bookmark.name}</div>\n              <div className=\"shrink truncate px-2 py-2 text-theme-500 dark:text-theme-300 text-xs bookmark-description\">\n                {description}\n              </div>\n            </div>\n          </div>\n        )}\n      </a>\n    </li>\n  );\n}\n"
  },
  {
    "path": "src/components/bookmarks/item.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nvi.mock(\"components/resolvedicon\", () => ({\n  default: ({ icon }) => <div data-testid=\"resolved-icon\" data-icon={icon} />,\n}));\n\nimport Item from \"./item\";\n\ndescribe(\"components/bookmarks/item\", () => {\n  it(\"falls back description to href hostname and uses settings.target\", () => {\n    renderWithProviders(<Item bookmark={{ name: \"A\", href: \"http://example.com/x\", abbr: \"A\" }} iconOnly={false} />, {\n      settings: { target: \"_self\", cardBlur: \"\" },\n    });\n\n    expect(screen.getByText(\"example.com\")).toBeInTheDocument();\n    expect(screen.getByRole(\"link\").getAttribute(\"target\")).toBe(\"_self\");\n  });\n\n  it(\"renders icon-only layout with icon when provided\", () => {\n    renderWithProviders(\n      <Item bookmark={{ name: \"A\", href: \"http://example.com/x\", abbr: \"A\", icon: \"mdi-home\" }} iconOnly />,\n      { settings: { target: \"_self\" } },\n    );\n\n    expect(screen.getByTestId(\"resolved-icon\").getAttribute(\"data-icon\")).toBe(\"mdi-home\");\n  });\n\n  it(\"renders the non-icon-only layout with an icon when provided\", () => {\n    renderWithProviders(\n      <Item bookmark={{ name: \"A\", href: \"http://example.com/x\", abbr: \"A\", icon: \"mdi-home\" }} iconOnly={false} />,\n      { settings: { target: \"_self\", cardBlur: \"\" } },\n    );\n\n    expect(screen.getByTestId(\"resolved-icon\").getAttribute(\"data-icon\")).toBe(\"mdi-home\");\n  });\n});\n"
  },
  {
    "path": "src/components/bookmarks/list.jsx",
    "content": "import classNames from \"classnames\";\nimport Item from \"components/bookmarks/item\";\n\nimport { columnMap } from \"../../utils/layout/columns\";\n\nexport default function List({ bookmarks, layout, bookmarksStyle }) {\n  let classes = layout?.style === \"row\" ? `grid ${columnMap[layout?.columns]} gap-x-2` : \"flex flex-col bookmark-list\";\n  const style = {};\n  if (layout?.iconsOnly || bookmarksStyle === \"icons\") {\n    classes = \"grid gap-2 bookmark-list\";\n    style.gridTemplateColumns = \"repeat(auto-fill, minmax(60px, 1fr))\";\n  }\n  return (\n    <ul className={classNames(classes, \"mb-2\", layout?.header === false ? \"\" : \"mt-3\")} style={style}>\n      {bookmarks.map((bookmark) => (\n        <Item\n          key={`${bookmark.name}-${bookmark.href}`}\n          bookmark={bookmark}\n          iconOnly={layout?.iconsOnly || bookmarksStyle === \"icons\"}\n        />\n      ))}\n    </ul>\n  );\n}\n"
  },
  {
    "path": "src/components/bookmarks/list.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { Item } = vi.hoisted(() => ({\n  Item: vi.fn(({ bookmark, iconOnly }) => (\n    <li data-testid=\"bookmark-item\" data-name={bookmark.name} data-icononly={String(iconOnly)} />\n  )),\n}));\n\nvi.mock(\"components/bookmarks/item\", () => ({\n  default: Item,\n}));\n\nimport List from \"./list\";\n\ndescribe(\"components/bookmarks/list\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders items with iconOnly when iconsOnly is set\", () => {\n    render(<List bookmarks={[{ name: \"A\", href: \"http://a\" }]} layout={{ iconsOnly: true }} bookmarksStyle=\"text\" />);\n\n    expect(Item).toHaveBeenCalled();\n    expect(Item.mock.calls[0][0].iconOnly).toBe(true);\n  });\n\n  it(\"applies gridTemplateColumns in icons style\", () => {\n    const { container } = render(\n      <List bookmarks={[{ name: \"A\", href: \"http://a\" }]} layout={{ header: false }} bookmarksStyle=\"icons\" />,\n    );\n\n    const ul = container.querySelector(\"ul\");\n    expect(ul.style.gridTemplateColumns).toContain(\"minmax(60px\");\n  });\n});\n"
  },
  {
    "path": "src/components/errorboundry.jsx",
    "content": "import React from \"react\";\n\nexport default class ErrorBoundary extends React.Component {\n  constructor(props) {\n    super(props);\n    this.state = { error: null, errorInfo: null };\n  }\n\n  componentDidCatch(error, errorInfo) {\n    // Catch errors in any components below and re-render with error message\n    this.setState({\n      error,\n      errorInfo,\n    });\n\n    // You can also log error messages to an error reporting service here\n    if (error || errorInfo) {\n      // eslint-disable-next-line no-console\n      console.error(\"component error: %s, info: %s\", error, errorInfo);\n    }\n  }\n\n  render() {\n    const { error, errorInfo } = this.state;\n    if (errorInfo) {\n      // Error path\n      return (\n        <div className=\"inline-block text-sm bg-rose-100 text-rose-900 dark:bg-rose-900 dark:text-rose-100 rounded-md p-2 m-1\">\n          <div className=\"font-medium mb-1\">Something went wrong.</div>\n          <details className=\"text-xs font-mono whitespace-pre\">\n            <summary>{error && error.toString()}</summary>\n            {errorInfo.componentStack}\n          </details>\n        </div>\n      );\n    }\n\n    // Normally, just render children\n    const { children } = this.props;\n    return children;\n  }\n}\n"
  },
  {
    "path": "src/components/errorboundry.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport ErrorBoundary from \"./errorboundry\";\n\ndescribe(\"components/errorboundry\", () => {\n  it(\"renders children when no error is thrown\", () => {\n    render(\n      <ErrorBoundary>\n        <div>ok</div>\n      </ErrorBoundary>,\n    );\n\n    expect(screen.getByText(\"ok\")).toBeInTheDocument();\n  });\n\n  it(\"renders a fallback UI when a child throws\", () => {\n    const consoleSpy = vi.spyOn(console, \"error\").mockImplementation(() => {});\n    try {\n      const Boom = () => {\n        throw new Error(\"boom\");\n      };\n\n      render(\n        <ErrorBoundary>\n          <Boom />\n        </ErrorBoundary>,\n      );\n\n      expect(screen.getByText(\"Something went wrong.\")).toBeInTheDocument();\n      expect(screen.getByText(\"Error: boom\")).toBeInTheDocument();\n    } finally {\n      consoleSpy.mockRestore();\n    }\n  });\n});\n"
  },
  {
    "path": "src/components/favicon.jsx",
    "content": "/* eslint-disable @next/next/no-img-element */\n/* eslint-disable jsx-a11y/alt-text */\nimport { useContext, useEffect, useRef } from \"react\";\nimport { ColorContext } from \"utils/contexts/color\";\n\nimport themes from \"utils/styles/themes\";\n\nexport function Svg({ svgRef = null }) {\n  const { color } = useContext(ColorContext);\n\n  const { iconStart, iconEnd } = themes[color];\n\n  return (\n    <svg\n      ref={svgRef}\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 1024 1024\"\n      style={{\n        enableBackground: \"new 0 0 1024 1024\",\n      }}\n      xmlSpace=\"preserve\"\n      className=\"w-full h-full\"\n    >\n      <style>\n        {\n          \".st0{display:none}.st3{stroke-linecap:square}.st3,.st4{fill:none;stroke:#fff;stroke-miterlimit:10}.st6{display:inline;fill:#333}.st7{fill:#fff}\"\n        }\n      </style>\n      <g id=\"Icon\">\n        <path\n          d=\"M771.9 191c27.7 0 50.1 26.5 50.1 59.3v186.4l-100.2.3V250.3c0-32.8 22.4-59.3 50.1-59.3z\"\n          style={{\n            fill: iconStart,\n          }}\n        />\n        <linearGradient\n          id=\"homepage_favicon_gradient\"\n          gradientUnits=\"userSpaceOnUse\"\n          x1={200.746}\n          y1={225.015}\n          x2={764.986}\n          y2={789.255}\n        >\n          <stop\n            offset={0}\n            style={{\n              stopColor: iconStart,\n            }}\n          />\n          <stop\n            offset={1}\n            style={{\n              stopColor: iconEnd,\n            }}\n          />\n        </linearGradient>\n        <path\n          d=\"M721.8 250.3c0-32.7 22.4-59.3 50.1-59.3H253.1c-27.7 0-50.1 26.5-50.1 59.3v582.2l90.2-75.7-.1-130.3H375v61.8l88-73.8 258.8 217.9V250.6\"\n          style={{\n            fill: \"url(#homepage_favicon_gradient})\",\n          }}\n        />\n      </g>\n    </svg>\n  );\n}\n\nexport default function Favicon() {\n  const svgRef = useRef();\n  const imgRef = useRef();\n  const canvasRef = useRef();\n\n  useEffect(() => {\n    const svg = svgRef.current;\n    const img = imgRef.current;\n    const canvas = canvasRef.current;\n\n    if (!svg || !img || !canvas) {\n      return;\n    }\n\n    const xml = new XMLSerializer().serializeToString(svg);\n\n    const svg64 = Buffer.from(xml).toString(\"base64\");\n    const b64Start = \"data:image/svg+xml;base64,\";\n\n    // prepend a \"header\"\n    const image64 = b64Start + svg64;\n\n    // set it as the source of the img element\n    img.onload = () => {\n      // draw the image onto the canvas\n      canvas.getContext(\"2d\").drawImage(img, 0, 0);\n      // canvas.width = 256;\n      // canvas.height = 256;\n\n      const link = window.document.createElement(\"link\");\n      link.type = \"image/x-icon\";\n      link.rel = \"shortcut icon\";\n      link.href = canvas.toDataURL(\"image/x-icon\");\n      document.getElementsByTagName(\"head\")[0].appendChild(link);\n    };\n\n    img.src = image64;\n  }, []);\n\n  return (\n    <div className=\"hidden\">\n      <Svg svgRef={svgRef} />\n      <img width={64} height={64} ref={imgRef} />\n      <canvas width={64} height={64} ref={canvasRef} />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/favicon.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, waitFor } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { ColorContext } from \"utils/contexts/color\";\n\nimport Favicon from \"./favicon\";\n\ndescribe(\"components/favicon\", () => {\n  beforeEach(() => {\n    document.head.querySelectorAll('link[rel=\"shortcut icon\"]').forEach((el) => el.remove());\n  });\n\n  it(\"appends a shortcut icon link after rendering the SVG to canvas\", async () => {\n    const drawImage = vi.fn();\n    const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, \"getContext\").mockReturnValue({ drawImage });\n    const toDataURLSpy = vi\n      .spyOn(HTMLCanvasElement.prototype, \"toDataURL\")\n      .mockReturnValue(\"data:image/x-icon;base64,AAA\");\n\n    const { container } = render(\n      <ColorContext.Provider value={{ color: \"slate\", setColor: vi.fn() }}>\n        <Favicon />\n      </ColorContext.Provider>,\n    );\n\n    const img = container.querySelector(\"img\");\n    await waitFor(() => {\n      expect(typeof img.onload).toBe(\"function\");\n    });\n\n    img.onload();\n\n    const link = document.head.querySelector('link[rel=\"shortcut icon\"]');\n    expect(link).not.toBeNull();\n    expect(link.getAttribute(\"href\")).toBe(\"data:image/x-icon;base64,AAA\");\n    expect(drawImage).toHaveBeenCalled();\n\n    getContextSpy.mockRestore();\n    toDataURLSpy.mockRestore();\n  });\n\n  it(\"returns early when refs are missing (defensive guard)\", async () => {\n    vi.resetModules();\n    vi.doMock(\"react\", async () => {\n      const actual = await vi.importActual(\"react\");\n      return {\n        ...actual,\n        // Run the effect immediately to hit the defensive guard before refs are attached.\n        useEffect: (fn) => fn(),\n      };\n    });\n\n    const { ColorContext: TestColorContext } = await import(\"utils/contexts/color\");\n    const { default: FaviconWithMissingRefs } = await import(\"./favicon\");\n\n    const { container } = render(\n      <TestColorContext.Provider value={{ color: \"slate\", setColor: vi.fn() }}>\n        <FaviconWithMissingRefs />\n      </TestColorContext.Provider>,\n    );\n\n    // Allow effects to flush; the guard should prevent the icon link from being appended.\n    await waitFor(() => {\n      expect(container.querySelector(\"img\")).toBeTruthy();\n    });\n\n    expect(document.head.querySelector('link[rel=\"shortcut icon\"]')).toBeNull();\n\n    vi.unmock(\"react\");\n    vi.resetModules();\n  });\n});\n"
  },
  {
    "path": "src/components/quicklaunch.jsx",
    "content": "import classNames from \"classnames\";\nimport { useTranslation } from \"next-i18next\";\nimport { useCallback, useContext, useEffect, useRef, useState } from \"react\";\nimport { FiSearch } from \"react-icons/fi\";\nimport useSWR from \"swr\";\nimport { SettingsContext } from \"utils/contexts/settings\";\n\nimport ResolvedIcon from \"./resolvedicon\";\nimport { getStoredProvider, searchProviders } from \"./widgets/search/search\";\n\nconst MOBILE_BUTTON_POSITIONS = {\n  \"top-left\": \"top-4 left-4\",\n  \"top-right\": \"top-4 right-4\",\n  \"bottom-left\": \"bottom-4 left-4\",\n  \"bottom-right\": \"bottom-4 right-4\",\n};\n\nexport default function QuickLaunch({ servicesAndBookmarks, searchString, setSearchString, isOpen, setSearching }) {\n  const { t } = useTranslation();\n\n  const { settings } = useContext(SettingsContext);\n  const { searchDescriptions = false, hideVisitURL = false } = settings?.quicklaunch ?? {};\n\n  const searchField = useRef();\n\n  const [results, setResults] = useState([]);\n  const [currentItemIndex, setCurrentItemIndex] = useState(null);\n  const [url, setUrl] = useState(null);\n  const [searchSuggestions, setSearchSuggestions] = useState([]);\n\n  const { data: widgets } = useSWR(\"/api/widgets\");\n  const searchWidget = Object.values(widgets).find((w) => w.type === \"search\");\n\n  let searchProvider;\n\n  if (settings?.quicklaunch?.provider === \"custom\" && settings?.quicklaunch?.url?.length > 0) {\n    searchProvider = settings.quicklaunch;\n  } else if (settings?.quicklaunch?.provider && settings?.quicklaunch?.provider !== \"custom\") {\n    searchProvider = searchProviders[settings.quicklaunch.provider];\n  } else if (searchWidget) {\n    // If there is no search provider in quick launch settings, try to get it from the search widget\n    if (Array.isArray(searchWidget.options?.provider)) {\n      // If search provider is a list, try to retrieve from localstorage, fall back to the first\n      searchProvider = getStoredProvider() ?? searchProviders[searchWidget.options.provider[0]];\n    } else if (searchWidget.options?.provider === \"custom\") {\n      searchProvider = searchWidget.options;\n    } else {\n      searchProvider = searchProviders[searchWidget.options?.provider];\n    }\n  }\n\n  if (searchProvider) {\n    searchProvider.showSearchSuggestions = !!(\n      settings?.quicklaunch?.showSearchSuggestions ??\n      searchWidget?.options?.showSearchSuggestions ??\n      false\n    );\n  }\n\n  let mobileButtonPosition = settings.quicklaunch?.mobileButtonPosition\n    ? MOBILE_BUTTON_POSITIONS[settings.quicklaunch.mobileButtonPosition]\n    : null;\n\n  function openCurrentItem(newWindow) {\n    const result = results[currentItemIndex];\n    window.open(\n      result.href,\n      newWindow ? \"_blank\" : (result.target ?? searchProvider?.target ?? settings.target ?? \"_blank\"),\n      \"noreferrer\",\n    );\n  }\n\n  const closeAndReset = useCallback(() => {\n    setSearching(false);\n    setTimeout(() => {\n      setSearchString(\"\");\n      setCurrentItemIndex(null);\n      setSearchSuggestions([]);\n    }, 200); // delay a little for animations\n  }, [setSearching, setSearchString, setCurrentItemIndex, setSearchSuggestions]);\n\n  function handleSearchChange(event) {\n    const rawSearchString = event.target.value;\n    try {\n      if (!/.+[.:].+/g.test(rawSearchString)) throw new Error(); // basic test for probably a url\n      let urlString = rawSearchString;\n      if (urlString.toLowerCase().indexOf(\"http\") !== 0) urlString = `https://${rawSearchString}`;\n      setUrl(new URL(urlString)); // basic validation\n      setSearchString(rawSearchString);\n      return;\n    } catch (e) {\n      setUrl(null);\n    }\n    setSearchString(rawSearchString.toLowerCase());\n  }\n\n  function handleSearchKeyDown(event) {\n    if (!isOpen) return;\n\n    if (event.key === \"Escape\") {\n      closeAndReset();\n      event.preventDefault();\n    } else if (event.key === \"Enter\" && results.length) {\n      closeAndReset();\n      openCurrentItem(event.metaKey);\n    } else if (event.key === \"ArrowDown\" && results[currentItemIndex + 1]) {\n      setCurrentItemIndex(currentItemIndex + 1);\n      event.preventDefault();\n    } else if (event.key === \"ArrowUp\" && currentItemIndex > 0) {\n      setCurrentItemIndex(currentItemIndex - 1);\n      event.preventDefault();\n    } else if (\n      event.key === \"ArrowRight\" &&\n      results[currentItemIndex] &&\n      results[currentItemIndex].type === \"searchSuggestion\"\n    ) {\n      setSearchString(results[currentItemIndex].name);\n    }\n  }\n\n  function handleItemHover(event) {\n    setCurrentItemIndex(parseInt(event.target?.dataset?.index, 10));\n  }\n\n  function handleItemClick(event) {\n    closeAndReset();\n    openCurrentItem(event.metaKey);\n  }\n\n  function handleItemKeyDown(event) {\n    if (!isOpen) return;\n\n    // native button handles other keys\n    if (event.key === \"Escape\") {\n      closeAndReset();\n      event.preventDefault();\n    }\n  }\n\n  useEffect(() => {\n    const abortController = new AbortController();\n\n    if (searchString.trim().length === 0) setResults([]);\n    else {\n      let newResults = servicesAndBookmarks.filter((r) => {\n        const nameMatch = r.name.toLowerCase().includes(searchString);\n        let descriptionMatch;\n        if (searchDescriptions) {\n          descriptionMatch = r.description?.toLowerCase().includes(searchString);\n          r.priority = nameMatch ? 2 * +nameMatch : +descriptionMatch; // eslint-disable-line no-param-reassign\n        }\n        return nameMatch || descriptionMatch;\n      });\n\n      if (searchDescriptions) {\n        newResults = newResults.sort((a, b) => b.priority - a.priority);\n      }\n\n      if (searchProvider) {\n        newResults.push({\n          href: searchProvider.url + encodeURIComponent(searchString),\n          name: `${searchProvider.name ?? t(\"quicklaunch.custom\")} ${t(\"quicklaunch.search\")}`,\n          type: \"search\",\n        });\n\n        if (searchProvider.showSearchSuggestions && searchProvider.suggestionUrl) {\n          if (searchString.trim() !== searchSuggestions[0]?.trim()) {\n            fetch(\n              `/api/search/searchSuggestion?query=${encodeURIComponent(searchString)}&providerName=${\n                searchProvider.name ?? \"Custom\"\n              }`,\n              { signal: abortController.signal },\n            )\n              .then(async (searchSuggestionResult) => {\n                const newSearchSuggestions = await searchSuggestionResult.json();\n\n                if (newSearchSuggestions) {\n                  if (newSearchSuggestions[1].length > 4) {\n                    newSearchSuggestions[1] = newSearchSuggestions[1].splice(0, 4);\n                  }\n                  setSearchSuggestions(newSearchSuggestions);\n                }\n              })\n              .catch(() => {\n                // If there is an error, just ignore it. There just will be no search suggestions.\n              });\n          }\n\n          if (searchSuggestions[1]) {\n            newResults = newResults.concat(\n              searchSuggestions[1].map((suggestion) => ({\n                href: searchProvider.url + encodeURIComponent(suggestion),\n                name: suggestion,\n                type: \"searchSuggestion\",\n              })),\n            );\n          }\n        }\n      }\n\n      if (!hideVisitURL && url) {\n        newResults.unshift({\n          href: url.toString(),\n          name: `${t(\"quicklaunch.visit\")} URL`,\n          type: \"url\",\n        });\n      }\n\n      setResults(newResults);\n\n      if (newResults.length) {\n        setCurrentItemIndex(0);\n      }\n    }\n\n    return () => {\n      abortController.abort();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [searchString, servicesAndBookmarks, searchDescriptions, hideVisitURL, searchSuggestions, searchProvider, url]);\n\n  const [hidden, setHidden] = useState(true);\n  useEffect(() => {\n    function handleBackdropClick(event) {\n      if (event.target?.tagName === \"DIV\") closeAndReset();\n    }\n\n    if (isOpen) {\n      searchField.current.focus();\n      document.body.addEventListener(\"click\", handleBackdropClick);\n      setHidden(false);\n    } else {\n      document.body.removeEventListener(\"click\", handleBackdropClick);\n      searchField.current.blur();\n      setTimeout(() => {\n        setHidden(true);\n      }, 300); // disable on close\n    }\n  }, [isOpen, closeAndReset]);\n\n  function highlightText(text) {\n    const parts = text.split(new RegExp(`(${searchString})`, \"gi\"));\n    return (\n      <span>\n        {parts.map((part, i) =>\n          part.toLowerCase() === searchString.toLowerCase() ? (\n            // eslint-disable-next-line react/no-array-index-key\n            <span key={`${searchString}_${i}`} className=\"bg-theme-300/10\">\n              {part}\n            </span>\n          ) : (\n            part\n          ),\n        )}\n      </span>\n    );\n  }\n\n  return (\n    <>\n      <div\n        className={classNames(\n          \"relative z-40 ease-in-out duration-300 transition-opacity\",\n          hidden && !isOpen && \"hidden\",\n          !hidden && isOpen && \"opacity-100\",\n          !isOpen && \"opacity-0\",\n        )}\n        role=\"dialog\"\n        aria-modal=\"true\"\n      >\n        <div className=\"fixed inset-0 bg-gray-500 opacity-50\" />\n        <div className=\"fixed inset-0 z-20 overflow-y-auto\">\n          <div className=\"flex min-h-full min-w-full items-start justify-center text-center\">\n            <dialog className=\"mt-[10%] mx-auto min-w-[90%] max-w-[90%] md:min-w-[40%] md:max-w-[40%] rounded-md p-0 block font-medium text-theme-700 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 bg-theme-50 dark:bg-theme-800\">\n              <input\n                placeholder=\"Search\"\n                className={classNames(\n                  results.length > 0 && \"rounded-t-md\",\n                  results.length === 0 && \"rounded-md\",\n                  \"w-full p-4 m-0 border-0 border-b border-slate-700 focus:border-slate-700 focus:outline-0 focus:ring-0 text-sm md:text-xl text-theme-700 dark:text-theme-200 bg-theme-60 dark:bg-theme-800\",\n                )}\n                type=\"text\"\n                autoCorrect=\"false\"\n                ref={searchField}\n                value={searchString}\n                onChange={handleSearchChange}\n                onKeyDown={handleSearchKeyDown}\n              />\n              {results.length > 0 && (\n                <ul className=\"max-h-[60vh] overflow-y-auto m-2\">\n                  {results.map((r, i) => (\n                    <li key={[r.name, r.container, r.app, r.href].filter((s) => s).join(\"-\")}>\n                      <button\n                        type=\"button\"\n                        data-index={i}\n                        onMouseEnter={handleItemHover}\n                        onClick={handleItemClick}\n                        onKeyDown={handleItemKeyDown}\n                        className={classNames(\n                          \"flex flex-row w-full items-center justify-between rounded-md text-sm md:text-xl py-2 px-4 cursor-pointer text-theme-700 dark:text-theme-200\",\n                          i === currentItemIndex && \"bg-theme-300/50 dark:bg-theme-700/50\",\n                        )}\n                      >\n                        <div className=\"flex flex-row items-center mr-4 pointer-events-none\">\n                          {(r.icon || r.abbr) && (\n                            <div className=\"w-5 text-xs mr-4\">\n                              {r.icon && <ResolvedIcon icon={r.icon} />}\n                              {r.abbr && r.abbr}\n                            </div>\n                          )}\n                          <div className=\"flex flex-col md:flex-row text-left items-baseline mr-4 pointer-events-none\">\n                            {r.type !== \"searchSuggestion\" && <span className=\"mr-4\">{r.name}</span>}\n                            {r.type === \"searchSuggestion\" && (\n                              <div className=\"flex-nowrap\">\n                                <span className=\"whitespace-pre\">\n                                  {r.name.indexOf(searchString) === 0 ? searchString : \"\"}\n                                </span>\n                                <span className=\"whitespace-pre opacity-50\">\n                                  {r.name.indexOf(searchString) === 0 ? r.name.substring(searchString.length) : r.name}\n                                </span>\n                              </div>\n                            )}\n                            {r.description && (\n                              <span className=\"text-xs text-theme-600 text-light\">\n                                {searchDescriptions && r.priority < 2 ? highlightText(r.description) : r.description}\n                              </span>\n                            )}\n                          </div>\n                        </div>\n                        <div className=\"text-xs text-theme-600 font-bold pointer-events-none\">\n                          {t(`quicklaunch.${r.type ? r.type.toLowerCase() : \"bookmark\"}`)}\n                        </div>\n                      </button>\n                    </li>\n                  ))}\n                </ul>\n              )}\n            </dialog>\n          </div>\n        </div>\n      </div>\n      {mobileButtonPosition && (\n        <button\n          type=\"button\"\n          onClick={setSearching.bind(this, !isOpen)}\n          className={`fixed ${mobileButtonPosition} z-40 p-2 rounded-full sm:hidden text-theme-700 dark:text-theme-200 bg-theme-50 dark:bg-theme-800 shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 transition-opacity duration-100`}\n          style={{ opacity: isOpen ? 0 : 1 }}\n        >\n          <FiSearch className=\"w-4 h-4\" />\n        </button>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/quicklaunch.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { act, fireEvent, screen, waitFor } from \"@testing-library/react\";\nimport { useState } from \"react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { state, useSWR, getStoredProvider } = vi.hoisted(() => ({\n  state: {\n    widgets: {},\n  },\n  useSWR: vi.fn((key) => {\n    if (key === \"/api/widgets\") return { data: state.widgets, error: undefined };\n    return { data: undefined, error: undefined };\n  }),\n  getStoredProvider: vi.fn(() => null),\n}));\n\nvi.mock(\"swr\", () => ({\n  default: useSWR,\n}));\n\nvi.mock(\"./resolvedicon\", () => ({\n  default: function ResolvedIconMock() {\n    return <div data-testid=\"resolved-icon\" />;\n  },\n}));\n\nvi.mock(\"./widgets/search/search\", () => ({\n  getStoredProvider,\n  searchProviders: {\n    duckduckgo: {\n      name: \"DuckDuckGo\",\n      url: \"https://duckduckgo.example/?q=\",\n      suggestionUrl: \"https://duckduckgo.example/ac/?q=\",\n      target: \"_self\",\n    },\n  },\n}));\n\nimport QuickLaunch from \"./quicklaunch\";\n\nfunction Wrapper({ servicesAndBookmarks = [], initialOpen = true } = {}) {\n  const [searchString, setSearchString] = useState(\"\");\n  const [isOpen, setSearching] = useState(initialOpen);\n\n  return (\n    <QuickLaunch\n      servicesAndBookmarks={servicesAndBookmarks}\n      searchString={searchString}\n      setSearchString={setSearchString}\n      isOpen={isOpen}\n      setSearching={setSearching}\n    />\n  );\n}\n\ndescribe(\"components/quicklaunch\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    state.widgets = {};\n  });\n\n  it(\"uses a custom provider from quicklaunch settings when configured\", async () => {\n    renderWithProviders(<Wrapper />, {\n      settings: {\n        quicklaunch: {\n          provider: \"custom\",\n          name: \"MySearch\",\n          url: \"https://custom.example/?q=\",\n          showSearchSuggestions: false,\n        },\n      },\n    });\n\n    const input = screen.getByPlaceholderText(\"Search\");\n    await waitFor(() => expect(input).toHaveFocus());\n\n    fireEvent.change(input, { target: { value: \"abc\" } });\n\n    expect(await screen.findByText(\"MySearch quicklaunch.search\")).toBeInTheDocument();\n  });\n\n  it(\"uses the search widget's custom provider configuration when quicklaunch settings are not provided\", async () => {\n    state.widgets = {\n      w: {\n        type: \"search\",\n        options: { provider: \"custom\", name: \"WidgetSearch\", url: \"https://widget.example/?q=\" },\n      },\n    };\n\n    renderWithProviders(<Wrapper />, { settings: { quicklaunch: { showSearchSuggestions: false } } });\n\n    const input = screen.getByPlaceholderText(\"Search\");\n    await waitFor(() => expect(input).toHaveFocus());\n\n    fireEvent.change(input, { target: { value: \"abc\" } });\n\n    expect(await screen.findByText(\"WidgetSearch quicklaunch.search\")).toBeInTheDocument();\n  });\n\n  it(\"uses the search widget's provider setting when quicklaunch settings are not provided\", async () => {\n    state.widgets = {\n      w: {\n        type: \"search\",\n        options: { provider: \"duckduckgo\" },\n      },\n    };\n\n    renderWithProviders(<Wrapper />, { settings: { quicklaunch: { showSearchSuggestions: false } } });\n\n    const input = screen.getByPlaceholderText(\"Search\");\n    await waitFor(() => expect(input).toHaveFocus());\n\n    fireEvent.change(input, { target: { value: \"abc\" } });\n\n    expect(await screen.findByText(\"DuckDuckGo quicklaunch.search\")).toBeInTheDocument();\n  });\n\n  it(\"renders results for urls and opens the selected result on Enter\", async () => {\n    const openSpy = vi.spyOn(window, \"open\").mockImplementation(() => null);\n\n    renderWithProviders(<Wrapper />, {\n      settings: {\n        target: \"_self\",\n        quicklaunch: {\n          provider: \"duckduckgo\",\n          showSearchSuggestions: false,\n        },\n      },\n    });\n\n    const input = screen.getByPlaceholderText(\"Search\");\n    await waitFor(() => expect(input).toHaveFocus());\n\n    fireEvent.change(input, { target: { value: \"example.com\" } });\n\n    expect(await screen.findByText(\"quicklaunch.visit URL\")).toBeInTheDocument();\n    expect(screen.getByText(\"DuckDuckGo quicklaunch.search\")).toBeInTheDocument();\n\n    fireEvent.keyDown(input, { key: \"Enter\" });\n\n    await act(async () => {\n      // Close/reset schedules timeouts (200ms + 300ms); flush them to avoid state updates after cleanup.\n      await new Promise((r) => setTimeout(r, 350));\n    });\n\n    expect(openSpy).toHaveBeenCalledWith(\"https://example.com/\", \"_self\", \"noreferrer\");\n\n    openSpy.mockRestore();\n  });\n\n  it(\"closes on Escape and clears the search string after the timeout\", async () => {\n    renderWithProviders(<Wrapper />, {\n      settings: {\n        quicklaunch: {\n          provider: \"duckduckgo\",\n          showSearchSuggestions: false,\n        },\n      },\n    });\n\n    const input = screen.getByPlaceholderText(\"Search\");\n    await waitFor(() => expect(input).toHaveFocus());\n\n    fireEvent.change(input, { target: { value: \"abc\" } });\n    expect(input).toHaveValue(\"abc\");\n\n    fireEvent.keyDown(input, { key: \"Escape\" });\n\n    await act(async () => {\n      await new Promise((r) => setTimeout(r, 350));\n    });\n\n    expect(input).toHaveValue(\"\");\n  });\n\n  it(\"supports ArrowUp/ArrowDown navigation and opens a result on click\", async () => {\n    const openSpy = vi.spyOn(window, \"open\").mockImplementation(() => null);\n\n    renderWithProviders(\n      <Wrapper\n        servicesAndBookmarks={[\n          { name: \"Alpha\", href: \"https://alpha.example\", icon: \"mdi:test\" },\n          { name: \"Alpine\", href: \"https://alpine.example\" },\n        ]}\n      />,\n      { settings: { target: \"_self\", quicklaunch: { showSearchSuggestions: false } } },\n    );\n\n    const input = screen.getByPlaceholderText(\"Search\");\n    await waitFor(() => expect(input).toHaveFocus());\n\n    fireEvent.change(input, { target: { value: \"al\" } });\n\n    await waitFor(() => {\n      expect(document.querySelector('button[data-index=\"0\"]')).toBeTruthy();\n      expect(document.querySelector('button[data-index=\"1\"]')).toBeTruthy();\n    });\n\n    // Icon/abbr container renders when icon is present.\n    expect(screen.getByTestId(\"resolved-icon\")).toBeInTheDocument();\n\n    const button0 = document.querySelector('button[data-index=\"0\"]');\n    const button1 = document.querySelector('button[data-index=\"1\"]');\n    expect(button0.className).toContain(\"bg-theme-300/50\");\n\n    fireEvent.keyDown(input, { key: \"ArrowDown\" });\n    expect(button1.className).toContain(\"bg-theme-300/50\");\n\n    fireEvent.keyDown(input, { key: \"ArrowUp\" });\n    expect(button0.className).toContain(\"bg-theme-300/50\");\n\n    fireEvent.click(button0);\n\n    await act(async () => {\n      await new Promise((r) => setTimeout(r, 350));\n    });\n\n    expect(openSpy).toHaveBeenCalledWith(\"https://alpha.example\", \"_self\", \"noreferrer\");\n    openSpy.mockRestore();\n  });\n\n  it(\"handles Escape on a result button (not just the input)\", async () => {\n    renderWithProviders(<Wrapper servicesAndBookmarks={[{ name: \"Alpha\", href: \"https://alpha.example\" }]} />, {\n      settings: { quicklaunch: { showSearchSuggestions: false } },\n    });\n\n    const input = screen.getByPlaceholderText(\"Search\");\n    await waitFor(() => expect(input).toHaveFocus());\n\n    fireEvent.change(input, { target: { value: \"al\" } });\n    await waitFor(() => expect(document.querySelector('button[data-index=\"0\"]')).toBeTruthy());\n    const button0 = document.querySelector('button[data-index=\"0\"]');\n\n    button0.focus();\n    fireEvent.keyDown(button0, { key: \"Escape\" });\n\n    await act(async () => {\n      await new Promise((r) => setTimeout(r, 350));\n    });\n\n    expect(input).toHaveValue(\"\");\n  });\n\n  it(\"highlights matching description text when searchDescriptions is enabled\", async () => {\n    renderWithProviders(\n      <Wrapper\n        servicesAndBookmarks={[\n          { name: \"Unrelated\", description: \"This has MatchMe inside\", href: \"https://example.com\" },\n        ]}\n      />,\n      {\n        settings: {\n          quicklaunch: {\n            provider: \"duckduckgo\",\n            searchDescriptions: true,\n            showSearchSuggestions: false,\n          },\n        },\n      },\n    );\n\n    const input = screen.getByPlaceholderText(\"Search\");\n    await waitFor(() => expect(input).toHaveFocus());\n\n    fireEvent.change(input, { target: { value: \"matchme\" } });\n\n    // A description-only match uses highlightText (bg-theme-300/10).\n    const highlight = await screen.findByText(/matchme/i);\n    expect(highlight.closest(\"span\")?.className).toContain(\"bg-theme-300/10\");\n  });\n\n  it(\"fetches search suggestions and ArrowRight autocompletes the selected suggestion\", async () => {\n    const originalFetch = globalThis.fetch;\n    const fetchSpy = vi.fn(async () => ({\n      json: async () => [\"test\", [\"test 1\", \"test 2\", \"test 3\", \"test 4\", \"test 5\"]],\n    }));\n\n    fetch = fetchSpy;\n\n    renderWithProviders(<Wrapper />, {\n      settings: {\n        quicklaunch: {\n          provider: \"duckduckgo\",\n          showSearchSuggestions: true,\n        },\n      },\n    });\n\n    const input = screen.getByPlaceholderText(\"Search\");\n    await waitFor(() => expect(input).toHaveFocus());\n\n    fireEvent.change(input, { target: { value: \"test\" } });\n\n    // Suggestions are fetched via the API route.\n    await waitFor(() => {\n      expect(fetchSpy).toHaveBeenCalledWith(\n        expect.stringContaining(\"/api/search/searchSuggestion?query=test\"),\n        expect.objectContaining({ signal: expect.any(AbortSignal) }),\n      );\n    });\n\n    await waitFor(() => {\n      expect(screen.getAllByText(\"quicklaunch.searchsuggestion\").length).toBeGreaterThan(0);\n    });\n\n    const suggestionButton = Array.from(document.querySelectorAll(\"button\")).find((btn) =>\n      btn.textContent?.includes(\"test 1\"),\n    );\n    expect(suggestionButton).toBeTruthy();\n    fireEvent.mouseEnter(suggestionButton);\n    fireEvent.keyDown(input, { key: \"ArrowRight\" });\n\n    expect(input).toHaveValue(\"test 1\");\n\n    fetch = originalFetch;\n  });\n\n  it(\"uses the stored provider when the search widget provides a provider list\", async () => {\n    state.widgets = {\n      w: {\n        type: \"search\",\n        options: { provider: [\"duckduckgo\"] },\n      },\n    };\n    getStoredProvider.mockReturnValue({\n      name: \"StoredProvider\",\n      url: \"https://stored.example/?q=\",\n      suggestionUrl: \"https://stored.example/ac/?q=\",\n    });\n\n    renderWithProviders(<Wrapper />, { settings: { quicklaunch: { showSearchSuggestions: false } } });\n\n    const input = screen.getByPlaceholderText(\"Search\");\n    await waitFor(() => expect(input).toHaveFocus());\n\n    fireEvent.change(input, { target: { value: \"abc\" } });\n\n    expect(await screen.findByText(\"StoredProvider quicklaunch.search\")).toBeInTheDocument();\n  });\n\n  it(\"renders the mobile button when configured and opens the dialog when clicked\", async () => {\n    renderWithProviders(<Wrapper initialOpen={false} />, {\n      settings: {\n        quicklaunch: {\n          mobileButtonPosition: \"top-right\",\n          provider: \"duckduckgo\",\n        },\n      },\n    });\n\n    const mobileButton = screen.getByRole(\"button\", { name: \"\" });\n    expect(mobileButton.className).toContain(\"top-4 right-4\");\n\n    fireEvent.click(mobileButton);\n    const input = await screen.findByPlaceholderText(\"Search\");\n    await waitFor(() => expect(input).toHaveFocus());\n  });\n\n  it(\"closes when the backdrop is clicked and clears the search string after the timeout\", async () => {\n    renderWithProviders(<Wrapper />, {\n      settings: {\n        quicklaunch: {\n          provider: \"duckduckgo\",\n          showSearchSuggestions: false,\n        },\n      },\n    });\n\n    const input = screen.getByPlaceholderText(\"Search\");\n    await waitFor(() => expect(input).toHaveFocus());\n\n    fireEvent.change(input, { target: { value: \"example.com\" } });\n    expect(input).toHaveValue(\"example.com\");\n\n    // The backdrop is a DIV; clicking it should close and schedule a reset.\n    const backdrop = document.querySelector(\".fixed.inset-0.bg-gray-500.opacity-50\");\n    expect(backdrop).toBeTruthy();\n    fireEvent.click(backdrop);\n\n    await act(async () => {\n      await new Promise((r) => setTimeout(r, 350));\n    });\n\n    expect(input).toHaveValue(\"\");\n  });\n});\n"
  },
  {
    "path": "src/components/resolvedicon.jsx",
    "content": "import Image from \"next/image\";\nimport { useContext } from \"react\";\nimport { SettingsContext } from \"utils/contexts/settings\";\nimport { ThemeContext } from \"utils/contexts/theme\";\n\nconst iconSetURLs = {\n  mdi: \"https://cdn.jsdelivr.net/npm/@mdi/svg@latest/svg/\",\n  si: \"https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/\",\n};\n\nexport default function ResolvedIcon({ icon, width = 32, height = 32, alt = \"logo\" }) {\n  const { settings } = useContext(SettingsContext);\n  const { theme } = useContext(ThemeContext);\n\n  // direct or relative URLs\n  if (icon.startsWith(\"http\") || icon.startsWith(\"/\")) {\n    return (\n      <Image\n        src={`${icon}`}\n        width={width}\n        height={height}\n        style={{\n          width,\n          height,\n          objectFit: \"contain\",\n          maxHeight: \"100%\",\n          maxWidth: \"100%\",\n        }}\n        alt={alt}\n      />\n    );\n  }\n\n  // check mdi- or si- prefixed icons\n  const prefix = icon.split(\"-\")[0];\n\n  if (prefix === \"sh\") {\n    const iconName = icon.replace(\"sh-\", \"\").replace(\".svg\", \"\").replace(\".png\", \"\").replace(\".webp\", \"\");\n\n    let extension;\n    if (icon.endsWith(\".svg\")) {\n      extension = \"svg\";\n    } else if (icon.endsWith(\".webp\")) {\n      extension = \"webp\";\n    } else {\n      extension = \"png\";\n    }\n\n    return (\n      <Image\n        src={`https://cdn.jsdelivr.net/gh/selfhst/icons@main/${extension}/${iconName}.${extension}`}\n        width={width}\n        height={height}\n        style={{\n          width,\n          height,\n          objectFit: \"contain\",\n          maxHeight: \"100%\",\n          maxWidth: \"100%\",\n        }}\n        alt={alt}\n      />\n    );\n  }\n\n  if (prefix in iconSetURLs) {\n    // default to theme setting\n    let iconName = icon.replace(`${prefix}-`, \"\").replace(\".svg\", \"\");\n    let iconColor =\n      settings.iconStyle === \"theme\"\n        ? `rgb(var(--color-${theme === \"dark\" ? 300 : 900}) / var(--tw-text-opacity, 1))`\n        : \"linear-gradient(180deg, rgb(var(--color-logo-start)), rgb(var(--color-logo-stop)))\";\n\n    // use custom hex color if provided\n    const colorMatches = icon.match(/[#][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]$/i);\n    if (colorMatches?.length) {\n      iconName = icon.replace(`${prefix}-`, \"\").replace(\".svg\", \"\").replace(`-${colorMatches[0]}`, \"\");\n      iconColor = `${colorMatches[0]}`;\n    }\n\n    const iconSource = `${iconSetURLs[prefix]}${iconName}.svg`;\n\n    return (\n      <div\n        style={{\n          width,\n          height,\n          maxWidth: \"100%\",\n          maxHeight: \"100%\",\n          background: `${iconColor}`,\n          mask: `url(${iconSource}) no-repeat center / contain`,\n          WebkitMask: `url(${iconSource}) no-repeat center / contain`,\n        }}\n      />\n    );\n  }\n\n  // fallback to dashboard-icons\n  if (icon.endsWith(\".svg\")) {\n    const iconName = icon.replace(\".svg\", \"\");\n    return (\n      <Image\n        src={`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/${iconName}.svg`}\n        width={width}\n        height={height}\n        style={{\n          width,\n          height,\n          objectFit: \"contain\",\n          maxHeight: \"100%\",\n          maxWidth: \"100%\",\n        }}\n        alt={alt}\n      />\n    );\n  }\n\n  if (icon.endsWith(\".webp\")) {\n    const iconName = icon.replace(\".webp\", \"\");\n    return (\n      <Image\n        src={`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/webp/${iconName}.webp`}\n        width={width}\n        height={height}\n        style={{\n          width,\n          height,\n          objectFit: \"contain\",\n          maxHeight: \"100%\",\n          maxWidth: \"100%\",\n        }}\n        alt={alt}\n      />\n    );\n  }\n\n  const iconName = icon.replace(\".png\", \"\");\n  return (\n    <Image\n      src={`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/${iconName}.png`}\n      width={width}\n      height={height}\n      style={{\n        width,\n        height,\n        objectFit: \"contain\",\n        maxHeight: \"100%\",\n        maxWidth: \"100%\",\n      }}\n      alt={alt}\n    />\n  );\n}\n"
  },
  {
    "path": "src/components/resolvedicon.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { SettingsContext } from \"utils/contexts/settings\";\nimport { ThemeContext } from \"utils/contexts/theme\";\n\nvi.mock(\"next/image\", () => ({\n  default: ({ src, alt }) => <div data-testid=\"next-image\" data-src={src} data-alt={alt} />,\n}));\n\nimport ResolvedIcon from \"./resolvedicon\";\n\nfunction renderWithContexts(ui, { settings = {}, theme = \"dark\" } = {}) {\n  return render(\n    <SettingsContext.Provider value={{ settings, setSettings: () => {} }}>\n      <ThemeContext.Provider value={{ theme, setTheme: vi.fn() }}>{ui}</ThemeContext.Provider>\n    </SettingsContext.Provider>,\n  );\n}\n\ndescribe(\"components/resolvedicon\", () => {\n  it(\"renders direct URL icons via next/image\", () => {\n    renderWithContexts(<ResolvedIcon icon=\"http://example.com/x.png\" alt=\"x\" />);\n    expect(screen.getByTestId(\"next-image\").getAttribute(\"data-src\")).toBe(\"http://example.com/x.png\");\n  });\n\n  it(\"renders relative URL icons via next/image\", () => {\n    renderWithContexts(<ResolvedIcon icon=\"/icons/x.png\" alt=\"x\" />);\n    expect(screen.getByTestId(\"next-image\").getAttribute(\"data-src\")).toBe(\"/icons/x.png\");\n  });\n\n  it(\"renders selfh.st icons for sh- prefix with extension\", () => {\n    renderWithContexts(<ResolvedIcon icon=\"sh-test.webp\" alt=\"x\" />);\n    expect(screen.getByTestId(\"next-image\").getAttribute(\"data-src\")).toContain(\"/webp/test.webp\");\n  });\n\n  it(\"renders selfh.st icons as svg or png based on file extension\", () => {\n    renderWithContexts(<ResolvedIcon icon=\"sh-test.svg\" alt=\"x\" />);\n    expect(screen.getByTestId(\"next-image\").getAttribute(\"data-src\")).toContain(\"/svg/test.svg\");\n\n    renderWithContexts(<ResolvedIcon icon=\"sh-test.png\" alt=\"x\" />);\n    expect(screen.getAllByTestId(\"next-image\")[1].getAttribute(\"data-src\")).toContain(\"/png/test.png\");\n  });\n\n  it(\"renders mdi icons as a masked div and supports custom hex colors\", () => {\n    const { container } = renderWithContexts(<ResolvedIcon icon=\"mdi-home-#ff00ff\" />, {\n      settings: { iconStyle: \"theme\" },\n      theme: \"dark\",\n    });\n\n    const div = container.querySelector(\"div\");\n    // Browser normalizes hex colors to rgb() strings on assignment.\n    expect(div.style.background).toMatch(/(#ff00ff|rgb\\(255, 0, 255\\))/);\n    expect(div.getAttribute(\"style\")).toContain(\"home.svg\");\n  });\n\n  it(\"renders si icons with a masked div using the configured icon style\", () => {\n    const { container } = renderWithContexts(<ResolvedIcon icon=\"si-github\" />, {\n      settings: { iconStyle: \"gradient\" },\n      theme: \"light\",\n    });\n\n    const div = container.querySelector(\"div\");\n    expect(div.getAttribute(\"style\")).toContain(\"github.svg\");\n    expect(div.style.background).toContain(\"linear-gradient\");\n  });\n\n  it(\"falls back to dashboard-icons for .svg\", () => {\n    renderWithContexts(<ResolvedIcon icon=\"foo.svg\" />);\n    expect(screen.getByTestId(\"next-image\").getAttribute(\"data-src\")).toContain(\"/dashboard-icons/svg/foo.svg\");\n  });\n\n  it(\"falls back to dashboard-icons for .webp and .png\", () => {\n    renderWithContexts(<ResolvedIcon icon=\"foo.webp\" />);\n    expect(screen.getAllByTestId(\"next-image\")[0].getAttribute(\"data-src\")).toContain(\"/dashboard-icons/webp/foo.webp\");\n\n    renderWithContexts(<ResolvedIcon icon=\"foo.png\" />);\n    expect(screen.getAllByTestId(\"next-image\")[1].getAttribute(\"data-src\")).toContain(\"/dashboard-icons/png/foo.png\");\n  });\n});\n"
  },
  {
    "path": "src/components/services/dropdown.jsx",
    "content": "import { Menu, Transition } from \"@headlessui/react\";\nimport classNames from \"classnames\";\nimport { Fragment } from \"react\";\nimport { BiCog } from \"react-icons/bi\";\n\nexport default function Dropdown({ options, value, setValue }) {\n  return (\n    <Menu as=\"div\" className=\"relative inline-block text-left\">\n      <div>\n        <Menu.Button className=\"text-xs inline-flex w-full items-center rounded-sm bg-theme-200/50 dark:bg-theme-900/20 px-3 py-1.5\">\n          {options.find((option) => option.value === value).label}\n          <BiCog className=\"-mr-1 ml-2 h-4 w-4\" aria-hidden=\"true\" />\n        </Menu.Button>\n      </div>\n\n      <Transition\n        as={Fragment}\n        enter=\"transition ease-out duration-100\"\n        enterFrom=\"transform opacity-0 scale-95\"\n        enterTo=\"transform opacity-100 scale-100\"\n        leave=\"transition ease-in duration-75\"\n        leaveFrom=\"transform opacity-100 scale-100\"\n        leaveTo=\"transform opacity-0 scale-95\"\n      >\n        <Menu.Items className=\"absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-theme-200/50 dark:bg-theme-900/50 backdrop-blur-sm shadow-md focus:outline-hidden text-theme-700 dark:text-theme-200\">\n          <div className=\"py-1\">\n            {options.map((option) => (\n              <Menu.Item key={option.value} as={Fragment}>\n                <button\n                  onClick={() => {\n                    setValue(option.value);\n                  }}\n                  type=\"button\"\n                  className={classNames(\n                    value === option.value ? \"bg-theme-300/40 dark:bg-theme-900/40\" : \"\",\n                    \"w-full block px-3 py-1.5 text-sm hover:bg-theme-300/70 dark:hover:bg-theme-900/70 text-left\",\n                  )}\n                >\n                  {option.label}\n                </button>\n              </Menu.Item>\n            ))}\n          </div>\n        </Menu.Items>\n      </Transition>\n    </Menu>\n  );\n}\n"
  },
  {
    "path": "src/components/services/dropdown.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { fireEvent, render, screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\n// Stub Menu/Transition to always render children (keeps tests deterministic).\nvi.mock(\"@headlessui/react\", async () => {\n  const React = await import(\"react\");\n  const { Fragment } = React;\n\n  function Transition({ as: As = Fragment, children }) {\n    if (As === Fragment) return <>{children}</>;\n    return <As>{children}</As>;\n  }\n\n  function Menu({ as: As = \"div\", children, ...props }) {\n    const content = typeof children === \"function\" ? children({ open: true }) : children;\n    return <As {...props}>{content}</As>;\n  }\n\n  function MenuButton(props) {\n    return <button type=\"button\" {...props} />;\n  }\n  function MenuItems(props) {\n    return <div {...props} />;\n  }\n  function MenuItem({ children }) {\n    return <>{children}</>;\n  }\n\n  Menu.Button = MenuButton;\n  Menu.Items = MenuItems;\n  Menu.Item = MenuItem;\n\n  return { Menu, Transition };\n});\n\nimport Dropdown from \"./dropdown\";\n\ndescribe(\"components/services/dropdown\", () => {\n  it(\"renders the selected label and updates value when an option is clicked\", () => {\n    const setValue = vi.fn();\n    const options = [\n      { value: \"a\", label: \"Alpha\" },\n      { value: \"b\", label: \"Beta\" },\n    ];\n\n    render(<Dropdown options={options} value=\"a\" setValue={setValue} />);\n\n    // \"Alpha\" appears both in the menu button and in the list of options.\n    expect(screen.getAllByRole(\"button\", { name: \"Alpha\" })[0]).toBeInTheDocument();\n\n    fireEvent.click(screen.getByRole(\"button\", { name: \"Beta\" }));\n    expect(setValue).toHaveBeenCalledWith(\"b\");\n  });\n});\n"
  },
  {
    "path": "src/components/services/group.jsx",
    "content": "import { Disclosure, Transition } from \"@headlessui/react\";\nimport classNames from \"classnames\";\nimport ResolvedIcon from \"components/resolvedicon\";\nimport List from \"components/services/list\";\nimport { useEffect, useRef } from \"react\";\nimport { MdKeyboardArrowDown } from \"react-icons/md\";\n\nimport { columnMap } from \"../../utils/layout/columns\";\n\nexport default function ServicesGroup({\n  group,\n  layout,\n  maxGroupColumns,\n  disableCollapse,\n  useEqualHeights,\n  groupsInitiallyCollapsed,\n  isSubgroup,\n}) {\n  const panel = useRef();\n\n  useEffect(() => {\n    if (layout?.initiallyCollapsed ?? groupsInitiallyCollapsed) panel.current.style.height = `0`;\n  }, [layout, groupsInitiallyCollapsed]);\n\n  let groupPadding = layout?.header === false ? \"px-1\" : \"p-1 pb-0\";\n  if (isSubgroup) groupPadding = \"\";\n\n  return (\n    <div\n      key={group.name}\n      className={classNames(\n        \"services-group flex-1\",\n        layout?.style === \"row\" ? \"basis-full\" : \"basis-full md:basis-1/2 lg:basis-1/3 xl:basis-1/4\",\n        layout?.style !== \"row\" && maxGroupColumns ? `3xl:basis-1/${maxGroupColumns}` : \"\",\n        groupPadding,\n        isSubgroup ? \"subgroup\" : \"\",\n      )}\n    >\n      <Disclosure defaultOpen={!(layout?.initiallyCollapsed ?? groupsInitiallyCollapsed)}>\n        {({ open }) => (\n          <>\n            {layout?.header !== false && (\n              <Disclosure.Button disabled={disableCollapse} className=\"flex w-full select-none items-center group\">\n                {layout?.icon && (\n                  <div className=\"shrink-0 mr-2 w-7 h-7 service-group-icon\">\n                    <ResolvedIcon icon={layout.icon} />\n                  </div>\n                )}\n                <h2 className=\"flex text-theme-800 dark:text-theme-300 text-xl font-medium service-group-name\">\n                  {group.name}\n                </h2>\n                <MdKeyboardArrowDown\n                  className={classNames(\n                    disableCollapse ? \"hidden\" : \"\",\n                    \"transition-all opacity-0 group-hover:opacity-100 ml-auto text-theme-800 dark:text-theme-300 text-xl\",\n                    open ? \"\" : \"rotate-180\",\n                  )}\n                />\n              </Disclosure.Button>\n            )}\n            <Transition\n              // Otherwise the transition group does display: none and cancels animation\n              className=\"block!\"\n              unmount={false}\n              beforeLeave={() => {\n                panel.current.style.height = `${panel.current.scrollHeight}px`;\n                setTimeout(() => {\n                  panel.current.style.height = `0`;\n                }, 1);\n              }}\n              beforeEnter={() => {\n                panel.current.style.height = `0px`;\n                setTimeout(() => {\n                  panel.current.style.height = `${panel.current.scrollHeight}px`;\n                }, 1);\n                setTimeout(() => {\n                  panel.current.style.height = \"auto\";\n                }, 150); // animation is 150ms\n              }}\n            >\n              <Disclosure.Panel className=\"transition-all overflow-hidden duration-300 ease-out\" ref={panel} static>\n                <List\n                  groupName={group.name}\n                  services={group.services}\n                  layout={layout}\n                  useEqualHeights={useEqualHeights}\n                  header={layout?.header !== false}\n                />\n                {group.groups?.length > 0 && (\n                  <div\n                    className={`grid ${\n                      layout?.style === \"row\" ? `grid ${columnMap[layout?.columns]} gap-x-2` : \"flex flex-col\"\n                    } gap-2`}\n                  >\n                    {group.groups.map((subgroup) => (\n                      <ServicesGroup\n                        key={subgroup.name}\n                        group={subgroup}\n                        layout={layout?.[subgroup.name]}\n                        maxGroupColumns={maxGroupColumns}\n                        disableCollapse={disableCollapse}\n                        useEqualHeights={useEqualHeights}\n                        groupsInitiallyCollapsed={groupsInitiallyCollapsed}\n                        isSubgroup\n                      />\n                    ))}\n                  </div>\n                )}\n              </Disclosure.Panel>\n            </Transition>\n          </>\n        )}\n      </Disclosure>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/services/group.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen, waitFor } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nvi.mock(\"@headlessui/react\", async () => {\n  const React = await import(\"react\");\n  const { Fragment } = React;\n\n  function Transition({ as: As = Fragment, children }) {\n    if (As === Fragment) return <>{children}</>;\n    return <As>{children}</As>;\n  }\n\n  function Disclosure({ defaultOpen = true, children }) {\n    const content = typeof children === \"function\" ? children({ open: defaultOpen }) : children;\n    return <div>{content}</div>;\n  }\n\n  function DisclosureButton(props) {\n    return <button type=\"button\" {...props} />;\n  }\n\n  const DisclosurePanel = React.forwardRef(function DisclosurePanel(props, ref) {\n    return <div ref={ref} data-testid=\"disclosure-panel\" {...props} static=\"true\" />;\n  });\n\n  Disclosure.Button = DisclosureButton;\n  Disclosure.Panel = DisclosurePanel;\n\n  return { Disclosure, Transition };\n});\n\nvi.mock(\"components/resolvedicon\", () => ({\n  default: function ResolvedIconMock() {\n    return <div data-testid=\"resolved-icon\" />;\n  },\n}));\n\nvi.mock(\"components/services/list\", () => ({\n  default: function ServicesListMock({ groupName, services }) {\n    return (\n      <div data-testid=\"services-list-mock\">\n        {groupName}:{services?.length ?? 0}\n      </div>\n    );\n  },\n}));\n\nimport ServicesGroup from \"./group\";\n\ndescribe(\"components/services/group\", () => {\n  it(\"renders group and subgroup headers\", () => {\n    render(\n      <ServicesGroup\n        group={{\n          name: \"Main\",\n          services: [{ name: \"svc\" }],\n          groups: [{ name: \"Sub\", services: [], groups: [] }],\n        }}\n        layout={{ icon: \"mdi:test\" }}\n        groupsInitiallyCollapsed={false}\n      />,\n    );\n\n    expect(screen.getByText(\"Main\")).toBeInTheDocument();\n    expect(screen.getByTestId(\"resolved-icon\")).toBeInTheDocument();\n    const lists = screen.getAllByTestId(\"services-list-mock\");\n    expect(lists[0]).toHaveTextContent(\"Main:1\");\n    expect(screen.getByText(\"Sub\")).toBeInTheDocument();\n  });\n\n  it(\"sets the panel height to 0 when initially collapsed\", async () => {\n    render(\n      <ServicesGroup\n        group={{ name: \"Main\", services: [], groups: [] }}\n        layout={{ initiallyCollapsed: true }}\n        groupsInitiallyCollapsed={false}\n      />,\n    );\n\n    const panel = screen.getAllByTestId(\"disclosure-panel\")[0];\n    await waitFor(() => {\n      expect(panel.style.height).toBe(\"0px\");\n    });\n  });\n});\n"
  },
  {
    "path": "src/components/services/group.transition.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { act, render, screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nvi.mock(\"@headlessui/react\", async () => {\n  const React = await import(\"react\");\n  const { Fragment, useEffect } = React;\n\n  function Transition({ as: As = Fragment, beforeEnter, beforeLeave, children }) {\n    useEffect(() => {\n      // Simulate a mount -> enter animation, then a leave animation shortly after.\n      beforeEnter?.();\n      setTimeout(() => beforeLeave?.(), 200);\n    }, [beforeEnter, beforeLeave]);\n\n    if (As === Fragment) return <>{children}</>;\n    return <As>{children}</As>;\n  }\n\n  function Disclosure({ defaultOpen = true, children }) {\n    const content = typeof children === \"function\" ? children({ open: defaultOpen }) : children;\n    return <div>{content}</div>;\n  }\n\n  function DisclosureButton(props) {\n    return <button type=\"button\" {...props} />;\n  }\n\n  const DisclosurePanel = React.forwardRef(function DisclosurePanel(props, ref) {\n    const { static: _static, ...rest } = props;\n    return (\n      <div\n        ref={(node) => {\n          if (node) {\n            // JSDOM doesn't calculate layout; give the panel a deterministic height.\n            Object.defineProperty(node, \"scrollHeight\", { value: 123, configurable: true });\n          }\n          if (typeof ref === \"function\") ref(node);\n          else if (ref) ref.current = node;\n        }}\n        data-testid=\"disclosure-panel\"\n        {...rest}\n      />\n    );\n  });\n\n  Disclosure.Button = DisclosureButton;\n  Disclosure.Panel = DisclosurePanel;\n\n  return { Disclosure, Transition };\n});\n\nvi.mock(\"components/resolvedicon\", () => ({\n  default: function ResolvedIconMock() {\n    return <div data-testid=\"resolved-icon\" />;\n  },\n}));\n\nvi.mock(\"components/services/list\", () => ({\n  default: function ServicesListMock() {\n    return <div data-testid=\"services-list\" />;\n  },\n}));\n\nimport ServicesGroup from \"./group\";\n\ndescribe(\"components/services/group transition hooks\", () => {\n  it(\"runs the Transition beforeEnter/beforeLeave height calculations\", async () => {\n    vi.useFakeTimers();\n\n    render(\n      <ServicesGroup\n        group={{ name: \"Main\", services: [], groups: [] }}\n        layout={{ initiallyCollapsed: false }}\n        groupsInitiallyCollapsed={false}\n      />,\n    );\n\n    const panel = screen.getByTestId(\"disclosure-panel\");\n    expect(panel).toBeTruthy();\n\n    await act(async () => {\n      vi.runAllTimers();\n    });\n\n    // The leave animation sets height back to 0.\n    expect(panel.style.height).toBe(\"0px\");\n\n    vi.useRealTimers();\n  });\n});\n"
  },
  {
    "path": "src/components/services/item.jsx",
    "content": "import classNames from \"classnames\";\nimport ResolvedIcon from \"components/resolvedicon\";\nimport { useContext, useState } from \"react\";\nimport { SettingsContext } from \"utils/contexts/settings\";\nimport Docker from \"widgets/docker/component\";\nimport Kubernetes from \"widgets/kubernetes/component\";\nimport ProxmoxVM from \"widgets/proxmoxvm/component\";\n\nimport KubernetesStatus from \"./kubernetes-status\";\nimport Ping from \"./ping\";\nimport ProxmoxStatus from \"./proxmox-status\";\nimport SiteMonitor from \"./site-monitor\";\nimport Status from \"./status\";\nimport Widget from \"./widget\";\n\nexport default function Item({ service, groupName, useEqualHeights }) {\n  const hasLink = service.href && service.href !== \"#\";\n  const { settings } = useContext(SettingsContext);\n  const showStats = service.showStats === false ? false : settings.showStats;\n  const statusStyle = service.statusStyle !== undefined ? service.statusStyle : settings.statusStyle;\n  const [statsOpen, setStatsOpen] = useState(service.showStats);\n  const [statsClosing, setStatsClosing] = useState(false);\n\n  // set stats to closed after 300ms\n  const closeStats = () => {\n    if (statsOpen) {\n      setStatsClosing(true);\n      setTimeout(() => {\n        setStatsOpen(false);\n        setStatsClosing(false);\n      }, 300);\n    }\n  };\n\n  return (\n    <li key={service.name} id={service.id} className=\"service\" data-name={service.name || \"\"}>\n      <div\n        className={classNames(\n          settings.cardBlur !== undefined && `backdrop-blur${settings.cardBlur.length ? \"-\" : \"\"}${settings.cardBlur}`,\n          useEqualHeights && \"h-[calc(100%-0.5rem)]\",\n          \"transition-all mb-2 p-1 rounded-md font-medium text-theme-700 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 bg-theme-100/20 hover:bg-theme-300/20 dark:bg-white/5 dark:hover:bg-white/10 relative overflow-clip service-card\",\n        )}\n      >\n        <div className=\"flex select-none z-0 service-title\">\n          {service.icon &&\n            (hasLink ? (\n              <a\n                href={service.href}\n                target={service.target ?? settings.target ?? \"_blank\"}\n                rel=\"noreferrer\"\n                className=\"shrink-0 flex items-center justify-center w-12 service-icon z-10\"\n                aria-label={service.icon}\n              >\n                <ResolvedIcon icon={service.icon} />\n              </a>\n            ) : (\n              <div className=\"shrink-0 flex items-center justify-center w-12 service-icon z-10\">\n                <ResolvedIcon icon={service.icon} />\n              </div>\n            ))}\n\n          {hasLink ? (\n            <a\n              href={service.href}\n              target={service.target ?? settings.target ?? \"_blank\"}\n              rel=\"noreferrer\"\n              className=\"flex-1 flex items-center justify-between rounded-r-md service-title-text\"\n            >\n              <div className=\"flex-1 px-2 py-2 text-sm text-left z-10 service-name\">\n                {service.name}\n                <p className=\"text-theme-500 dark:text-theme-300 text-xs font-light service-description\">\n                  {service.description}\n                </p>\n              </div>\n            </a>\n          ) : (\n            <div className=\"flex-1 flex items-center justify-between rounded-r-md service-title-text\">\n              <div className=\"flex-1 px-2 py-2 text-sm text-left z-10 service-name\">\n                {service.name}\n                <p className=\"text-theme-500 dark:text-theme-300 text-xs font-light service-description\">\n                  {service.description}\n                </p>\n              </div>\n            </div>\n          )}\n\n          <div\n            className={`absolute top-0 right-0 flex flex-row justify-end ${\n              statusStyle === \"dot\" ? \"gap-0\" : \"gap-2 mr-2\"\n            } z-10 service-tags`}\n          >\n            {service.ping && (\n              <div className=\"shrink-0 flex items-center justify-center service-tag service-ping\">\n                <Ping groupName={groupName} serviceName={service.name} style={statusStyle} />\n                <span className=\"sr-only\">Ping status</span>\n              </div>\n            )}\n\n            {service.siteMonitor && (\n              <div className=\"shrink-0 flex items-center justify-center service-tag service-site-monitor\">\n                <SiteMonitor groupName={groupName} serviceName={service.name} style={statusStyle} />\n                <span className=\"sr-only\">Site monitor status</span>\n              </div>\n            )}\n\n            {service.container && (\n              <button\n                type=\"button\"\n                onClick={() => (statsOpen ? closeStats() : setStatsOpen(true))}\n                className=\"shrink-0 flex items-center justify-center cursor-pointer service-tag service-container-stats\"\n              >\n                <Status service={service} style={statusStyle} />\n                <span className=\"sr-only\">View container stats</span>\n              </button>\n            )}\n            {service.app && !service.external && (\n              <button\n                type=\"button\"\n                onClick={() => (statsOpen ? closeStats() : setStatsOpen(true))}\n                className=\"shrink-0 flex items-center justify-center cursor-pointer service-tag service-app\"\n              >\n                <KubernetesStatus service={service} style={statusStyle} />\n                <span className=\"sr-only\">View container stats</span>\n              </button>\n            )}\n            {service.proxmoxNode && service.proxmoxVMID && (\n              <button\n                type=\"button\"\n                onClick={() => (statsOpen ? closeStats() : setStatsOpen(true))}\n                className=\"shrink-0 flex items-center justify-center cursor-pointer service-tag service-proxmoxstatus\"\n              >\n                <ProxmoxStatus service={service} style={statusStyle} />\n                <span className=\"sr-only\">View Proxmox stats</span>\n              </button>\n            )}\n          </div>\n        </div>\n\n        {service.container && service.server && (\n          <div\n            className={classNames(\n              showStats || (statsOpen && !statsClosing) ? \"max-h-[110px] opacity-100\" : \" max-h-0 opacity-0\",\n              \"w-full overflow-hidden transition-all duration-300 ease-in-out service-stats\",\n            )}\n          >\n            {(showStats || statsOpen) && (\n              <Docker service={{ widget: { container: service.container, server: service.server } }} />\n            )}\n          </div>\n        )}\n        {service.app && (\n          <div\n            className={classNames(\n              showStats || (statsOpen && !statsClosing) ? \"max-h-[55px] opacity-100\" : \" max-h-0 opacity-0\",\n              \"w-full overflow-hidden transition-all duration-300 ease-in-out service-stats\",\n            )}\n          >\n            {(showStats || statsOpen) && (\n              <Kubernetes\n                service={{\n                  widget: { namespace: service.namespace, app: service.app, podSelector: service.podSelector },\n                }}\n              />\n            )}\n          </div>\n        )}\n        {service.proxmoxNode && service.proxmoxVMID && (\n          <div\n            className={classNames(\n              showStats || (statsOpen && !statsClosing) ? \"max-h-[110px] opacity-100\" : \" max-h-0 opacity-0\",\n              \"w-full overflow-hidden transition-all duration-300 ease-in-out service-stats\",\n            )}\n          >\n            {(showStats || statsOpen) && (\n              <ProxmoxVM\n                service={{\n                  widget: {\n                    node: service.proxmoxNode,\n                    vmid: service.proxmoxVMID,\n                    type: service.proxmoxType,\n                  },\n                }}\n              />\n            )}\n          </div>\n        )}\n\n        {service.widgets.map((widget) => (\n          <Widget widget={widget} service={service} key={widget.index} />\n        ))}\n      </div>\n    </li>\n  );\n}\n"
  },
  {
    "path": "src/components/services/item.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { act, fireEvent, screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nvi.mock(\"components/resolvedicon\", () => ({\n  default: function ResolvedIconMock() {\n    return <div data-testid=\"resolved-icon\" />;\n  },\n}));\n\nvi.mock(\"widgets/docker/component\", () => ({\n  default: function DockerWidgetMock() {\n    return <div data-testid=\"docker-widget\" />;\n  },\n}));\n\nvi.mock(\"widgets/kubernetes/component\", () => ({\n  default: function KubernetesWidgetMock() {\n    return <div data-testid=\"kubernetes-widget\" />;\n  },\n}));\n\nvi.mock(\"widgets/proxmoxvm/component\", () => ({\n  default: function ProxmoxVMWidgetMock() {\n    return <div data-testid=\"proxmoxvm-widget\" />;\n  },\n}));\n\nvi.mock(\"./ping\", () => ({\n  default: function PingMock() {\n    return <div data-testid=\"ping\" />;\n  },\n}));\nvi.mock(\"./site-monitor\", () => ({\n  default: function SiteMonitorMock() {\n    return <div data-testid=\"site-monitor\" />;\n  },\n}));\nvi.mock(\"./status\", () => ({\n  default: function StatusMock() {\n    return <div data-testid=\"status\" />;\n  },\n}));\nvi.mock(\"./kubernetes-status\", () => ({\n  default: function KubernetesStatusMock() {\n    return <div data-testid=\"kubernetes-status\" />;\n  },\n}));\nvi.mock(\"./proxmox-status\", () => ({\n  default: function ProxmoxStatusMock() {\n    return <div data-testid=\"proxmox-status\" />;\n  },\n}));\nvi.mock(\"./widget\", () => ({\n  default: function ServiceWidgetMock({ widget }) {\n    return <div data-testid=\"service-widget\">idx:{widget.index}</div>;\n  },\n}));\n\nimport Item from \"./item\";\n\ndescribe(\"components/services/item\", () => {\n  it(\"renders the service title as a link when href is provided\", () => {\n    renderWithProviders(\n      <Item\n        groupName=\"G\"\n        useEqualHeights={false}\n        service={{\n          id: \"svc1\",\n          name: \"My Service\",\n          description: \"Desc\",\n          href: \"https://example.com\",\n          icon: \"mdi:test\",\n          widgets: [],\n        }}\n      />,\n      { settings: { target: \"_self\", showStats: false, statusStyle: \"basic\" } },\n    );\n\n    const links = screen.getAllByRole(\"link\");\n    expect(links.some((l) => l.getAttribute(\"href\") === \"https://example.com\")).toBe(true);\n    expect(screen.getByText(\"My Service\")).toBeInTheDocument();\n  });\n\n  it(\"renders the icon without a link when href is missing or '#'\", () => {\n    renderWithProviders(\n      <Item\n        groupName=\"G\"\n        useEqualHeights={false}\n        service={{\n          id: \"svc1\",\n          name: \"My Service\",\n          description: \"Desc\",\n          href: \"#\",\n          icon: \"mdi:test\",\n          widgets: [],\n        }}\n      />,\n      { settings: { target: \"_self\", showStats: false, statusStyle: \"basic\" } },\n    );\n\n    // The title area should not create a clickable href=\"#\" link.\n    expect(screen.queryByRole(\"link\")).not.toBeInTheDocument();\n    expect(screen.getByTestId(\"resolved-icon\")).toBeInTheDocument();\n  });\n\n  it(\"toggles container stats on click when stats are hidden by default\", () => {\n    renderWithProviders(\n      <Item\n        groupName=\"G\"\n        useEqualHeights={false}\n        service={{\n          id: \"svc1\",\n          name: \"My Service\",\n          description: \"Desc\",\n          href: \"https://example.com\",\n          container: \"c\",\n          server: \"s\",\n          ping: true,\n          siteMonitor: true,\n          widgets: [{ index: 1 }, { index: 2 }],\n        }}\n      />,\n      { settings: { showStats: false, statusStyle: \"basic\" } },\n    );\n\n    expect(screen.queryByTestId(\"docker-widget\")).not.toBeInTheDocument();\n    expect(screen.getByTestId(\"ping\")).toBeInTheDocument();\n    expect(screen.getByTestId(\"site-monitor\")).toBeInTheDocument();\n\n    fireEvent.click(screen.getByRole(\"button\", { name: \"View container stats\" }));\n    expect(screen.getByTestId(\"docker-widget\")).toBeInTheDocument();\n\n    expect(screen.getAllByTestId(\"service-widget\")).toHaveLength(2);\n  });\n\n  it(\"shows stats by default when settings.showStats is enabled, unless overridden by the service\", () => {\n    const baseService = {\n      id: \"svc1\",\n      name: \"My Service\",\n      description: \"Desc\",\n      container: \"c\",\n      server: \"s\",\n      widgets: [],\n    };\n\n    renderWithProviders(<Item groupName=\"G\" useEqualHeights={false} service={baseService} />, {\n      settings: { showStats: true, statusStyle: \"basic\" },\n    });\n    expect(screen.getByTestId(\"docker-widget\")).toBeInTheDocument();\n\n    renderWithProviders(\n      <Item groupName=\"G\" useEqualHeights={false} service={{ ...baseService, id: \"svc2\", showStats: false }} />,\n      {\n        settings: { showStats: true, statusStyle: \"basic\" },\n      },\n    );\n    expect(screen.getAllByTestId(\"docker-widget\")).toHaveLength(1);\n  });\n\n  it(\"closes stats after a short delay when toggled closed\", async () => {\n    vi.useFakeTimers();\n\n    renderWithProviders(\n      <Item\n        groupName=\"G\"\n        useEqualHeights={false}\n        service={{\n          id: \"svc1\",\n          name: \"My Service\",\n          description: \"Desc\",\n          container: \"c\",\n          server: \"s\",\n          widgets: [],\n        }}\n      />,\n      { settings: { showStats: false, statusStyle: \"basic\" } },\n    );\n\n    const btn = screen.getByRole(\"button\", { name: \"View container stats\" });\n    fireEvent.click(btn);\n    expect(screen.getByTestId(\"docker-widget\")).toBeInTheDocument();\n\n    fireEvent.click(btn);\n    // Still rendered while the close animation runs.\n    expect(screen.getByTestId(\"docker-widget\")).toBeInTheDocument();\n\n    act(() => {\n      vi.advanceTimersByTime(300);\n    });\n    expect(screen.queryByTestId(\"docker-widget\")).not.toBeInTheDocument();\n\n    vi.useRealTimers();\n  });\n\n  it(\"toggles app and proxmox stats using their respective status tags\", () => {\n    renderWithProviders(\n      <Item\n        groupName=\"G\"\n        useEqualHeights={false}\n        service={{\n          id: \"svc1\",\n          name: \"My Service\",\n          description: \"Desc\",\n          app: \"app\",\n          namespace: \"default\",\n          proxmoxNode: \"pve\",\n          proxmoxVMID: \"100\",\n          proxmoxType: \"qemu\",\n          widgets: [],\n        }}\n      />,\n      { settings: { showStats: false, statusStyle: \"basic\" } },\n    );\n\n    const appBtn = screen.getByTestId(\"kubernetes-status\").closest(\"button\");\n    expect(appBtn).toBeTruthy();\n    fireEvent.click(appBtn);\n    expect(screen.getByTestId(\"kubernetes-widget\")).toBeInTheDocument();\n\n    const proxmoxBtn = screen.getByTestId(\"proxmox-status\").closest(\"button\");\n    expect(proxmoxBtn).toBeTruthy();\n    fireEvent.click(proxmoxBtn);\n    expect(screen.getByTestId(\"proxmoxvm-widget\")).toBeInTheDocument();\n  });\n\n  it(\"does not render the app status tag when the service is marked external\", () => {\n    renderWithProviders(\n      <Item\n        groupName=\"G\"\n        useEqualHeights={false}\n        service={{\n          id: \"svc1\",\n          name: \"My Service\",\n          description: \"Desc\",\n          app: \"app\",\n          external: true,\n          widgets: [],\n        }}\n      />,\n      { settings: { showStats: false, statusStyle: \"basic\" } },\n    );\n\n    expect(screen.queryByTestId(\"kubernetes-status\")).not.toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/components/services/kubernetes-status.jsx",
    "content": "import { t } from \"i18next\";\nimport useSWR from \"swr\";\n\nexport default function KubernetesStatus({ service, style }) {\n  const podSelectorString = service.podSelector !== undefined ? `podSelector=${service.podSelector}` : \"\";\n  const { data, error } = useSWR(`/api/kubernetes/status/${service.namespace}/${service.app}?${podSelectorString}`);\n\n  let statusLabel = t(\"docker.unknown\");\n  let statusTitle = \"\";\n  let backgroundClass = \"px-1.5 py-0.5 bg-theme-500/10 dark:bg-theme-900/50\";\n  let colorClass = \"text-black/20 dark:text-white/40 opacity-20\";\n\n  if (error) {\n    statusTitle = t(\"docker.error\");\n    statusLabel = statusTitle;\n    colorClass = \"text-rose-500/80\";\n  } else if (data) {\n    if (data.status === \"running\") {\n      statusTitle = data.health ?? data.status;\n      statusLabel = statusTitle;\n      colorClass = \"text-emerald-500/80\";\n    }\n\n    if (data.status === \"not found\" || data.status === \"down\" || data.status === \"partial\") {\n      statusTitle = data.status;\n      statusLabel = statusTitle;\n      colorClass = \"text-orange-400/50 dark:text-orange-400/80\";\n    }\n  }\n\n  if (style === \"dot\") {\n    colorClass = colorClass.replace(/text-/g, \"bg-\").replace(/\\/\\d\\d/g, \"\");\n    backgroundClass = \"p-4 hover:bg-theme-500/10 dark:hover:bg-theme-900/20\";\n  }\n\n  return (\n    <div\n      className={`w-auto text-center overflow-hidden ${backgroundClass} rounded-b-[3px] k8s-status`}\n      title={statusTitle}\n    >\n      {style !== \"dot\" ? (\n        <div className={`text-[8px] font-bold ${colorClass} uppercase`}>{statusLabel}</div>\n      ) : (\n        <div className={`rounded-full h-3 w-3 ${colorClass}`} />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/services/kubernetes-status.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));\n\nvi.mock(\"swr\", () => ({\n  default: useSWR,\n}));\n\nvi.mock(\"i18next\", () => ({\n  t: (key) => key,\n}));\n\nimport KubernetesStatus from \"./kubernetes-status\";\n\ndescribe(\"components/services/kubernetes-status\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"includes podSelector in the request when provided\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    render(<KubernetesStatus service={{ namespace: \"ns\", app: \"app\", podSelector: \"x=y\" }} />);\n\n    expect(useSWR).toHaveBeenCalledWith(\"/api/kubernetes/status/ns/app?podSelector=x=y\");\n  });\n\n  it(\"renders the health/status label when running\", () => {\n    useSWR.mockReturnValue({ data: { status: \"running\", health: \"healthy\" }, error: undefined });\n\n    render(<KubernetesStatus service={{ namespace: \"ns\", app: \"app\" }} />);\n\n    expect(screen.getByText(\"healthy\")).toBeInTheDocument();\n  });\n\n  it(\"renders a dot when style is dot\", () => {\n    useSWR.mockReturnValue({ data: { status: \"running\" }, error: undefined });\n\n    const { container } = render(<KubernetesStatus service={{ namespace: \"ns\", app: \"app\" }} style=\"dot\" />);\n\n    expect(container.querySelector(\".rounded-full\")).toBeTruthy();\n  });\n\n  it(\"renders an error label when SWR returns an error\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: new Error(\"nope\") });\n\n    const { container } = render(<KubernetesStatus service={{ namespace: \"ns\", app: \"app\" }} />);\n\n    expect(screen.getByText(\"docker.error\")).toBeInTheDocument();\n    expect(container.querySelector(\".k8s-status\")?.getAttribute(\"title\")).toBe(\"docker.error\");\n  });\n\n  it(\"renders orange status labels when the workload is down/partial/not found\", () => {\n    useSWR.mockReturnValue({ data: { status: \"down\" }, error: undefined });\n\n    const { container } = render(<KubernetesStatus service={{ namespace: \"ns\", app: \"app\" }} />);\n\n    expect(screen.getByText(\"down\")).toBeInTheDocument();\n    // Ensure the status is used as a tooltip/title too.\n    expect(container.querySelector(\".k8s-status\")?.getAttribute(\"title\")).toBe(\"down\");\n  });\n});\n"
  },
  {
    "path": "src/components/services/list.jsx",
    "content": "import classNames from \"classnames\";\nimport Item from \"components/services/item\";\n\nimport { columnMap } from \"../../utils/layout/columns\";\n\nexport default function List({ groupName, services, layout, useEqualHeights, header }) {\n  return (\n    <ul\n      className={classNames(\n        layout?.style === \"row\" ? `grid ${columnMap[layout?.columns]} gap-x-2` : \"flex flex-col\",\n        header ? \"mt-3\" : \"\",\n        \"services-list\",\n      )}\n    >\n      {services.map((service) => (\n        <Item\n          key={[service.container, service.app, service.name].filter((s) => s).join(\"-\")}\n          service={service}\n          groupName={groupName}\n          useEqualHeights={layout?.useEqualHeights ?? useEqualHeights}\n        />\n      ))}\n    </ul>\n  );\n}\n"
  },
  {
    "path": "src/components/services/list.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nvi.mock(\"components/services/item\", () => ({\n  default: function ServiceItemMock({ service, groupName, useEqualHeights }) {\n    return (\n      <li data-testid=\"service-item\">\n        {groupName}:{service.name}:{String(useEqualHeights)}\n      </li>\n    );\n  },\n}));\n\nimport List from \"./list\";\n\ndescribe(\"components/services/list\", () => {\n  it(\"renders items and passes the computed useEqualHeights value\", () => {\n    render(\n      <List\n        groupName=\"G\"\n        services={[{ name: \"A\" }, { name: \"B\" }]}\n        layout={{ useEqualHeights: true }}\n        useEqualHeights={false}\n        header\n      />,\n    );\n\n    const items = screen.getAllByTestId(\"service-item\");\n    expect(items).toHaveLength(2);\n    expect(items[0]).toHaveTextContent(\"G:A:true\");\n    expect(items[1]).toHaveTextContent(\"G:B:true\");\n  });\n});\n"
  },
  {
    "path": "src/components/services/ping.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport useSWR from \"swr\";\n\nexport default function Ping({ groupName, serviceName, style }) {\n  const { t } = useTranslation();\n  const { data, error } = useSWR(`/api/ping?${new URLSearchParams({ groupName, serviceName }).toString()}`, {\n    refreshInterval: 30000,\n  });\n\n  let colorClass = \"text-black/20 dark:text-white/40 opacity-20\";\n  let backgroundClass = \"bg-theme-500/10 dark:bg-theme-900/50 px-1.5 py-0.5\";\n  let statusTitle = t(\"ping.ping\");\n  let statusText = \"\";\n\n  if (error) {\n    colorClass = \"text-rose-500\";\n    statusText = t(\"ping.error\");\n    statusTitle += ` ${t(\"ping.error\")}`;\n  } else if (!data) {\n    statusText = t(\"ping.ping\");\n    statusTitle += ` ${t(\"ping.not_available\")}`;\n  } else if (!data.alive) {\n    colorClass = \"text-rose-500/80\";\n    statusTitle += ` ${t(\"ping.down\")}`;\n    statusText = t(\"ping.down\");\n  } else if (data.alive) {\n    const ping = t(\"common.ms\", { value: data.time, style: \"unit\", unit: \"millisecond\", maximumFractionDigits: 0 });\n    statusTitle += ` ${t(\"ping.up\")} (${ping})`;\n    colorClass = \"text-emerald-500/80\";\n\n    if (style === \"basic\") {\n      statusText = t(\"ping.up\");\n    } else {\n      statusText = ping;\n      colorClass += \" lowercase\";\n    }\n  }\n\n  if (style === \"dot\") {\n    backgroundClass = \"p-4\";\n    colorClass = colorClass.replace(/text-/g, \"bg-\").replace(/\\/\\d\\d/g, \"\");\n  }\n\n  return (\n    <div\n      className={`w-auto text-center rounded-b-[3px] overflow-hidden ping-status ${backgroundClass}`}\n      title={statusTitle}\n    >\n      {style !== \"dot\" && <div className={`font-bold uppercase text-[8px] ${colorClass}`}>{statusText}</div>}\n      {style === \"dot\" && <div className={`rounded-full h-3 w-3 ${colorClass}`} />}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/services/ping.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));\n\nvi.mock(\"swr\", () => ({\n  default: useSWR,\n}));\n\nimport Ping from \"./ping\";\n\ndescribe(\"components/services/ping\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders a loading state when data is not available yet\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    render(<Ping groupName=\"g\" serviceName=\"s\" />);\n\n    expect(screen.getByText(\"ping.ping\")).toBeInTheDocument();\n    expect(screen.getByText(\"ping.ping\").closest(\".ping-status\")).toHaveAttribute(\n      \"title\",\n      expect.stringContaining(\"ping.not_available\"),\n    );\n  });\n\n  it(\"renders an error label when SWR returns error\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: new Error(\"boom\") });\n\n    render(<Ping groupName=\"g\" serviceName=\"s\" />);\n\n    expect(screen.getByText(\"ping.error\")).toBeInTheDocument();\n  });\n\n  it(\"renders down when the host is not alive\", () => {\n    useSWR.mockReturnValue({ data: { alive: false, time: 0 }, error: undefined });\n\n    render(<Ping groupName=\"g\" serviceName=\"s\" />);\n\n    expect(screen.getByText(\"ping.down\")).toBeInTheDocument();\n  });\n\n  it(\"renders the ping time when the host is alive\", () => {\n    useSWR.mockReturnValue({ data: { alive: true, time: 123 }, error: undefined });\n\n    render(<Ping groupName=\"g\" serviceName=\"s\" />);\n\n    expect(useSWR).toHaveBeenCalledWith(\"/api/ping?groupName=g&serviceName=s\", { refreshInterval: 30000 });\n    expect(screen.getByText(\"123\")).toBeInTheDocument();\n    expect(screen.getByText(\"123\").closest(\".ping-status\")).toHaveAttribute(\n      \"title\",\n      expect.stringContaining(\"ping.up\"),\n    );\n  });\n\n  it(\"renders an up label for basic style\", () => {\n    useSWR.mockReturnValue({ data: { alive: true, time: 1 }, error: undefined });\n\n    render(<Ping groupName=\"g\" serviceName=\"s\" style=\"basic\" />);\n\n    expect(screen.getByText(\"ping.up\")).toBeInTheDocument();\n  });\n\n  it(\"renders a dot when style is dot\", () => {\n    useSWR.mockReturnValue({ data: { alive: true, time: 5 }, error: undefined });\n\n    const { container } = render(<Ping groupName=\"g\" serviceName=\"s\" style=\"dot\" />);\n\n    expect(screen.queryByText(\"5\")).not.toBeInTheDocument();\n    expect(container.querySelector(\".rounded-full\")).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "src/components/services/proxmox-status.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport useSWR from \"swr\";\n\nexport default function ProxmoxStatus({ service, style }) {\n  const { t } = useTranslation();\n\n  const vmType = service.proxmoxType || \"qemu\";\n  const apiUrl = `/api/proxmox/stats/${service.proxmoxNode}/${service.proxmoxVMID}?type=${vmType}`;\n\n  const { data, error } = useSWR(apiUrl);\n\n  let statusLabel = t(\"docker.unknown\");\n  let backgroundClass = \"px-1.5 py-0.5 bg-theme-500/10 dark:bg-theme-900/50\";\n  let colorClass = \"text-black/20 dark:text-white/40 \";\n\n  if (error) {\n    statusLabel = t(\"docker.error\");\n    colorClass = \"text-rose-500/80\";\n  } else if (data) {\n    if (data.status === \"running\") {\n      statusLabel = t(\"docker.running\");\n      colorClass = \"text-emerald-500/80\";\n    }\n\n    if (data.status === \"stopped\") {\n      statusLabel = t(\"docker.exited\");\n      colorClass = \"text-orange-400/50 dark:text-orange-400/80\";\n    }\n\n    if (data.status === \"paused\") {\n      statusLabel = \"paused\";\n      colorClass = \"text-blue-500/80\";\n    }\n\n    if (data.status === \"offline\") {\n      statusLabel = \"offline\";\n      colorClass = \"text-orange-400/50 dark:text-orange-400/80\";\n    }\n\n    if (data.status === \"not found\") {\n      statusLabel = t(\"docker.not_found\");\n      colorClass = \"text-orange-400/50 dark:text-orange-400/80\";\n    }\n  }\n\n  if (style === \"dot\") {\n    colorClass = colorClass.replace(/text-/g, \"bg-\").replace(/\\/\\d\\d/g, \"\");\n    backgroundClass = \"p-4 hover:bg-theme-500/10 dark:hover:bg-theme-900/20\";\n  }\n\n  return (\n    <div\n      className={`w-auto text-center overflow-hidden ${backgroundClass} rounded-b-[3px] proxmoxstatus proxmoxstatus-${statusLabel\n        .toLowerCase()\n        .replace(\" \", \"-\")}`}\n      title={statusLabel}\n    >\n      {style !== \"dot\" ? (\n        <div className={`text-[8px] font-bold ${colorClass} uppercase`}>{statusLabel}</div>\n      ) : (\n        <div className={`rounded-full h-3 w-3 ${colorClass}`} />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/services/proxmox-status.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));\n\nvi.mock(\"swr\", () => ({\n  default: useSWR,\n}));\n\nimport ProxmoxStatus from \"./proxmox-status\";\n\ndescribe(\"components/services/proxmox-status\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders unknown when data is not available yet\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    render(<ProxmoxStatus service={{ proxmoxNode: \"n1\", proxmoxVMID: \"100\" }} />);\n\n    expect(screen.getByText(\"docker.unknown\")).toBeInTheDocument();\n  });\n\n  it(\"renders error when SWR returns an error\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: new Error(\"nope\") });\n\n    render(<ProxmoxStatus service={{ proxmoxNode: \"n1\", proxmoxVMID: \"100\" }} />);\n\n    expect(screen.getByText(\"docker.error\")).toBeInTheDocument();\n  });\n\n  it(\"requests vm stats and renders running when status is running\", () => {\n    useSWR.mockReturnValue({ data: { status: \"running\" }, error: undefined });\n\n    render(<ProxmoxStatus service={{ proxmoxNode: \"n1\", proxmoxVMID: \"100\" }} />);\n\n    expect(useSWR).toHaveBeenCalledWith(\"/api/proxmox/stats/n1/100?type=qemu\");\n    expect(screen.getByText(\"docker.running\")).toBeInTheDocument();\n  });\n\n  it(\"renders paused for paused vms\", () => {\n    useSWR.mockReturnValue({ data: { status: \"paused\" }, error: undefined });\n\n    render(<ProxmoxStatus service={{ proxmoxNode: \"n1\", proxmoxVMID: \"100\", proxmoxType: \"lxc\" }} />);\n\n    expect(useSWR).toHaveBeenCalledWith(\"/api/proxmox/stats/n1/100?type=lxc\");\n    expect(screen.getByText(\"paused\")).toBeInTheDocument();\n  });\n\n  it(\"renders other terminal statuses (stopped/offline/not found)\", () => {\n    useSWR.mockReturnValue({ data: { status: \"stopped\" }, error: undefined });\n    render(<ProxmoxStatus service={{ proxmoxNode: \"n1\", proxmoxVMID: \"100\" }} />);\n    expect(screen.getByText(\"docker.exited\")).toBeInTheDocument();\n\n    useSWR.mockReturnValue({ data: { status: \"offline\" }, error: undefined });\n    render(<ProxmoxStatus service={{ proxmoxNode: \"n1\", proxmoxVMID: \"100\" }} />);\n    expect(screen.getByText(\"offline\")).toBeInTheDocument();\n\n    useSWR.mockReturnValue({ data: { status: \"not found\" }, error: undefined });\n    render(<ProxmoxStatus service={{ proxmoxNode: \"n1\", proxmoxVMID: \"100\" }} />);\n    expect(screen.getByText(\"docker.not_found\")).toBeInTheDocument();\n  });\n\n  it(\"renders a dot status when style=dot\", () => {\n    useSWR.mockReturnValue({ data: { status: \"running\" }, error: undefined });\n\n    const { container } = render(<ProxmoxStatus service={{ proxmoxNode: \"n1\", proxmoxVMID: \"100\" }} style=\"dot\" />);\n\n    expect(container.querySelector(\".rounded-full\")).toBeTruthy();\n    expect(screen.queryByText(\"docker.running\")).not.toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/components/services/site-monitor.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport useSWR from \"swr\";\n\nexport default function SiteMonitor({ groupName, serviceName, style }) {\n  const { t } = useTranslation();\n  const { data, error } = useSWR(`/api/siteMonitor?${new URLSearchParams({ groupName, serviceName }).toString()}`, {\n    refreshInterval: 30000,\n  });\n\n  let colorClass = \"text-black/20 dark:text-white/40 opacity-20\";\n  let backgroundClass = \"bg-theme-500/10 dark:bg-theme-900/50 px-1.5 py-0.5\";\n  let statusTitle = t(\"siteMonitor.http_status\");\n  let statusText = \"\";\n\n  if (error || (data && data.error)) {\n    colorClass = \"text-rose-500\";\n    statusText = t(\"siteMonitor.error\");\n    statusTitle += ` ${t(\"siteMonitor.error\")}`;\n  } else if (!data) {\n    statusText = t(\"siteMonitor.response\");\n    statusTitle += ` ${t(\"siteMonitor.not_available\")}`;\n  } else if (data.status > 403) {\n    colorClass = \"text-rose-500/80\";\n    statusTitle += ` ${data.status}`;\n\n    if (style === \"basic\") {\n      statusText = t(\"siteMonitor.down\");\n    } else {\n      statusText = data.status;\n    }\n  } else if (data) {\n    const responseTime = t(\"common.ms\", {\n      value: data.latency,\n      style: \"unit\",\n      unit: \"millisecond\",\n      maximumFractionDigits: 0,\n    });\n    statusTitle += ` ${data.status} (${responseTime})`;\n    colorClass = \"text-emerald-500/80\";\n\n    if (style === \"basic\") {\n      statusText = t(\"siteMonitor.up\");\n    } else {\n      statusText = responseTime;\n      colorClass += \" lowercase\";\n    }\n  }\n\n  if (style === \"dot\") {\n    backgroundClass = \"p-4\";\n    colorClass = colorClass.replace(/text-/g, \"bg-\").replace(/\\/\\d\\d/g, \"\");\n  }\n\n  return (\n    <div\n      className={`w-auto text-center rounded-b-[3px] overflow-hidden site-monitor-status ${backgroundClass}`}\n      title={statusTitle}\n    >\n      {style !== \"dot\" && <div className={`font-bold uppercase text-[8px] ${colorClass}`}>{statusText}</div>}\n      {style === \"dot\" && <div className={`rounded-full h-3 w-3 ${colorClass}`} />}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/services/site-monitor.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));\n\nvi.mock(\"swr\", () => ({\n  default: useSWR,\n}));\n\nimport SiteMonitor from \"./site-monitor\";\n\ndescribe(\"components/services/site-monitor\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders a loading state when data is not available yet\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    render(<SiteMonitor groupName=\"g\" serviceName=\"s\" />);\n\n    expect(screen.getByText(\"siteMonitor.response\")).toBeInTheDocument();\n    expect(screen.getByText(\"siteMonitor.response\").closest(\".site-monitor-status\")).toHaveAttribute(\n      \"title\",\n      expect.stringContaining(\"siteMonitor.not_available\"),\n    );\n  });\n\n  it(\"renders response time when status is up\", () => {\n    useSWR.mockReturnValue({ data: { status: 200, latency: 10 }, error: undefined });\n\n    render(<SiteMonitor groupName=\"g\" serviceName=\"s\" />);\n\n    expect(useSWR).toHaveBeenCalledWith(\"/api/siteMonitor?groupName=g&serviceName=s\", { refreshInterval: 30000 });\n    expect(screen.getByText(\"10\")).toBeInTheDocument();\n  });\n\n  it(\"renders up label for basic style when status is ok\", () => {\n    useSWR.mockReturnValue({ data: { status: 200, latency: 1 }, error: undefined });\n\n    render(<SiteMonitor groupName=\"g\" serviceName=\"s\" style=\"basic\" />);\n\n    expect(screen.getByText(\"siteMonitor.up\")).toBeInTheDocument();\n  });\n\n  it(\"renders down label for failing status in basic style\", () => {\n    useSWR.mockReturnValue({ data: { status: 500, latency: 0 }, error: undefined });\n\n    render(<SiteMonitor groupName=\"g\" serviceName=\"s\" style=\"basic\" />);\n\n    expect(screen.getByText(\"siteMonitor.down\")).toBeInTheDocument();\n  });\n\n  it(\"renders the http status code for failing status in non-basic style\", () => {\n    useSWR.mockReturnValue({ data: { status: 500, latency: 0 }, error: undefined });\n\n    render(<SiteMonitor groupName=\"g\" serviceName=\"s\" />);\n\n    expect(screen.getByText(\"500\")).toBeInTheDocument();\n  });\n\n  it(\"renders an error label when SWR returns error\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: new Error(\"boom\") });\n\n    render(<SiteMonitor groupName=\"g\" serviceName=\"s\" />);\n\n    expect(screen.getByText(\"siteMonitor.error\")).toBeInTheDocument();\n  });\n\n  it(\"treats an embedded data.error as an error state\", () => {\n    useSWR.mockReturnValue({ data: { error: \"bad\" }, error: undefined });\n\n    render(<SiteMonitor groupName=\"g\" serviceName=\"s\" />);\n\n    expect(screen.getByText(\"siteMonitor.error\")).toBeInTheDocument();\n  });\n\n  it(\"renders a dot when style is dot\", () => {\n    useSWR.mockReturnValue({ data: { status: 500, latency: 0 }, error: undefined });\n\n    const { container } = render(<SiteMonitor groupName=\"g\" serviceName=\"s\" style=\"dot\" />);\n\n    expect(container.querySelector(\".rounded-full\")).toBeTruthy();\n    expect(screen.queryByText(\"500\")).toBeNull();\n  });\n});\n"
  },
  {
    "path": "src/components/services/status.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport useSWR from \"swr\";\n\nexport default function Status({ service, style }) {\n  const { t } = useTranslation();\n\n  const { data, error } = useSWR(`/api/docker/status/${service.container}/${service.server || \"\"}`);\n\n  let statusLabel = t(\"docker.unknown\");\n  let backgroundClass = \"px-1.5 py-0.5 bg-theme-500/10 dark:bg-theme-900/50\";\n  let colorClass = \"text-black/20 dark:text-white/40 \";\n\n  if (error) {\n    statusLabel = t(\"docker.error\");\n    colorClass = \"text-rose-500/80\";\n  } else if (data) {\n    if (data.status?.includes(\"running\")) {\n      colorClass = \"text-emerald-500/80\";\n\n      if (!data.health) {\n        statusLabel = data.status.replace(\"running\", t(\"docker.running\"));\n      } else {\n        statusLabel = data.health === \"healthy\" ? t(\"docker.healthy\") : data.health;\n\n        if (data.health === \"starting\") {\n          statusLabel = t(\"docker.starting\");\n          colorClass = \"text-blue-500/80\";\n        }\n\n        if (data.health === \"unhealthy\") {\n          statusLabel = t(\"docker.unhealthy\");\n          colorClass = \"text-orange-400/50 dark:text-orange-400/80\";\n        }\n      }\n    }\n\n    if (data.status === \"not found\" || data.status === \"exited\" || data.status?.startsWith(\"partial\")) {\n      if (data.status === \"not found\") statusLabel = t(\"docker.not_found\");\n      else if (data.status === \"exited\") statusLabel = t(\"docker.exited\");\n      else statusLabel = data.status.replace(\"partial\", t(\"docker.partial\"));\n      colorClass = \"text-orange-400/50 dark:text-orange-400/80\";\n    }\n  }\n\n  if (style === \"dot\") {\n    colorClass = colorClass.replace(/text-/g, \"bg-\").replace(/\\/\\d\\d/g, \"\");\n    backgroundClass = \"p-4 hover:bg-theme-500/10 dark:hover:bg-theme-900/20\";\n  }\n\n  return (\n    <div\n      className={`w-auto text-center overflow-hidden ${backgroundClass} rounded-b-[3px] docker-status docker-status-${statusLabel\n        .toLowerCase()\n        .replace(\" \", \"-\")}`}\n      title={statusLabel}\n    >\n      {style !== \"dot\" ? (\n        <div className={`text-[8px] font-bold ${colorClass} uppercase`}>{statusLabel}</div>\n      ) : (\n        <div className={`rounded-full h-3 w-3 ${colorClass}`} />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/services/status.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));\n\nvi.mock(\"swr\", () => ({\n  default: useSWR,\n}));\n\nimport Status from \"./status\";\n\ndescribe(\"components/services/status\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"requests docker status and renders unknown by default\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    render(<Status service={{ container: \"c\", server: \"s\" }} />);\n\n    expect(useSWR).toHaveBeenCalledWith(\"/api/docker/status/c/s\");\n    expect(screen.getByText(\"docker.unknown\")).toBeInTheDocument();\n  });\n\n  it(\"renders error when SWR fails\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: new Error(\"nope\") });\n\n    render(<Status service={{ container: \"c\", server: \"s\" }} />);\n\n    expect(screen.getByText(\"docker.error\")).toBeInTheDocument();\n  });\n\n  it(\"renders healthy/unhealthy and partial/exited/not found statuses\", () => {\n    useSWR.mockReturnValue({ data: { status: \"running\", health: \"healthy\" }, error: undefined });\n    render(<Status service={{ container: \"c\", server: \"s\" }} />);\n    expect(screen.getByText(\"docker.healthy\")).toBeInTheDocument();\n\n    useSWR.mockReturnValue({ data: { status: \"running\", health: \"unhealthy\" }, error: undefined });\n    render(<Status service={{ container: \"c\", server: \"s\" }} />);\n    expect(screen.getByText(\"docker.unhealthy\")).toBeInTheDocument();\n\n    useSWR.mockReturnValue({ data: { status: \"partial 1/2\" }, error: undefined });\n    render(<Status service={{ container: \"c\", server: \"s\" }} />);\n    expect(screen.getByText(\"docker.partial 1/2\")).toBeInTheDocument();\n\n    useSWR.mockReturnValue({ data: { status: \"exited\" }, error: undefined });\n    render(<Status service={{ container: \"c\", server: \"s\" }} />);\n    expect(screen.getByText(\"docker.exited\")).toBeInTheDocument();\n\n    useSWR.mockReturnValue({ data: { status: \"not found\" }, error: undefined });\n    render(<Status service={{ container: \"c\", server: \"s\" }} />);\n    expect(screen.getByText(\"docker.not_found\")).toBeInTheDocument();\n  });\n\n  it(\"renders starting health when container is running and starting\", () => {\n    useSWR.mockReturnValue({ data: { status: \"running\", health: \"starting\" }, error: undefined });\n\n    render(<Status service={{ container: \"c\", server: \"s\" }} />);\n\n    expect(screen.getByText(\"docker.starting\")).toBeInTheDocument();\n  });\n\n  it(\"renders a dot when style is dot\", () => {\n    useSWR.mockReturnValue({ data: { status: \"running\" }, error: undefined });\n\n    const { container } = render(<Status service={{ container: \"c\", server: \"s\" }} style=\"dot\" />);\n\n    expect(screen.queryByText(\"docker.running\")).not.toBeInTheDocument();\n    expect(container.querySelector(\".rounded-full\")).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "src/components/services/widget/block.jsx",
    "content": "import classNames from \"classnames\";\nimport { useTranslation } from \"next-i18next\";\nimport { useContext, useMemo } from \"react\";\n\nimport { BlockHighlightContext } from \"./highlight-context\";\n\nimport { evaluateHighlight, getHighlightClass } from \"utils/highlights\";\n\nexport default function Block({ value, highlightValue, label, field }) {\n  const { t } = useTranslation();\n  const highlightConfig = useContext(BlockHighlightContext);\n\n  const highlight = useMemo(() => {\n    if (!highlightConfig) return null;\n    const labels = Array.isArray(label) ? label : [label];\n    const candidates = [];\n    if (typeof field === \"string\") candidates.push(field);\n    for (const candidateLabel of labels) {\n      if (typeof candidateLabel === \"string\") candidates.push(candidateLabel);\n    }\n\n    for (const candidate of candidates) {\n      const result = evaluateHighlight(candidate, highlightValue ?? value, highlightConfig);\n      if (result) return result;\n    }\n\n    return null;\n  }, [field, label, value, highlightValue, highlightConfig]);\n\n  const highlightClass = useMemo(() => {\n    if (!highlight?.level) return undefined;\n    return getHighlightClass(highlight.level, highlightConfig);\n  }, [highlight, highlightConfig]);\n\n  const applyToValueOnly = highlight?.valueOnly === true;\n\n  return (\n    <div\n      className={classNames(\n        \"bg-theme-200/50 dark:bg-theme-900/20 rounded-sm m-1 flex-1 flex flex-col items-center justify-center text-center p-1\",\n        value === undefined ? \"animate-pulse\" : \"\",\n        highlightClass,\n        \"service-block\",\n      )}\n      data-highlight-level={highlight?.level}\n      data-highlight-source={highlight?.source}\n    >\n      <div className=\"font-thin text-sm\">{value === undefined || value === null ? \"-\" : value}</div>\n      <div\n        className={classNames(\"font-bold text-xs uppercase\", applyToValueOnly && \"text-theme-700 dark:text-theme-200\")}\n      >\n        {t(label)}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/services/widget/block.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { describe, expect, it } from \"vitest\";\n\nimport Block from \"./block\";\nimport { BlockHighlightContext } from \"./highlight-context\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\ndescribe(\"components/services/widget/block\", () => {\n  it(\"renders a placeholder when value is undefined\", () => {\n    const { container } = renderWithProviders(<Block label=\"some.label\" />, { settings: {} });\n\n    // Value slot is rendered as \"-\" while loading.\n    expect(container.textContent).toContain(\"-\");\n    expect(container.textContent).toContain(\"some.label\");\n  });\n\n  it(\"sets highlight metadata when a rule matches\", () => {\n    const highlightConfig = {\n      levels: { danger: \"danger-class\" },\n      fields: {\n        foo: {\n          numeric: { when: \"gt\", value: 10, level: \"danger\" },\n        },\n      },\n    };\n\n    const { container } = renderWithProviders(\n      <BlockHighlightContext.Provider value={highlightConfig}>\n        <Block label=\"foo.label\" field=\"foo\" value=\"11\" />\n      </BlockHighlightContext.Provider>,\n      { settings: {} },\n    );\n\n    const el = container.querySelector(\".service-block\");\n    expect(el).not.toBeNull();\n    expect(el.getAttribute(\"data-highlight-level\")).toBe(\"danger\");\n    expect(el.className).toContain(\"danger-class\");\n  });\n\n  it(\"prefers highlightValue over the rendered value for numeric highlighting\", () => {\n    const highlightConfig = {\n      levels: { warn: \"warn-class\" },\n      fields: {\n        foo: {\n          numeric: { when: \"gt\", value: 5, level: \"warn\" },\n        },\n      },\n    };\n\n    const { container } = renderWithProviders(\n      <BlockHighlightContext.Provider value={highlightConfig}>\n        <Block label=\"foo.label\" field=\"foo\" value=\"5.791 ms\" highlightValue={5.791} />\n      </BlockHighlightContext.Provider>,\n      { settings: {} },\n    );\n\n    const el = container.querySelector(\".service-block\");\n    expect(el).not.toBeNull();\n    expect(el.getAttribute(\"data-highlight-level\")).toBe(\"warn\");\n    expect(el.className).toContain(\"warn-class\");\n  });\n});\n"
  },
  {
    "path": "src/components/services/widget/container.jsx",
    "content": "import { useContext, useMemo } from \"react\";\nimport { SettingsContext } from \"utils/contexts/settings\";\n\nimport Error from \"./error\";\nimport { BlockHighlightContext } from \"./highlight-context\";\n\nimport { buildHighlightConfig } from \"utils/highlights\";\n\nconst ALIASED_WIDGETS = {\n  pialert: \"netalertx\",\n  hoarder: \"karakeep\",\n  jellyseerr: \"seerr\",\n  overseerr: \"seerr\",\n};\n\nexport default function Container({ error = false, children, service }) {\n  const { settings } = useContext(SettingsContext);\n\n  const highlightConfig = useMemo(\n    () => buildHighlightConfig(settings?.blockHighlights, service?.widget?.highlight, service?.widget?.type),\n    [settings?.blockHighlights, service?.widget?.highlight, service?.widget?.type],\n  );\n\n  if (error) {\n    if (settings.hideErrors || service.widget.hide_errors) {\n      return null;\n    }\n\n    return <Error service={service} error={error} />;\n  }\n\n  const childrenArray = Array.isArray(children) ? children : [children];\n\n  let visibleChildren = childrenArray;\n  let fields = service?.widget?.fields;\n  if (typeof fields === \"string\") fields = JSON.parse(service.widget.fields);\n  const type = service?.widget?.type;\n  if (fields && type) {\n    // if the field contains a \".\" then it most likely contains a common loc value\n    // logic now allows a fields array that can look like:\n    // fields: [ \"resources.cpu\", \"resources.mem\", \"field\"]\n    // or even\n    // fields: [ \"resources.cpu\", \"widget_type.field\" ]\n    visibleChildren = childrenArray?.filter((child) =>\n      fields.some((field) => {\n        let fullField = field;\n        if (!field.includes(\".\")) {\n          fullField = `${type}.${field}`;\n        }\n        let matches = fullField === (child?.props?.field || child?.props?.label);\n        // check if the field is an 'alias'\n        if (matches) {\n          return true;\n        } else if (ALIASED_WIDGETS[type]) {\n          matches = fullField.replace(type, ALIASED_WIDGETS[type]) === (child?.props?.field || child?.props?.label);\n\n          return matches;\n        }\n        // no match\n        return false;\n      }),\n    );\n  }\n  const content = <div className=\"relative flex flex-row w-full service-container\">{visibleChildren}</div>;\n\n  if (!highlightConfig) {\n    return content;\n  }\n\n  return <BlockHighlightContext.Provider value={highlightConfig}>{content}</BlockHighlightContext.Provider>;\n}\n"
  },
  {
    "path": "src/components/services/widget/container.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { useContext } from \"react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nimport Container from \"./container\";\nimport { BlockHighlightContext } from \"./highlight-context\";\n\nfunction Dummy({ label }) {\n  return <div data-testid={label} />;\n}\n\nfunction HighlightProbe() {\n  const value = useContext(BlockHighlightContext);\n  return <div data-testid=\"highlight-probe\" data-highlight={value ? \"yes\" : \"no\"} />;\n}\n\ndescribe(\"components/services/widget/container\", () => {\n  it(\"filters children based on widget.fields (auto-namespaced by widget type)\", () => {\n    renderWithProviders(\n      <Container service={{ widget: { type: \"omada\", fields: [\"connectedAp\", \"alerts\"] } }}>\n        <Dummy label=\"omada.connectedAp\" />\n        <Dummy label=\"omada.alerts\" />\n        <Dummy label=\"omada.activeUser\" />\n      </Container>,\n      { settings: {} },\n    );\n\n    expect(screen.getByTestId(\"omada.connectedAp\")).toBeInTheDocument();\n    expect(screen.getByTestId(\"omada.alerts\")).toBeInTheDocument();\n    expect(screen.queryByTestId(\"omada.activeUser\")).toBeNull();\n  });\n\n  it(\"accepts widget.fields as a JSON string\", () => {\n    renderWithProviders(\n      <Container service={{ widget: { type: \"omada\", fields: JSON.stringify([\"alerts\"]) } }}>\n        <Dummy label=\"omada.connectedAp\" />\n        <Dummy label=\"omada.alerts\" />\n      </Container>,\n      { settings: {} },\n    );\n\n    expect(screen.getByTestId(\"omada.alerts\")).toBeInTheDocument();\n    expect(screen.queryByTestId(\"omada.connectedAp\")).toBeNull();\n  });\n\n  it(\"supports aliased widget types when filtering (hoarder -> karakeep)\", () => {\n    renderWithProviders(\n      <Container service={{ widget: { type: \"hoarder\", fields: [\"hoarder.count\"] } }}>\n        <Dummy label=\"karakeep.count\" />\n      </Container>,\n      { settings: {} },\n    );\n\n    expect(screen.getByTestId(\"karakeep.count\")).toBeInTheDocument();\n  });\n\n  it(\"supports seerr aliases when filtering (jellyseerr/overseerr -> seerr)\", () => {\n    renderWithProviders(\n      <Container service={{ widget: { type: \"jellyseerr\", fields: [\"pending\"] } }}>\n        <Dummy label=\"seerr.pending\" />\n      </Container>,\n      { settings: {} },\n    );\n\n    expect(screen.getByTestId(\"seerr.pending\")).toBeInTheDocument();\n\n    renderWithProviders(\n      <Container service={{ widget: { type: \"overseerr\", fields: [\"processing\"] } }}>\n        <Dummy label=\"seerr.processing\" />\n      </Container>,\n      { settings: {} },\n    );\n\n    expect(screen.getByTestId(\"seerr.processing\")).toBeInTheDocument();\n  });\n\n  it(\"returns null when errors are hidden via settings.hideErrors\", () => {\n    const { container } = renderWithProviders(\n      <Container error=\"nope\" service={{ widget: { type: \"omada\", hide_errors: false } }}>\n        <Dummy label=\"omada.alerts\" />\n      </Container>,\n      { settings: { hideErrors: true } },\n    );\n\n    expect(container).toBeEmptyDOMElement();\n  });\n\n  it(\"skips the highlight provider when highlight levels are fully disabled\", () => {\n    renderWithProviders(\n      <Container service={{ widget: { type: \"omada\" } }}>\n        <HighlightProbe />\n      </Container>,\n      {\n        settings: {\n          blockHighlights: { levels: { good: null, warn: null, danger: null } },\n        },\n      },\n    );\n\n    expect(screen.getByTestId(\"highlight-probe\").getAttribute(\"data-highlight\")).toBe(\"no\");\n  });\n});\n"
  },
  {
    "path": "src/components/services/widget/error.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport { IoAlertCircle } from \"react-icons/io5\";\n\nfunction displayError(error) {\n  return JSON.stringify(error[1] ? error[1] : error, null, 4);\n}\n\nfunction displayData(data) {\n  return data.type === \"Buffer\" ? Buffer.from(data).toString() : JSON.stringify(data, 4);\n}\n\nexport default function Error({ error }) {\n  const { t } = useTranslation();\n\n  if (typeof error === \"string\") {\n    error = { message: error }; // eslint-disable-line no-param-reassign\n  } else if (typeof error === \"number\") {\n    error = { message: `Error ${error}` }; // eslint-disable-line no-param-reassign\n  }\n\n  if (error?.data?.error) {\n    error = error.data.error; // eslint-disable-line no-param-reassign\n  }\n\n  return (\n    <details className=\"px-1 pb-1\">\n      <summary className=\"block text-center mt-1 mb-0 mx-auto p-3 rounded-sm bg-rose-900/80 hover:bg-rose-900/95 text-theme-900 cursor-pointer\">\n        <div className=\"flex items-center justify-center text-xs font-bold\">\n          <IoAlertCircle className=\"mr-1 w-5 h-5\" />\n          {t(\"widget.api_error\")} {error.message && t(\"widget.information\")}\n        </div>\n      </summary>\n      <div className=\"bg-white dark:bg-theme-200/50 mt-2 rounded-sm text-rose-900 text-xs font-mono whitespace-pre-wrap break-all\">\n        <ul className=\"p-4\">\n          {error.message && (\n            <li>\n              <span className=\"text-black\">{t(\"widget.api_error\")}:</span> {error.message}\n            </li>\n          )}\n          {error.url && (\n            <li className=\"mt-2\">\n              <span className=\"text-black\">{t(\"widget.url\")}:</span> {error.url}\n            </li>\n          )}\n          {error.rawError && (\n            <li className=\"mt-2\">\n              <span className=\"text-black\">{t(\"widget.raw_error\")}:</span>\n              <div className=\"ml-2\">{displayError(error.rawError)}</div>\n            </li>\n          )}\n          {error.data && (\n            <li className=\"mt-2\">\n              <span className=\"text-black\">{t(\"widget.response_data\")}:</span>\n              <div className=\"ml-2\">{displayData(error.data)}</div>\n            </li>\n          )}\n        </ul>\n      </div>\n    </details>\n  );\n}\n"
  },
  {
    "path": "src/components/services/widget/error.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport Error from \"./error\";\n\ndescribe(\"components/services/widget/error\", () => {\n  it(\"normalizes string errors to an object with a message\", () => {\n    render(<Error error=\"boom\" />);\n\n    expect(screen.getByText((_, el) => el?.textContent === \"widget.api_error:\")).toBeInTheDocument();\n    expect(screen.getByText(/boom/)).toBeInTheDocument();\n  });\n\n  it(\"normalizes numeric errors to an object with a message\", () => {\n    render(<Error error={500} />);\n\n    expect(screen.getByText(/Error 500/)).toBeInTheDocument();\n  });\n\n  it(\"unwraps nested response errors and renders raw/data sections\", () => {\n    render(\n      <Error\n        error={{\n          message: \"outer\",\n          data: {\n            error: {\n              message: \"inner\",\n              url: \"https://example.com\",\n              rawError: [\"oops\", { code: 1 }],\n              data: { type: \"Buffer\", data: [97, 98] },\n            },\n          },\n        }}\n      />,\n    );\n\n    expect(screen.getByText(/inner/)).toBeInTheDocument();\n    expect(screen.getByText(\"https://example.com\")).toBeInTheDocument();\n    expect(screen.getByText(/\\\"code\\\": 1/)).toBeInTheDocument();\n    // Buffer.from({type:\"Buffer\",data:[97,98]}).toString() === \"ab\"\n    expect(screen.getByText(/ab/)).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/components/services/widget/highlight-context.jsx",
    "content": "import { createContext } from \"react\";\n\nexport const BlockHighlightContext = createContext(null);\n"
  },
  {
    "path": "src/components/services/widget/highlight-context.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport { useContext } from \"react\";\n\nimport { BlockHighlightContext } from \"./highlight-context\";\n\nfunction Reader() {\n  const value = useContext(BlockHighlightContext);\n  return <div data-testid=\"value\">{value === null ? \"null\" : value}</div>;\n}\n\ndescribe(\"components/services/widget/highlight-context\", () => {\n  it(\"defaults to null\", () => {\n    render(<Reader />);\n    expect(screen.getByTestId(\"value\")).toHaveTextContent(\"null\");\n  });\n\n  it(\"provides a value to consumers\", () => {\n    render(\n      <BlockHighlightContext.Provider value=\"on\">\n        <Reader />\n      </BlockHighlightContext.Provider>,\n    );\n    expect(screen.getByTestId(\"value\")).toHaveTextContent(\"on\");\n  });\n});\n"
  },
  {
    "path": "src/components/services/widget.jsx",
    "content": "import ErrorBoundary from \"components/errorboundry\";\nimport { useTranslation } from \"next-i18next\";\n\nimport components from \"widgets/components\";\n\nexport default function Widget({ widget, service }) {\n  const { t } = useTranslation(\"common\");\n\n  const ServiceWidget = components[widget.type];\n\n  const fullService = { ...service, widget };\n  if (ServiceWidget) {\n    return (\n      <ErrorBoundary>\n        <ServiceWidget service={fullService} />\n      </ErrorBoundary>\n    );\n  }\n\n  return (\n    <div className=\"bg-theme-200/50 dark:bg-theme-900/20 rounded-sm m-1 flex-1 flex flex-col items-center justify-center p-1 service-missing\">\n      <div className=\"font-thin text-sm\">{t(\"widget.missing_type\", { type: widget.type })}</div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/services/widget.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nvi.mock(\"components/errorboundry\", () => ({\n  default: function ErrorBoundaryMock({ children }) {\n    return <>{children}</>;\n  },\n}));\n\nvi.mock(\"widgets/components\", () => ({\n  default: {\n    mock: function MockWidget({ service }) {\n      return (\n        <div data-testid=\"mock-service-widget\">\n          {service.name}:{service.widget?.type}\n        </div>\n      );\n    },\n  },\n}));\n\nimport Widget from \"./widget\";\n\ndescribe(\"components/services/widget\", () => {\n  it(\"renders the mapped widget component and passes merged service.widget\", () => {\n    render(<Widget widget={{ type: \"mock\" }} service={{ name: \"Svc\" }} />);\n\n    expect(screen.getByTestId(\"mock-service-widget\")).toHaveTextContent(\"Svc:mock\");\n  });\n\n  it(\"renders a missing widget message when the type is unknown\", () => {\n    render(<Widget widget={{ type: \"nope\" }} service={{ name: \"Svc\" }} />);\n\n    expect(screen.getByText(\"widget.missing_type\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/components/tab.jsx",
    "content": "import classNames from \"classnames\";\nimport { useContext } from \"react\";\nimport { TabContext } from \"utils/contexts/tab\";\n\nfunction slugify(tabName) {\n  return tabName.toString().replace(/\\s+/g, \"-\").toLowerCase();\n}\n\nexport function slugifyAndEncode(tabName) {\n  return tabName !== undefined ? encodeURIComponent(slugify(tabName)) : \"\";\n}\n\nexport default function Tab({ tab }) {\n  const { activeTab, setActiveTab } = useContext(TabContext);\n\n  const matchesTab = decodeURIComponent(activeTab) === slugify(tab);\n\n  return (\n    <li\n      key={tab}\n      role=\"presentation\"\n      className={classNames(\"text-theme-700 dark:text-theme-200 relative h-10 w-full rounded-md flex\")}\n    >\n      <button\n        id={`${tab}-tab`}\n        type=\"button\"\n        role=\"tab\"\n        aria-controls={`#${tab}`}\n        aria-selected={matchesTab ? \"true\" : \"false\"}\n        className={classNames(\n          \"w-full rounded-md m-1\",\n          matchesTab ? \"bg-theme-300/20 dark:bg-white/10\" : \"hover:bg-theme-100/20 dark:hover:bg-white/5\",\n        )}\n        onClick={() => {\n          setActiveTab(slugifyAndEncode(tab));\n          window.location.hash = `#${slugifyAndEncode(tab)}`;\n        }}\n      >\n        {tab}\n      </button>\n    </li>\n  );\n}\n"
  },
  {
    "path": "src/components/tab.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { fireEvent, render, screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { TabContext } from \"utils/contexts/tab\";\n\nimport Tab, { slugifyAndEncode } from \"./tab\";\n\ndescribe(\"components/tab\", () => {\n  it(\"slugifyAndEncode lowercases and encodes spaces\", () => {\n    expect(slugifyAndEncode(\"My Tab\")).toBe(\"my-tab\");\n    expect(slugifyAndEncode(undefined)).toBe(\"\");\n  });\n\n  it(\"marks the matching tab as selected and updates hash on click\", () => {\n    const setActiveTab = vi.fn();\n\n    render(\n      <TabContext.Provider value={{ activeTab: \"my-tab\", setActiveTab }}>\n        <Tab tab=\"My Tab\" />\n      </TabContext.Provider>,\n    );\n\n    const btn = screen.getByRole(\"tab\");\n    expect(btn.getAttribute(\"aria-selected\")).toBe(\"true\");\n\n    fireEvent.click(btn);\n    expect(setActiveTab).toHaveBeenCalledWith(\"my-tab\");\n    expect(window.location.hash).toBe(\"#my-tab\");\n  });\n});\n"
  },
  {
    "path": "src/components/toggles/color.jsx",
    "content": "import { Popover, Transition } from \"@headlessui/react\";\nimport classNames from \"classnames\";\nimport { Fragment, useContext } from \"react\";\nimport { IoColorPalette } from \"react-icons/io5\";\nimport { ColorContext } from \"utils/contexts/color\";\n\nconst colors = [\n  \"slate\",\n  \"gray\",\n  \"zinc\",\n  \"neutral\",\n  \"stone\",\n  \"amber\",\n  \"yellow\",\n  \"lime\",\n  \"green\",\n  \"emerald\",\n  \"teal\",\n  \"cyan\",\n  \"sky\",\n  \"blue\",\n  \"indigo\",\n  \"violet\",\n  \"purple\",\n  \"fuchsia\",\n  \"pink\",\n  \"rose\",\n  \"red\",\n  \"white\",\n];\n\nexport default function ColorToggle() {\n  const { color: active, setColor } = useContext(ColorContext);\n\n  if (!active) {\n    return null;\n  }\n\n  return (\n    <div id=\"color\" className=\"w-full self-center\">\n      <Popover className=\"relative flex items-center\">\n        <Popover.Button className=\"outline-hidden\">\n          <IoColorPalette\n            className=\"h-5 w-5 text-theme-800 dark:text-theme-200 transition duration-150 ease-in-out\"\n            aria-hidden=\"true\"\n          />\n          <span className=\"sr-only\">Change color</span>\n        </Popover.Button>\n        <Transition\n          as={Fragment}\n          enter=\"transition ease-out duration-200\"\n          enterFrom=\"opacity-0 translate-y-1\"\n          enterTo=\"opacity-100 translate-y-0\"\n          leave=\"transition ease-in duration-150\"\n          leaveFrom=\"opacity-100 translate-y-0\"\n          leaveTo=\"opacity-0 translate-y-1\"\n        >\n          <Popover.Panel className=\"absolute -top-[75px] left-0 z-10\">\n            <div className=\"rounded-md shadow-lg ring-1 ring-black ring-opacity-5 w-[85vw] sm:w-full\">\n              <div className=\"relative grid gap-2 p-2 grid-cols-11 bg-white/50 dark:bg-white/10 shadow-black/10 dark:shadow-black/20 rounded-md shadow-md\">\n                {colors.map((color) => (\n                  <button type=\"button\" onClick={() => setColor(color)} key={color}>\n                    <div\n                      title={color}\n                      className={classNames(\n                        active === color ? \"border-2\" : \"border-0\",\n                        `rounded-md w-5 h-5 border-black/50 dark:border-white/50 theme-${color} bg-theme-400`,\n                      )}\n                    />\n                    <span className=\"sr-only\">{color}</span>\n                  </button>\n                ))}\n              </div>\n            </div>\n          </Popover.Panel>\n        </Transition>\n      </Popover>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/toggles/color.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { fireEvent, render, screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { ColorContext } from \"utils/contexts/color\";\n\n// Stub Popover/Transition to always render children.\nvi.mock(\"@headlessui/react\", async () => {\n  const React = await import(\"react\");\n  const { Fragment } = React;\n\n  function passthrough({ as: As = \"div\", children, ...props }) {\n    if (As === Fragment) return <>{typeof children === \"function\" ? children({ open: true }) : children}</>;\n    const content = typeof children === \"function\" ? children({ open: true }) : children;\n    return <As {...props}>{content}</As>;\n  }\n\n  function Popover({ children }) {\n    return <div>{typeof children === \"function\" ? children({ open: true }) : children}</div>;\n  }\n  function PopoverButton(props) {\n    return <button type=\"button\" {...props} />;\n  }\n  function PopoverPanel(props) {\n    return <div {...props} />;\n  }\n  Popover.Button = PopoverButton;\n  Popover.Panel = PopoverPanel;\n\n  return { Popover, Transition: passthrough };\n});\n\nimport ColorToggle from \"./color\";\n\ndescribe(\"components/toggles/color\", () => {\n  it(\"renders nothing when no active color is set\", () => {\n    const { container } = render(\n      <ColorContext.Provider value={{ color: null, setColor: vi.fn() }}>\n        <ColorToggle />\n      </ColorContext.Provider>,\n    );\n    expect(container).toBeEmptyDOMElement();\n  });\n\n  it(\"invokes setColor when a color button is clicked\", () => {\n    const setColor = vi.fn();\n    render(\n      <ColorContext.Provider value={{ color: \"slate\", setColor }}>\n        <ColorToggle />\n      </ColorContext.Provider>,\n    );\n\n    // Buttons contain a sr-only span with the color name.\n    const blue = screen.getByText(\"blue\").closest(\"button\");\n    fireEvent.click(blue);\n    expect(setColor).toHaveBeenCalledWith(\"blue\");\n  });\n});\n"
  },
  {
    "path": "src/components/toggles/revalidate.jsx",
    "content": "import { MdRefresh } from \"react-icons/md\";\n\nexport default function Revalidate() {\n  const revalidate = () => {\n    fetch(\"/api/revalidate\").then((res) => {\n      if (res.ok) {\n        window.location.reload();\n      }\n    });\n  };\n\n  return (\n    <div id=\"revalidate\" className=\"rounded-full flex align-middle self-center mr-3\">\n      <MdRefresh onClick={() => revalidate()} className=\"text-theme-800 dark:text-theme-200 w-6 h-6 cursor-pointer\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/toggles/revalidate.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { fireEvent, render } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport Revalidate from \"./revalidate\";\n\ndescribe(\"components/toggles/revalidate\", () => {\n  it(\"calls /api/revalidate and reloads when ok\", async () => {\n    const reload = vi.fn();\n    const fetchSpy = vi.spyOn(globalThis, \"fetch\").mockResolvedValueOnce({ ok: true });\n    vi.stubGlobal(\"location\", { reload });\n\n    render(<Revalidate />);\n    const icon = document.querySelector(\"svg\");\n    fireEvent.click(icon);\n\n    // allow promise chain to flush\n    await Promise.resolve();\n\n    expect(fetchSpy).toHaveBeenCalledWith(\"/api/revalidate\");\n    expect(reload).toHaveBeenCalledTimes(1);\n\n    fetchSpy.mockRestore();\n    vi.unstubAllGlobals();\n  });\n});\n"
  },
  {
    "path": "src/components/toggles/theme.jsx",
    "content": "import { useContext } from \"react\";\nimport { MdDarkMode, MdLightMode, MdToggleOff, MdToggleOn } from \"react-icons/md\";\nimport { ThemeContext } from \"utils/contexts/theme\";\n\nexport default function ThemeToggle() {\n  const { theme, setTheme } = useContext(ThemeContext);\n\n  if (!theme) {\n    return null;\n  }\n\n  return (\n    <div id=\"theme\" className=\"rounded-full flex self-end\">\n      <MdLightMode className=\"text-theme-800 dark:text-theme-200 w-5 h-5 m-1.5\" />\n      {theme === \"dark\" ? (\n        <MdToggleOn\n          onClick={() => setTheme(theme === \"dark\" ? \"light\" : \"dark\")}\n          className=\"text-theme-800 dark:text-theme-200 w-8 h-8 cursor-pointer\"\n        />\n      ) : (\n        <MdToggleOff\n          onClick={() => setTheme(theme === \"dark\" ? \"light\" : \"dark\")}\n          className=\"text-theme-800 dark:text-theme-200 w-8 h-8 cursor-pointer\"\n        />\n      )}\n      <MdDarkMode className=\"text-theme-800 dark:text-theme-200 w-5 h-5 m-1.5\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/toggles/theme.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { fireEvent, render } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { ThemeContext } from \"utils/contexts/theme\";\n\nimport ThemeToggle from \"./theme\";\n\ndescribe(\"components/toggles/theme\", () => {\n  it(\"renders nothing when theme is missing\", () => {\n    const { container } = render(\n      <ThemeContext.Provider value={{ theme: null, setTheme: vi.fn() }}>\n        <ThemeToggle />\n      </ThemeContext.Provider>,\n    );\n    expect(container).toBeEmptyDOMElement();\n  });\n\n  it(\"toggles from dark to light when clicked\", () => {\n    const setTheme = vi.fn();\n    render(\n      <ThemeContext.Provider value={{ theme: \"dark\", setTheme }}>\n        <ThemeToggle />\n      </ThemeContext.Provider>,\n    );\n\n    // The toggle is a clickable icon rendered as an svg (react-icons).\n    const toggles = document.querySelectorAll(\"svg\");\n    fireEvent.click(toggles[1]);\n    expect(setTheme).toHaveBeenCalledWith(\"light\");\n  });\n\n  it(\"toggles from light to dark when clicked\", () => {\n    const setTheme = vi.fn();\n    render(\n      <ThemeContext.Provider value={{ theme: \"light\", setTheme }}>\n        <ThemeToggle />\n      </ThemeContext.Provider>,\n    );\n\n    const toggles = document.querySelectorAll(\"svg\");\n    fireEvent.click(toggles[1]);\n    expect(setTheme).toHaveBeenCalledWith(\"dark\");\n  });\n});\n"
  },
  {
    "path": "src/components/version.jsx",
    "content": "import { compareVersions, validate } from \"compare-versions\";\nimport cache from \"memory-cache\";\nimport { useTranslation } from \"next-i18next\";\nimport { MdNewReleases } from \"react-icons/md\";\nimport useSWR from \"swr\";\n\nconst LATEST_RELEASE_CACHE_KEY = \"latestRelease\";\n\nexport default function Version({ disableUpdateCheck = false }) {\n  const { t, i18n } = useTranslation();\n\n  const buildTime = process.env.NEXT_PUBLIC_BUILDTIME?.length\n    ? process.env.NEXT_PUBLIC_BUILDTIME\n    : new Date().toISOString();\n  const revision = process.env.NEXT_PUBLIC_REVISION?.length ? process.env.NEXT_PUBLIC_REVISION : \"dev\";\n  const version = process.env.NEXT_PUBLIC_VERSION?.length ? process.env.NEXT_PUBLIC_VERSION : \"dev\";\n\n  // use Intl.DateTimeFormat to format the date\n  const formatDate = (date) => {\n    const options = {\n      year: \"numeric\",\n      month: \"short\",\n      day: \"numeric\",\n    };\n    return new Intl.DateTimeFormat(i18n.language, options).format(new Date(date));\n  };\n\n  let latestRelease = cache.get(LATEST_RELEASE_CACHE_KEY);\n\n  const { data: releaseData } = useSWR(latestRelease || disableUpdateCheck ? null : \"/api/releases\");\n\n  if (releaseData) {\n    latestRelease = releaseData?.[0];\n    // cache the latest release for 1h\n    cache.put(LATEST_RELEASE_CACHE_KEY, latestRelease, 3600000);\n  }\n\n  return (\n    <div id=\"version\" className=\"flex flex-row items-center\">\n      <span className=\"text-xs text-theme-500 dark:text-theme-400\">\n        {version === \"main\" || version === \"dev\" || version === \"nightly\" ? (\n          <>\n            {version} ({revision.substring(0, 7)}, {formatDate(buildTime)})\n          </>\n        ) : (\n          <a\n            href={`https://github.com/gethomepage/homepage/releases/tag/${version}`}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"ml-2 text-xs text-theme-500 dark:text-theme-400 flex flex-row items-center\"\n          >\n            {version} ({revision.substring(0, 7)}, {formatDate(buildTime)})\n          </a>\n        )}\n      </span>\n      {!validate(version)\n        ? null\n        : latestRelease &&\n          compareVersions(latestRelease.tag_name, version) > 0 && (\n            <a\n              href={latestRelease.html_url}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"ml-2 text-xs text-theme-500 dark:text-theme-400 flex flex-row items-center\"\n            >\n              <MdNewReleases className=\"mr-1\" /> {t(\"Update Available\")}\n            </a>\n          )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/version.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { cache, cv, useSWR } = vi.hoisted(() => ({\n  cache: {\n    get: vi.fn(),\n    put: vi.fn(),\n  },\n  cv: {\n    validate: vi.fn(),\n    compareVersions: vi.fn(),\n  },\n  useSWR: vi.fn(),\n}));\n\nvi.mock(\"memory-cache\", () => ({\n  default: cache,\n}));\n\nvi.mock(\"compare-versions\", () => ({\n  validate: cv.validate,\n  compareVersions: cv.compareVersions,\n}));\n\nvi.mock(\"swr\", () => ({\n  default: useSWR,\n}));\n\nimport Version from \"./version\";\n\ndescribe(\"components/version\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    process.env.NEXT_PUBLIC_VERSION = \"dev\";\n    process.env.NEXT_PUBLIC_REVISION = \"abcdef012345\";\n    process.env.NEXT_PUBLIC_BUILDTIME = \"2020-01-01T00:00:00.000Z\";\n  });\n\n  it(\"renders non-link version text for dev/main/nightly\", () => {\n    cv.validate.mockReturnValue(false);\n    cache.get.mockReturnValue(null);\n    useSWR.mockReturnValue({ data: undefined });\n\n    render(<Version />);\n\n    expect(screen.getByText(/dev \\(abcdef0/)).toBeInTheDocument();\n    expect(screen.queryAllByRole(\"link\")).toHaveLength(0);\n  });\n\n  it(\"renders tag link and shows update available when a newer release exists\", () => {\n    process.env.NEXT_PUBLIC_VERSION = \"1.2.3\";\n    cv.validate.mockReturnValue(true);\n    cache.get.mockReturnValue(null);\n    useSWR.mockReturnValue({\n      data: [{ tag_name: \"1.2.4\", html_url: \"http://example.com/release\" }],\n    });\n    cv.compareVersions.mockReturnValue(1);\n\n    render(<Version />);\n\n    const links = screen.getAllByRole(\"link\");\n    expect(links.find((a) => a.getAttribute(\"href\")?.includes(\"/releases/tag/1.2.3\"))).toBeTruthy();\n    expect(links.find((a) => a.getAttribute(\"href\") === \"http://example.com/release\")).toBeTruthy();\n  });\n\n  it(\"falls back build time to the current date when NEXT_PUBLIC_BUILDTIME is missing\", () => {\n    vi.useFakeTimers();\n    try {\n      vi.setSystemTime(new Date(\"2021-01-02T12:00:00.000Z\"));\n      process.env.NEXT_PUBLIC_BUILDTIME = \"\";\n\n      cv.validate.mockReturnValue(false);\n      cache.get.mockReturnValue(null);\n      useSWR.mockReturnValue({ data: undefined });\n\n      render(<Version />);\n\n      expect(screen.getByText(/2021/)).toBeInTheDocument();\n    } finally {\n      vi.useRealTimers();\n    }\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/datetime/datetime.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport { useEffect, useState } from \"react\";\n\nimport Container from \"../widget/container\";\nimport Raw from \"../widget/raw\";\n\nconst textSizes = {\n  \"4xl\": \"text-4xl\",\n  \"3xl\": \"text-3xl\",\n  \"2xl\": \"text-2xl\",\n  xl: \"text-xl\",\n  lg: \"text-lg\",\n  md: \"text-md\",\n  sm: \"text-sm\",\n  xs: \"text-xs\",\n};\n\nexport default function DateTime({ options }) {\n  const { text_size: textSize, locale, format } = options;\n  const { i18n } = useTranslation();\n  const [date, setDate] = useState(\"\");\n  const dateLocale = locale ?? i18n.language;\n\n  useEffect(() => {\n    const dateFormat = new Intl.DateTimeFormat(dateLocale, { ...format });\n    setDate(dateFormat.format(new Date()));\n    const interval = setInterval(() => {\n      setDate(dateFormat.format(new Date()));\n    }, 1000);\n    return () => clearInterval(interval);\n  }, [date, setDate, dateLocale, format]);\n\n  return (\n    <Container options={options} additionalClassNames=\"information-widget-datetime\">\n      <Raw>\n        <div className=\"flex flex-row items-center grow justify-end\">\n          <span className={`text-theme-800 dark:text-theme-200 tabular-nums ${textSizes[textSize || \"lg\"]}`}>\n            {date}\n          </span>\n        </div>\n      </Raw>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/datetime/datetime.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { act, screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nimport DateTime from \"./datetime\";\n\ndescribe(\"components/widgets/datetime\", () => {\n  it(\"renders formatted date/time and updates on an interval\", async () => {\n    vi.useFakeTimers();\n    try {\n      vi.setSystemTime(new Date(\"2020-01-01T00:00:00.000Z\"));\n\n      const format = { timeZone: \"UTC\", hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" };\n      const expected0 = new Intl.DateTimeFormat(\"en-US\", format).format(new Date());\n\n      renderWithProviders(<DateTime options={{ locale: \"en-US\", format }} />, { settings: { target: \"_self\" } });\n\n      // `render` wraps in `act`, so effects should flush synchronously.\n      expect(screen.getByText(expected0)).toBeInTheDocument();\n\n      act(() => {\n        vi.advanceTimersByTime(1000);\n      });\n      const expected1 = new Intl.DateTimeFormat(\"en-US\", format).format(new Date());\n\n      expect(screen.getByText(expected1)).toBeInTheDocument();\n    } finally {\n      vi.useRealTimers();\n    }\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/glances/glances.jsx",
    "content": "import classNames from \"classnames\";\nimport { useTranslation } from \"next-i18next\";\nimport { useContext } from \"react\";\nimport { FaMemory, FaRegClock, FaThermometerHalf } from \"react-icons/fa\";\nimport { FiCpu, FiHardDrive } from \"react-icons/fi\";\nimport useSWR from \"swr\";\nimport { SettingsContext } from \"utils/contexts/settings\";\n\nimport Error from \"../widget/error\";\nimport Resource from \"../widget/resource\";\nimport Resources from \"../widget/resources\";\nimport WidgetLabel from \"../widget/widget_label\";\n\nconst cpuSensorLabels = [\"cpu_thermal\", \"Core\", \"Tctl\", \"Temperature\"];\n\nfunction convertToFahrenheit(t) {\n  return (t * 9) / 5 + 32;\n}\n\nexport default function Widget({ options }) {\n  const { t, i18n } = useTranslation();\n  const { settings } = useContext(SettingsContext);\n  const diskUnits = options.diskUnits === \"bbytes\" ? \"common.bbytes\" : \"common.bytes\";\n\n  const { data, error } = useSWR(\n    `/api/widgets/glances?${new URLSearchParams({ lang: i18n.language, ...options }).toString()}`,\n    {\n      refreshInterval: 1500,\n    },\n  );\n\n  if (error || data?.error) {\n    return <Error options={options} />;\n  }\n\n  if (!data) {\n    return (\n      <Resources options={options} additionalClassNames=\"information-widget-glances\">\n        {options.cpu !== false && <Resource icon={FiCpu} label={t(\"glances.wait\")} percentage=\"0\" />}\n        {options.mem !== false && <Resource icon={FaMemory} label={t(\"glances.wait\")} percentage=\"0\" />}\n        {options.cputemp && <Resource icon={FaThermometerHalf} label={t(\"glances.wait\")} percentage=\"0\" />}\n        {options.disk && !Array.isArray(options.disk) && (\n          <Resource key={options.disk} icon={FiHardDrive} label={t(\"glances.wait\")} percentage=\"0\" />\n        )}\n        {options.disk &&\n          Array.isArray(options.disk) &&\n          options.disk.map((disk) => (\n            <Resource key={`disk_${disk}`} icon={FiHardDrive} label={t(\"glances.wait\")} percentage=\"0\" />\n          ))}\n        {options.uptime && <Resource icon={FaRegClock} label={t(\"glances.wait\")} percentage=\"0\" />}\n        {options.label && <WidgetLabel label={options.label} />}\n      </Resources>\n    );\n  }\n\n  const unit = options.units === \"imperial\" ? \"fahrenheit\" : \"celsius\";\n  let mainTemp = 0;\n  let maxTemp = 80;\n  const cpuSensors = data.sensors?.filter(\n    (s) => cpuSensorLabels.some((label) => s.label.startsWith(label)) && s.type === \"temperature_core\",\n  );\n  if (options.cputemp && cpuSensors) {\n    try {\n      mainTemp = cpuSensors.reduce((acc, s) => acc + s.value, 0) / cpuSensors.length;\n      maxTemp = Math.max(\n        cpuSensors.reduce((acc, s) => acc + (s.warning > 0 ? s.warning : 0), 0) / cpuSensors.length,\n        maxTemp,\n      );\n      if (unit === \"fahrenheit\") {\n        mainTemp = convertToFahrenheit(mainTemp);\n        maxTemp = convertToFahrenheit(maxTemp);\n      }\n    } catch (e) {\n      // cpu sensor retrieval failed\n    }\n  }\n  const tempPercent = Math.round((mainTemp / maxTemp) * 100);\n\n  let disks = [];\n\n  if (options.disk) {\n    disks = Array.isArray(options.disk)\n      ? options.disk.map((disk) => data.fs.find((d) => d.mnt_point === disk)).filter((d) => d)\n      : [data.fs.find((d) => d.mnt_point === options.disk)].filter((d) => d);\n  }\n\n  const addedClasses = classNames(\"information-widget-glances\", { expanded: options.expanded });\n\n  return (\n    <Resources options={options} target={settings.target ?? \"_blank\"} additionalClassNames={addedClasses}>\n      {options.cpu !== false && (\n        <Resource\n          icon={FiCpu}\n          value={t(\"common.number\", {\n            value: data.cpu.total,\n            style: \"unit\",\n            unit: \"percent\",\n            maximumFractionDigits: 0,\n          })}\n          label={t(\"glances.cpu\")}\n          expandedValue={t(\"common.number\", {\n            value: data.load.min15,\n            style: \"unit\",\n            unit: \"percent\",\n            maximumFractionDigits: 0,\n          })}\n          expandedLabel={t(\"glances.load\")}\n          percentage={data.cpu.total}\n          expanded={options.expanded}\n        />\n      )}\n      {options.mem !== false && (\n        <Resource\n          icon={FaMemory}\n          value={t(\"common.bytes\", {\n            value: data.mem.available,\n            maximumFractionDigits: 1,\n            binary: true,\n          })}\n          label={t(\"glances.free\")}\n          expandedValue={t(\"common.bytes\", {\n            value: data.mem.total,\n            maximumFractionDigits: 1,\n            binary: true,\n          })}\n          expandedLabel={t(\"glances.total\")}\n          percentage={data.mem.percent}\n          expanded={options.expanded}\n        />\n      )}\n      {disks.map((disk) => (\n        <Resource\n          key={`disk_${disk.mnt_point ?? disk.device_name}`}\n          icon={FiHardDrive}\n          value={t(diskUnits, { value: disk.free })}\n          label={t(\"glances.free\")}\n          expandedValue={t(diskUnits, { value: disk.size })}\n          expandedLabel={t(\"glances.total\")}\n          percentage={disk.percent}\n          expanded={options.expanded}\n        />\n      ))}\n      {options.cputemp && mainTemp > 0 && (\n        <Resource\n          icon={FaThermometerHalf}\n          value={t(\"common.number\", {\n            value: mainTemp,\n            maximumFractionDigits: 1,\n            style: \"unit\",\n            unit,\n          })}\n          label={t(\"glances.temp\")}\n          expandedValue={t(\"common.number\", {\n            value: maxTemp,\n            maximumFractionDigits: 1,\n            style: \"unit\",\n            unit,\n          })}\n          expandedLabel={t(\"glances.warn\")}\n          percentage={tempPercent}\n          expanded={options.expanded}\n        />\n      )}\n      {options.uptime && data.uptime && (\n        <Resource\n          icon={FaRegClock}\n          value={data.uptime.replace(\" days,\", t(\"glances.days\")).replace(/:\\d\\d:\\d\\d$/g, t(\"glances.hours\"))}\n          label={t(\"glances.uptime\")}\n          percentage={Math.round((new Date().getSeconds() / 60) * 100).toString()}\n        />\n      )}\n      {options.label && <WidgetLabel label={options.label} />}\n    </Resources>\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/glances/glances.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));\nvi.mock(\"swr\", () => ({ default: useSWR }));\n\nimport Glances from \"./glances\";\n\ndescribe(\"components/widgets/glances\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders an error state when SWR errors\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: new Error(\"nope\") });\n\n    renderWithProviders(<Glances options={{ cpu: true, mem: true }} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getByText(\"widget.api_error\")).toBeInTheDocument();\n  });\n\n  it(\"renders placeholder resources while loading\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<Glances options={{ cpu: true, mem: true, cputemp: true, disk: \"/\", uptime: true }} />, {\n      settings: { target: \"_self\" },\n    });\n\n    // All placeholders use glances.wait.\n    expect(screen.getAllByText(\"glances.wait\").length).toBeGreaterThan(0);\n  });\n\n  it(\"renders placeholder disk resources when loading and disk is an array\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<Glances options={{ disk: [\"/\", \"/data\"] }} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getAllByText(\"glances.wait\").length).toBeGreaterThan(0);\n  });\n\n  it(\"renders cpu percent and memory available when data is present\", () => {\n    useSWR.mockReturnValue({\n      data: {\n        cpu: { total: 12.34 },\n        load: { min15: 5 },\n        mem: { available: 1024, total: 2048, percent: 50 },\n        fs: [{ mnt_point: \"/\", free: 100, size: 200, percent: 50 }],\n        sensors: [],\n        uptime: \"1 days, 00:00:00\",\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<Glances options={{ cpu: true, mem: true, disk: \"/\", uptime: true }} />, {\n      settings: { target: \"_self\" },\n    });\n\n    // common.number is mocked to return the numeric value as a string.\n    expect(screen.getByText(\"12.34\")).toBeInTheDocument();\n    // common.bytes is mocked similarly; we just assert the numeric value is present.\n    expect(screen.getByText(\"1024\")).toBeInTheDocument();\n  });\n\n  it(\"handles cpu sensor retrieval failures gracefully\", () => {\n    const sensor = {\n      label: \"cpu_thermal-0\",\n      type: \"temperature_core\",\n      get value() {\n        throw new Error(\"boom\");\n      },\n      warning: 90,\n    };\n\n    useSWR.mockReturnValue({\n      data: {\n        cpu: { total: 1 },\n        load: { min15: 1 },\n        mem: { available: 1, total: 1, percent: 1 },\n        fs: [],\n        sensors: [sensor],\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<Glances options={{ cputemp: true }} />, { settings: { target: \"_self\" } });\n\n    // When sensor processing fails, it should not render the temp block.\n    expect(screen.queryByText(\"glances.temp\")).toBeNull();\n    expect(screen.getByText(\"glances.cpu\")).toBeInTheDocument();\n  });\n\n  it(\"renders temperature in fahrenheit for matching cpu sensors and marks the widget expanded\", () => {\n    useSWR.mockReturnValue({\n      data: {\n        cpu: { total: 1 },\n        load: { min15: 1 },\n        mem: { available: 1, total: 1, percent: 1 },\n        fs: [],\n        sensors: [\n          { label: \"cpu_thermal-0\", type: \"temperature_core\", value: 40, warning: 90 },\n          { label: \"Core 1\", type: \"temperature_core\", value: 50, warning: 100 },\n        ],\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(\n      <Glances options={{ cputemp: true, units: \"imperial\", expanded: true, url: \"http://glances\" }} />,\n      {\n        settings: { target: \"_self\" },\n      },\n    );\n\n    // avg(40,50)=45C => 113F\n    expect(screen.getByText(\"113\")).toBeInTheDocument();\n    expect(screen.getByRole(\"link\")).toHaveClass(\"expanded\");\n  });\n\n  it(\"renders disk resources for an array of mount points and filters missing mounts\", () => {\n    useSWR.mockReturnValue({\n      data: {\n        cpu: { total: 1 },\n        load: { min15: 1 },\n        mem: { available: 1, total: 1, percent: 1 },\n        fs: [{ mnt_point: \"/\", free: 10, size: 20, percent: 50 }],\n        sensors: [],\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(\n      <Glances options={{ disk: [\"/\", \"/missing\"], diskUnits: \"bbytes\", expanded: true, url: \"http://glances\" }} />,\n      {\n        settings: { target: \"_self\" },\n      },\n    );\n\n    // only one mount exists, but both free + total values should render for it\n    expect(screen.getByText(\"10\")).toBeInTheDocument();\n    expect(screen.getByText(\"20\")).toBeInTheDocument();\n  });\n\n  it(\"formats uptime into translated day/hour labels\", () => {\n    useSWR.mockReturnValue({\n      data: {\n        cpu: { total: 1 },\n        load: { min15: 1 },\n        mem: { available: 1, total: 1, percent: 1 },\n        fs: [],\n        sensors: [],\n        uptime: \"1 days, 00:00:00\",\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<Glances options={{ uptime: true, url: \"http://glances\" }} />, {\n      settings: { target: \"_self\" },\n    });\n\n    expect(screen.getByText(\"1glances.days 00glances.hours\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/greeting/greeting.jsx",
    "content": "import Container from \"../widget/container\";\nimport Raw from \"../widget/raw\";\n\nconst textSizes = {\n  \"4xl\": \"text-4xl\",\n  \"3xl\": \"text-3xl\",\n  \"2xl\": \"text-2xl\",\n  xl: \"text-xl\",\n  lg: \"text-lg\",\n  md: \"text-md\",\n  sm: \"text-sm\",\n  xs: \"text-xs\",\n};\n\nexport default function Greeting({ options }) {\n  if (options.text) {\n    return (\n      <Container options={options} additionalClassNames=\"information-widget-greeting\">\n        <Raw>\n          <span className={`text-theme-800 dark:text-theme-200 mr-3 ${textSizes[options.text_size || \"xl\"]}`}>\n            {options.text}\n          </span>\n        </Raw>\n      </Container>\n    );\n  }\n}\n"
  },
  {
    "path": "src/components/widgets/greeting/greeting.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nimport Greeting from \"./greeting\";\n\ndescribe(\"components/widgets/greeting\", () => {\n  it(\"renders nothing when text is not configured\", () => {\n    const { container } = renderWithProviders(<Greeting options={{}} />, { settings: { target: \"_self\" } });\n    expect(container).toBeEmptyDOMElement();\n  });\n\n  it(\"renders configured greeting text\", () => {\n    renderWithProviders(<Greeting options={{ text: \"Hello there\" }} />, { settings: { target: \"_self\" } });\n    expect(screen.getByText(\"Hello there\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/kubernetes/kubernetes.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport useSWR from \"swr\";\n\nimport Container from \"../widget/container\";\nimport Error from \"../widget/error\";\nimport Raw from \"../widget/raw\";\n\nimport Node from \"./node\";\n\nexport default function Widget({ options }) {\n  const { cluster, nodes } = options;\n  const { i18n } = useTranslation();\n\n  const defaultData = {\n    cpu: {\n      load: 0,\n      total: 0,\n      percent: 0,\n    },\n    memory: {\n      used: 0,\n      total: 0,\n      free: 0,\n      percent: 0,\n    },\n  };\n\n  const { data, error } = useSWR(`/api/widgets/kubernetes?${new URLSearchParams({ lang: i18n.language }).toString()}`, {\n    refreshInterval: 1500,\n  });\n\n  if (error || data?.error) {\n    return <Error options={options} />;\n  }\n\n  if (!data) {\n    return (\n      <Container options={options} additionalClassNames=\"information-widget-kubernetes\">\n        <Raw>\n          <div className=\"flex flex-row self-center flex-wrap justify-between\">\n            {cluster.show && <Node type=\"cluster\" key=\"cluster\" options={options.cluster} data={defaultData} />}\n            {nodes.show && <Node type=\"node\" key=\"nodes\" options={options.nodes} data={defaultData} />}\n          </div>\n        </Raw>\n      </Container>\n    );\n  }\n\n  return (\n    <Container options={options} additionalClassNames=\"information-widget-kubernetes\">\n      <Raw>\n        <div className=\"flex flex-row self-center flex-wrap justify-between\">\n          {cluster.show && <Node key=\"cluster\" type=\"cluster\" options={options.cluster} data={data.cluster} />}\n          {nodes.show &&\n            data.nodes &&\n            data.nodes.map((node) => <Node key={node.name} type=\"node\" options={options.nodes} data={node} />)}\n        </div>\n      </Raw>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/kubernetes/kubernetes.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));\nvi.mock(\"swr\", () => ({ default: useSWR }));\n\nvi.mock(\"./node\", () => ({\n  default: ({ type }) => <div data-testid=\"kube-node\" data-type={type} />,\n}));\n\nimport Kubernetes from \"./kubernetes\";\n\ndescribe(\"components/widgets/kubernetes\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders an error state when SWR errors\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: new Error(\"nope\") });\n\n    renderWithProviders(<Kubernetes options={{ cluster: { show: true }, nodes: { show: true } }} />, {\n      settings: { target: \"_self\" },\n    });\n\n    expect(screen.getByText(\"widget.api_error\")).toBeInTheDocument();\n  });\n\n  it(\"renders placeholder nodes while loading\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<Kubernetes options={{ cluster: { show: true }, nodes: { show: true } }} />, {\n      settings: { target: \"_self\" },\n    });\n\n    expect(screen.getAllByTestId(\"kube-node\").map((n) => n.getAttribute(\"data-type\"))).toEqual([\"cluster\", \"node\"]);\n  });\n\n  it(\"renders a node per returned entry when data is available\", () => {\n    useSWR.mockReturnValue({\n      data: { cluster: {}, nodes: [{ name: \"n1\" }, { name: \"n2\" }] },\n      error: undefined,\n    });\n\n    renderWithProviders(<Kubernetes options={{ cluster: { show: true }, nodes: { show: true } }} />, {\n      settings: { target: \"_self\" },\n    });\n\n    // cluster + 2 nodes\n    expect(screen.getAllByTestId(\"kube-node\")).toHaveLength(3);\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/kubernetes/node.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport { FaMemory } from \"react-icons/fa\";\nimport { FiAlertTriangle, FiCpu, FiServer } from \"react-icons/fi\";\nimport { SiKubernetes } from \"react-icons/si\";\n\nimport UsageBar from \"../resources/usage-bar\";\n\nexport default function Node({ type, options, data }) {\n  const { t } = useTranslation();\n\n  function icon() {\n    if (type === \"cluster\") {\n      return <SiKubernetes className=\"text-theme-800 dark:text-theme-200 w-5 h-5\" />;\n    }\n    if (data.ready) {\n      return <FiServer className=\"text-theme-800 dark:text-theme-200 w-5 h-5\" />;\n    }\n    return <FiAlertTriangle className=\"text-theme-800 dark:text-theme-200 w-5 h-5\" />;\n  }\n\n  return (\n    <div className=\"flex flex-col max-w:full sm:basis-auto self-center grow-0 flex-wrap ml-4\">\n      <div className=\"flex flex-row self-center flex-wrap justify-between\">\n        <div className=\"flex-none flex flex-row items-center mr-3 py-1.5\">\n          {icon()}\n          <div className=\"flex flex-col ml-3 text-left min-w-[85px]\">\n            <div className=\"text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between\">\n              <div className=\"pl-0.5\">\n                {t(\"common.number\", {\n                  value: data?.cpu?.percent ?? 0,\n                  style: \"unit\",\n                  unit: \"percent\",\n                  maximumFractionDigits: 0,\n                })}\n              </div>\n              <FiCpu className=\"text-theme-800 dark:text-theme-200 w-3 h-3\" />\n            </div>\n            <UsageBar percent={data?.cpu?.percent ?? 0} />\n            <div className=\"text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between\">\n              <div className=\"pl-0.5\">\n                {t(\"common.bytes\", {\n                  value: data?.memory?.free ?? 0,\n                  maximumFractionDigits: 0,\n                  binary: true,\n                })}\n              </div>\n              <FaMemory className=\"text-theme-800 dark:text-theme-200 w-3 h-3\" />\n            </div>\n            <UsageBar percent={data?.memory?.percent} />\n            {options.showLabel && (\n              <div className=\"pt-1 text-center text-theme-800 dark:text-theme-200 text-xs\">\n                {type === \"cluster\" ? options.label : data.name}\n              </div>\n            )}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/kubernetes/node.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport Node from \"./node\";\n\ndescribe(\"components/widgets/kubernetes/node\", () => {\n  it(\"renders cluster label when showLabel is enabled\", () => {\n    const data = { cpu: { percent: 50 }, memory: { free: 123, percent: 10 } };\n\n    const { container } = render(<Node type=\"cluster\" options={{ showLabel: true, label: \"Cluster A\" }} data={data} />);\n\n    expect(screen.getByText(\"50\")).toBeInTheDocument();\n    expect(screen.getByText(\"123\")).toBeInTheDocument();\n    expect(screen.getByText(\"Cluster A\")).toBeInTheDocument();\n    expect(container.querySelectorAll('div[style*=\"width:\"]').length).toBeGreaterThan(0);\n  });\n\n  it(\"renders node name when showLabel is enabled for node type\", () => {\n    const data = { name: \"node-1\", ready: true, cpu: { percent: 1 }, memory: { free: 2, percent: 3 } };\n\n    render(<Node type=\"node\" options={{ showLabel: true }} data={data} />);\n\n    expect(screen.getByText(\"node-1\")).toBeInTheDocument();\n  });\n\n  it(\"renders a warning icon when the node is not ready\", () => {\n    const data = { name: \"node-2\", ready: false, cpu: { percent: 1 }, memory: { free: 2, percent: 3 } };\n\n    render(<Node type=\"node\" options={{ showLabel: true }} data={data} />);\n\n    expect(screen.getByText(\"node-2\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/logo/logo.jsx",
    "content": "import ResolvedIcon from \"components/resolvedicon\";\n\nimport Container from \"../widget/container\";\nimport Raw from \"../widget/raw\";\n\nexport default function Logo({ options }) {\n  return (\n    <Container\n      options={options}\n      additionalClassNames={`information-widget-logo ${options.icon ? \"resolved\" : \"fallback\"}`}\n    >\n      <Raw>\n        {options.icon ? (\n          <div className=\"resolved mr-3\">\n            <ResolvedIcon icon={options.icon} width={48} height={48} />\n          </div>\n        ) : (\n          // fallback to homepage logo\n          <div className=\"fallback w-12 h-12\">\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              viewBox=\"0 0 1024 1024\"\n              style={{\n                enableBackground: \"new 0 0 1024 1024\",\n              }}\n              xmlSpace=\"preserve\"\n              className=\"w-full h-full\"\n            >\n              <style>\n                {\n                  \".st0{display:none}.st3{stroke-linecap:square}.st3,.st4{fill:none;stroke:#fff;stroke-miterlimit:10}.st6{display:inline;fill:#333}.st7{fill:#fff}\"\n                }\n              </style>\n              <g id=\"Icon\">\n                <path\n                  d=\"M771.9 191c27.7 0 50.1 26.5 50.1 59.3v186.4l-100.2.3V250.3c0-32.8 22.4-59.3 50.1-59.3z\"\n                  style={{\n                    fill: \"rgba(var(--color-logo-start))\",\n                  }}\n                />\n                <linearGradient\n                  id=\"homepage_logo_gradient\"\n                  gradientUnits=\"userSpaceOnUse\"\n                  x1={200.746}\n                  y1={225.015}\n                  x2={764.986}\n                  y2={789.255}\n                >\n                  <stop\n                    offset={0}\n                    style={{\n                      stopColor: \"rgba(var(--color-logo-start))\",\n                    }}\n                  />\n                  <stop\n                    offset={1}\n                    style={{\n                      stopColor: \"rgba(var(--color-logo-stop))\",\n                    }}\n                  />\n                </linearGradient>\n                <path\n                  d=\"M721.8 250.3c0-32.7 22.4-59.3 50.1-59.3H253.1c-27.7 0-50.1 26.5-50.1 59.3v582.2l90.2-75.7-.1-130.3H375v61.8l88-73.8 258.8 217.9V250.6\"\n                  style={{\n                    fill: \"url(#homepage_logo_gradient)\",\n                  }}\n                />\n              </g>\n            </svg>\n          </div>\n        )}\n      </Raw>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/logo/logo.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nvi.mock(\"components/resolvedicon\", () => ({\n  default: ({ icon }) => <div data-testid=\"resolved-icon\" data-icon={icon} />,\n}));\n\nimport Logo from \"./logo\";\n\ndescribe(\"components/widgets/logo\", () => {\n  it(\"renders a fallback SVG when no icon is configured\", () => {\n    const { container } = renderWithProviders(<Logo options={{}} />, { settings: { target: \"_self\" } });\n    expect(screen.queryByTestId(\"resolved-icon\")).toBeNull();\n    expect(container.querySelector(\"svg\")).not.toBeNull();\n  });\n\n  it(\"renders the configured icon via ResolvedIcon\", () => {\n    renderWithProviders(<Logo options={{ icon: \"mdi:home\" }} />, { settings: { target: \"_self\" } });\n    const icon = screen.getByTestId(\"resolved-icon\");\n    expect(icon.getAttribute(\"data-icon\")).toBe(\"mdi:home\");\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/longhorn/longhorn.jsx",
    "content": "import useSWR from \"swr\";\n\nimport Container from \"../widget/container\";\nimport Error from \"../widget/error\";\nimport Raw from \"../widget/raw\";\n\nimport Node from \"./node\";\n\nexport default function Longhorn({ options }) {\n  const { expanded, total, labels, include, nodes } = options;\n  const { data, error } = useSWR(`/api/widgets/longhorn`, {\n    refreshInterval: 1500,\n  });\n\n  if (error || data?.error) {\n    return <Error options={options} />;\n  }\n\n  if (!data) {\n    return (\n      <Container options={options} additionalClassNames=\"infomation-widget-longhorn\">\n        <Raw>\n          <div className=\"flex flex-row self-center flex-wrap justify-between\" />\n        </Raw>\n      </Container>\n    );\n  }\n\n  return (\n    <Container options={options} additionalClassNames=\"infomation-widget-longhorn\">\n      <Raw>\n        <div className=\"flex flex-row self-center flex-wrap justify-between\">\n          {data.nodes\n            .filter((node) => {\n              if (node.id === \"total\") {\n                return total;\n              }\n              if (!nodes) {\n                return false;\n              }\n              if (include && !include.includes(node.id)) {\n                return false;\n              }\n              return true;\n            })\n            .map((node) => (\n              <div key={node.id}>\n                <Node data={{ node }} expanded={expanded} labels={labels} />\n              </div>\n            ))}\n        </div>\n      </Raw>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/longhorn/longhorn.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));\n\nvi.mock(\"swr\", () => ({ default: useSWR }));\n\nvi.mock(\"./node\", () => ({\n  default: ({ data }) => <div data-testid=\"longhorn-node\" data-id={data.node.id} />,\n}));\n\nimport Longhorn from \"./longhorn\";\n\ndescribe(\"components/widgets/longhorn\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders an error state when SWR errors\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: new Error(\"nope\") });\n\n    renderWithProviders(<Longhorn options={{ nodes: true, total: true }} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getByText(\"widget.api_error\")).toBeInTheDocument();\n  });\n\n  it(\"renders an empty container while loading\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Longhorn options={{ nodes: true, total: true }} />, {\n      settings: { target: \"_self\" },\n    });\n\n    expect(container.querySelector(\".infomation-widget-longhorn\")).not.toBeNull();\n    expect(screen.queryAllByTestId(\"longhorn-node\")).toHaveLength(0);\n  });\n\n  it(\"filters nodes based on options (total/include)\", () => {\n    useSWR.mockReturnValue({\n      data: {\n        nodes: [{ id: \"total\" }, { id: \"node1\" }, { id: \"node2\" }],\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(\n      <Longhorn options={{ nodes: true, total: true, include: [\"node1\"], expanded: false, labels: false }} />,\n      { settings: { target: \"_self\" } },\n    );\n\n    const nodes = screen.getAllByTestId(\"longhorn-node\");\n    expect(nodes.map((n) => n.getAttribute(\"data-id\"))).toEqual([\"total\", \"node1\"]);\n  });\n\n  it(\"omits non-total nodes when options.nodes is false\", () => {\n    useSWR.mockReturnValue({\n      data: {\n        nodes: [{ id: \"total\" }, { id: \"node1\" }],\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<Longhorn options={{ nodes: false, total: true }} />, { settings: { target: \"_self\" } });\n\n    const nodes = screen.getAllByTestId(\"longhorn-node\");\n    expect(nodes.map((n) => n.getAttribute(\"data-id\"))).toEqual([\"total\"]);\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/longhorn/node.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport { FiHardDrive } from \"react-icons/fi\";\n\nimport Resource from \"../widget/resource\";\nimport WidgetLabel from \"../widget/widget_label\";\n\nexport default function Node({ data, expanded, labels }) {\n  const { t } = useTranslation();\n\n  return (\n    <Resource\n      additionalClassNames=\"information-widget-longhorn-node\"\n      icon={FiHardDrive}\n      value={t(\"common.bytes\", { value: data.node.available })}\n      label={t(\"resources.free\")}\n      expandedValue={t(\"common.bytes\", { value: data.node.maximum })}\n      expandedLabel={t(\"resources.total\")}\n      percentage={Math.round(((data.node.maximum - data.node.available) / data.node.maximum) * 100)}\n      expanded={expanded}\n    >\n      {labels && <WidgetLabel label={data.node.id} />}\n    </Resource>\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/longhorn/node.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nconst { Resource } = vi.hoisted(() => ({\n  Resource: vi.fn(({ children }) => <div data-testid=\"lh-resource\">{children}</div>),\n}));\n\nvi.mock(\"../widget/resource\", () => ({\n  default: Resource,\n}));\n\nvi.mock(\"../widget/widget_label\", () => ({\n  default: ({ label }) => <div data-testid=\"lh-label\">{label}</div>,\n}));\n\nimport Node from \"./node\";\n\ndescribe(\"components/widgets/longhorn/node\", () => {\n  it(\"passes calculated percentage and renders label when enabled\", () => {\n    const data = { node: { id: \"n1\", available: 25, maximum: 100 } };\n\n    render(<Node data={{ node: data.node }} expanded labels />);\n\n    expect(Resource).toHaveBeenCalledTimes(1);\n    const callProps = Resource.mock.calls[0][0];\n    expect(callProps.percentage).toBe(75);\n    expect(callProps.expanded).toBe(true);\n    expect(screen.getByTestId(\"lh-label\")).toHaveTextContent(\"n1\");\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/openmeteo/openmeteo.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport { useCallback, useEffect, useState } from \"react\";\nimport { MdLocationDisabled, MdLocationSearching } from \"react-icons/md\";\nimport { WiCloudDown } from \"react-icons/wi\";\nimport useSWR from \"swr\";\n\nimport mapIcon from \"../../../utils/weather/openmeteo-condition-map\";\nimport Container from \"../widget/container\";\nimport ContainerButton from \"../widget/container_button\";\nimport Error from \"../widget/error\";\nimport PrimaryText from \"../widget/primary_text\";\nimport SecondaryText from \"../widget/secondary_text\";\nimport WidgetIcon from \"../widget/widget_icon\";\n\nfunction Widget({ options }) {\n  const { t } = useTranslation();\n\n  const { data, error } = useSWR(`/api/widgets/openmeteo?${new URLSearchParams({ ...options }).toString()}`);\n\n  if (error || data?.error) {\n    return <Error options={options} />;\n  }\n\n  if (!data) {\n    return (\n      <Container options={options} additionalClassNames=\"information-widget-openmeteo\">\n        <PrimaryText>{t(\"weather.updating\")}</PrimaryText>\n        <SecondaryText>{t(\"weather.wait\")}</SecondaryText>\n        <WidgetIcon icon={WiCloudDown} size=\"l\" />\n      </Container>\n    );\n  }\n\n  const unit = options.units === \"metric\" ? \"celsius\" : \"fahrenheit\";\n  const condition = data.current_weather.weathercode;\n  const timeOfDay =\n    data.current_weather.time > data.daily.sunrise[0] && data.current_weather.time < data.daily.sunset[0]\n      ? \"day\"\n      : \"night\";\n\n  return (\n    <Container options={options} additionalClassNames=\"information-widget-openmeteo\">\n      <PrimaryText>\n        {options.label && `${options.label}, `}\n        {t(\"common.number\", {\n          value: data.current_weather.temperature,\n          style: \"unit\",\n          unit,\n          ...options.format,\n        })}\n      </PrimaryText>\n      <SecondaryText>{t(`wmo.${data.current_weather.weathercode}-${timeOfDay}`)}</SecondaryText>\n      <WidgetIcon icon={mapIcon(condition, timeOfDay)} size=\"xl\" />\n    </Container>\n  );\n}\n\nexport default function OpenMeteo({ options }) {\n  const { t } = useTranslation();\n  const [location, setLocation] = useState(false);\n  const [requesting, setRequesting] = useState(false);\n\n  if (!location && options.latitude && options.longitude) {\n    setLocation({ latitude: options.latitude, longitude: options.longitude });\n  }\n\n  const requestLocation = useCallback(() => {\n    setRequesting(true);\n    if (typeof window !== \"undefined\") {\n      navigator.geolocation.getCurrentPosition(\n        (position) => {\n          setLocation({ latitude: position.coords.latitude, longitude: position.coords.longitude });\n          setRequesting(false);\n        },\n        () => {\n          setRequesting(false);\n        },\n        {\n          enableHighAccuracy: true,\n          maximumAge: 1000 * 60 * 60 * 3,\n          timeout: 1000 * 30,\n        },\n      );\n    }\n  }, []);\n\n  useEffect(() => {\n    if (!options.latitude && !options.longitude && typeof navigator !== \"undefined\") {\n      navigator.permissions?.query({ name: \"geolocation\" }).then((result) => {\n        if (result.state === \"granted\") {\n          requestLocation();\n        }\n      });\n    }\n  }, [options.latitude, options.longitude, requestLocation]);\n\n  if (!location) {\n    return (\n      <ContainerButton\n        options={options}\n        callback={requestLocation}\n        additionalClassNames=\"information-widget-openmeteo-location-button\"\n      >\n        <PrimaryText>{t(\"weather.current\")}</PrimaryText>\n        <SecondaryText>{t(\"weather.allow\")}</SecondaryText>\n        <WidgetIcon icon={requesting ? MdLocationSearching : MdLocationDisabled} size=\"m\" pulse />\n      </ContainerButton>\n    );\n  }\n\n  return <Widget options={{ ...location, ...options }} />;\n}\n"
  },
  {
    "path": "src/components/widgets/openmeteo/openmeteo.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { fireEvent, screen, waitFor } from \"@testing-library/react\";\nimport { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));\nvi.mock(\"swr\", () => ({ default: useSWR }));\n\nvi.mock(\"react-icons/md\", () => ({\n  MdLocationDisabled: (props) => <svg data-testid=\"location-disabled\" {...props} />,\n  MdLocationSearching: (props) => <svg data-testid=\"location-searching\" {...props} />,\n}));\n\nimport OpenMeteo from \"./openmeteo\";\n\ndescribe(\"components/widgets/openmeteo\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  afterEach(() => {\n    vi.unstubAllGlobals();\n  });\n\n  it(\"renders an error state when the widget api returns an error\", async () => {\n    useSWR.mockReturnValue({ data: { error: \"nope\" }, error: undefined });\n\n    renderWithProviders(<OpenMeteo options={{ latitude: 1, longitude: 2 }} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getByText(\"widget.api_error\")).toBeInTheDocument();\n  });\n\n  it(\"renders a location prompt when no coordinates are available\", () => {\n    renderWithProviders(<OpenMeteo options={{}} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getByText(\"weather.current\")).toBeInTheDocument();\n    expect(screen.getByText(\"weather.allow\")).toBeInTheDocument();\n  });\n\n  it(\"requests browser geolocation on click and then renders the updating state\", async () => {\n    const getCurrentPosition = vi.fn((success) => success({ coords: { latitude: 10, longitude: 20 } }));\n    vi.stubGlobal(\"navigator\", {\n      permissions: { query: vi.fn().mockResolvedValue({ state: \"prompt\" }) },\n      geolocation: { getCurrentPosition },\n    });\n\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<OpenMeteo options={{}} />, { settings: { target: \"_self\" } });\n\n    screen.getByRole(\"button\").click();\n\n    await waitFor(() => {\n      expect(getCurrentPosition).toHaveBeenCalled();\n    });\n    expect(screen.getByText(\"weather.updating\")).toBeInTheDocument();\n  });\n\n  it(\"clears the requesting state when the browser denies geolocation\", async () => {\n    const getCurrentPosition = vi.fn((_success, failure) => setTimeout(() => failure(), 10));\n    vi.stubGlobal(\"navigator\", {\n      permissions: { query: vi.fn().mockResolvedValue({ state: \"prompt\" }) },\n      geolocation: { getCurrentPosition },\n    });\n\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<OpenMeteo options={{}} />, { settings: { target: \"_self\" } });\n\n    fireEvent.click(screen.getByRole(\"button\"));\n    expect(screen.getByTestId(\"location-searching\")).toBeInTheDocument();\n\n    await waitFor(() => {\n      expect(screen.getByTestId(\"location-disabled\")).toBeInTheDocument();\n    });\n  });\n\n  it(\"auto-requests geolocation when permissions are granted\", async () => {\n    const getCurrentPosition = vi.fn((success) => success({ coords: { latitude: 30, longitude: 40 } }));\n    const query = vi.fn().mockResolvedValue({ state: \"granted\" });\n    vi.stubGlobal(\"navigator\", {\n      permissions: { query },\n      geolocation: { getCurrentPosition },\n    });\n\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<OpenMeteo options={{}} />, { settings: { target: \"_self\" } });\n\n    await waitFor(() => {\n      expect(query).toHaveBeenCalled();\n      expect(getCurrentPosition).toHaveBeenCalled();\n    });\n  });\n\n  it(\"renders temperature and condition when coordinates are provided\", async () => {\n    useSWR.mockReturnValue({\n      data: {\n        current_weather: { temperature: 22.2, weathercode: 0, time: \"2020-01-01T12:00\" },\n        daily: { sunrise: [\"2020-01-01T06:00\"], sunset: [\"2020-01-01T18:00\"] },\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<OpenMeteo options={{ latitude: 1, longitude: 2, label: \"Home\", format: {} }} />, {\n      settings: { target: \"_self\" },\n    });\n\n    await waitFor(() => {\n      expect(screen.getByText(\"Home, 22.2\")).toBeInTheDocument();\n    });\n    expect(screen.getByText(\"wmo.0-day\")).toBeInTheDocument();\n  });\n\n  it(\"uses night conditions and fahrenheit units when configured\", async () => {\n    useSWR.mockReturnValue({\n      data: {\n        current_weather: { temperature: 72, weathercode: 1, time: \"2020-01-01T23:00\" },\n        daily: { sunrise: [\"2020-01-01T06:00\"], sunset: [\"2020-01-01T18:00\"] },\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<OpenMeteo options={{ latitude: 1, longitude: 2, units: \"imperial\", format: {} }} />, {\n      settings: { target: \"_self\" },\n    });\n\n    await waitFor(() => {\n      expect(screen.getByText(\"72\")).toBeInTheDocument();\n    });\n    expect(screen.getByText(\"wmo.1-night\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/openweathermap/weather.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport { useCallback, useEffect, useState } from \"react\";\nimport { MdLocationDisabled, MdLocationSearching } from \"react-icons/md\";\nimport { WiCloudDown } from \"react-icons/wi\";\nimport useSWR from \"swr\";\n\nimport mapIcon from \"../../../utils/weather/owm-condition-map\";\nimport Container from \"../widget/container\";\nimport ContainerButton from \"../widget/container_button\";\nimport Error from \"../widget/error\";\nimport PrimaryText from \"../widget/primary_text\";\nimport SecondaryText from \"../widget/secondary_text\";\nimport WidgetIcon from \"../widget/widget_icon\";\n\nfunction Widget({ options }) {\n  const { t, i18n } = useTranslation();\n\n  const { data, error } = useSWR(\n    `/api/widgets/openweathermap?${new URLSearchParams({ lang: i18n.language, ...options }).toString()}`,\n  );\n\n  if (error || data?.cod === 401 || data?.error) {\n    return <Error options={options} />;\n  }\n\n  if (!data) {\n    return (\n      <Container options={options} additionalClassNames=\"information-widget-openweathermap\">\n        <PrimaryText>{t(\"weather.updating\")}</PrimaryText>\n        <SecondaryText>{t(\"weather.wait\")}</SecondaryText>\n        <WidgetIcon icon={WiCloudDown} size=\"l\" />\n      </Container>\n    );\n  }\n\n  const unit = options.units === \"metric\" ? \"celsius\" : \"fahrenheit\";\n\n  const condition = data.weather[0].id;\n  const timeOfDay = data.dt > data.sys.sunrise && data.dt < data.sys.sunset ? \"day\" : \"night\";\n\n  return (\n    <Container options={options} additionalClassNames=\"information-widget-openweathermap\">\n      <PrimaryText>\n        {options.label && `${options.label}, `}\n        {t(\"common.number\", { value: data.main.temp, style: \"unit\", unit, ...options.format })}\n      </PrimaryText>\n      <SecondaryText>{data.weather[0].description}</SecondaryText>\n      <WidgetIcon icon={mapIcon(condition, timeOfDay)} size=\"xl\" />\n    </Container>\n  );\n}\n\nexport default function OpenWeatherMap({ options }) {\n  const { t } = useTranslation();\n  const [location, setLocation] = useState(false);\n  const [requesting, setRequesting] = useState(false);\n\n  if (!location && options.latitude && options.longitude) {\n    setLocation({ latitude: options.latitude, longitude: options.longitude });\n  }\n\n  const requestLocation = useCallback(() => {\n    setRequesting(true);\n    if (typeof window !== \"undefined\") {\n      navigator.geolocation.getCurrentPosition(\n        (position) => {\n          setLocation({ latitude: position.coords.latitude, longitude: position.coords.longitude });\n          setRequesting(false);\n        },\n        () => {\n          setRequesting(false);\n        },\n        {\n          enableHighAccuracy: true,\n          maximumAge: 1000 * 60 * 60 * 3,\n          timeout: 1000 * 30,\n        },\n      );\n    }\n  }, []);\n\n  useEffect(() => {\n    if (!options.latitude && !options.longitude && typeof navigator !== \"undefined\") {\n      navigator.permissions?.query({ name: \"geolocation\" }).then((result) => {\n        if (result.state === \"granted\") {\n          requestLocation();\n        }\n      });\n    }\n  }, [options.latitude, options.longitude, requestLocation]);\n\n  if (!location) {\n    return (\n      <ContainerButton options={options} callback={requestLocation}>\n        <PrimaryText>{t(\"weather.current\")}</PrimaryText>\n        <SecondaryText>{t(\"weather.allow\")}</SecondaryText>\n        <WidgetIcon icon={requesting ? MdLocationSearching : MdLocationDisabled} size=\"m\" pulse />\n      </ContainerButton>\n    );\n  }\n\n  return <Widget options={{ ...location, ...options }} />;\n}\n"
  },
  {
    "path": "src/components/widgets/openweathermap/weather.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { fireEvent, screen, waitFor } from \"@testing-library/react\";\nimport { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));\nvi.mock(\"swr\", () => ({ default: useSWR }));\n\nvi.mock(\"react-icons/md\", () => ({\n  MdLocationDisabled: (props) => <svg data-testid=\"location-disabled\" {...props} />,\n  MdLocationSearching: (props) => <svg data-testid=\"location-searching\" {...props} />,\n}));\n\nimport OpenWeatherMap from \"./weather\";\n\ndescribe(\"components/widgets/openweathermap\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  afterEach(() => {\n    vi.unstubAllGlobals();\n  });\n\n  it(\"renders an error state when SWR errors or the API reports an auth error\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: new Error(\"nope\") });\n    renderWithProviders(<OpenWeatherMap options={{ latitude: 1, longitude: 2 }} />, { settings: { target: \"_self\" } });\n    expect(screen.getByText(\"widget.api_error\")).toBeInTheDocument();\n\n    useSWR.mockReturnValue({ data: { cod: 401 }, error: undefined });\n    renderWithProviders(<OpenWeatherMap options={{ latitude: 1, longitude: 2 }} />, { settings: { target: \"_self\" } });\n    expect(screen.getAllByText(\"widget.api_error\").length).toBeGreaterThan(0);\n  });\n\n  it(\"renders a location prompt when no coordinates are available\", () => {\n    renderWithProviders(<OpenWeatherMap options={{}} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getByText(\"weather.current\")).toBeInTheDocument();\n    expect(screen.getByText(\"weather.allow\")).toBeInTheDocument();\n  });\n\n  it(\"auto-requests geolocation when permissions are granted\", async () => {\n    const getCurrentPosition = vi.fn((success) => success({ coords: { latitude: 30, longitude: 40 } }));\n    const query = vi.fn().mockResolvedValue({ state: \"granted\" });\n    vi.stubGlobal(\"navigator\", {\n      permissions: { query },\n      geolocation: { getCurrentPosition },\n    });\n\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<OpenWeatherMap options={{}} />, { settings: { target: \"_self\" } });\n\n    await waitFor(() => {\n      expect(query).toHaveBeenCalled();\n      expect(getCurrentPosition).toHaveBeenCalled();\n    });\n  });\n\n  it(\"requests browser geolocation on click and then renders the updating state\", async () => {\n    const getCurrentPosition = vi.fn((success) => success({ coords: { latitude: 10, longitude: 20 } }));\n    vi.stubGlobal(\"navigator\", {\n      permissions: { query: vi.fn().mockResolvedValue({ state: \"prompt\" }) },\n      geolocation: { getCurrentPosition },\n    });\n\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<OpenWeatherMap options={{}} />, { settings: { target: \"_self\" } });\n\n    screen.getByRole(\"button\").click();\n\n    await waitFor(() => {\n      expect(getCurrentPosition).toHaveBeenCalled();\n    });\n    expect(screen.getByText(\"weather.updating\")).toBeInTheDocument();\n  });\n\n  it(\"clears the requesting state when the browser denies geolocation\", async () => {\n    const getCurrentPosition = vi.fn((_success, failure) => setTimeout(() => failure(), 10));\n    vi.stubGlobal(\"navigator\", {\n      permissions: { query: vi.fn().mockResolvedValue({ state: \"prompt\" }) },\n      geolocation: { getCurrentPosition },\n    });\n\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<OpenWeatherMap options={{}} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getByTestId(\"location-disabled\")).toBeInTheDocument();\n    fireEvent.click(screen.getByRole(\"button\"));\n    expect(screen.getByTestId(\"location-searching\")).toBeInTheDocument();\n\n    await waitFor(() => {\n      expect(screen.getByTestId(\"location-disabled\")).toBeInTheDocument();\n    });\n  });\n\n  it(\"renders temperature and description when coordinates are provided\", async () => {\n    useSWR.mockReturnValue({\n      data: {\n        main: { temp: 71 },\n        weather: [{ id: 800, description: \"clear sky\" }],\n        dt: 10,\n        sys: { sunrise: 0, sunset: 100 },\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<OpenWeatherMap options={{ latitude: 1, longitude: 2, label: \"Home\", format: {} }} />, {\n      settings: { target: \"_self\" },\n    });\n\n    await waitFor(() => {\n      expect(screen.getByText(\"Home, 71\")).toBeInTheDocument();\n    });\n    expect(screen.getByText(\"clear sky\")).toBeInTheDocument();\n  });\n\n  it(\"uses night conditions and celsius units when configured\", async () => {\n    useSWR.mockReturnValue({\n      data: {\n        main: { temp: 10 },\n        weather: [{ id: 800, description: \"clear sky\" }],\n        dt: 200,\n        sys: { sunrise: 0, sunset: 100 },\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<OpenWeatherMap options={{ latitude: 1, longitude: 2, units: \"metric\", format: {} }} />, {\n      settings: { target: \"_self\" },\n    });\n\n    await waitFor(() => {\n      expect(screen.getByText(\"10\")).toBeInTheDocument();\n    });\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/queue/queueEntry.jsx",
    "content": "export default function QueueEntry({ title, activity, timeLeft, progress, size }) {\n  return (\n    <div className=\"text-theme-700 dark:text-theme-200 relative h-5 rounded-md bg-theme-200/50 dark:bg-theme-900/20 m-1 px-1 flex\">\n      <div\n        className=\"absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0 -ml-1\"\n        style={{\n          width: `${progress}%`,\n        }}\n      />\n      <div className=\"text-xs z-10 self-center ml-2 relative h-4 grow mr-2\">\n        <div className=\"absolute w-full whitespace-nowrap text-ellipsis overflow-hidden text-left\">{title}</div>\n      </div>\n      <div className=\"self-center text-xs flex justify-end mr-1.5 pl-1 z-10 text-ellipsis overflow-hidden whitespace-nowrap\">\n        {size && `${size} - `}\n        {timeLeft ? `${activity} - ${timeLeft}` : activity}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/queue/queueEntry.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport QueueEntry from \"./queueEntry\";\n\ndescribe(\"components/widgets/queue/queueEntry\", () => {\n  it(\"renders title and progress width\", () => {\n    const { container } = render(\n      <QueueEntry title=\"Download\" activity=\"Downloading\" timeLeft=\"1m\" progress={42} size=\"1GB\" />,\n    );\n\n    expect(screen.getByText(\"Download\")).toBeInTheDocument();\n    expect(screen.getByText(\"1GB - Downloading - 1m\")).toBeInTheDocument();\n\n    const bar = container.querySelector(\"div[style]\");\n    expect(bar.style.width).toBe(\"42%\");\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/resources/cpu.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport { FiCpu } from \"react-icons/fi\";\nimport useSWR from \"swr\";\n\nimport Error from \"../widget/error\";\nimport Resource from \"../widget/resource\";\n\nexport default function Cpu({ expanded, refresh = 1500 }) {\n  const { t } = useTranslation();\n\n  const { data, error } = useSWR(`/api/widgets/resources?type=cpu`, {\n    refreshInterval: refresh,\n  });\n\n  if (error || data?.error) {\n    return <Error />;\n  }\n\n  if (!data) {\n    return (\n      <Resource\n        icon={FiCpu}\n        value=\"-\"\n        label={t(\"resources.cpu\")}\n        expandedValue=\"-\"\n        expandedLabel={t(\"resources.load\")}\n        percentage=\"0\"\n        expanded={expanded}\n      />\n    );\n  }\n\n  return (\n    <Resource\n      icon={FiCpu}\n      value={t(\"common.number\", {\n        value: data.cpu.usage,\n        style: \"unit\",\n        unit: \"percent\",\n        maximumFractionDigits: 0,\n      })}\n      label={t(\"resources.cpu\")}\n      expandedValue={t(\"common.number\", {\n        value: data.cpu.load,\n        maximumFractionDigits: 2,\n      })}\n      expandedLabel={t(\"resources.load\")}\n      percentage={data.cpu.usage}\n      expanded={expanded}\n    />\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/resources/cpu.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { useSWR, Resource, Error } = vi.hoisted(() => ({\n  useSWR: vi.fn(),\n  Resource: vi.fn(() => <div data-testid=\"resource\" />),\n  Error: vi.fn(() => <div data-testid=\"error\" />),\n}));\n\nvi.mock(\"swr\", () => ({ default: useSWR }));\nvi.mock(\"../widget/resource\", () => ({ default: Resource }));\nvi.mock(\"../widget/error\", () => ({ default: Error }));\n\nimport Cpu from \"./cpu\";\n\ndescribe(\"components/widgets/resources/cpu\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders a placeholder Resource while loading\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    render(<Cpu expanded refresh={1000} />);\n\n    expect(Resource).toHaveBeenCalled();\n    const props = Resource.mock.calls[0][0];\n    expect(props.value).toBe(\"-\");\n    expect(props.expanded).toBe(true);\n  });\n\n  it(\"renders usage/load values when data is present\", () => {\n    useSWR.mockReturnValue({\n      data: { cpu: { usage: 12.3, load: 1.23 } },\n      error: undefined,\n    });\n\n    render(<Cpu expanded={false} />);\n\n    const props = Resource.mock.calls[0][0];\n    expect(props.value).toBe(\"12.3\");\n    expect(props.expandedValue).toBe(\"1.23\");\n    expect(props.percentage).toBe(12.3);\n  });\n\n  it(\"renders Error when SWR errors\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: new Error(\"nope\") });\n\n    render(<Cpu expanded />);\n\n    expect(Error).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/resources/cputemp.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport { FaThermometerHalf } from \"react-icons/fa\";\nimport useSWR from \"swr\";\n\nimport Error from \"../widget/error\";\nimport Resource from \"../widget/resource\";\n\nfunction convertToFahrenheit(t) {\n  return (t * 9) / 5 + 32;\n}\n\nexport default function CpuTemp({ expanded, units, refresh = 1500, tempmin = 0, tempmax = -1 }) {\n  const { t } = useTranslation();\n\n  const { data, error } = useSWR(`/api/widgets/resources?type=cputemp`, {\n    refreshInterval: refresh,\n  });\n\n  if (error || data?.error) {\n    return <Error />;\n  }\n\n  if (!data || !data.cputemp) {\n    return (\n      <Resource\n        icon={FaThermometerHalf}\n        value=\"-\"\n        label={t(\"resources.temp\")}\n        expandedValue=\"-\"\n        expandedLabel={t(\"resources.max\")}\n        expanded={expanded}\n      />\n    );\n  }\n\n  let mainTemp = data.cputemp.main;\n  if (data.cputemp.cores?.length) {\n    mainTemp = data.cputemp.cores.reduce((a, b) => a + b) / data.cputemp.cores.length;\n  }\n  const unit = units === \"imperial\" ? \"fahrenheit\" : \"celsius\";\n  mainTemp = unit === \"celsius\" ? mainTemp : convertToFahrenheit(mainTemp);\n\n  const minTemp = tempmin < mainTemp ? tempmin : mainTemp;\n  let maxTemp = tempmax;\n  if (maxTemp < minTemp) {\n    maxTemp = unit === \"celsius\" ? data.cputemp.max : convertToFahrenheit(data.cputemp.max);\n  }\n\n  return (\n    <Resource\n      icon={FaThermometerHalf}\n      value={t(\"common.number\", {\n        value: mainTemp,\n        maximumFractionDigits: 1,\n        style: \"unit\",\n        unit,\n      })}\n      label={t(\"resources.temp\")}\n      expandedValue={t(\"common.number\", {\n        value: maxTemp,\n        maximumFractionDigits: 1,\n        style: \"unit\",\n        unit,\n      })}\n      expandedLabel={t(\"resources.max\")}\n      percentage={Math.round(((mainTemp - minTemp) / (maxTemp - minTemp)) * 100)}\n      expanded={expanded}\n    />\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/resources/cputemp.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { useSWR, Resource, Error } = vi.hoisted(() => ({\n  useSWR: vi.fn(),\n  Resource: vi.fn(() => <div data-testid=\"resource\" />),\n  Error: vi.fn(() => <div data-testid=\"error\" />),\n}));\n\nvi.mock(\"swr\", () => ({ default: useSWR }));\nvi.mock(\"../widget/resource\", () => ({ default: Resource }));\nvi.mock(\"../widget/error\", () => ({ default: Error }));\n\nimport CpuTemp from \"./cputemp\";\n\ndescribe(\"components/widgets/resources/cputemp\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholder when temperature data is missing\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n    render(<CpuTemp expanded units=\"metric\" />);\n\n    const props = Resource.mock.calls[0][0];\n    expect(props.value).toBe(\"-\");\n  });\n\n  it(\"averages core temps, converts to fahrenheit and computes percentage\", () => {\n    useSWR.mockReturnValue({\n      data: { cputemp: { main: 10, cores: [10, 10], max: 20 } },\n      error: undefined,\n    });\n\n    render(<CpuTemp expanded={false} units=\"imperial\" tempmin={0} tempmax={-1} />);\n\n    const props = Resource.mock.calls[0][0];\n    // common.number mock returns string of value\n    expect(props.value).toBe(\"50\");\n    expect(props.expandedValue).toBe(\"68\");\n    expect(props.percentage).toBe(74);\n  });\n\n  it(\"renders Error when SWR errors\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: new Error(\"nope\") });\n\n    render(<CpuTemp expanded units=\"metric\" />);\n\n    expect(Error).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/resources/disk.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport { FiHardDrive } from \"react-icons/fi\";\nimport useSWR from \"swr\";\n\nimport Error from \"../widget/error\";\nimport Resource from \"../widget/resource\";\n\nexport default function Disk({ options, expanded, diskUnits, refresh = 1500 }) {\n  const { t } = useTranslation();\n  const diskUnitsName = diskUnits === \"bbytes\" ? \"common.bbytes\" : \"common.bytes\";\n\n  const { data, error } = useSWR(`/api/widgets/resources?type=disk&target=${options.disk}`, {\n    refreshInterval: refresh,\n  });\n\n  if (error || data?.error) {\n    return <Error options={options} />;\n  }\n\n  if (!data || !data.drive) {\n    return (\n      <Resource\n        icon={FiHardDrive}\n        value=\"-\"\n        label={t(\"resources.free\")}\n        expandedValue=\"-\"\n        expandedLabel={t(\"resources.total\")}\n        expanded={expanded}\n        percentage=\"0\"\n      />\n    );\n  }\n\n  // data.drive.used not accurate?\n  const percent = Math.round(((data.drive.size - data.drive.available) / data.drive.size) * 100);\n\n  return (\n    <Resource\n      icon={FiHardDrive}\n      value={t(diskUnitsName, { value: data.drive.available })}\n      label={t(\"resources.free\")}\n      expandedValue={t(diskUnitsName, { value: data.drive.size })}\n      expandedLabel={t(\"resources.total\")}\n      percentage={percent}\n      expanded={expanded}\n    />\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/resources/disk.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { useSWR, Resource, Error } = vi.hoisted(() => ({\n  useSWR: vi.fn(),\n  Resource: vi.fn(() => <div data-testid=\"resource\" />),\n  Error: vi.fn(() => <div data-testid=\"error\" />),\n}));\n\nvi.mock(\"swr\", () => ({ default: useSWR }));\nvi.mock(\"../widget/resource\", () => ({ default: Resource }));\nvi.mock(\"../widget/error\", () => ({ default: Error }));\n\nimport Disk from \"./disk\";\n\ndescribe(\"components/widgets/resources/disk\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders a placeholder Resource while loading\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    render(<Disk options={{ disk: \"/\" }} expanded />);\n\n    const props = Resource.mock.calls[0][0];\n    expect(props.value).toBe(\"-\");\n  });\n\n  it(\"computes percent used from size/available and renders bytes\", () => {\n    useSWR.mockReturnValue({\n      data: { drive: { size: 100, available: 40 } },\n      error: undefined,\n    });\n\n    render(<Disk options={{ disk: \"/data\" }} diskUnits=\"bytes\" expanded={false} />);\n\n    const props = Resource.mock.calls[0][0];\n    expect(props.value).toBe(\"40\");\n    expect(props.expandedValue).toBe(\"100\");\n    expect(props.percentage).toBe(60);\n  });\n\n  it(\"renders Error when SWR errors\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: new Error(\"nope\") });\n\n    render(<Disk options={{ disk: \"/\" }} expanded />);\n\n    expect(Error).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/resources/memory.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport { FaMemory } from \"react-icons/fa\";\nimport useSWR from \"swr\";\n\nimport Error from \"../widget/error\";\nimport Resource from \"../widget/resource\";\n\nexport default function Memory({ expanded, refresh = 1500 }) {\n  const { t } = useTranslation();\n\n  const { data, error } = useSWR(`/api/widgets/resources?type=memory`, {\n    refreshInterval: refresh,\n  });\n\n  if (error || data?.error) {\n    return <Error />;\n  }\n\n  if (!data) {\n    return (\n      <Resource\n        icon={FaMemory}\n        value=\"-\"\n        label={t(\"resources.free\")}\n        expandedValue=\"-\"\n        expandedLabel={t(\"resources.total\")}\n        expanded={expanded}\n        percentage=\"0\"\n      />\n    );\n  }\n\n  const percent = Math.round((data.memory.active / data.memory.total) * 100);\n\n  return (\n    <Resource\n      icon={FaMemory}\n      value={t(\"common.bytes\", { value: data.memory.available, maximumFractionDigits: 1, binary: true })}\n      label={t(\"resources.free\")}\n      expandedValue={t(\"common.bytes\", { value: data.memory.total, maximumFractionDigits: 1, binary: true })}\n      expandedLabel={t(\"resources.total\")}\n      percentage={percent}\n      expanded={expanded}\n    />\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/resources/memory.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { useSWR, Resource, Error } = vi.hoisted(() => ({\n  useSWR: vi.fn(),\n  Resource: vi.fn(() => <div data-testid=\"resource\" />),\n  Error: vi.fn(() => <div data-testid=\"error\" />),\n}));\n\nvi.mock(\"swr\", () => ({ default: useSWR }));\nvi.mock(\"../widget/resource\", () => ({ default: Resource }));\nvi.mock(\"../widget/error\", () => ({ default: Error }));\n\nimport Memory from \"./memory\";\n\ndescribe(\"components/widgets/resources/memory\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders a placeholder Resource while loading\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    render(<Memory expanded />);\n\n    const props = Resource.mock.calls[0][0];\n    expect(props.value).toBe(\"-\");\n  });\n\n  it(\"calculates percentage from active/total and renders available/total\", () => {\n    useSWR.mockReturnValue({\n      data: { memory: { available: 10, total: 20, active: 5 } },\n      error: undefined,\n    });\n\n    render(<Memory expanded={false} />);\n\n    const props = Resource.mock.calls[0][0];\n    expect(props.value).toBe(\"10\");\n    expect(props.expandedValue).toBe(\"20\");\n    expect(props.percentage).toBe(25);\n  });\n\n  it(\"renders Error when SWR errors\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: new Error(\"nope\") });\n\n    render(<Memory expanded />);\n\n    expect(Error).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/resources/network.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport { FaNetworkWired } from \"react-icons/fa\";\nimport useSWR from \"swr\";\n\nimport Error from \"../widget/error\";\nimport Resource from \"../widget/resource\";\n\nexport default function Network({ options, refresh = 1500 }) {\n  const { t } = useTranslation();\n  // eslint-disable-next-line no-param-reassign\n  if (options.network === true) options.network = \"default\";\n\n  const { data, error } = useSWR(`/api/widgets/resources?type=network&interfaceName=${options.network}`, {\n    refreshInterval: refresh,\n  });\n\n  if (error || data?.error) {\n    return <Error />;\n  }\n\n  if (!data || !data.network || !data.network.rx_sec || !data.network.tx_sec) {\n    return (\n      <Resource\n        icon={FaNetworkWired}\n        value=\"- ↑\"\n        label=\"- ↓\"\n        expandedValue=\"- ↑\"\n        expandedLabel=\"- ↓\"\n        percentage=\"0\"\n        wide\n      />\n    );\n  }\n\n  return (\n    <Resource\n      icon={FaNetworkWired}\n      value={`${t(\"common.byterate\", { value: data.network.tx_sec })} ↑`}\n      label={`${t(\"common.byterate\", { value: data.network.rx_sec })} ↓`}\n      expandedValue={`${t(\"common.bytes\", { value: data.network.tx_bytes })} ↑`}\n      expandedLabel={`${t(\"common.bytes\", { value: data.network.rx_bytes })} ↓`}\n      expanded={options.expanded}\n      wide\n      percentage={(100 * data.network.rx_sec) / (data.network.rx_sec + data.network.tx_sec)}\n    />\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/resources/network.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { useSWR, Resource, Error } = vi.hoisted(() => ({\n  useSWR: vi.fn(),\n  Resource: vi.fn(() => <div data-testid=\"resource\" />),\n  Error: vi.fn(() => <div data-testid=\"error\" />),\n}));\n\nvi.mock(\"swr\", () => ({ default: useSWR }));\nvi.mock(\"../widget/resource\", () => ({ default: Resource }));\nvi.mock(\"../widget/error\", () => ({ default: Error }));\n\nimport Network from \"./network\";\n\ndescribe(\"components/widgets/resources/network\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"normalizes options.network=true to default interfaceName in the request\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    render(<Network options={{ network: true }} />);\n\n    expect(useSWR).toHaveBeenCalledWith(expect.stringContaining(\"interfaceName=default\"), expect.any(Object));\n  });\n\n  it(\"renders rates and usage percentage when data is present\", () => {\n    useSWR.mockReturnValue({\n      data: {\n        network: { rx_sec: 3, tx_sec: 1, rx_bytes: 30, tx_bytes: 10 },\n      },\n      error: undefined,\n    });\n\n    render(<Network options={{ network: \"en0\", expanded: true }} />);\n\n    const props = Resource.mock.calls[0][0];\n    expect(props.value).toContain(\"1\");\n    expect(props.value).toContain(\"↑\");\n    expect(props.label).toContain(\"3\");\n    expect(props.label).toContain(\"↓\");\n    expect(props.percentage).toBe(75);\n    expect(props.wide).toBe(true);\n  });\n\n  it(\"renders Error when SWR errors\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: new Error(\"nope\") });\n\n    render(<Network options={{ network: \"en0\" }} />);\n\n    expect(Error).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/resources/resources.jsx",
    "content": "import Container from \"../widget/container\";\nimport Raw from \"../widget/raw\";\n\nimport Cpu from \"./cpu\";\nimport CpuTemp from \"./cputemp\";\nimport Disk from \"./disk\";\nimport Memory from \"./memory\";\nimport Network from \"./network\";\nimport Uptime from \"./uptime\";\n\nexport default function Resources({ options }) {\n  const { expanded, units, diskUnits, tempmin, tempmax } = options;\n  let { refresh } = options;\n  if (!refresh) refresh = 1500;\n  refresh = Math.max(refresh, 1000);\n  return (\n    <Container options={options}>\n      <Raw>\n        <div className=\"flex flex-row self-center flex-wrap justify-between\">\n          {options.cpu && <Cpu expanded={expanded} refresh={refresh} />}\n          {options.memory && <Memory expanded={expanded} refresh={refresh} />}\n          {Array.isArray(options.disk)\n            ? options.disk.map((disk) => (\n                <Disk key={disk} options={{ disk }} expanded={expanded} diskUnits={diskUnits} refresh={refresh} />\n              ))\n            : options.disk && <Disk options={options} expanded={expanded} diskUnits={diskUnits} refresh={refresh} />}\n          {options.network && <Network options={options} refresh={refresh} />}\n          {options.cputemp && (\n            <CpuTemp expanded={expanded} units={units} refresh={refresh} tempmin={tempmin} tempmax={tempmax} />\n          )}\n          {options.uptime && <Uptime refresh={refresh} />}\n        </div>\n        {options.label && (\n          <div className=\"ml-6 pt-1 text-center text-theme-800 dark:text-theme-200 text-xs\">{options.label}</div>\n        )}\n      </Raw>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/resources/resources.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nvi.mock(\"./cpu\", () => ({ default: () => <div data-testid=\"resources-cpu\" /> }));\nvi.mock(\"./memory\", () => ({ default: () => <div data-testid=\"resources-memory\" /> }));\nvi.mock(\"./disk\", () => ({ default: ({ options }) => <div data-testid=\"resources-disk\" data-disk={options.disk} /> }));\nvi.mock(\"./network\", () => ({ default: () => <div data-testid=\"resources-network\" /> }));\nvi.mock(\"./cputemp\", () => ({ default: () => <div data-testid=\"resources-cputemp\" /> }));\nvi.mock(\"./uptime\", () => ({ default: () => <div data-testid=\"resources-uptime\" /> }));\n\nimport Resources from \"./resources\";\n\ndescribe(\"components/widgets/resources\", () => {\n  it(\"renders selected resource blocks and an optional label\", () => {\n    renderWithProviders(\n      <Resources\n        options={{\n          cpu: true,\n          memory: true,\n          disk: [\"/\", \"/data\"],\n          network: true,\n          cputemp: true,\n          uptime: true,\n          label: \"Host A\",\n        }}\n      />,\n      { settings: { target: \"_self\" } },\n    );\n\n    expect(screen.getByTestId(\"resources-cpu\")).toBeInTheDocument();\n    expect(screen.getByTestId(\"resources-memory\")).toBeInTheDocument();\n    expect(screen.getAllByTestId(\"resources-disk\")).toHaveLength(2);\n    expect(screen.getByTestId(\"resources-network\")).toBeInTheDocument();\n    expect(screen.getByTestId(\"resources-cputemp\")).toBeInTheDocument();\n    expect(screen.getByTestId(\"resources-uptime\")).toBeInTheDocument();\n    expect(screen.getByText(\"Host A\")).toBeInTheDocument();\n  });\n\n  it(\"renders a single disk block when disk is not an array\", () => {\n    renderWithProviders(<Resources options={{ disk: true }} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getAllByTestId(\"resources-disk\")).toHaveLength(1);\n    expect(screen.getByTestId(\"resources-disk\").getAttribute(\"data-disk\")).toBe(\"true\");\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/resources/uptime.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport { FaRegClock } from \"react-icons/fa\";\nimport useSWR from \"swr\";\n\nimport Error from \"../widget/error\";\nimport Resource from \"../widget/resource\";\n\nexport default function Uptime({ refresh = 1500 }) {\n  const { t } = useTranslation();\n\n  const { data, error } = useSWR(`/api/widgets/resources?type=uptime`, {\n    refreshInterval: refresh,\n  });\n\n  if (error || data?.error) {\n    return <Error />;\n  }\n\n  if (!data) {\n    return <Resource icon={FaRegClock} value=\"-\" label={t(\"resources.uptime\")} percentage=\"0\" />;\n  }\n\n  const percent = Math.round((new Date().getSeconds() / 60) * 100).toString();\n\n  return (\n    <Resource\n      icon={FaRegClock}\n      value={t(\"common.duration\", { value: data.uptime })}\n      label={t(\"resources.uptime\")}\n      percentage={percent}\n    />\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/resources/uptime.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { useSWR, Resource, Error } = vi.hoisted(() => ({\n  useSWR: vi.fn(),\n  Resource: vi.fn(() => <div data-testid=\"resource\" />),\n  Error: vi.fn(() => <div data-testid=\"error\" />),\n}));\n\nvi.mock(\"swr\", () => ({ default: useSWR }));\nvi.mock(\"../widget/resource\", () => ({ default: Resource }));\nvi.mock(\"../widget/error\", () => ({ default: Error }));\n\nimport Uptime from \"./uptime\";\n\ndescribe(\"components/widgets/resources/uptime\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders a placeholder while loading\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    render(<Uptime />);\n    expect(Resource).toHaveBeenCalled();\n    expect(Resource.mock.calls[0][0].value).toBe(\"-\");\n  });\n\n  it(\"renders formatted duration and sets percentage based on current seconds\", () => {\n    vi.useFakeTimers();\n    try {\n      vi.setSystemTime(new Date(\"2020-01-01T00:00:30.000Z\"));\n\n      useSWR.mockReturnValue({ data: { uptime: 1234 }, error: undefined });\n      render(<Uptime />);\n\n      const props = Resource.mock.calls[0][0];\n      expect(props.value).toBe(\"1234\");\n      expect(props.percentage).toBe(\"50\");\n    } finally {\n      vi.useRealTimers();\n    }\n  });\n\n  it(\"renders Error when SWR errors\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: new Error(\"nope\") });\n\n    render(<Uptime />);\n\n    expect(Error).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/resources/usage-bar.jsx",
    "content": "export default function UsageBar({ percent, additionalClassNames = \"\" }) {\n  const normalized = Math.min(100, Math.max(0, percent));\n  return (\n    <div className={`mt-0.5 w-full bg-theme-800/30 rounded-full h-1 dark:bg-theme-200/20 ${additionalClassNames}`}>\n      <div\n        className=\"bg-theme-800/70 h-1 rounded-full dark:bg-theme-200/50 transition-all duration-1000\"\n        style={{\n          width: `${normalized}%`,\n        }}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/resources/usage-bar.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render } from \"@testing-library/react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport UsageBar from \"./usage-bar\";\n\ndescribe(\"components/widgets/resources/usage-bar\", () => {\n  it(\"normalizes percent to [0, 100] and applies width style\", () => {\n    const { container: c0 } = render(<UsageBar percent={-5} />);\n    const inner0 = c0.querySelector(\"div > div > div\");\n    expect(inner0.style.width).toBe(\"0%\");\n\n    const { container: c1 } = render(<UsageBar percent={150} />);\n    const inner1 = c1.querySelector(\"div > div > div\");\n    expect(inner1.style.width).toBe(\"100%\");\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/search/search.jsx",
    "content": "import {\n  Combobox,\n  ComboboxInput,\n  ComboboxOption,\n  ComboboxOptions,\n  Listbox,\n  ListboxButton,\n  ListboxOption,\n  ListboxOptions,\n  Transition,\n} from \"@headlessui/react\";\nimport classNames from \"classnames\";\nimport { useTranslation } from \"next-i18next\";\nimport { Fragment, useEffect, useState } from \"react\";\nimport { BiLogoBing } from \"react-icons/bi\";\nimport { FiSearch } from \"react-icons/fi\";\nimport { SiBaidu, SiBrave, SiDuckduckgo, SiGoogle } from \"react-icons/si\";\n\nimport ContainerForm from \"../widget/container_form\";\nimport Raw from \"../widget/raw\";\n\nexport const searchProviders = {\n  google: {\n    name: \"Google\",\n    url: \"https://www.google.com/search?q=\",\n    suggestionUrl: \"https://www.google.com/complete/search?client=chrome&q=\",\n    icon: SiGoogle,\n  },\n  duckduckgo: {\n    name: \"DuckDuckGo\",\n    url: \"https://duckduckgo.com/?q=\",\n    suggestionUrl: \"https://duckduckgo.com/ac/?type=list&q=\",\n    icon: SiDuckduckgo,\n  },\n  bing: {\n    name: \"Bing\",\n    url: \"https://www.bing.com/search?q=\",\n    suggestionUrl: \"https://api.bing.com/osjson.aspx?query=\",\n    icon: BiLogoBing,\n  },\n  baidu: {\n    name: \"Baidu\",\n    url: \"https://www.baidu.com/s?wd=\",\n    suggestionUrl: \"http://suggestion.baidu.com/su?&action=opensearch&ie=utf-8&wd=\",\n    icon: SiBaidu,\n  },\n  brave: {\n    name: \"Brave\",\n    url: \"https://search.brave.com/search?q=\",\n    suggestionUrl: \"https://search.brave.com/api/suggest?&rich=false&q=\",\n    icon: SiBrave,\n  },\n  custom: {\n    name: \"Custom\",\n    url: false,\n    icon: FiSearch,\n  },\n};\n\nfunction getAvailableProviderIds(options) {\n  if (options.provider && Array.isArray(options.provider)) {\n    return options.provider.filter((value) => searchProviders.hasOwnProperty(value));\n  }\n  if (options.provider && searchProviders[options.provider]) {\n    return [options.provider];\n  }\n  return null;\n}\n\nconst localStorageKey = \"search-name\";\n\nexport function getStoredProvider() {\n  if (typeof window !== \"undefined\") {\n    const storedName = localStorage.getItem(localStorageKey);\n    if (storedName) {\n      return Object.values(searchProviders).find((el) => el.name === storedName);\n    }\n  }\n  return null;\n}\n\nexport default function Search({ options }) {\n  const { t } = useTranslation();\n\n  const availableProviderIds = getAvailableProviderIds(options) ?? [];\n\n  const [query, setQuery] = useState(\"\");\n  const [selectedProvider, setSelectedProvider] = useState(searchProviders[availableProviderIds[0] ?? \"google\"]);\n  const [searchSuggestions, setSearchSuggestions] = useState([]);\n\n  useEffect(() => {\n    const storedProvider = getStoredProvider();\n    let storedProviderKey = null;\n    storedProviderKey = Object.keys(searchProviders).find((pkey) => searchProviders[pkey] === storedProvider);\n    if (storedProvider && availableProviderIds.includes(storedProviderKey)) {\n      setSelectedProvider(storedProvider);\n    }\n  }, [availableProviderIds]);\n\n  useEffect(() => {\n    const abortController = new AbortController();\n\n    if (\n      options.showSearchSuggestions &&\n      (selectedProvider.suggestionUrl || options.suggestionUrl) && // custom providers pass url via options\n      query.trim().length > 0 &&\n      query.trim() !== searchSuggestions[0]\n    ) {\n      fetch(`/api/search/searchSuggestion?query=${encodeURIComponent(query)}&providerName=${selectedProvider.name}`, {\n        signal: abortController.signal,\n      })\n        .then(async (searchSuggestionResult) => {\n          const newSearchSuggestions = await searchSuggestionResult.json();\n\n          if (newSearchSuggestions) {\n            if (newSearchSuggestions[1].length > 4) {\n              newSearchSuggestions[1] = newSearchSuggestions[1].splice(0, 4);\n            }\n            setSearchSuggestions(newSearchSuggestions);\n          }\n        })\n        .catch(() => {\n          // If there is an error, just ignore it. There just will be no search suggestions.\n        });\n    }\n\n    return () => {\n      abortController.abort();\n    };\n  }, [selectedProvider, options, query, searchSuggestions]);\n\n  let currentSuggestion;\n\n  function doSearch(value) {\n    const q = encodeURIComponent(value);\n    const { url } = selectedProvider;\n    if (url) {\n      window.open(`${url}${q}`, options.target || \"_blank\");\n    } else {\n      window.open(`${options.url}${q}`, options.target || \"_blank\");\n    }\n\n    setQuery(\"\");\n    currentSuggestion = null;\n  }\n\n  const handleSearchKeyDown = (event) => {\n    const useSuggestion = searchSuggestions.length && currentSuggestion;\n    if (event.key === \"Enter\") {\n      doSearch(useSuggestion ? currentSuggestion : event.target.value);\n    }\n  };\n\n  if (!availableProviderIds.length) {\n    return null;\n  }\n\n  const onChangeProvider = (provider) => {\n    setSelectedProvider(provider);\n    localStorage.setItem(localStorageKey, provider.name);\n  };\n\n  return (\n    <ContainerForm options={options} additionalClassNames=\"grow information-widget-search\">\n      <Raw>\n        <div className=\"flex-col relative h-8 my-4 min-w-fit z-20\">\n          <div className=\"flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none w-full text-theme-800 dark:text-white\" />\n          <Combobox value={query}>\n            <ComboboxInput\n              type=\"text\"\n              className=\"\n              overflow-hidden w-full h-full rounded-md\n              text-xs text-theme-900 dark:text-white\n              placeholder-theme-900 dark:placeholder-white/80\n              bg-white/50 dark:bg-white/10\n              focus:ring-theme-500 dark:focus:ring-white/50\n              focus:border-theme-500 dark:focus:border-white/50\n              border border-theme-300 dark:border-theme-200/50\"\n              placeholder={t(\"search.placeholder\")}\n              onChange={(event) => {\n                setQuery(event.target.value);\n              }}\n              required\n              autoCapitalize=\"off\"\n              autoCorrect=\"off\"\n              autoComplete=\"off\"\n              // eslint-disable-next-line jsx-a11y/no-autofocus\n              autoFocus={options.focus}\n              onBlur={(e) => e.preventDefault()}\n              onKeyDown={handleSearchKeyDown}\n            />\n            <Listbox\n              as=\"div\"\n              value={selectedProvider}\n              onChange={onChangeProvider}\n              className=\"relative text-left\"\n              disabled={availableProviderIds?.length === 1}\n            >\n              <div>\n                <ListboxButton\n                  className=\"\n                  absolute right-0.5 bottom-0.5 rounded-r-md px-4 py-2\n                  text-white font-medium text-sm\n                  bg-theme-600/40 dark:bg-white/10\n                  focus:ring-theme-500 dark:focus:ring-white/50\"\n                >\n                  <selectedProvider.icon className=\"text-white w-3 h-3\" />\n                  <span className=\"sr-only\">{t(\"search.search\")}</span>\n                </ListboxButton>\n              </div>\n              <Transition\n                as={Fragment}\n                enter=\"transition ease-out duration-100\"\n                enterFrom=\"transform opacity-0 scale-95\"\n                enterTo=\"transform opacity-100 scale-100\"\n                leave=\"transition ease-in duration-75\"\n                leaveFrom=\"transform opacity-100 scale-100\"\n                leaveTo=\"transform opacity-0 scale-95\"\n              >\n                <ListboxOptions\n                  className=\"absolute right-0 z-10 mt-1 origin-top-right rounded-md\n                  bg-theme-100 dark:bg-theme-600 shadow-lg\n                  ring-1 ring-black ring-opacity-5 focus:outline-hidden\"\n                >\n                  <div className=\"flex flex-col\">\n                    {availableProviderIds.map((providerId) => {\n                      const p = searchProviders[providerId];\n                      return (\n                        <ListboxOption key={providerId} value={p} as={Fragment}>\n                          {({ active }) => (\n                            <li\n                              className={classNames(\n                                \"rounded-md cursor-pointer\",\n                                active ? \"bg-theme-600/10 dark:bg-white/10 dark:text-gray-900\" : \"dark:text-gray-100\",\n                              )}\n                            >\n                              <p.icon className=\"h-4 w-4 mx-4 my-2\" />\n                            </li>\n                          )}\n                        </ListboxOption>\n                      );\n                    })}\n                  </div>\n                </ListboxOptions>\n              </Transition>\n            </Listbox>\n\n            {searchSuggestions[1]?.length > 0 && (\n              <ComboboxOptions className=\"mt-1 rounded-md bg-theme-50 dark:bg-theme-800 border border-theme-300 dark:border-theme-200/30 cursor-pointer shadow-lg\">\n                <div className=\"p-1 bg-white/50 dark:bg-white/10 text-theme-900/90 dark:text-white/90 text-xs\">\n                  <ComboboxOption key={query} value={query} />\n                  {searchSuggestions[1].map((suggestion) => (\n                    <ComboboxOption\n                      key={suggestion}\n                      value={suggestion}\n                      onMouseDown={() => {\n                        doSearch(suggestion);\n                      }}\n                      className=\"flex w-full\"\n                    >\n                      {({ active }) => {\n                        if (active) currentSuggestion = suggestion;\n                        return (\n                          <div\n                            className={classNames(\n                              \"px-2 py-1 rounded-md w-full flex-nowrap\",\n                              active ? \"bg-theme-300/20 dark:bg-white/10\" : \"\",\n                            )}\n                          >\n                            <span className=\"whitespace-pre\">{suggestion.indexOf(query) === 0 ? query : \"\"}</span>\n                            <span className=\"mr-4 whitespace-pre opacity-50\">\n                              {suggestion.indexOf(query) === 0 ? suggestion.substring(query.length) : suggestion}\n                            </span>\n                          </div>\n                        );\n                      }}\n                    </ComboboxOption>\n                  ))}\n                </div>\n              </ComboboxOptions>\n            )}\n          </Combobox>\n        </div>\n      </Raw>\n    </ContainerForm>\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/search/search.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { fireEvent, screen, waitFor } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\n// HeadlessUI is hard to test reliably; stub the primitives to simple pass-through components.\nvi.mock(\"@headlessui/react\", async () => {\n  const React = await import(\"react\");\n  const { Fragment, createContext, useContext } = React;\n  const ListboxContext = createContext(null);\n\n  function passthrough({ as: As = \"div\", children, ...props }) {\n    if (As === Fragment) return <>{typeof children === \"function\" ? children({ active: false }) : children}</>;\n    const content = typeof children === \"function\" ? children({ active: false }) : children;\n    return <As {...props}>{content}</As>;\n  }\n\n  return {\n    Combobox: passthrough,\n    ComboboxInput: (props) => <input {...props} />,\n    ComboboxOption: passthrough,\n    ComboboxOptions: passthrough,\n    Listbox: ({ value, onChange, children, ...props }) => (\n      <ListboxContext.Provider value={{ value, onChange }}>\n        <div {...props}>{typeof children === \"function\" ? children({}) : children}</div>\n      </ListboxContext.Provider>\n    ),\n    ListboxButton: (props) => <button type=\"button\" {...props} />,\n    ListboxOption: ({ as: _as, value, children, ...props }) => {\n      const ctx = useContext(ListboxContext);\n      const content = typeof children === \"function\" ? children({ active: false }) : children;\n      return (\n        <div\n          role=\"option\"\n          data-provider={value?.name}\n          aria-selected={ctx?.value === value}\n          onClick={() => ctx?.onChange?.(value)}\n          {...props}\n        >\n          {content}\n        </div>\n      );\n    },\n    ListboxOptions: passthrough,\n    Transition: ({ children }) => <>{children}</>,\n  };\n});\n\nimport Search from \"./search\";\n\ndescribe(\"components/widgets/search\", () => {\n  beforeEach(() => {\n    localStorage.clear();\n  });\n\n  it(\"opens a search URL when Enter is pressed\", () => {\n    const openSpy = vi.spyOn(window, \"open\").mockImplementation(() => null);\n\n    renderWithProviders(<Search options={{ provider: [\"google\"], showSearchSuggestions: false, target: \"_self\" }} />, {\n      settings: { target: \"_blank\" },\n    });\n\n    const input = screen.getByPlaceholderText(\"search.placeholder\");\n    fireEvent.change(input, { target: { value: \"hello world\" } });\n    fireEvent.keyDown(input, { key: \"Enter\" });\n\n    expect(openSpy).toHaveBeenCalledWith(\"https://www.google.com/search?q=hello%20world\", \"_self\");\n    openSpy.mockRestore();\n  });\n\n  it(\"accepts provider configured as a string\", () => {\n    const openSpy = vi.spyOn(window, \"open\").mockImplementation(() => null);\n\n    renderWithProviders(\n      <Search options={{ provider: \"duckduckgo\", showSearchSuggestions: false, target: \"_self\" }} />,\n      {\n        settings: {},\n      },\n    );\n\n    const input = screen.getByPlaceholderText(\"search.placeholder\");\n    fireEvent.change(input, { target: { value: \"hello\" } });\n    fireEvent.keyDown(input, { key: \"Enter\" });\n\n    expect(openSpy).toHaveBeenCalledWith(\"https://duckduckgo.com/?q=hello\", \"_self\");\n    openSpy.mockRestore();\n  });\n\n  it(\"returns null when the configured provider list contains no supported providers\", () => {\n    const { container } = renderWithProviders(<Search options={{ provider: \"nope\", showSearchSuggestions: false }} />, {\n      settings: {},\n    });\n\n    expect(container).toBeEmptyDOMElement();\n  });\n\n  it(\"stores the selected provider in localStorage when it is changed\", async () => {\n    const openSpy = vi.spyOn(window, \"open\").mockImplementation(() => null);\n\n    renderWithProviders(\n      <Search options={{ provider: [\"google\", \"duckduckgo\"], showSearchSuggestions: false, target: \"_self\" }} />,\n      {\n        settings: {},\n      },\n    );\n\n    const option = document.querySelector('[data-provider=\"DuckDuckGo\"]');\n    expect(option).not.toBeNull();\n    fireEvent.click(option);\n\n    await waitFor(() => {\n      expect(localStorage.getItem(\"search-name\")).toBe(\"DuckDuckGo\");\n    });\n\n    const input = screen.getByPlaceholderText(\"search.placeholder\");\n    fireEvent.change(input, { target: { value: \"hello\" } });\n    fireEvent.keyDown(input, { key: \"Enter\" });\n\n    expect(openSpy).toHaveBeenCalledWith(\"https://duckduckgo.com/?q=hello\", \"_self\");\n    openSpy.mockRestore();\n  });\n\n  it(\"uses a stored provider from localStorage when it is available and allowed\", () => {\n    const openSpy = vi.spyOn(window, \"open\").mockImplementation(() => null);\n\n    localStorage.setItem(\"search-name\", \"DuckDuckGo\");\n\n    renderWithProviders(\n      <Search options={{ provider: [\"google\", \"duckduckgo\"], showSearchSuggestions: false, target: \"_self\" }} />,\n      {\n        settings: {},\n      },\n    );\n\n    const input = screen.getByPlaceholderText(\"search.placeholder\");\n    fireEvent.change(input, { target: { value: \"hello\" } });\n    fireEvent.keyDown(input, { key: \"Enter\" });\n\n    expect(openSpy).toHaveBeenCalledWith(\"https://duckduckgo.com/?q=hello\", \"_self\");\n    openSpy.mockRestore();\n  });\n\n  it(\"uses a custom provider URL when the selected provider is custom\", () => {\n    const openSpy = vi.spyOn(window, \"open\").mockImplementation(() => null);\n\n    renderWithProviders(\n      <Search\n        options={{\n          provider: [\"custom\"],\n          url: \"https://example.com/search?q=\",\n          showSearchSuggestions: false,\n          target: \"_self\",\n        }}\n      />,\n      { settings: {} },\n    );\n\n    const input = screen.getByPlaceholderText(\"search.placeholder\");\n    fireEvent.change(input, { target: { value: \"hello world\" } });\n    fireEvent.keyDown(input, { key: \"Enter\" });\n\n    expect(openSpy).toHaveBeenCalledWith(\"https://example.com/search?q=hello%20world\", \"_self\");\n    openSpy.mockRestore();\n  });\n\n  it(\"fetches search suggestions and triggers a search when a suggestion is selected\", async () => {\n    const openSpy = vi.spyOn(window, \"open\").mockImplementation(() => null);\n\n    const originalFetch = globalThis.fetch;\n    const fetchSpy = vi.fn(async () => ({\n      json: async () => [\"hel\", [\"hello\", \"help\", \"helm\", \"helium\", \"held\"]],\n    }));\n\n    fetch = fetchSpy;\n\n    renderWithProviders(<Search options={{ provider: [\"google\"], showSearchSuggestions: true, target: \"_self\" }} />, {\n      settings: {},\n    });\n\n    const input = screen.getByPlaceholderText(\"search.placeholder\");\n    fireEvent.change(input, { target: { value: \"hel\" } });\n\n    await waitFor(() => {\n      expect(fetchSpy).toHaveBeenCalledWith(\n        expect.stringContaining(\"/api/search/searchSuggestion?query=hel&providerName=Google\"),\n        expect.objectContaining({ signal: expect.any(AbortSignal) }),\n      );\n    });\n\n    await waitFor(() => {\n      expect(document.querySelector('[value=\"hello\"]')).toBeTruthy();\n    });\n    expect(document.querySelector('[value=\"held\"]')).toBeNull();\n    fireEvent.mouseDown(document.querySelector('[value=\"hello\"]'));\n\n    expect(openSpy).toHaveBeenCalledWith(\"https://www.google.com/search?q=hello\", \"_self\");\n\n    openSpy.mockRestore();\n\n    fetch = originalFetch;\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/stocks/stocks.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport { useState } from \"react\";\nimport { FaChartLine } from \"react-icons/fa6\";\nimport useSWR from \"swr\";\n\nimport Container from \"../widget/container\";\nimport Error from \"../widget/error\";\nimport PrimaryText from \"../widget/primary_text\";\nimport Raw from \"../widget/raw\";\nimport WidgetIcon from \"../widget/widget_icon\";\n\nexport default function Widget({ options }) {\n  const { t, i18n } = useTranslation();\n\n  const [viewingPercentChange, setViewingPercentChange] = useState(false);\n\n  const { color } = options;\n\n  const { data, error } = useSWR(\n    `/api/widgets/stocks?${new URLSearchParams({ lang: i18n.language, ...options }).toString()}`,\n  );\n\n  if (error || data?.error) {\n    return <Error options={options} />;\n  }\n\n  if (!data) {\n    return (\n      <Container>\n        <WidgetIcon icon={FaChartLine} />\n        <PrimaryText>{t(\"stocks.loading\")}...</PrimaryText>\n      </Container>\n    );\n  }\n\n  if (data) {\n    return (\n      <Container options={options} additionalClassNames=\"information-widget-stocks\">\n        <Raw>\n          <button\n            type=\"button\"\n            onClick={() => setViewingPercentChange(!viewingPercentChange)}\n            className=\"flex items-center w-full h-full hover:outline-hidden focus:outline-hidden\"\n          >\n            <FaChartLine className=\"flex-none w-5 h-5 text-theme-800 dark:text-theme-200 mr-2\" />\n            <div className=\"flex flex-wrap items-center gap-0.5\">\n              {data.stocks.map(\n                (stock) =>\n                  stock && (\n                    <div\n                      key={stock.ticker}\n                      className=\"rounded-sm h-full text-xs px-1 w-[4.75rem] flex flex-col items-center justify-center\"\n                    >\n                      <span className=\"text-theme-800 dark:text-theme-200 text-xs\">\n                        {stock.ticker.split(\":\").pop()}\n                      </span>\n                      {!viewingPercentChange ? (\n                        <span\n                          className={\n                            color !== false\n                              ? `text-xs ${stock.percentChange < 0 ? \"text-rose-300/70\" : \"text-emerald-300/70\"}`\n                              : \"text-theme-800/70 dark:text-theme-200/50 text-xs\"\n                          }\n                        >\n                          {stock.currentPrice !== null\n                            ? t(\"common.number\", {\n                                value: stock.currentPrice,\n                                style: \"currency\",\n                                currency: \"USD\",\n                              })\n                            : t(\"widget.api_error\")}\n                        </span>\n                      ) : (\n                        <span\n                          className={\n                            color !== false\n                              ? `text-xs ${stock.percentChange < 0 ? \"text-rose-300/70\" : \"text-emerald-300/70\"}`\n                              : \"text-theme-800/70 dark:text-theme-200/70 text-xs\"\n                          }\n                        >\n                          {stock.percentChange !== null ? `${stock.percentChange}%` : t(\"widget.api_error\")}\n                        </span>\n                      )}\n                    </div>\n                  ),\n              )}\n            </div>\n          </button>\n        </Raw>\n      </Container>\n    );\n  }\n}\n"
  },
  {
    "path": "src/components/widgets/stocks/stocks.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { fireEvent, screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));\nvi.mock(\"swr\", () => ({ default: useSWR }));\n\nimport Stocks from \"./stocks\";\n\ndescribe(\"components/widgets/stocks\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders an error widget when the api call fails\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: new Error(\"nope\") });\n\n    renderWithProviders(<Stocks options={{}} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getByText(\"widget.api_error\")).toBeInTheDocument();\n  });\n\n  it(\"renders a loading state while waiting for data\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<Stocks options={{}} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getByText(/stocks\\.loading/)).toBeInTheDocument();\n  });\n\n  it(\"toggles between price and percent change on click\", () => {\n    useSWR.mockReturnValue({\n      data: {\n        stocks: [\n          { ticker: \"NASDAQ:AAPL\", currentPrice: 123.45, percentChange: 1.23 },\n          { ticker: \"MSFT\", currentPrice: 99.99, percentChange: -0.5 },\n        ],\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<Stocks options={{ color: false }} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getByText(\"AAPL\")).toBeInTheDocument();\n    expect(screen.getByText(\"123.45\")).toBeInTheDocument();\n\n    fireEvent.click(screen.getByRole(\"button\"));\n    expect(screen.getByText(\"1.23%\")).toBeInTheDocument();\n    expect(screen.getByText(\"-0.5%\")).toBeInTheDocument();\n  });\n\n  it(\"shows api_error for null prices and uses colored classes when enabled\", () => {\n    useSWR.mockReturnValue({\n      data: {\n        stocks: [{ ticker: \"NASDAQ:AAPL\", currentPrice: null, percentChange: -1 }],\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<Stocks options={{}} />, { settings: { target: \"_self\" } });\n\n    const apiError = screen.getByText(\"widget.api_error\");\n    expect(apiError.className).toContain(\"text-rose\");\n\n    fireEvent.click(screen.getByRole(\"button\"));\n    const percent = screen.getByText(\"-1%\");\n    expect(percent.className).toContain(\"text-rose\");\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/unifi_console/unifi_console.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport { BiCheckCircle, BiError, BiNetworkChart, BiWifi, BiXCircle } from \"react-icons/bi\";\nimport { MdSettingsEthernet } from \"react-icons/md\";\nimport { SiUbiquiti } from \"react-icons/si\";\n\nimport Container from \"../widget/container\";\nimport Error from \"../widget/error\";\nimport PrimaryText from \"../widget/primary_text\";\nimport Raw from \"../widget/raw\";\nimport WidgetIcon from \"../widget/widget_icon\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Widget({ options }) {\n  const { t } = useTranslation();\n\n  // eslint-disable-next-line no-param-reassign, no-multi-assign\n  options.service_group = options.service_name = \"unifi_console\";\n  const { data: statsData, error: statsError } = useWidgetAPI(options, \"stat/sites\", { index: options.index });\n\n  if (statsError) {\n    return <Error options={options} />;\n  }\n\n  const defaultSite = options.site\n    ? statsData?.data.find((s) => s.desc === options.site)\n    : statsData?.data?.find((s) => s.name === \"default\");\n\n  if (!defaultSite) {\n    return (\n      <Container options={options} additionalClassNames=\"information-widget-unifi-console\">\n        <PrimaryText>{t(\"unifi.wait\")}</PrimaryText>\n        <WidgetIcon icon={SiUbiquiti} />\n      </Container>\n    );\n  }\n\n  const wan = defaultSite.health.find((h) => h.subsystem === \"wan\");\n  const lan = defaultSite.health.find((h) => h.subsystem === \"lan\");\n  const wlan = defaultSite.health.find((h) => h.subsystem === \"wlan\");\n  [wan, lan, wlan].forEach((s) => {\n    s.up = s.status === \"ok\"; // eslint-disable-line no-param-reassign\n    s.show = s.status !== \"unknown\"; // eslint-disable-line no-param-reassign\n  });\n  const name = wan.gw_name ?? defaultSite.desc;\n  const uptime = wan[\"gw_system-stats\"] ? wan[\"gw_system-stats\"].uptime : null;\n\n  const dataEmpty = !(wan.show || lan.show || wlan.show || uptime);\n\n  return (\n    <Container options={options} additionalClassNames=\"information-widget-unifi-console\">\n      <Raw>\n        <div className=\"flex-none flex flex-row items-center mr-3 py-1.5\">\n          <div className=\"flex flex-col\">\n            <div className=\"flex flex-row ml-3 mb-0.5\">\n              <SiUbiquiti className=\"text-theme-800 dark:text-theme-200 w-3 h-3 mr-1\" />\n              <div className=\"text-theme-800 dark:text-theme-200 text-xs font-bold flex flex-row justify-between\">\n                {name}\n              </div>\n            </div>\n            {dataEmpty && (\n              <div className=\"flex flex-row ml-3 text-[8px] justify-between\">\n                <div className=\"flex flex-row items-center justify-end\">\n                  <div className=\"flex flex-row\">\n                    <BiError className=\"w-4 h-4 text-theme-800 dark:text-theme-200\" />\n                    <span className=\"text-theme-800 dark:text-theme-200 text-xs\">{t(\"unifi.empty_data\")}</span>\n                  </div>\n                </div>\n              </div>\n            )}\n            <div className=\"flex flex-row ml-3 text-[10px] justify-between\">\n              {uptime && (\n                <div className=\"flex flex-row\" title={t(\"unifi.uptime\")}>\n                  <div className=\"pr-0.5 text-theme-800 dark:text-theme-200\">\n                    {t(\"common.number\", {\n                      value: uptime / 86400,\n                      maximumFractionDigits: 1,\n                    })}\n                  </div>\n                  <div className=\"pr-1 text-theme-800 dark:text-theme-200\">{t(\"unifi.days\")}</div>\n                </div>\n              )}\n              {wan.show && (\n                <div className=\"flex flex-row\">\n                  <div className=\"pr-1 text-theme-800 dark:text-theme-200\">{t(\"unifi.wan\")}</div>\n                  {wan.up ? (\n                    <BiCheckCircle className=\"text-theme-800 dark:text-theme-200 h-4 w-3\" />\n                  ) : (\n                    <BiXCircle className=\"text-theme-800 dark:text-theme-200 h-4 w-3\" />\n                  )}\n                </div>\n              )}\n              {!wan.show && !lan.show && wlan.show && (\n                <div className=\"flex flex-row\">\n                  <div className=\"pr-1 text-theme-800 dark:text-theme-200\">{t(\"unifi.wlan\")}</div>\n                  {wlan.up ? (\n                    <BiCheckCircle className=\"text-theme-800 dark:text-theme-200 h-4 w-3\" />\n                  ) : (\n                    <BiXCircle className=\"text-theme-800 dark:text-theme-200 h-4 w-3\" />\n                  )}\n                </div>\n              )}\n              {!wan.show && !wlan.show && lan.show && (\n                <div className=\"flex flex-row\">\n                  <div className=\"pr-1 text-theme-800 dark:text-theme-200\">{t(\"unifi.lan\")}</div>\n                  {lan.up ? (\n                    <BiCheckCircle className=\"text-theme-800 dark:text-theme-200 h-4 w-3\" />\n                  ) : (\n                    <BiXCircle className=\"text-theme-800 dark:text-theme-200 h-4 w-3\" />\n                  )}\n                </div>\n              )}\n            </div>\n          </div>\n          <div className=\"flex flex-col\">\n            {wlan.show && (\n              <div className=\"flex flex-row ml-3 py-0.5\">\n                <BiWifi className=\"text-theme-800 dark:text-theme-200 w-4 h-4 mr-1\" />\n                <div\n                  className=\"text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between\"\n                  title={t(\"unifi.users\")}\n                >\n                  <div className=\"pr-0.5\">\n                    {t(\"common.number\", {\n                      value: wlan.num_user,\n                      maximumFractionDigits: 0,\n                    })}\n                  </div>\n                </div>\n              </div>\n            )}\n            {lan.show && (\n              <div className=\"flex flex-row ml-3 pb-0.5\">\n                <MdSettingsEthernet className=\"text-theme-800 dark:text-theme-200 w-4 h-4 mr-1\" />\n                <div\n                  className=\"text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between\"\n                  title={t(\"unifi.users\")}\n                >\n                  <div className=\"pr-0.5\">\n                    {t(\"common.number\", {\n                      value: lan.num_user,\n                      maximumFractionDigits: 0,\n                    })}\n                  </div>\n                </div>\n              </div>\n            )}\n            {((wlan.show && !lan.show) || (!wlan.show && lan.show)) && (\n              <div className=\"flex flex-row ml-3 py-0.5\">\n                <BiNetworkChart className=\"text-theme-800 dark:text-theme-200 w-4 h-4 mr-1\" />\n                <div\n                  className=\"text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between\"\n                  title={t(\"unifi.devices\")}\n                >\n                  <div className=\"pr-0.5\">\n                    {t(\"common.number\", {\n                      value: wlan.show ? wlan.num_adopted : lan.num_adopted,\n                      maximumFractionDigits: 0,\n                    })}\n                  </div>\n                </div>\n              </div>\n            )}\n          </div>\n        </div>\n      </Raw>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/unifi_console/unifi_console.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nvi.mock(\"react-icons/bi\", async (importOriginal) => {\n  const actual = await importOriginal();\n  return {\n    ...actual,\n    BiWifi: (props) => <svg data-testid=\"bi-wifi\" {...props} />,\n    BiNetworkChart: (props) => <svg data-testid=\"bi-network-chart\" {...props} />,\n    BiError: (props) => <svg data-testid=\"bi-error\" {...props} />,\n    BiCheckCircle: (props) => <svg data-testid=\"bi-check-circle\" {...props} />,\n    BiXCircle: (props) => <svg data-testid=\"bi-x-circle\" {...props} />,\n  };\n});\n\nimport UnifiConsole from \"./unifi_console\";\n\ndescribe(\"components/widgets/unifi_console\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders an api error state when the widget api call fails\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: new Error(\"nope\") });\n\n    renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getByText(\"widget.api_error\")).toBeInTheDocument();\n  });\n\n  it(\"renders a wait state when no site is available yet\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getByText(\"unifi.wait\")).toBeInTheDocument();\n  });\n\n  it(\"renders site name and uptime when data is available\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          {\n            name: \"default\",\n            desc: \"Home\",\n            health: [\n              {\n                subsystem: \"wan\",\n                status: \"ok\",\n                gw_name: \"Router\",\n                \"gw_system-stats\": { uptime: 172800 },\n              },\n              { subsystem: \"lan\", status: \"unknown\" },\n              { subsystem: \"wlan\", status: \"unknown\" },\n            ],\n          },\n        ],\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getByText(\"Router\")).toBeInTheDocument();\n    // common.number is mocked to return the numeric value as a string.\n    expect(screen.getByText(\"2\")).toBeInTheDocument();\n    expect(screen.getByText(\"unifi.days\")).toBeInTheDocument();\n  });\n\n  it(\"selects a site by description when options.site is set\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          {\n            name: \"default\",\n            desc: \"Other\",\n            health: [\n              { subsystem: \"wan\", status: \"unknown\" },\n              { subsystem: \"lan\", status: \"unknown\" },\n              { subsystem: \"wlan\", status: \"unknown\" },\n            ],\n          },\n          {\n            name: \"site-2\",\n            desc: \"My Site\",\n            health: [\n              { subsystem: \"wan\", status: \"ok\", gw_name: \"My GW\", \"gw_system-stats\": { uptime: 86400 } },\n              { subsystem: \"lan\", status: \"unknown\" },\n              { subsystem: \"wlan\", status: \"unknown\" },\n            ],\n          },\n        ],\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<UnifiConsole options={{ index: 0, site: \"My Site\" }} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getByText(\"My GW\")).toBeInTheDocument();\n    expect(screen.getByText(\"1\")).toBeInTheDocument();\n  });\n\n  it(\"shows wlan user/device counts when wlan is available and lan is unknown\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          {\n            name: \"default\",\n            desc: \"Home\",\n            health: [\n              { subsystem: \"wan\", status: \"unknown\" },\n              { subsystem: \"lan\", status: \"unknown\" },\n              { subsystem: \"wlan\", status: \"ok\", num_user: 3, num_adopted: 10 },\n            ],\n          },\n        ],\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getByText(\"Home\")).toBeInTheDocument();\n    expect(screen.getByText(\"unifi.wlan\")).toBeInTheDocument();\n    expect(screen.getByText(\"3\")).toBeInTheDocument();\n    expect(screen.getByTitle(\"unifi.devices\")).toBeInTheDocument();\n    expect(screen.getByText(\"10\")).toBeInTheDocument();\n  });\n\n  it(\"renders an empty data hint when all subsystems are unknown and uptime is missing\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          {\n            name: \"default\",\n            desc: \"Home\",\n            health: [\n              { subsystem: \"wan\", status: \"unknown\" },\n              { subsystem: \"lan\", status: \"unknown\" },\n              { subsystem: \"wlan\", status: \"unknown\" },\n            ],\n          },\n        ],\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getByText(\"unifi.empty_data\")).toBeInTheDocument();\n  });\n\n  it(\"shows wan state when wan is available but reports a non-ok status\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          {\n            name: \"default\",\n            desc: \"Home\",\n            health: [\n              { subsystem: \"wan\", status: \"error\", gw_name: \"Router\" },\n              { subsystem: \"lan\", status: \"unknown\" },\n              { subsystem: \"wlan\", status: \"unknown\" },\n            ],\n          },\n        ],\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getByText(\"Router\")).toBeInTheDocument();\n    expect(screen.getByText(\"unifi.wan\")).toBeInTheDocument();\n  });\n\n  it(\"shows wlan down state when only wlan is available\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          {\n            name: \"default\",\n            desc: \"Home\",\n            health: [\n              { subsystem: \"wan\", status: \"unknown\" },\n              { subsystem: \"lan\", status: \"unknown\" },\n              { subsystem: \"wlan\", status: \"error\", num_user: 1, num_adopted: 2 },\n            ],\n          },\n        ],\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getByText(\"unifi.wlan\")).toBeInTheDocument();\n    expect(screen.getByText(\"1\")).toBeInTheDocument();\n    expect(screen.getByText(\"2\")).toBeInTheDocument();\n  });\n\n  it(\"shows lan user/device counts when only lan is available\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          {\n            name: \"default\",\n            desc: \"Home\",\n            health: [\n              { subsystem: \"wan\", status: \"unknown\" },\n              { subsystem: \"lan\", status: \"ok\", num_user: 2, num_adopted: 5 },\n              { subsystem: \"wlan\", status: \"unknown\" },\n            ],\n          },\n        ],\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getByText(\"unifi.lan\")).toBeInTheDocument();\n    expect(screen.getByText(\"2\")).toBeInTheDocument();\n    expect(screen.getByText(\"5\")).toBeInTheDocument();\n    expect(screen.getByTitle(\"unifi.devices\")).toBeInTheDocument();\n  });\n\n  it(\"shows a lan down state when only lan is available and reports a non-ok status\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          {\n            name: \"default\",\n            desc: \"Home\",\n            health: [\n              { subsystem: \"wan\", status: \"unknown\" },\n              { subsystem: \"lan\", status: \"error\", num_user: 1, num_adopted: 2 },\n              { subsystem: \"wlan\", status: \"unknown\" },\n            ],\n          },\n        ],\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getByText(\"unifi.lan\")).toBeInTheDocument();\n    expect(screen.getByTestId(\"bi-x-circle\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/weather/weather.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport { useCallback, useEffect, useState } from \"react\";\nimport { MdLocationDisabled, MdLocationSearching } from \"react-icons/md\";\nimport { WiCloudDown } from \"react-icons/wi\";\nimport useSWR from \"swr\";\n\nimport mapIcon from \"../../../utils/weather/condition-map\";\nimport Container from \"../widget/container\";\nimport ContainerButton from \"../widget/container_button\";\nimport Error from \"../widget/error\";\nimport PrimaryText from \"../widget/primary_text\";\nimport SecondaryText from \"../widget/secondary_text\";\nimport WidgetIcon from \"../widget/widget_icon\";\n\nfunction Widget({ options }) {\n  const { t, i18n } = useTranslation();\n\n  const { data, error } = useSWR(\n    `/api/widgets/weather?${new URLSearchParams({ lang: i18n.language, ...options }).toString()}`,\n  );\n\n  if (error || data?.error) {\n    return <Error options={options} />;\n  }\n\n  if (!data) {\n    return (\n      <Container options={options} additionalClassNames=\"information-widget-weather\">\n        <PrimaryText>{t(\"weather.updating\")}</PrimaryText>\n        <SecondaryText>{t(\"weather.wait\")}</SecondaryText>\n        <WidgetIcon icon={WiCloudDown} size=\"l\" />\n      </Container>\n    );\n  }\n\n  const unit = options.units === \"metric\" ? \"celsius\" : \"fahrenheit\";\n  const condition = data.current.condition.code;\n  const timeOfDay = data.current.is_day ? \"day\" : \"night\";\n\n  return (\n    <Container options={options} additionalClassNames=\"information-widget-weather\">\n      <PrimaryText>\n        {options.label && `${options.label}, `}\n        {t(\"common.number\", {\n          value: options.units === \"metric\" ? data.current.temp_c : data.current.temp_f,\n          style: \"unit\",\n          unit,\n          ...options.format,\n        })}\n      </PrimaryText>\n      <SecondaryText>{data.current.condition.text}</SecondaryText>\n      <WidgetIcon icon={mapIcon(condition, timeOfDay)} size=\"xl\" />\n    </Container>\n  );\n}\n\nexport default function WeatherApi({ options }) {\n  const { t } = useTranslation();\n  const [location, setLocation] = useState(false);\n  const [requesting, setRequesting] = useState(false);\n\n  if (!location && options.latitude && options.longitude) {\n    setLocation({ latitude: options.latitude, longitude: options.longitude });\n  }\n\n  const requestLocation = useCallback(() => {\n    setRequesting(true);\n    if (typeof window !== \"undefined\") {\n      navigator.geolocation.getCurrentPosition(\n        (position) => {\n          setLocation({ latitude: position.coords.latitude, longitude: position.coords.longitude });\n          setRequesting(false);\n        },\n        () => {\n          setRequesting(false);\n        },\n        {\n          enableHighAccuracy: true,\n          maximumAge: 1000 * 60 * 60 * 3,\n          timeout: 1000 * 30,\n        },\n      );\n    }\n  }, []);\n\n  useEffect(() => {\n    if (!options.latitude && !options.longitude && typeof navigator !== \"undefined\") {\n      navigator.permissions?.query({ name: \"geolocation\" }).then((result) => {\n        if (result.state === \"granted\") {\n          requestLocation();\n        }\n      });\n    }\n  }, [options.latitude, options.longitude, requestLocation]);\n\n  if (!location) {\n    return (\n      <ContainerButton options={options} callback={requestLocation}>\n        <PrimaryText>{t(\"weather.current\")}</PrimaryText>\n        <SecondaryText>{t(\"weather.allow\")}</SecondaryText>\n        <WidgetIcon icon={requesting ? MdLocationSearching : MdLocationDisabled} size=\"m\" pulse />\n      </ContainerButton>\n    );\n  }\n\n  return <Widget options={{ ...location, ...options }} />;\n}\n"
  },
  {
    "path": "src/components/widgets/weather/weather.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { fireEvent, screen, waitFor } from \"@testing-library/react\";\nimport { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));\nvi.mock(\"swr\", () => ({ default: useSWR }));\n\nvi.mock(\"react-icons/md\", () => ({\n  MdLocationDisabled: (props) => <svg data-testid=\"location-disabled\" {...props} />,\n  MdLocationSearching: (props) => <svg data-testid=\"location-searching\" {...props} />,\n}));\n\nimport WeatherApi from \"./weather\";\n\ndescribe(\"components/widgets/weather\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  afterEach(() => {\n    vi.unstubAllGlobals();\n  });\n\n  it(\"renders an error state when SWR errors or the API payload indicates an error\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: new Error(\"nope\") });\n    renderWithProviders(<WeatherApi options={{ latitude: 1, longitude: 2 }} />, { settings: { target: \"_self\" } });\n    expect(screen.getByText(\"widget.api_error\")).toBeInTheDocument();\n\n    useSWR.mockReturnValue({ data: { error: \"nope\" }, error: undefined });\n    renderWithProviders(<WeatherApi options={{ latitude: 1, longitude: 2 }} />, { settings: { target: \"_self\" } });\n    expect(screen.getAllByText(\"widget.api_error\").length).toBeGreaterThan(0);\n  });\n\n  it(\"renders a location prompt when no coordinates are available\", () => {\n    renderWithProviders(<WeatherApi options={{}} />, { settings: { target: \"_self\" } });\n\n    expect(screen.getByText(\"weather.current\")).toBeInTheDocument();\n    expect(screen.getByText(\"weather.allow\")).toBeInTheDocument();\n  });\n\n  it(\"auto-requests geolocation when permissions are granted\", async () => {\n    const getCurrentPosition = vi.fn((success) => success({ coords: { latitude: 30, longitude: 40 } }));\n    const query = vi.fn().mockResolvedValue({ state: \"granted\" });\n    vi.stubGlobal(\"navigator\", {\n      permissions: { query },\n      geolocation: { getCurrentPosition },\n    });\n\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<WeatherApi options={{}} />, { settings: { target: \"_self\" } });\n\n    await waitFor(() => {\n      expect(query).toHaveBeenCalled();\n      expect(getCurrentPosition).toHaveBeenCalled();\n    });\n  });\n\n  it(\"requests browser geolocation on click and then renders the updating state\", async () => {\n    const getCurrentPosition = vi.fn((success) => success({ coords: { latitude: 10, longitude: 20 } }));\n    vi.stubGlobal(\"navigator\", {\n      permissions: { query: vi.fn().mockResolvedValue({ state: \"prompt\" }) },\n      geolocation: { getCurrentPosition },\n    });\n\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<WeatherApi options={{}} />, { settings: { target: \"_self\" } });\n\n    screen.getByRole(\"button\").click();\n\n    await waitFor(() => {\n      expect(getCurrentPosition).toHaveBeenCalled();\n    });\n    expect(screen.getByText(\"weather.updating\")).toBeInTheDocument();\n  });\n\n  it(\"clears the requesting state when the browser denies geolocation\", async () => {\n    const getCurrentPosition = vi.fn((_success, failure) => setTimeout(() => failure(), 10));\n    vi.stubGlobal(\"navigator\", {\n      permissions: { query: vi.fn().mockResolvedValue({ state: \"prompt\" }) },\n      geolocation: { getCurrentPosition },\n    });\n\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<WeatherApi options={{}} />, { settings: { target: \"_self\" } });\n\n    fireEvent.click(screen.getByRole(\"button\"));\n    expect(screen.getByTestId(\"location-searching\")).toBeInTheDocument();\n\n    await waitFor(() => {\n      expect(screen.getByTestId(\"location-disabled\")).toBeInTheDocument();\n    });\n  });\n\n  it(\"renders temperature and condition when coordinates are provided\", async () => {\n    useSWR.mockReturnValue({\n      data: {\n        current: {\n          temp_c: 21.5,\n          temp_f: 70.7,\n          is_day: 1,\n          condition: { code: 1000, text: \"Sunny\" },\n        },\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(\n      <WeatherApi options={{ latitude: 1, longitude: 2, units: \"metric\", label: \"Home\", format: {} }} />,\n      { settings: { target: \"_self\" } },\n    );\n\n    await waitFor(() => {\n      expect(screen.getByText(\"Home, 21.5\")).toBeInTheDocument();\n    });\n    expect(screen.getByText(\"Sunny\")).toBeInTheDocument();\n  });\n\n  it(\"uses fahrenheit and night conditions when configured\", async () => {\n    useSWR.mockReturnValue({\n      data: {\n        current: {\n          temp_c: 21.5,\n          temp_f: 70.7,\n          is_day: 0,\n          condition: { code: 1000, text: \"Clear\" },\n        },\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<WeatherApi options={{ latitude: 1, longitude: 2, units: \"imperial\", format: {} }} />, {\n      settings: { target: \"_self\" },\n    });\n\n    await waitFor(() => {\n      expect(screen.getByText(\"70.7\")).toBeInTheDocument();\n    });\n    expect(screen.getByText(\"Clear\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/widget/container.jsx",
    "content": "import classNames from \"classnames\";\nimport { useContext } from \"react\";\nimport { SettingsContext } from \"utils/contexts/settings\";\n\nimport PrimaryText from \"./primary_text\";\nimport Raw from \"./raw\";\nimport SecondaryText from \"./secondary_text\";\nimport WidgetIcon from \"./widget_icon\";\n\nexport function getAllClasses(options, additionalClassNames = \"\") {\n  if (options?.style?.header === \"boxedWidgets\") {\n    if (options?.style?.cardBlur !== undefined) {\n      // eslint-disable-next-line no-param-reassign\n      additionalClassNames = [\n        additionalClassNames,\n        `backdrop-blur${options.style.cardBlur.length ? \"-\" : \"\"}${options.style.cardBlur}`,\n      ].join(\" \");\n    }\n\n    return classNames(\n      \"flex flex-col justify-center\",\n      \"mt-2 m:mb-0 rounded-md shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 bg-theme-100/20 dark:bg-white/5 p-2 pl-3 pr-3\",\n      additionalClassNames,\n    );\n  }\n\n  let widgetAlignedClasses = \"flex flex-col max-w:full sm:basis-auto self-center grow-0 flex-wrap\";\n  if (options?.style?.isRightAligned) {\n    widgetAlignedClasses = \"flex flex-col justify-center\";\n  }\n\n  return classNames(widgetAlignedClasses, additionalClassNames);\n}\n\nexport function getInnerBlock(children) {\n  // children won't be an array if it's Raw component\n  return (\n    Array.isArray(children) && (\n      <div className=\"flex flex-row items-center justify-end widget-inner\">\n        <div className=\"flex flex-col items-center widget-inner-icon\">\n          {children.find((child) => child.type === WidgetIcon)}\n        </div>\n        <div className=\"flex flex-col ml-3 text-left widget-inner-text\">\n          {children.find((child) => child.type === PrimaryText)}\n          {children.find((child) => child.type === SecondaryText)}\n        </div>\n      </div>\n    )\n  );\n}\n\nexport function getBottomBlock(children) {\n  if (children.type !== Raw) {\n    return children.find((child) => child.type === Raw) || [];\n  }\n\n  return [children];\n}\n\nexport default function Container({ children = [], options, additionalClassNames = \"\" }) {\n  const { settings } = useContext(SettingsContext);\n  return options?.href ? (\n    <a\n      href={options.href}\n      target={options.target ?? settings.target ?? \"_blank\"}\n      className={getAllClasses(options, `${additionalClassNames} widget-container`)}\n    >\n      {getInnerBlock(children)}\n      {getBottomBlock(children)}\n    </a>\n  ) : (\n    <div className={getAllClasses(options, `${additionalClassNames} widget-container`)}>\n      {getInnerBlock(children)}\n      {getBottomBlock(children)}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/widget/container.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nimport Container, { getAllClasses } from \"./container\";\nimport PrimaryText from \"./primary_text\";\nimport Raw from \"./raw\";\nimport SecondaryText from \"./secondary_text\";\nimport WidgetIcon from \"./widget_icon\";\n\nfunction FakeIcon(props) {\n  return <svg data-testid=\"fake-icon\" {...props} />;\n}\n\ndescribe(\"components/widgets/widget/container\", () => {\n  it(\"getAllClasses supports boxedWidgets + cardBlur and right alignment\", () => {\n    const boxed = getAllClasses({ style: { header: \"boxedWidgets\", cardBlur: \"md\" } }, \"x\");\n    expect(boxed).toContain(\"backdrop-blur-md\");\n    expect(boxed).toContain(\"x\");\n\n    const right = getAllClasses({ style: { isRightAligned: true } }, \"y\");\n    expect(right).toContain(\"justify-center\");\n    expect(right).toContain(\"y\");\n    expect(right).not.toContain(\"max-w:full\");\n  });\n\n  it(\"renders an anchor when href is provided and prefers options.target over settings.target\", () => {\n    renderWithProviders(\n      <Container options={{ href: \"http://example\", target: \"_self\" }}>\n        <WidgetIcon icon={FakeIcon} />\n        <PrimaryText>P</PrimaryText>\n        <SecondaryText>S</SecondaryText>\n        <Raw>\n          <div data-testid=\"bottom\">B</div>\n        </Raw>\n      </Container>,\n      { settings: { target: \"_blank\" } },\n    );\n\n    const link = screen.getByRole(\"link\");\n    expect(link.getAttribute(\"href\")).toBe(\"http://example\");\n    expect(link.getAttribute(\"target\")).toBe(\"_self\");\n    expect(screen.getByTestId(\"fake-icon\")).toBeInTheDocument();\n    expect(screen.getByText(\"P\")).toBeInTheDocument();\n    expect(screen.getByText(\"S\")).toBeInTheDocument();\n    expect(screen.getByTestId(\"bottom\")).toBeInTheDocument();\n  });\n\n  it(\"renders only bottom content when children are a single Raw element\", () => {\n    const { container } = renderWithProviders(\n      <Container options={{}}>\n        <Raw>\n          <div data-testid=\"only-bottom\">B</div>\n        </Raw>\n      </Container>,\n      { settings: { target: \"_self\" } },\n    );\n\n    expect(container.querySelector(\".widget-inner\")).toBeNull();\n    expect(screen.getByTestId(\"only-bottom\")).toBeInTheDocument();\n  });\n\n  it(\"does not crash when clicked (href case is normal link)\", () => {\n    renderWithProviders(\n      <Container options={{ href: \"http://example\" }}>\n        <Raw>\n          <div>Bottom</div>\n        </Raw>\n      </Container>,\n      { settings: { target: \"_self\" } },\n    );\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/widget/container_button.jsx",
    "content": "import { getAllClasses, getBottomBlock, getInnerBlock } from \"./container\";\n\nexport default function ContainerButton({ children = [], options, additionalClassNames = \"\", callback }) {\n  return (\n    <button\n      type=\"button\"\n      onClick={callback}\n      className={`${getAllClasses(options, additionalClassNames)} information-widget-container-button`}\n    >\n      {getInnerBlock(children)}\n      {getBottomBlock(children)}\n    </button>\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/widget/container_button.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { fireEvent, render, screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport ContainerButton from \"./container_button\";\nimport Raw from \"./raw\";\n\ndescribe(\"components/widgets/widget/container_button\", () => {\n  it(\"invokes callback on click\", () => {\n    const cb = vi.fn();\n    render(\n      <ContainerButton options={{}} callback={cb}>\n        <Raw>\n          <div>child</div>\n        </Raw>\n      </ContainerButton>,\n    );\n\n    fireEvent.click(screen.getByRole(\"button\"));\n    expect(cb).toHaveBeenCalledTimes(1);\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/widget/container_form.jsx",
    "content": "import { getAllClasses, getBottomBlock, getInnerBlock } from \"./container\";\n\nexport default function ContainerForm({ children = [], options, additionalClassNames = \"\", callback }) {\n  return (\n    <form onSubmit={callback} className={`${getAllClasses(options, additionalClassNames)} information-widget-form`}>\n      {getInnerBlock(children)}\n      {getBottomBlock(children)}\n    </form>\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/widget/container_form.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { fireEvent, render } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport ContainerForm from \"./container_form\";\n\ndescribe(\"components/widgets/widget/container_form\", () => {\n  it(\"calls callback on submit\", () => {\n    const cb = vi.fn((e) => e.preventDefault());\n\n    const { container } = render(\n      <ContainerForm options={{}} callback={cb}>\n        {[<div key=\"c\">child</div>]}\n      </ContainerForm>,\n    );\n\n    const form = container.querySelector(\"form\");\n    fireEvent.submit(form);\n\n    expect(cb).toHaveBeenCalledTimes(1);\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/widget/container_link.jsx",
    "content": "import { getAllClasses, getBottomBlock, getInnerBlock } from \"./container\";\n\nexport default function ContainerLink({ children = [], options, additionalClassNames = \"\", target }) {\n  return (\n    <a\n      href={options.href || options.url}\n      target={target}\n      className={`${getAllClasses(options, additionalClassNames)} information-widget-link`}\n    >\n      {getInnerBlock(children)}\n      {getBottomBlock(children)}\n    </a>\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/widget/container_link.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport ContainerLink from \"./container_link\";\nimport Raw from \"./raw\";\n\ndescribe(\"components/widgets/widget/container_link\", () => {\n  it(\"renders an anchor using href or url\", () => {\n    const { rerender } = render(<ContainerLink options={{ href: \"http://a\" }} target=\"_self\" />);\n    expect(screen.getByRole(\"link\").getAttribute(\"href\")).toBe(\"http://a\");\n    expect(screen.getByRole(\"link\").getAttribute(\"target\")).toBe(\"_self\");\n\n    rerender(\n      <ContainerLink options={{ url: \"http://b\" }} target=\"_blank\">\n        <Raw>\n          <div>child</div>\n        </Raw>\n      </ContainerLink>,\n    );\n    expect(screen.getByRole(\"link\").getAttribute(\"href\")).toBe(\"http://b\");\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/widget/error.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport { BiError } from \"react-icons/bi\";\n\nimport Container from \"./container\";\nimport PrimaryText from \"./primary_text\";\nimport WidgetIcon from \"./widget_icon\";\n\nexport default function Error({ options }) {\n  const { t } = useTranslation();\n\n  return (\n    <Container options={options} additionalClassNames=\"information-widget-error\">\n      <PrimaryText>{t(\"widget.api_error\")}</PrimaryText>\n      <WidgetIcon icon={BiError} size=\"l\" />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/widget/error.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nimport Error from \"./error\";\n\ndescribe(\"components/widgets/widget/error\", () => {\n  it(\"renders the api_error message\", () => {\n    renderWithProviders(<Error options={{}} />, { settings: { target: \"_self\" } });\n    expect(screen.getByText(\"widget.api_error\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/widget/primary_text.jsx",
    "content": "export default function PrimaryText({ children }) {\n  return <span className=\"primary-text text-theme-800 dark:text-theme-200 text-sm\">{children}</span>;\n}\n"
  },
  {
    "path": "src/components/widgets/widget/primary_text.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport PrimaryText from \"./primary_text\";\n\ndescribe(\"components/widgets/widget/primary_text\", () => {\n  it(\"renders children\", () => {\n    render(<PrimaryText>hello</PrimaryText>);\n    expect(screen.getByText(\"hello\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/widget/raw.jsx",
    "content": "export default function Raw({ children }) {\n  if (children.type === Raw) {\n    return [children];\n  }\n\n  return children;\n}\n"
  },
  {
    "path": "src/components/widgets/widget/raw.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport Raw from \"./raw\";\n\ndescribe(\"components/widgets/widget/raw\", () => {\n  it(\"renders nested Raw content\", () => {\n    render(\n      <Raw>\n        <Raw>\n          <div>inner</div>\n        </Raw>\n      </Raw>,\n    );\n\n    expect(screen.getByText(\"inner\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/widget/resource.jsx",
    "content": "import UsageBar from \"../resources/usage-bar\";\n\nexport default function Resource({\n  children,\n  icon,\n  value,\n  label,\n  expandedValue = \"\",\n  expandedLabel = \"\",\n  percentage,\n  expanded = false,\n  additionalClassNames = \"\",\n  wide = false,\n}) {\n  const Icon = icon;\n\n  return (\n    <div\n      className={`flex-none flex flex-row items-center mr-3 py-1.5 information-widget-resource ${additionalClassNames}`}\n    >\n      <Icon className=\"text-theme-800 dark:text-theme-200 w-5 h-5 resource-icon\" />\n      <div\n        className={`flex flex-col ml-3 text-left ${expanded ? \" expanded\" : \"\"} ${\n          wide ? \" min-w-[120px]\" : \"min-w-[85px]\"\n        }`}\n      >\n        <div className=\"text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between gap-2\">\n          <div className=\"pl-0.5\">{value}</div>\n          <div className=\"pr-1\">{label}</div>\n        </div>\n        {expanded && (\n          <div className=\"text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between gap-2\">\n            <div className=\"pl-0.5\">{expandedValue}</div>\n            <div className=\"pr-1\">{expandedLabel}</div>\n          </div>\n        )}\n        {percentage >= 0 && <UsageBar percent={percentage} additionalClassNames=\"resource-usage\" />}\n        {children}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/widget/resource.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nconst { UsageBar } = vi.hoisted(() => ({\n  UsageBar: vi.fn(({ percent }) => <div data-testid=\"usagebar\" data-percent={String(percent)} />),\n}));\n\nvi.mock(\"../resources/usage-bar\", () => ({\n  default: UsageBar,\n}));\n\nimport Resource from \"./resource\";\n\nfunction FakeIcon(props) {\n  return <svg data-testid=\"resource-icon\" {...props} />;\n}\n\ndescribe(\"components/widgets/widget/resource\", () => {\n  it(\"renders icon/value/label and shows usage bar when percentage is set\", () => {\n    render(<Resource icon={FakeIcon} value=\"v\" label=\"l\" percentage={0} />);\n\n    expect(screen.getByTestId(\"resource-icon\")).toBeInTheDocument();\n    expect(screen.getByText(\"v\")).toBeInTheDocument();\n    expect(screen.getByText(\"l\")).toBeInTheDocument();\n    expect(screen.getByTestId(\"usagebar\").getAttribute(\"data-percent\")).toBe(\"0\");\n  });\n\n  it(\"renders expanded values when expanded\", () => {\n    render(\n      <Resource icon={FakeIcon} value=\"v\" label=\"l\" expanded expandedValue=\"ev\" expandedLabel=\"el\" percentage={10} />,\n    );\n\n    expect(screen.getByText(\"ev\")).toBeInTheDocument();\n    expect(screen.getByText(\"el\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/widget/resources.jsx",
    "content": "import classNames from \"classnames\";\n\nimport ContainerLink from \"./container_link\";\nimport Raw from \"./raw\";\nimport Resource from \"./resource\";\nimport WidgetLabel from \"./widget_label\";\n\nexport default function Resources({ options, children, target, additionalClassNames }) {\n  const widgetParts = [].concat(...children);\n  const addedClassNames = classNames(\"information-widget-resources\", additionalClassNames);\n\n  return (\n    <ContainerLink options={options} target={target} additionalClassNames={addedClassNames}>\n      <Raw>\n        <div className=\"flex flex-row self-center flex-wrap justify-between\">\n          {widgetParts.filter((child) => child && child.type === Resource)}\n        </div>\n        {widgetParts.filter((child) => child && child.type === WidgetLabel)}\n      </Raw>\n    </ContainerLink>\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/widget/resources.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport Resource from \"./resource\";\nimport Resources from \"./resources\";\nimport WidgetLabel from \"./widget_label\";\n\nfunction FakeIcon() {\n  return <svg />;\n}\n\ndescribe(\"components/widgets/widget/resources\", () => {\n  it(\"filters children to Resource + WidgetLabel and wraps them in a link\", () => {\n    render(\n      <Resources options={{ href: \"http://example\" }} target=\"_self\" additionalClassNames=\"x\">\n        {[\n          <Resource key=\"r\" icon={FakeIcon} value=\"v\" label=\"l\" />,\n          <WidgetLabel key=\"w\" label=\"Label\" />,\n          <div key=\"o\">Other</div>,\n        ]}\n      </Resources>,\n    );\n\n    expect(screen.getByRole(\"link\").getAttribute(\"href\")).toBe(\"http://example\");\n    expect(screen.getByText(\"v\")).toBeInTheDocument();\n    expect(screen.getByText(\"Label\")).toBeInTheDocument();\n    expect(screen.queryByText(\"Other\")).toBeNull();\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/widget/secondary_text.jsx",
    "content": "export default function SecondaryText({ children }) {\n  return <span className=\"secondary-text text-theme-800 dark:text-theme-200 text-xs\">{children}</span>;\n}\n"
  },
  {
    "path": "src/components/widgets/widget/secondary_text.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport SecondaryText from \"./secondary_text\";\n\ndescribe(\"components/widgets/widget/secondary_text\", () => {\n  it(\"renders children\", () => {\n    render(<SecondaryText>world</SecondaryText>);\n    expect(screen.getByText(\"world\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/widget/widget_icon.jsx",
    "content": "export default function WidgetIcon({ icon, size = \"s\", pulse = false }) {\n  const Icon = icon;\n  let additionalClasses = \"information-widget-icon text-theme-800 dark:text-theme-200 \";\n\n  switch (size) {\n    case \"m\":\n      additionalClasses += \"w-6 h-6 \";\n      break;\n    case \"l\":\n      additionalClasses += \"w-8 h-8 \";\n      break;\n    case \"xl\":\n      additionalClasses += \"w-10 h-10 \";\n      break;\n    default:\n      additionalClasses += \"w-5 h-5 \";\n  }\n\n  if (pulse) {\n    additionalClasses += \"animate-pulse \";\n  }\n\n  return <Icon className={additionalClasses} />;\n}\n"
  },
  {
    "path": "src/components/widgets/widget/widget_icon.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport WidgetIcon from \"./widget_icon\";\n\nfunction FakeIcon(props) {\n  return <svg data-testid=\"icon\" {...props} />;\n}\n\ndescribe(\"components/widgets/widget/widget_icon\", () => {\n  it(\"applies size classes and pulse animation\", () => {\n    render(\n      <>\n        <WidgetIcon icon={FakeIcon} size=\"s\" />\n        <WidgetIcon icon={FakeIcon} size=\"m\" />\n        <WidgetIcon icon={FakeIcon} size=\"l\" pulse />\n        <WidgetIcon icon={FakeIcon} size=\"xl\" />\n      </>,\n    );\n\n    const icons = screen.getAllByTestId(\"icon\");\n    expect(icons[0].getAttribute(\"class\")).toContain(\"w-5 h-5\");\n    expect(icons[1].getAttribute(\"class\")).toContain(\"w-6 h-6\");\n    expect(icons[2].getAttribute(\"class\")).toContain(\"w-8 h-8\");\n    expect(icons[2].getAttribute(\"class\")).toContain(\"animate-pulse\");\n    expect(icons[3].getAttribute(\"class\")).toContain(\"w-10 h-10\");\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/widget/widget_label.jsx",
    "content": "export default function WidgetLabel({ label = \"\" }) {\n  return (\n    <div className=\"information-widget-label pt-1 text-center text-theme-800 dark:text-theme-200 text-xs\">{label}</div>\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/widget/widget_label.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport WidgetLabel from \"./widget_label\";\n\ndescribe(\"components/widgets/widget/widget_label\", () => {\n  it(\"renders label text\", () => {\n    render(<WidgetLabel label=\"Label A\" />);\n    expect(screen.getByText(\"Label A\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/components/widgets/widget.jsx",
    "content": "import ErrorBoundary from \"components/errorboundry\";\nimport dynamic from \"next/dynamic\";\n\nconst widgetMappings = {\n  weatherapi: dynamic(() => import(\"components/widgets/weather/weather\")),\n  openweathermap: dynamic(() => import(\"components/widgets/openweathermap/weather\")),\n  resources: dynamic(() => import(\"components/widgets/resources/resources\")),\n  search: dynamic(() => import(\"components/widgets/search/search\")),\n  greeting: dynamic(() => import(\"components/widgets/greeting/greeting\")),\n  datetime: dynamic(() => import(\"components/widgets/datetime/datetime\")),\n  logo: dynamic(() => import(\"components/widgets/logo/logo\"), { ssr: false }),\n  unifi_console: dynamic(() => import(\"components/widgets/unifi_console/unifi_console\")),\n  glances: dynamic(() => import(\"components/widgets/glances/glances\")),\n  openmeteo: dynamic(() => import(\"components/widgets/openmeteo/openmeteo\")),\n  longhorn: dynamic(() => import(\"components/widgets/longhorn/longhorn\")),\n  kubernetes: dynamic(() => import(\"components/widgets/kubernetes/kubernetes\")),\n  stocks: dynamic(() => import(\"components/widgets/stocks/stocks\")),\n};\n\nexport default function Widget({ widget, style }) {\n  const InfoWidget = widgetMappings[widget.type];\n\n  if (InfoWidget) {\n    return (\n      <ErrorBoundary>\n        <InfoWidget options={{ ...widget.options, style }} />\n      </ErrorBoundary>\n    );\n  }\n\n  return (\n    <div className=\"flex-none flex flex-row items-center justify-center\">\n      Missing <strong>{widget.type}</strong>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/widgets/widget.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nconst { dynamic } = vi.hoisted(() => {\n  const dynamic = vi.fn((loader, opts) => {\n    const loaderStr = loader.toString();\n    const ssr = opts?.ssr === false ? \"false\" : \"true\";\n\n    return function DynamicWidget({ options }) {\n      return (\n        <div\n          data-testid=\"dynamic-widget\"\n          data-loader={loaderStr}\n          data-ssr={ssr}\n          data-options={JSON.stringify(options)}\n        />\n      );\n    };\n  });\n\n  return { dynamic };\n});\n\nvi.mock(\"next/dynamic\", () => ({\n  default: dynamic,\n}));\n\nvi.mock(\"components/errorboundry\", () => ({\n  default: ({ children }) => <div data-testid=\"error-boundary\">{children}</div>,\n}));\n\nimport Widget from \"./widget\";\n\ndescribe(\"components/widgets/widget\", () => {\n  it(\"renders the mapped widget component and forwards style into options\", () => {\n    render(\n      <Widget widget={{ type: \"search\", options: { provider: [\"google\"] } }} style={{ header: \"boxedWidgets\" }} />,\n    );\n\n    const boundary = screen.getByTestId(\"error-boundary\");\n    expect(boundary).toBeInTheDocument();\n\n    const el = screen.getByTestId(\"dynamic-widget\");\n    expect(el.getAttribute(\"data-loader\")).toContain(\"search/search\");\n\n    const forwarded = JSON.parse(el.getAttribute(\"data-options\"));\n    expect(forwarded.provider).toEqual([\"google\"]);\n    expect(forwarded.style).toEqual({ header: \"boxedWidgets\" });\n  });\n\n  it(\"renders a missing message when widget type is unknown\", () => {\n    render(<Widget widget={{ type: \"nope\", options: {} }} style={{}} />);\n    expect(screen.getByText(\"Missing\")).toBeInTheDocument();\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/middleware.js",
    "content": "import { NextResponse } from \"next/server\";\n\nexport function middleware(req) {\n  // Check the Host header, if HOMEPAGE_ALLOWED_HOSTS is set\n  const host = req.headers.get(\"host\");\n  const port = process.env.PORT || 3000;\n  let allowedHosts = [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`];\n  const allowAll = process.env.HOMEPAGE_ALLOWED_HOSTS === \"*\";\n  if (process.env.HOMEPAGE_ALLOWED_HOSTS) {\n    allowedHosts = allowedHosts.concat(process.env.HOMEPAGE_ALLOWED_HOSTS.split(\",\"));\n  }\n  if (!allowAll && (!host || !allowedHosts.includes(host))) {\n    console.error(\n      `Host validation failed for: ${host}. Hint: Set the HOMEPAGE_ALLOWED_HOSTS environment variable to allow requests from this host / port.`,\n    );\n    return NextResponse.json({ error: \"Host validation failed. See logs for more details.\" }, { status: 400 });\n  }\n  return NextResponse.next();\n}\n\nexport const config = {\n  matcher: \"/api/:path*\",\n};\n"
  },
  {
    "path": "src/middleware.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { NextResponse } = vi.hoisted(() => ({\n  NextResponse: {\n    json: vi.fn((body, init) => ({ type: \"json\", body, init })),\n    next: vi.fn(() => ({ type: \"next\" })),\n  },\n}));\n\nvi.mock(\"next/server\", () => ({ NextResponse }));\n\nimport { middleware } from \"./middleware\";\n\nfunction createReq(host) {\n  return {\n    headers: {\n      get: (key) => (key === \"host\" ? host : null),\n    },\n  };\n}\n\ndescribe(\"middleware\", () => {\n  const originalEnv = process.env;\n  const originalConsoleError = console.error;\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    process.env = { ...originalEnv };\n    console.error = originalConsoleError;\n  });\n\n  it(\"allows requests for default localhost hosts\", () => {\n    process.env.PORT = \"3000\";\n    const res = middleware(createReq(\"localhost:3000\"));\n\n    expect(NextResponse.next).toHaveBeenCalled();\n    expect(res).toEqual({ type: \"next\" });\n  });\n\n  it(\"blocks requests when host is not allowed\", () => {\n    process.env.PORT = \"3000\";\n    const errSpy = vi.spyOn(console, \"error\").mockImplementation(() => {});\n\n    const res = middleware(createReq(\"evil.com\"));\n\n    expect(errSpy).toHaveBeenCalled();\n    expect(NextResponse.json).toHaveBeenCalledWith(\n      { error: \"Host validation failed. See logs for more details.\" },\n      { status: 400 },\n    );\n    expect(res.type).toBe(\"json\");\n    expect(res.init.status).toBe(400);\n  });\n\n  it(\"allows requests when HOMEPAGE_ALLOWED_HOSTS is '*'\", () => {\n    process.env.HOMEPAGE_ALLOWED_HOSTS = \"*\";\n    const res = middleware(createReq(\"anything.example\"));\n\n    expect(NextResponse.next).toHaveBeenCalled();\n    expect(res).toEqual({ type: \"next\" });\n  });\n\n  it(\"allows requests when host is included in HOMEPAGE_ALLOWED_HOSTS\", () => {\n    process.env.PORT = \"3000\";\n    process.env.HOMEPAGE_ALLOWED_HOSTS = \"example.com:3000,other:3000\";\n\n    const res = middleware(createReq(\"example.com:3000\"));\n\n    expect(NextResponse.next).toHaveBeenCalled();\n    expect(res).toEqual({ type: \"next\" });\n  });\n});\n"
  },
  {
    "path": "src/pages/_app.jsx",
    "content": "/* eslint-disable react/jsx-props-no-spreading */\nimport { appWithTranslation } from \"next-i18next\";\nimport Head from \"next/head\";\nimport \"styles/globals.css\";\nimport \"styles/manrope.css\";\nimport \"styles/theme.css\";\nimport { SWRConfig } from \"swr\";\nimport { ColorProvider } from \"utils/contexts/color\";\nimport { SettingsProvider } from \"utils/contexts/settings\";\nimport { TabProvider } from \"utils/contexts/tab\";\nimport { ThemeProvider } from \"utils/contexts/theme\";\n\nimport nextI18nextConfig from \"../../next-i18next.config\";\n\n// eslint-disable-next-line no-unused-vars\nconst tailwindSafelist = [\n  // TODO: remove pending https://github.com/tailwindlabs/tailwindcss/pull/17147\n  \"backdrop-blur\",\n  \"backdrop-blur-xs\",\n  \"backdrop-blur-sm\",\n  \"backdrop-blur-md\",\n  \"backdrop-blur-xl\",\n  \"backdrop-saturate-0\",\n  \"backdrop-saturate-50\",\n  \"backdrop-saturate-100\",\n  \"backdrop-saturate-150\",\n  \"backdrop-saturate-200\",\n  \"backdrop-brightness-0\",\n  \"backdrop-brightness-50\",\n  \"backdrop-brightness-75\",\n  \"backdrop-brightness-90\",\n  \"backdrop-brightness-95\",\n  \"backdrop-brightness-100\",\n  \"backdrop-brightness-105\",\n  \"backdrop-brightness-110\",\n  \"backdrop-brightness-125\",\n  \"backdrop-brightness-150\",\n  \"backdrop-brightness-200\",\n  \"grid-cols-1\",\n  \"md:grid-cols-1\",\n  \"md:grid-cols-2\",\n  \"lg:grid-cols-1\",\n  \"lg:grid-cols-2\",\n  \"lg:grid-cols-3\",\n  \"lg:grid-cols-4\",\n  \"lg:grid-cols-5\",\n  \"lg:grid-cols-6\",\n  \"lg:grid-cols-7\",\n  \"lg:grid-cols-8\",\n  // for status\n  \"bg-white\",\n  \"bg-black\",\n  \"dark:bg-white\",\n  \"bg-orange-400\",\n  \"dark:bg-orange-400\",\n  // maxGroupColumns\n  \"3xl:basis-1/5\",\n  \"3xl:basis-1/6\",\n  \"3xl:basis-1/7\",\n  \"3xl:basis-1/8\",\n  // yep\n  \"h-0 h-1 h-2 h-3 h-4 h-5 h-6 h-7 h-8 h-9 h-10 h-11 h-12 h-13 h-14 h-15 h-16 h-17 h-18 h-19 h-20 h-21 h-22 h-23 h-24 h-25 h-26 h-27 h-28 h-29 h-30 h-31 h-32 h-33 h-34 h-35 h-36 h-37 h-38 h-39 h-40 h-41 h-42 h-43 h-44 h-45 h-46 h-47 h-48 h-49 h-50 h-51 h-52 h-53 h-54 h-55 h-56 h-57 h-58 h-59 h-60 h-61 h-62 h-63 h-64 h-65 h-66 h-67 h-68 h-69 h-70 h-71 h-72 h-73 h-74 h-75 h-76 h-77 h-78 h-79 h-80 h-81 h-82 h-83 h-84 h-85 h-86 h-87 h-88 h-89 h-90 h-91 h-92 h-93 h-94 h-95 h-96\",\n  \"sm:h-0 sm:h-1 sm:h-2 sm:h-3 sm:h-4 sm:h-5 sm:h-6 sm:h-7 sm:h-8 sm:h-9 sm:h-10 sm:h-11 sm:h-12 sm:h-13 sm:h-14 sm:h-15 sm:h-16 sm:h-17 sm:h-18 sm:h-19 sm:h-20 sm:h-21 sm:h-22 sm:h-23 sm:h-24 sm:h-25 sm:h-26 sm:h-27 sm:h-28 sm:h-29 sm:h-30 sm:h-31 sm:h-32 sm:h-33 sm:h-34 sm:h-35 sm:h-36 sm:h-37 sm:h-38 sm:h-39 sm:h-40 sm:h-41 sm:h-42 sm:h-43 sm:h-44 sm:h-45 sm:h-46 sm:h-47 sm:h-48 sm:h-49 sm:h-50 sm:h-51 sm:h-52 sm:h-53 sm:h-54 sm:h-55 sm:h-56 sm:h-57 sm:h-58 sm:h-59 sm:h-60 sm:h-61 sm:h-62 sm:h-63 sm:h-64 sm:h-65 sm:h-66 sm:h-67 sm:h-68 sm:h-69 sm:h-70 sm:h-71 sm:h-72 sm:h-73 sm:h-74 sm:h-75 sm:h-76 sm:h-77 sm:h-78 sm:h-79 sm:h-80 sm:h-81 sm:h-82 sm:h-83 sm:h-84 sm:h-85 sm:h-86 sm:h-87 sm:h-88 sm:h-89 sm:h-90 sm:h-91 sm:h-92 sm:h-93 sm:h-94 sm:h-95 sm:h-96\",\n  \"md:h-0 md:h-1 md:h-2 md:h-3 md:h-4 md:h-5 md:h-6 md:h-7 md:h-8 md:h-9 md:h-10 md:h-11 md:h-12 md:h-13 md:h-14 md:h-15 md:h-16 md:h-17 md:h-18 md:h-19 md:h-20 md:h-21 md:h-22 md:h-23 md:h-24 md:h-25 md:h-26 md:h-27 md:h-28 md:h-29 md:h-30 md:h-31 md:h-32 md:h-33 md:h-34 md:h-35 md:h-36 md:h-37 md:h-38 md:h-39 md:h-40 md:h-41 md:h-42 md:h-43 md:h-44 md:h-45 md:h-46 md:h-47 md:h-48 md:h-49 md:h-50 md:h-51 md:h-52 md:h-53 md:h-54 md:h-55 md:h-56 md:h-57 md:h-58 md:h-59 md:h-60 md:h-61 md:h-62 md:h-63 md:h-64 md:h-65 md:h-66 md:h-67 md:h-68 md:h-69 md:h-70 md:h-71 md:h-72 md:h-73 md:h-74 md:h-75 md:h-76 md:h-77 md:h-78 md:h-79 md:h-80 md:h-81 md:h-82 md:h-83 md:h-84 md:h-85 md:h-86 md:h-87 md:h-88 md:h-89 md:h-90 md:h-91 md:h-92 md:h-93 md:h-94 md:h-95 md:h-96\",\n  \"lg:h-0 lg:h-1 lg:h-2 lg:h-3 lg:h-4 lg:h-5 lg:h-6 lg:h-7 lg:h-8 lg:h-9 lg:h-10 lg:h-11 lg:h-12 lg:h-13 lg:h-14 lg:h-15 lg:h-16 lg:h-17 lg:h-18 lg:h-19 lg:h-20 lg:h-21 lg:h-22 lg:h-23 lg:h-24 lg:h-25 lg:h-26 lg:h-27 lg:h-28 lg:h-29 lg:h-30 lg:h-31 lg:h-32 lg:h-33 lg:h-34 lg:h-35 lg:h-36 lg:h-37 lg:h-38 lg:h-39 lg:h-40 lg:h-41 lg:h-42 lg:h-43 lg:h-44 lg:h-45 lg:h-46 lg:h-47 lg:h-48 lg:h-49 lg:h-50 lg:h-51 lg:h-52 lg:h-53 lg:h-54 lg:h-55 lg:h-56 lg:h-57 lg:h-58 lg:h-59 lg:h-60 lg:h-61 lg:h-62 lg:h-63 lg:h-64 lg:h-65 lg:h-66 lg:h-67 lg:h-68 lg:h-69 lg:h-70 lg:h-71 lg:h-72 lg:h-73 lg:h-74 lg:h-75 lg:h-76 lg:h-77 lg:h-78 lg:h-79 lg:h-80 lg:h-81 lg:h-82 lg:h-83 lg:h-84 lg:h-85 lg:h-86 lg:h-87 lg:h-88 lg:h-89 lg:h-90 lg:h-91 lg:h-92 lg:h-93 lg:h-94 lg:h-95 lg:h-96\",\n  \"xl:h-0 xl:h-1 xl:h-2 xl:h-3 xl:h-4 xl:h-5 xl:h-6 xl:h-7 xl:h-8 xl:h-9 xl:h-10 xl:h-11 xl:h-12 xl:h-13 xl:h-14 xl:h-15 xl:h-16 xl:h-17 xl:h-18 xl:h-19 xl:h-20 xl:h-21 xl:h-22 xl:h-23 xl:h-24 xl:h-25 xl:h-26 xl:h-27 xl:h-28 xl:h-29 xl:h-30 xl:h-31 xl:h-32 xl:h-33 xl:h-34 xl:h-35 xl:h-36 xl:h-37 xl:h-38 xl:h-39 xl:h-40 xl:h-41 xl:h-42 xl:h-43 xl:h-44 xl:h-45 xl:h-46 xl:h-47 xl:h-48 xl:h-49 xl:h-50 xl:h-51 xl:h-52 xl:h-53 xl:h-54 xl:h-55 xl:h-56 xl:h-57 xl:h-58 xl:h-59 xl:h-60 xl:h-61 xl:h-62 xl:h-63 xl:h-64 xl:h-65 xl:h-66 xl:h-67 xl:h-68 xl:h-69 xl:h-70 xl:h-71 xl:h-72 xl:h-73 xl:h-74 xl:h-75 xl:h-76 xl:h-77 xl:h-78 xl:h-79 xl:h-80 xl:h-81 xl:h-82 xl:h-83 xl:h-84 xl:h-85 xl:h-86 xl:h-87 xl:h-88 xl:h-89 xl:h-90 xl:h-91 xl:h-92 xl:h-93 xl:h-94 xl:h-95 xl:h-96\",\n  \"2xl:h-0 2xl:h-1 2xl:h-2 2xl:h-3 2xl:h-4 2xl:h-5 2xl:h-6 2xl:h-7 2xl:h-8 2xl:h-9 2xl:h-10 2xl:h-11 2xl:h-12 2xl:h-13 2xl:h-14 2xl:h-15 2xl:h-16 2xl:h-17 2xl:h-18 2xl:h-19 2xl:h-20 2xl:h-21 2xl:h-22 2xl:h-23 2xl:h-24 2xl:h-25 2xl:h-26 2xl:h-27 2xl:h-28 2xl:h-29 2xl:h-30 2xl:h-31 2xl:h-32 2xl:h-33 2xl:h-34 2xl:h-35 2xl:h-36 2xl:h-37 2xl:h-38 2xl:h-39 2xl:h-40 2xl:h-41 2xl:h-42 2xl:h-43 2xl:h-44 2xl:h-45 2xl:h-46 2xl:h-47 2xl:h-48 2xl:h-49 2xl:h-50 2xl:h-51 2xl:h-52 2xl:h-53 2xl:h-54 2xl:h-55 2xl:h-56 2xl:h-57 2xl:h-58 2xl:h-59 2xl:h-60 2xl:h-61 2xl:h-62 2xl:h-63 2xl:h-64 2xl:h-65 2xl:h-66 2xl:h-67 2xl:h-68 2xl:h-69 2xl:h-70 2xl:h-71 2xl:h-72 2xl:h-73 2xl:h-74 2xl:h-75 2xl:h-76 2xl:h-77 2xl:h-78 2xl:h-79 2xl:h-80 2xl:h-81 2xl:h-82 2xl:h-83 2xl:h-84 2xl:h-85 2xl:h-86 2xl:h-87 2xl:h-88 2xl:h-89 2xl:h-90 2xl:h-91 2xl:h-92 2xl:h-93 2xl:h-94 2xl:h-95 2xl:h-96\",\n];\n\nfunction MyApp({ Component, pageProps }) {\n  return (\n    <SWRConfig\n      value={{\n        fetcher: (resource, init) => fetch(resource, init).then((res) => res.json()),\n      }}\n    >\n      <Head>\n        {/* https://nextjs.org/docs/messages/no-document-viewport-meta */}\n        <meta\n          name=\"viewport\"\n          content=\"width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no\"\n        />\n      </Head>\n      <ColorProvider>\n        <ThemeProvider>\n          <SettingsProvider>\n            <TabProvider>\n              <Component {...pageProps} />\n            </TabProvider>\n          </SettingsProvider>\n        </ThemeProvider>\n      </ColorProvider>\n    </SWRConfig>\n  );\n}\n\nexport default appWithTranslation(MyApp, nextI18nextConfig);\n"
  },
  {
    "path": "src/pages/_document.jsx",
    "content": "import { Head, Html, Main, NextScript } from \"next/document\";\n\nexport default function Document() {\n  return (\n    <Html>\n      <Head>\n        <meta name=\"mobile-web-app-capable\" content=\"yes\" />\n        <link rel=\"manifest\" href=\"/site.webmanifest?v=4\" crossOrigin=\"use-credentials\" />\n        <link rel=\"preload\" href=\"/api/config/custom.css\" as=\"style\" />\n        <link rel=\"stylesheet\" href=\"/api/config/custom.css\" /> {/* eslint-disable-line @next/next/no-css-tags */}\n      </Head>\n      <body>\n        <Main />\n        <NextScript />\n      </body>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "src/pages/api/bookmarks.js",
    "content": "import { bookmarksResponse } from \"utils/config/api-response\";\n\nexport default async function handler(req, res) {\n  res.send(await bookmarksResponse());\n}\n"
  },
  {
    "path": "src/pages/api/config/[path].js",
    "content": "import fs from \"fs\";\nimport path from \"path\";\n\nimport { CONF_DIR } from \"utils/config/config\";\nimport createLogger from \"utils/logger\";\n\nconst logger = createLogger(\"configFileService\");\n\n/**\n * @param {import(\"next\").NextApiRequest} req\n * @param {import(\"next\").NextApiResponse} res\n */\nexport default async function handler(req, res) {\n  const { path: relativePath } = req.query;\n\n  // only two supported files, for now\n  if (![\"custom.css\", \"custom.js\"].includes(relativePath)) {\n    return res.status(422).end(\"Unsupported file\");\n  }\n\n  const filePath = path.join(CONF_DIR, relativePath);\n\n  try {\n    // Read the content of the file or return empty content\n    const fileContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, \"utf-8\") : \"\";\n    // hard-coded since we only support two known files for now\n    const mimeType = relativePath === \"custom.css\" ? \"text/css\" : \"text/javascript\";\n    res.setHeader(\"Content-Type\", mimeType);\n    return res.status(200).send(fileContent);\n  } catch (error) {\n    if (error) logger.error(error);\n    return res.status(500).end(\"Internal Server Error\");\n  }\n}\n"
  },
  {
    "path": "src/pages/api/docker/stats/[...service].js",
    "content": "import Docker from \"dockerode\";\n\nimport getDockerArguments from \"utils/config/docker\";\nimport createLogger from \"utils/logger\";\n\nconst logger = createLogger(\"dockerStatsService\");\n\nexport default async function handler(req, res) {\n  const { service } = req.query;\n  const [containerName, containerServer] = service;\n\n  if (!containerName && !containerServer) {\n    return res.status(400).send({\n      error: \"docker query parameters are required\",\n    });\n  }\n\n  try {\n    const dockerArgs = getDockerArguments(containerServer);\n    const docker = new Docker(dockerArgs.conn);\n    const containers = await docker.listContainers({\n      all: true,\n    });\n\n    // bad docker connections can result in a <Buffer ...> object?\n    // in any case, this ensures the result is the expected array\n    if (!Array.isArray(containers)) {\n      return res.status(500).send({\n        error: \"query failed\",\n      });\n    }\n\n    const containerNames = containers.flatMap((container) => container.Names.map((name) => name.replace(/^\\//, \"\")));\n    const containerExists = containerNames.includes(containerName);\n\n    if (containerExists) {\n      const container = docker.getContainer(containerName);\n      const stats = await container.stats({ stream: false });\n\n      return res.status(200).json({\n        stats,\n      });\n    }\n\n    // Try with a service deployed in Docker Swarm, if enabled\n    if (dockerArgs.swarm) {\n      const tasks = await docker\n        .listTasks({\n          filters: {\n            service: [containerName],\n            // A service can have several offline containers, so we only look for an active one.\n            \"desired-state\": [\"running\"],\n          },\n        })\n        .catch(() => []);\n\n      // TODO: Show the result for all replicas/containers?\n      // We can only get stats for 'local' containers so try to find one\n      const localContainerIDs = containers.map((c) => c.Id);\n      const task = tasks.find((t) => localContainerIDs.includes(t.Status?.ContainerStatus?.ContainerID)) ?? tasks.at(0);\n      const taskContainerId = task?.Status?.ContainerStatus?.ContainerID;\n\n      if (taskContainerId) {\n        try {\n          const container = docker.getContainer(taskContainerId);\n          const stats = await container.stats({ stream: false });\n\n          return res.status(200).json({\n            stats,\n          });\n        } catch (e) {\n          return res.status(200).json({\n            error: \"Unable to retrieve stats\",\n          });\n        }\n      }\n    }\n\n    return res.status(404).send({\n      error: \"not found\",\n    });\n  } catch (e) {\n    if (e) logger.error(e);\n    return res.status(500).send({\n      error: { message: e?.message ?? \"Unknown error\" },\n    });\n  }\n}\n"
  },
  {
    "path": "src/pages/api/docker/status/[...service].js",
    "content": "import Docker from \"dockerode\";\n\nimport getDockerArguments from \"utils/config/docker\";\nimport createLogger from \"utils/logger\";\n\nconst logger = createLogger(\"dockerStatusService\");\n\nexport default async function handler(req, res) {\n  const { service } = req.query;\n  const [containerName, containerServer] = service;\n\n  if (!containerName && !containerServer) {\n    return res.status(400).send({\n      error: \"docker query parameters are required\",\n    });\n  }\n\n  try {\n    const dockerArgs = getDockerArguments(containerServer);\n    const docker = new Docker(dockerArgs.conn);\n    const containers = await docker.listContainers({\n      all: true,\n    });\n\n    // bad docker connections can result in a <Buffer ...> object?\n    // in any case, this ensures the result is the expected array\n    if (!Array.isArray(containers)) {\n      return res.status(500).send({\n        error: \"query failed\",\n      });\n    }\n\n    const containerNames = containers.flatMap((container) => container.Names.map((name) => name.replace(/^\\//, \"\")));\n    const containerExists = containerNames.includes(containerName);\n\n    if (containerExists) {\n      const container = docker.getContainer(containerName);\n      const info = await container.inspect();\n\n      return res.status(200).json({\n        status: info.State.Status,\n        health: info.State.Health?.Status,\n      });\n    }\n\n    if (dockerArgs.swarm) {\n      const serviceInfo = await docker\n        .getService(containerName)\n        .inspect()\n        .catch(() => undefined);\n\n      if (!serviceInfo) {\n        return res.status(404).send({\n          status: \"not found\",\n        });\n      }\n\n      const tasks = await docker\n        .listTasks({\n          filters: {\n            service: [containerName],\n            \"desired-state\": [\"running\"],\n          },\n        })\n        .catch(() => []);\n\n      if (serviceInfo.Spec.Mode?.Replicated) {\n        // Replicated service, check n replicas\n        const replicas = parseInt(serviceInfo.Spec.Mode?.Replicated?.Replicas, 10);\n        if (tasks.length === replicas) {\n          return res.status(200).json({\n            status: `running ${tasks.length}/${replicas}`,\n          });\n        }\n        if (tasks.length > 0) {\n          return res.status(200).json({\n            status: `partial ${tasks.length}/${replicas}`,\n          });\n        }\n      } else {\n        // Global service, prefer 'local' containers\n        const localContainerIDs = containers.map((c) => c.Id);\n        const task =\n          tasks.find((t) => localContainerIDs.includes(t.Status?.ContainerStatus?.ContainerID)) ?? tasks.at(0);\n        const taskContainerId = task?.Status?.ContainerStatus?.ContainerID;\n\n        if (taskContainerId) {\n          try {\n            const container = docker.getContainer(taskContainerId);\n            const info = await container.inspect();\n\n            return res.status(200).json({\n              status: info.State.Status,\n              health: info.State.Health?.Status,\n            });\n          } catch (e) {\n            if (task) {\n              return res.status(200).json({\n                status: task.Status.State,\n              });\n            }\n          }\n        }\n      }\n    }\n\n    return res.status(404).send({\n      status: \"not found\",\n    });\n  } catch (e) {\n    if (e) logger.error(e);\n    return res.status(500).send({\n      error: { message: e?.message ?? \"Unknown error\" },\n    });\n  }\n}\n"
  },
  {
    "path": "src/pages/api/hash.js",
    "content": "import { createHash } from \"crypto\";\nimport { readFileSync } from \"fs\";\nimport { join } from \"path\";\n\nimport checkAndCopyConfig, { CONF_DIR } from \"utils/config/config\";\n\nconst configs = [\n  \"docker.yaml\",\n  \"settings.yaml\",\n  \"services.yaml\",\n  \"bookmarks.yaml\",\n  \"widgets.yaml\",\n  \"custom.css\",\n  \"custom.js\",\n];\n\nfunction hash(buffer) {\n  const hashSum = createHash(\"sha256\");\n  hashSum.update(buffer);\n  return hashSum.digest(\"hex\");\n}\n\nexport default async function handler(req, res) {\n  const hashes = configs.map((config) => {\n    checkAndCopyConfig(config);\n    const configYaml = join(CONF_DIR, config);\n    return hash(readFileSync(configYaml, \"utf8\"));\n  });\n\n  // set to date by docker entrypoint, will force revalidation between restarts/recreates\n  const buildTime = process.env.HOMEPAGE_BUILDTIME?.length ? process.env.HOMEPAGE_BUILDTIME : \"\";\n\n  const combinedHash = hash(hashes.join(\"\") + buildTime);\n\n  res.send({\n    hash: combinedHash,\n  });\n}\n"
  },
  {
    "path": "src/pages/api/healthcheck.js",
    "content": "export default function handler(req, res) {\n  res.send(\"up\");\n}\n"
  },
  {
    "path": "src/pages/api/kubernetes/stats/[...service].js",
    "content": "import { CoreV1Api, Metrics } from \"@kubernetes/client-node\";\n\nimport { getKubeConfig } from \"../../../../utils/config/kubernetes\";\nimport { parseCpu, parseMemory } from \"../../../../utils/kubernetes/utils\";\nimport createLogger from \"../../../../utils/logger\";\n\nconst logger = createLogger(\"kubernetesStatsService\");\n\nexport default async function handler(req, res) {\n  const APP_LABEL = \"app.kubernetes.io/name\";\n  const { service, podSelector } = req.query;\n\n  const [namespace, appName] = service;\n  if (!namespace && !appName) {\n    res.status(400).send({\n      error: \"kubernetes query parameters are required\",\n    });\n    return;\n  }\n  const labelSelector = podSelector !== undefined ? podSelector : `${APP_LABEL}=${appName}`;\n\n  try {\n    const kc = getKubeConfig();\n    if (!kc) {\n      res.status(500).send({\n        error: \"No kubernetes configuration\",\n      });\n      return;\n    }\n    const coreApi = kc.makeApiClient(CoreV1Api);\n    const metricsApi = new Metrics(kc);\n    const podsResponse = await coreApi\n      .listNamespacedPod({\n        namespace,\n        labelSelector,\n      })\n      .catch((err) => {\n        logger.error(\"Error getting pods: %d %s %s\", err.statusCode, err.body, err.response);\n        return null;\n      });\n    if (!podsResponse) {\n      res.status(500).send({\n        error: \"Error communicating with kubernetes\",\n      });\n      return;\n    }\n    const pods = podsResponse.items;\n\n    if (pods.length === 0) {\n      res.status(404).send({\n        error: `no pods found with namespace=${namespace} and labelSelector=${labelSelector}`,\n      });\n      return;\n    }\n\n    const podNames = new Set();\n    let cpuLimit = 0;\n    let memLimit = 0;\n    pods.forEach((pod) => {\n      podNames.add(pod.metadata.name);\n      pod.spec.containers.forEach((container) => {\n        if (container?.resources?.limits?.cpu) {\n          cpuLimit += parseCpu(container?.resources?.limits?.cpu);\n        }\n        if (container?.resources?.limits?.memory) {\n          memLimit += parseMemory(container?.resources?.limits?.memory);\n        }\n      });\n    });\n\n    const namespaceMetrics = await metricsApi\n      .getPodMetrics(namespace)\n      .then((response) => response.items)\n      .catch((err) => {\n        // 404 generally means that the metrics have not been populated yet\n        if (err.statusCode !== 404) {\n          logger.error(\"Error getting pod metrics: %d %s %s\", err.statusCode, err.body, err.response);\n        }\n        return null;\n      });\n\n    const stats = {\n      mem: 0,\n      cpu: 0,\n    };\n\n    if (namespaceMetrics) {\n      const podMetrics = namespaceMetrics.filter((item) => podNames.has(item.metadata.name));\n      podMetrics.forEach((metrics) => {\n        metrics.containers.forEach((container) => {\n          stats.mem += parseMemory(container.usage.memory);\n          stats.cpu += parseCpu(container.usage.cpu);\n        });\n      });\n    }\n\n    stats.cpuLimit = cpuLimit;\n    stats.memLimit = memLimit;\n    stats.cpuUsage = cpuLimit ? 100 * (stats.cpu / cpuLimit) : 0;\n    stats.memUsage = memLimit ? 100 * (stats.mem / memLimit) : 0;\n    res.status(200).json({\n      stats,\n    });\n  } catch (e) {\n    if (e) logger.error(e);\n    res.status(500).send({\n      error: \"unknown error\",\n    });\n  }\n}\n"
  },
  {
    "path": "src/pages/api/kubernetes/status/[...service].js",
    "content": "import { CoreV1Api } from \"@kubernetes/client-node\";\n\nimport { getKubeConfig } from \"../../../../utils/config/kubernetes\";\nimport createLogger from \"../../../../utils/logger\";\n\nconst logger = createLogger(\"kubernetesStatusService\");\n\nexport default async function handler(req, res) {\n  const APP_LABEL = \"app.kubernetes.io/name\";\n  const { service, podSelector } = req.query;\n\n  const [namespace, appName] = service;\n  if (!namespace && !appName) {\n    res.status(400).send({\n      error: \"kubernetes query parameters are required\",\n    });\n    return;\n  }\n  const labelSelector = podSelector !== undefined ? podSelector : `${APP_LABEL}=${appName}`;\n  try {\n    const kc = getKubeConfig();\n    if (!kc) {\n      res.status(500).send({\n        error: \"No kubernetes configuration\",\n      });\n      return;\n    }\n    const coreApi = kc.makeApiClient(CoreV1Api);\n    const podsResponse = await coreApi\n      .listNamespacedPod({\n        namespace,\n        labelSelector,\n      })\n      .catch((err) => {\n        logger.error(\"Error getting pods: %d %s %s\", err.statusCode, err.body, err.response);\n        return null;\n      });\n    if (!podsResponse) {\n      res.status(500).send({\n        error: \"Error communicating with kubernetes\",\n      });\n      return;\n    }\n    const pods = podsResponse.items;\n\n    if (pods.length === 0) {\n      res.status(404).send({\n        status: \"not found\",\n      });\n      logger.error(`no pods found with namespace=${namespace} and labelSelector=${labelSelector}`);\n      return;\n    }\n    const someReady = pods.find((pod) => [\"Succeeded\", \"Running\"].includes(pod.status.phase));\n    const allReady = pods.every((pod) => [\"Succeeded\", \"Running\"].includes(pod.status.phase));\n    let status = \"down\";\n    if (allReady) {\n      status = \"running\";\n    } else if (someReady) {\n      status = \"partial\";\n    }\n    res.status(200).json({\n      status,\n    });\n  } catch (e) {\n    if (e) logger.error(e);\n    res.status(500).send({\n      error: \"unknown error\",\n    });\n  }\n}\n"
  },
  {
    "path": "src/pages/api/ping.js",
    "content": "import { promise as ping } from \"ping\";\n\nimport { getServiceItem } from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\n\nconst logger = createLogger(\"ping\");\n\nexport default async function handler(req, res) {\n  const { groupName, serviceName } = req.query;\n  const serviceItem = await getServiceItem(groupName, serviceName);\n  if (!serviceItem) {\n    logger.debug(`No service item found for group ${groupName} named ${serviceName}`);\n    return res.status(400).send({\n      error: \"Unable to find service, see log for details.\",\n    });\n  }\n\n  const { ping: pingHostOrURL } = serviceItem;\n\n  if (!pingHostOrURL) {\n    logger.debug(\"No ping host specified\");\n    return res.status(400).send({\n      error: \"No ping host given\",\n    });\n  }\n\n  let hostname = pingHostOrURL;\n  try {\n    // maintain backwards compatibility with old ping where may be http://...\n    hostname = new URL(pingHostOrURL).hostname;\n  } catch (e) {}\n\n  try {\n    const response = await ping.probe(hostname);\n    return res.status(200).json(response);\n  } catch (e) {\n    logger.debug(\"Error attempting ping: %s\", e);\n    return res.status(400).send({\n      error: \"Error attempting ping, see logs.\",\n    });\n  }\n}\n"
  },
  {
    "path": "src/pages/api/proxmox/stats/[...service].js",
    "content": "import { getProxmoxConfig } from \"utils/config/proxmox\";\nimport createLogger from \"utils/logger\";\nimport { httpProxy } from \"utils/proxy/http\";\n\nconst logger = createLogger(\"proxmoxStatsService\");\n\nexport default async function handler(req, res) {\n  const { service, type: vmType } = req.query;\n\n  const [node, vmid] = service;\n\n  if (!node) {\n    return res.status(400).send({\n      error: \"Proxmox node parameter is required\",\n    });\n  }\n\n  try {\n    const proxmoxConfig = getProxmoxConfig();\n\n    if (!proxmoxConfig) {\n      return res.status(500).send({\n        error: \"Proxmox server configuration not found\",\n      });\n    }\n\n    // Prefer per-node config (new format), fall back to legacy flat creds.\n    const nodeConfig =\n      (node && proxmoxConfig && proxmoxConfig[node]) ||\n      (proxmoxConfig && proxmoxConfig.url && proxmoxConfig.token && proxmoxConfig.secret\n        ? {\n            url: proxmoxConfig.url,\n            token: proxmoxConfig.token,\n            secret: proxmoxConfig.secret,\n          }\n        : null);\n\n    if (!nodeConfig) {\n      return res.status(400).json({\n        error:\n          \"Proxmox config not found for the specified node and no legacy credentials detected. \" +\n          \"Add a node block in proxmox.yaml (e.g., 'pve: { url, token, secret }') or restore legacy top-level url/token/secret.\",\n      });\n    }\n\n    const baseUrl = `${nodeConfig.url}/api2/json`;\n    const headers = {\n      Authorization: `PVEAPIToken=${nodeConfig.token}=${nodeConfig.secret}`,\n    };\n\n    const statusUrl = `${baseUrl}/nodes/${node}/${vmType}/${vmid}/status/current`;\n\n    const [status, , data] = await httpProxy(statusUrl, {\n      method: \"GET\",\n      headers,\n    });\n\n    if (status !== 200) {\n      logger.error(\"HTTP Error %d calling Proxmox API\", status);\n      return res.status(status).send({\n        error: `Failed to fetch Proxmox ${vmType} status`,\n      });\n    }\n\n    let parsedData = JSON.parse(Buffer.from(data).toString());\n\n    if (!parsedData || !parsedData.data) {\n      return res.status(500).send({\n        error: \"Invalid response from Proxmox API\",\n      });\n    }\n\n    return res.status(200).json({\n      status: parsedData.data.status || \"unknown\",\n      cpu: parsedData.data.cpu,\n      mem: parsedData.data.mem,\n    });\n  } catch (error) {\n    logger.error(\"Error fetching Proxmox status:\", error);\n    return res.status(500).send({\n      error: \"Failed to fetch Proxmox status\",\n    });\n  }\n}\n"
  },
  {
    "path": "src/pages/api/releases.js",
    "content": "import createLogger from \"utils/logger\";\nimport { cachedRequest } from \"utils/proxy/http\";\n\nconst logger = createLogger(\"releases\");\n\nexport default async function handler(req, res) {\n  const releasesURL = \"https://api.github.com/repos/gethomepage/homepage/releases\";\n  try {\n    return res.send(await cachedRequest(releasesURL, 5));\n  } catch (e) {\n    logger.error(`Error checking GitHub releases: ${e}`);\n    return res.send([]);\n  }\n}\n"
  },
  {
    "path": "src/pages/api/revalidate.js",
    "content": "export default async function handler(req, res) {\n  try {\n    await res.revalidate(\"/\");\n    return res.json({ revalidated: true });\n  } catch (err) {\n    return res.status(500).send(\"Error revalidating\");\n  }\n}\n"
  },
  {
    "path": "src/pages/api/search/searchSuggestion.js",
    "content": "import { searchProviders } from \"components/widgets/search/search\";\n\nimport { getSettings } from \"utils/config/config\";\nimport { widgetsFromConfig } from \"utils/config/widget-helpers\";\nimport { cachedRequest } from \"utils/proxy/http\";\n\nexport default async function handler(req, res) {\n  const { query, providerName } = req.query;\n\n  const provider = Object.values(searchProviders).find(({ name }) => name === providerName);\n\n  if (!provider) {\n    return res.json([query, []]);\n  }\n\n  if (provider.name === \"Custom\") {\n    const widgets = await widgetsFromConfig();\n    const searchWidget = widgets.find((w) => w.type === \"search\");\n\n    if (searchWidget) {\n      provider.url = searchWidget.options.url;\n      provider.suggestionUrl = searchWidget.options.suggestionUrl;\n    } else {\n      const settings = getSettings();\n      if (settings.quicklaunch && settings.quicklaunch.provider === \"custom\") {\n        provider.url = settings.quicklaunch.url;\n        provider.suggestionUrl = settings.quicklaunch.suggestionUrl;\n      }\n    }\n  }\n\n  if (!provider.suggestionUrl) {\n    return res.json([query, []]); // Responde with the same array format but with no suggestions.\n  }\n\n  return res.send(await cachedRequest(`${provider.suggestionUrl}${encodeURIComponent(query)}`, 5, \"Mozilla/5.0\"));\n}\n"
  },
  {
    "path": "src/pages/api/services/index.js",
    "content": "import { servicesResponse } from \"utils/config/api-response\";\n\nexport default async function handler(req, res) {\n  res.send(await servicesResponse());\n}\n"
  },
  {
    "path": "src/pages/api/services/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport genericProxyHandler from \"utils/proxy/handlers/generic\";\nimport calendarProxyHandler from \"widgets/calendar/proxy\";\nimport widgets from \"widgets/widgets\";\n\nconst logger = createLogger(\"servicesProxy\");\n\nexport default async function handler(req, res) {\n  try {\n    const { service, group, index } = req.query;\n    const serviceWidget = await getServiceWidget(group, service, index);\n    let type = serviceWidget?.type;\n\n    // exceptions\n    if (type === \"calendar\") type = \"ical\";\n    else if (service === \"unifi_console\" && group === \"unifi_console\") type = \"unifi_console\";\n\n    const widget = widgets[type];\n\n    if (!widget) {\n      logger.debug(\"Unknown proxy service type: %s\", type);\n      return res.status(403).json({ error: \"Unknown proxy service type\" });\n    }\n\n    const serviceProxyHandler = widget.proxyHandler || genericProxyHandler;\n\n    if (serviceProxyHandler instanceof Function) {\n      // quick return for no endpoint services, calendar is an exception\n      if (!req.query.endpoint || serviceProxyHandler === calendarProxyHandler) {\n        return await serviceProxyHandler(req, res);\n      }\n\n      // map opaque endpoints to their actual endpoint\n      if (widget?.mappings) {\n        const mapping = widget?.mappings?.[req.query.endpoint];\n        const mappingParams = mapping?.params;\n        const optionalParams = mapping?.optionalParams;\n        const map = mapping?.map;\n        const endpoint = mapping?.endpoint;\n        const endpointProxy = mapping?.proxyHandler || serviceProxyHandler;\n\n        if (mapping?.method && mapping.method !== req.method) {\n          logger.debug(\"Unsupported method: %s\", req.method);\n          return res.status(403).json({ error: \"Unsupported method\" });\n        }\n\n        if (!endpoint) {\n          logger.debug(\"Unsupported service endpoint: %s\", type);\n          return res.status(403).json({ error: \"Unsupported service endpoint\" });\n        }\n\n        req.method = mapping?.method || \"GET\";\n        if (mapping?.body) req.body = mapping?.body;\n        req.query.endpoint = endpoint;\n\n        if (req.query.segments) {\n          const segments = JSON.parse(req.query.segments);\n          let validSegments = true;\n          Object.keys(segments).forEach((key) => {\n            if (!mapping.segments.includes(key)) {\n              logger.debug(\"Unsupported segment: %s\", key);\n              validSegments = false;\n            } else if (segments[key].includes(\"/\") || segments[key].includes(\"\\\\\") || segments[key].includes(\"..\")) {\n              logger.debug(\"Unsupported segment value: %s\", segments[key]);\n              validSegments = false;\n            }\n          });\n          if (!validSegments) return res.status(403).json({ error: \"Unsupported segment\" });\n          req.query.endpoint = formatApiCall(endpoint, segments);\n        }\n\n        if (req.query.query && (mappingParams || optionalParams)) {\n          const queryParams = JSON.parse(req.query.query);\n\n          let filteredOptionalParams = [];\n          if (optionalParams) filteredOptionalParams = optionalParams.filter((p) => queryParams[p] !== undefined);\n\n          let params = [];\n          if (mappingParams) params = params.concat(mappingParams);\n          if (filteredOptionalParams) params = params.concat(filteredOptionalParams);\n\n          const query = new URLSearchParams(params.map((p) => [p, queryParams[p]]));\n          req.query.endpoint = `${req.query.endpoint}?${query}`;\n        }\n\n        if (mapping?.headers) {\n          req.extraHeaders = mapping.headers;\n        }\n\n        if (endpointProxy instanceof Function) {\n          return await endpointProxy(req, res, map);\n        }\n\n        return await serviceProxyHandler(req, res, map);\n      }\n\n      if (widget.allowedEndpoints instanceof RegExp) {\n        if (widget.allowedEndpoints.test(req.query.endpoint)) {\n          return await serviceProxyHandler(req, res);\n        }\n      }\n\n      logger.debug(\"Unmapped proxy request.\");\n      return res.status(403).json({ error: \"Unmapped proxy request.\" });\n    }\n\n    logger.debug(\"Unknown proxy service type: %s\", type);\n    return res.status(403).json({ error: \"Unknown proxy service type\" });\n  } catch (e) {\n    if (e) logger.error(e);\n    return res.status(500).send({ error: \"Unexpected error\" });\n  }\n}\n"
  },
  {
    "path": "src/pages/api/siteMonitor.js",
    "content": "import { performance } from \"perf_hooks\";\n\nimport { getServiceItem } from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { httpProxy } from \"utils/proxy/http\";\n\nconst logger = createLogger(\"siteMonitor\");\n\nexport default async function handler(req, res) {\n  const { groupName, serviceName } = req.query;\n  const serviceItem = await getServiceItem(groupName, serviceName);\n  if (!serviceItem) {\n    logger.debug(`No service item found for group ${groupName} named ${serviceName}`);\n    return res.status(400).send({\n      error: \"Unable to find service, see log for details.\",\n    });\n  }\n\n  const { siteMonitor: monitorURL } = serviceItem;\n\n  if (!monitorURL) {\n    logger.debug(\"No http monitor URL specified\");\n    return res.status(400).send({\n      error: \"No http monitor URL given\",\n    });\n  }\n\n  try {\n    let startTime = performance.now();\n    let [status] = await httpProxy(monitorURL, {\n      method: \"HEAD\",\n    });\n    let endTime = performance.now();\n\n    if (status > 403) {\n      // try one more time as a GET in case HEAD is rejected for whatever reason\n      startTime = performance.now();\n      [status] = await httpProxy(monitorURL);\n      endTime = performance.now();\n    }\n\n    return res.status(200).json({\n      status,\n      latency: endTime - startTime,\n    });\n  } catch (e) {\n    logger.debug(\"Error attempting http monitor: %s\", e);\n    return res.status(400).send({\n      error: \"Error attempting http monitor, see logs.\",\n    });\n  }\n}\n"
  },
  {
    "path": "src/pages/api/theme.js",
    "content": "import checkAndCopyConfig, { getSettings } from \"utils/config/config\";\n\nexport default function handler({ res }) {\n  checkAndCopyConfig(\"settings.yaml\");\n  const settings = getSettings();\n\n  const color = settings.color || \"slate\";\n  const theme = settings.theme || \"dark\";\n\n  return res.status(200).json({\n    color,\n    theme,\n  });\n}\n"
  },
  {
    "path": "src/pages/api/validate.js",
    "content": "import checkAndCopyConfig from \"utils/config/config\";\n\nconst configs = [\"docker.yaml\", \"settings.yaml\", \"services.yaml\", \"bookmarks.yaml\", \"kubernetes.yaml\", \"proxmox.yaml\"];\n\nexport default async function handler(req, res) {\n  const errors = configs.map((config) => checkAndCopyConfig(config)).filter((status) => status !== true);\n\n  res.send(errors);\n}\n"
  },
  {
    "path": "src/pages/api/widgets/glances.js",
    "content": "import { getPrivateWidgetOptions } from \"utils/config/widget-helpers\";\nimport createLogger from \"utils/logger\";\nimport { httpProxy } from \"utils/proxy/http\";\n\nconst logger = createLogger(\"glances\");\n\nasync function retrieveFromGlancesAPI(privateWidgetOptions, endpoint) {\n  let errorMessage;\n  const url = privateWidgetOptions?.url;\n  if (!url) {\n    errorMessage = \"Missing Glances URL\";\n    logger.error(errorMessage);\n    throw new Error(errorMessage);\n  }\n\n  const apiUrl = `${url}/api/${privateWidgetOptions.version}/${endpoint}`;\n  const headers = {\n    \"Accept-Encoding\": \"application/json\",\n  };\n  if (privateWidgetOptions.username && privateWidgetOptions.password) {\n    headers.Authorization = `Basic ${Buffer.from(\n      `${privateWidgetOptions.username}:${privateWidgetOptions.password}`,\n    ).toString(\"base64\")}`;\n  }\n  const params = { method: \"GET\", headers };\n\n  const [status, , data] = await httpProxy(apiUrl, params);\n\n  if (status === 401) {\n    errorMessage = `Authorization failure getting data from glances API. Data: ${data.toString()}`;\n    logger.error(errorMessage);\n    throw new Error(errorMessage);\n  }\n\n  if (status !== 200) {\n    errorMessage = `HTTP ${status} getting data from glances API. Data: ${data.toString()}`;\n    logger.error(errorMessage);\n    throw new Error(errorMessage);\n  }\n\n  return JSON.parse(Buffer.from(data).toString());\n}\n\nexport default async function handler(req, res) {\n  const { index, cputemp: includeCpuTemp, uptime: includeUptime, disk: includeDisks, version } = req.query;\n\n  const privateWidgetOptions = await getPrivateWidgetOptions(\"glances\", index);\n  privateWidgetOptions.version = version ?? 3;\n\n  try {\n    const cpuData = await retrieveFromGlancesAPI(privateWidgetOptions, \"cpu\");\n    const loadData = await retrieveFromGlancesAPI(privateWidgetOptions, \"load\");\n    const memoryData = await retrieveFromGlancesAPI(privateWidgetOptions, \"mem\");\n    const data = {\n      cpu: cpuData,\n      load: loadData,\n      mem: memoryData,\n    };\n\n    // Disabled by default, dont call unless needed\n    if (includeUptime) {\n      data.uptime = await retrieveFromGlancesAPI(privateWidgetOptions, \"uptime\");\n    }\n\n    if (includeCpuTemp) {\n      data.sensors = await retrieveFromGlancesAPI(privateWidgetOptions, \"sensors\");\n    }\n\n    if (includeDisks) {\n      data.fs = await retrieveFromGlancesAPI(privateWidgetOptions, \"fs\");\n    }\n\n    return res.status(200).send(data);\n  } catch (e) {\n    return res.status(400).json({ error: e.message });\n  }\n}\n"
  },
  {
    "path": "src/pages/api/widgets/index.js",
    "content": "import { widgetsResponse } from \"utils/config/api-response\";\n\nexport default async function handler(req, res) {\n  res.send(await widgetsResponse());\n}\n"
  },
  {
    "path": "src/pages/api/widgets/kubernetes.js",
    "content": "import { CoreV1Api, Metrics } from \"@kubernetes/client-node\";\n\nimport { getKubeConfig } from \"../../../utils/config/kubernetes\";\nimport { parseCpu, parseMemory } from \"../../../utils/kubernetes/utils\";\nimport createLogger from \"../../../utils/logger\";\n\nconst logger = createLogger(\"widget\");\n\nexport default async function handler(req, res) {\n  try {\n    const kc = getKubeConfig();\n    if (!kc) {\n      return res.status(500).send({\n        error: \"No kubernetes configuration\",\n      });\n    }\n    const coreApi = kc.makeApiClient(CoreV1Api);\n    const metricsApi = new Metrics(kc);\n\n    const nodes = await coreApi.listNode().catch((error) => {\n      logger.error(\"Error getting nodes: %d %s %s\", error.statusCode, error.body, error.response);\n      logger.debug(error);\n      return null;\n    });\n    if (!nodes) {\n      return res.status(500).send({\n        error: \"An error occurred while fetching nodes, check logs for more details.\",\n      });\n    }\n    let cpuTotal = 0;\n    let cpuUsage = 0;\n    let memTotal = 0;\n    let memUsage = 0;\n\n    const nodeMap = {};\n    nodes.items.forEach((node) => {\n      const cpu = Number.parseInt(node.status.capacity.cpu, 10);\n      const mem = parseMemory(node.status.capacity.memory);\n      const ready =\n        node.status.conditions.filter((condition) => condition.type === \"Ready\" && condition.status === \"True\").length >\n        0;\n      nodeMap[node.metadata.name] = {\n        name: node.metadata.name,\n        ready,\n        cpu: {\n          total: cpu,\n        },\n        memory: {\n          total: mem,\n        },\n      };\n      cpuTotal += cpu;\n      memTotal += mem;\n    });\n\n    try {\n      const nodeMetrics = await metricsApi.getNodeMetrics();\n      nodeMetrics.items.forEach((nodeMetric) => {\n        const cpu = parseCpu(nodeMetric.usage.cpu);\n        const mem = parseMemory(nodeMetric.usage.memory);\n        cpuUsage += cpu;\n        memUsage += mem;\n        nodeMap[nodeMetric.metadata.name].cpu.load = cpu;\n        nodeMap[nodeMetric.metadata.name].cpu.percent = (cpu / nodeMap[nodeMetric.metadata.name].cpu.total) * 100;\n        nodeMap[nodeMetric.metadata.name].memory.used = mem;\n        nodeMap[nodeMetric.metadata.name].memory.free = nodeMap[nodeMetric.metadata.name].memory.total - mem;\n        nodeMap[nodeMetric.metadata.name].memory.percent = (mem / nodeMap[nodeMetric.metadata.name].memory.total) * 100;\n      });\n    } catch (error) {\n      logger.error(\"Error getting metrics, ensure you have metrics-server installed:\", JSON.stringify(error));\n      return res.status(500).send({\n        error: \"Error getting metrics, check logs for more details\",\n      });\n    }\n\n    const cluster = {\n      cpu: {\n        load: cpuUsage,\n        total: cpuTotal,\n        percent: (cpuUsage / cpuTotal) * 100,\n      },\n      memory: {\n        used: memUsage,\n        total: memTotal,\n        free: memTotal - memUsage,\n        percent: (memUsage / memTotal) * 100,\n      },\n    };\n\n    return res.status(200).json({\n      cluster,\n      nodes: Object.entries(nodeMap).map(([name, node]) => ({ name, ...node })),\n    });\n  } catch (e) {\n    if (e) logger.error(e);\n    return res.status(500).send({\n      error: \"unknown error\",\n    });\n  }\n}\n"
  },
  {
    "path": "src/pages/api/widgets/longhorn.js",
    "content": "import { getSettings } from \"../../../utils/config/config\";\nimport createLogger from \"../../../utils/logger\";\nimport { httpProxy } from \"../../../utils/proxy/http\";\n\nconst logger = createLogger(\"longhorn\");\n\nfunction parseLonghornData(data) {\n  const json = JSON.parse(data);\n\n  if (!json) {\n    return null;\n  }\n\n  const nodes = json.data.map((node) => {\n    let available = 0;\n    let maximum = 0;\n    let reserved = 0;\n    let scheduled = 0;\n    if (node.disks) {\n      Object.keys(node.disks).forEach((diskKey) => {\n        const disk = node.disks[diskKey];\n        available += disk.storageAvailable;\n        maximum += disk.storageMaximum;\n        reserved += disk.storageReserved;\n        scheduled += disk.storageScheduled;\n      });\n    }\n    return {\n      id: node.id,\n      available,\n      maximum,\n      reserved,\n      scheduled,\n    };\n  });\n  const total = nodes.reduce(\n    (summary, node) => ({\n      available: summary.available + node.available,\n      maximum: summary.maximum + node.maximum,\n      reserved: summary.reserved + node.reserved,\n      scheduled: summary.scheduled + node.scheduled,\n    }),\n    { available: 0, maximum: 0, reserved: 0, scheduled: 0 },\n  );\n  total.id = \"total\";\n  nodes.push(total);\n  return nodes;\n}\n\nexport default async function handler(req, res) {\n  const settings = getSettings();\n  const longhornSettings = settings?.providers?.longhorn || {};\n  const { url, username, password } = longhornSettings;\n\n  if (!url) {\n    const errorMessage = \"Missing Longhorn URL\";\n    logger.error(errorMessage);\n    return res.status(400).json({ error: errorMessage });\n  }\n\n  const apiUrl = `${url}/v1/nodes`;\n  const headers = {\n    \"Accept-Encoding\": \"application/json\",\n  };\n  if (username && password) {\n    headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString(\"base64\")}`;\n  }\n  const params = { method: \"GET\", headers };\n\n  const [status, contentType, data] = await httpProxy(apiUrl, params);\n\n  if (status === 401) {\n    logger.error(\"Authorization failure getting data from Longhorn API. Data: %s\", data);\n  }\n\n  if (status !== 200) {\n    logger.error(\"HTTP %d getting data from Longhorn API. Data: %s\", status, data);\n  }\n\n  if (contentType) res.setHeader(\"Content-Type\", contentType);\n\n  const nodes = parseLonghornData(data);\n\n  return res.status(200).json({\n    nodes,\n  });\n}\n"
  },
  {
    "path": "src/pages/api/widgets/openmeteo.js",
    "content": "import { cachedRequest } from \"utils/proxy/http\";\n\nexport default async function handler(req, res) {\n  const { latitude, longitude, units, cache, timezone } = req.query;\n  const degrees = units === \"metric\" ? \"celsius\" : \"fahrenheit\";\n  const timezeone = timezone ?? \"auto\";\n  const apiUrl = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&daily=sunrise,sunset&current_weather=true&temperature_unit=${degrees}&timezone=${timezeone}`;\n  return res.send(await cachedRequest(apiUrl, cache));\n}\n"
  },
  {
    "path": "src/pages/api/widgets/openweathermap.js",
    "content": "import { getSettings } from \"utils/config/config\";\nimport { getPrivateWidgetOptions } from \"utils/config/widget-helpers\";\nimport { cachedRequest } from \"utils/proxy/http\";\n\nexport default async function handler(req, res) {\n  const { latitude, longitude, units, provider, cache, lang, index } = req.query;\n  const privateWidgetOptions = await getPrivateWidgetOptions(\"openweathermap\", index);\n  let { apiKey } = privateWidgetOptions;\n\n  if (!apiKey && !provider) {\n    return res.status(400).json({ error: \"Missing API key or provider\" });\n  }\n\n  if (!apiKey && provider !== \"openweathermap\") {\n    return res.status(400).json({ error: \"Invalid provider for endpoint\" });\n  }\n\n  if (!apiKey && provider) {\n    const settings = getSettings();\n    apiKey = settings?.providers?.openweathermap;\n  }\n\n  if (!apiKey) {\n    return res.status(400).json({ error: \"Missing API key\" });\n  }\n\n  const apiUrl = `https://api.openweathermap.org/data/2.5/weather?lat=${latitude}&lon=${longitude}&appid=${apiKey}&units=${units}&lang=${lang}`;\n\n  return res.send(await cachedRequest(apiUrl, cache));\n}\n"
  },
  {
    "path": "src/pages/api/widgets/resources.js",
    "content": "import si from \"systeminformation\";\n\nimport createLogger from \"utils/logger\";\n\nconst logger = createLogger(\"resources\");\n\nfunction isMissingNetworkStat(networkData, interfaceName) {\n  return (\n    networkData.operstate === \"unknown\" &&\n    networkData.rx_bytes === 0 &&\n    networkData.rx_dropped === 0 &&\n    networkData.rx_errors === 0 &&\n    networkData.tx_bytes === 0 &&\n    networkData.tx_dropped === 0 &&\n    networkData.tx_errors === 0 &&\n    networkData.rx_sec === null &&\n    networkData.tx_sec === null &&\n    networkData.ms === 0\n  );\n}\n\nexport default async function handler(req, res) {\n  const { type, target, interfaceName = \"default\" } = req.query;\n\n  if (type === \"cpu\") {\n    const load = await si.currentLoad();\n    return res.status(200).json({\n      cpu: {\n        usage: load.currentLoad,\n        load: load.avgLoad,\n      },\n    });\n  }\n\n  if (type === \"disk\") {\n    const requested = typeof target === \"string\" && target ? target : \"/\";\n    const fsSize = await si.fsSize();\n    logger.debug(\"fsSize:\", JSON.stringify(fsSize));\n\n    const drive = fsSize.find((fs) => {\n      return fs.mount === requested;\n    });\n\n    if (!drive) {\n      logger.warn(`Drive not found for target: ${requested}`);\n      return res.status(404).json({ error: \"Resource not available.\" });\n    }\n\n    return res.status(200).json({ drive });\n  }\n\n  if (type === \"memory\") {\n    const memory = await si.mem();\n    logger.debug(\"memory:\", JSON.stringify(memory));\n    return res.status(200).json({\n      memory,\n    });\n  }\n\n  if (type === \"cputemp\") {\n    const cputemp = await si.cpuTemperature();\n    logger.debug(\"cputemp:\", JSON.stringify(cputemp));\n    return res.status(200).json({\n      cputemp,\n    });\n  }\n\n  if (type === \"uptime\") {\n    const timeData = await si.time();\n    logger.debug(\"timeData:\", JSON.stringify(timeData));\n    return res.status(200).json({\n      uptime: timeData.uptime,\n    });\n  }\n\n  if (type === \"network\") {\n    let networkData = await si.networkStats(\"*\");\n    let interfaceDefault;\n    logger.debug(\"networkData:\", JSON.stringify(networkData));\n    if (interfaceName && interfaceName !== \"default\") {\n      networkData = networkData.filter((network) => network.iface === interfaceName).at(0);\n      if (!networkData) {\n        // Fallback for e.g. docker where networkStats(\"*\") may not return stats for host interfaces\n        const directNetworkData = await si.networkStats(interfaceName);\n        logger.debug(\"directNetworkData:\", JSON.stringify(directNetworkData));\n        networkData = Array.isArray(directNetworkData) ? directNetworkData.at(0) : null;\n\n        // si returns unknown + zeroes when interface truly does not exist\n        if (!networkData || isMissingNetworkStat(networkData, interfaceName)) {\n          networkData = null;\n        }\n      }\n      if (!networkData) {\n        return res.status(404).json({\n          error: \"Interface not found\",\n        });\n      }\n    } else {\n      interfaceDefault = await si.networkInterfaceDefault();\n      networkData = networkData.filter((network) => network.iface === interfaceDefault).at(0);\n      if (!networkData) {\n        return res.status(404).json({\n          error: \"Default interface not found\",\n        });\n      }\n    }\n    return res.status(200).json({\n      network: networkData,\n      interface: interfaceName !== \"default\" ? interfaceName : interfaceDefault,\n    });\n  }\n\n  return res.status(400).json({\n    error: \"invalid type\",\n  });\n}\n"
  },
  {
    "path": "src/pages/api/widgets/stocks.js",
    "content": "import { getSettings } from \"utils/config/config\";\nimport createLogger from \"utils/logger\";\nimport { cachedRequest } from \"utils/proxy/http\";\n\nconst logger = createLogger(\"stocks\");\n\nexport default async function handler(req, res) {\n  const { watchlist, provider, cache } = req.query;\n\n  logger.debug(\"Stocks API request: %o\", { watchlist, provider, cache });\n\n  if (!watchlist) {\n    return res.status(400).json({ error: \"Missing watchlist\" });\n  }\n\n  const watchlistArr = watchlist.split(\",\") || [watchlist];\n\n  if (!watchlistArr.length || watchlistArr[0] === \"null\" || !watchlistArr[0]) {\n    return res.status(400).json({ error: \"Missing watchlist\" });\n  }\n\n  if (watchlistArr.length > 8) {\n    return res.status(400).json({ error: \"Max items in watchlist is 8\" });\n  }\n\n  const hasDuplicates = new Set(watchlistArr).size !== watchlistArr.length;\n\n  if (hasDuplicates) {\n    return res.status(400).json({ error: \"Watchlist contains duplicates\" });\n  }\n\n  if (!provider) {\n    return res.status(400).json({ error: \"Missing provider\" });\n  }\n\n  if (provider !== \"finnhub\") {\n    return res.status(400).json({ error: \"Invalid provider\" });\n  }\n\n  const providersInConfig = getSettings()?.providers ?? {};\n\n  let apiKey;\n  Object.entries(providersInConfig).forEach(([key, val]) => {\n    if (key === provider) apiKey = val;\n  });\n\n  if (typeof apiKey === \"undefined\") {\n    return res.status(400).json({ error: \"Missing or invalid API Key for provider\" });\n  }\n\n  if (provider === \"finnhub\") {\n    // Finnhub allows up to 30 calls/second\n    // https://finnhub.io/docs/api/rate-limit\n    const results = await Promise.all(\n      watchlistArr.map(async (ticker) => {\n        if (!ticker) {\n          return { ticker: null, currentPrice: null, percentChange: null };\n        }\n        // https://finnhub.io/docs/api/quote\n        const apiUrl = `https://finnhub.io/api/v1/quote?symbol=${ticker}&token=${apiKey}`;\n        // Finnhub free accounts allow up to 60 calls/minute\n        // https://finnhub.io/pricing\n        const { c, dp } = await cachedRequest(apiUrl, cache || 1);\n        logger.debug(\"Finnhub API response for %s: %o\", ticker, { c, dp });\n\n        // API sometimes returns 200, but values returned are `null`\n        if (c === null || dp === null) {\n          return { ticker, currentPrice: null, percentChange: null };\n        }\n\n        // Rounding percentage, but we want it back to a number for comparison\n        return { ticker, currentPrice: c.toFixed(2), percentChange: parseFloat(dp.toFixed(2)) };\n      }),\n    );\n\n    return res.send({\n      stocks: results,\n    });\n  }\n\n  /* c8 ignore next 2 -- provider validation above currently makes this unreachable */\n  return res.status(400).json({ error: \"Invalid configuration\" });\n}\n"
  },
  {
    "path": "src/pages/api/widgets/weather.js",
    "content": "import { getSettings } from \"utils/config/config\";\nimport { getPrivateWidgetOptions } from \"utils/config/widget-helpers\";\nimport { cachedRequest } from \"utils/proxy/http\";\n\nexport default async function handler(req, res) {\n  const { latitude, longitude, provider, cache, lang, index } = req.query;\n  const privateWidgetOptions = await getPrivateWidgetOptions(\"weatherapi\", index);\n  let { apiKey } = privateWidgetOptions;\n\n  if (!apiKey && !provider) {\n    return res.status(400).json({ error: \"Missing API key or provider\" });\n  }\n\n  if (!apiKey && provider !== \"weatherapi\") {\n    return res.status(400).json({ error: \"Invalid provider for endpoint\" });\n  }\n\n  if (!apiKey && provider) {\n    const settings = getSettings();\n    apiKey = settings?.providers?.weatherapi;\n  }\n\n  if (!apiKey) {\n    return res.status(400).json({ error: \"Missing API key\" });\n  }\n\n  const apiUrl = `http://api.weatherapi.com/v1/current.json?q=${latitude},${longitude}&key=${apiKey}&lang=${lang}`;\n\n  return res.send(await cachedRequest(apiUrl, cache));\n}\n"
  },
  {
    "path": "src/pages/browserconfig.xml.jsx",
    "content": "import { getSettings } from \"utils/config/config\";\nimport themes from \"utils/styles/themes\";\n\nexport async function getServerSideProps({ res }) {\n  const settings = getSettings();\n\n  const color = settings.color || \"slate\";\n  const theme = settings.theme || \"dark\";\n\n  const xml = `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<browserconfig>\n    <msapplication>\n        <tile>\n            <square150x150logo src=\"/mstile-150x150.png?v=2\"/>\n            <TileColor>${themes[color][theme]}</TileColor>\n        </tile>\n    </msapplication>\n</browserconfig>`;\n\n  res.setHeader(\"Content-Type\", \"text/xml\");\n  res.write(xml);\n  res.end();\n\n  return {\n    props: {},\n  };\n}\n\nexport default function BrowserConfig() {}\n"
  },
  {
    "path": "src/pages/index.jsx",
    "content": "/* eslint-disable react/no-array-index-key */\nimport classNames from \"classnames\";\nimport BookmarksGroup from \"components/bookmarks/group\";\nimport ErrorBoundary from \"components/errorboundry\";\nimport QuickLaunch from \"components/quicklaunch\";\nimport ServicesGroup from \"components/services/group\";\nimport Tab, { slugifyAndEncode } from \"components/tab\";\nimport Revalidate from \"components/toggles/revalidate\";\nimport Widget from \"components/widgets/widget\";\nimport { useTranslation } from \"next-i18next\";\nimport { serverSideTranslations } from \"next-i18next/serverSideTranslations\";\nimport dynamic from \"next/dynamic\";\nimport Head from \"next/head\";\nimport { useRouter } from \"next/router\";\nimport Script from \"next/script\";\nimport { useContext, useEffect, useMemo, useState } from \"react\";\nimport { BiError } from \"react-icons/bi\";\nimport useSWR, { SWRConfig } from \"swr\";\nimport { ColorContext } from \"utils/contexts/color\";\nimport { SettingsContext } from \"utils/contexts/settings\";\nimport { TabContext } from \"utils/contexts/tab\";\nimport { ThemeContext } from \"utils/contexts/theme\";\n\nimport { bookmarksResponse, servicesResponse, widgetsResponse } from \"utils/config/api-response\";\nimport { getSettings } from \"utils/config/config\";\nimport useWindowFocus from \"utils/hooks/window-focus\";\nimport createLogger from \"utils/logger\";\nimport themes from \"utils/styles/themes\";\n\nconst ThemeToggle = dynamic(() => import(\"components/toggles/theme\"), {\n  ssr: false,\n});\n\nconst ColorToggle = dynamic(() => import(\"components/toggles/color\"), {\n  ssr: false,\n});\n\nconst Version = dynamic(() => import(\"components/version\"), {\n  ssr: false,\n});\n\nconst rightAlignedWidgets = [\"weatherapi\", \"openweathermap\", \"weather\", \"openmeteo\", \"search\", \"datetime\"];\n\n// Normalize language codes so older config values like zh-CN still point to Crowdin-provided ones\nconst LANGUAGE_ALIASES = {\n  \"zh-cn\": \"zh-Hans\",\n};\n\nconst normalizeLanguage = (language) => {\n  if (!language) return \"en\";\n  const alias = LANGUAGE_ALIASES[language.toLowerCase()];\n  return alias || language;\n};\n\nexport async function getStaticProps() {\n  let logger;\n  try {\n    logger = createLogger(\"index\");\n    const { providers, ...settings } = getSettings();\n\n    const services = await servicesResponse();\n    const bookmarks = await bookmarksResponse();\n    const widgets = await widgetsResponse();\n    const language = normalizeLanguage(settings.language);\n\n    return {\n      props: {\n        initialSettings: settings,\n        fallback: {\n          \"/api/services\": services,\n          \"/api/bookmarks\": bookmarks,\n          \"/api/widgets\": widgets,\n          \"/api/hash\": false,\n        },\n        ...(await serverSideTranslations(language)),\n      },\n    };\n  } catch (e) {\n    if (logger && e) {\n      logger.error(e);\n    }\n    return {\n      props: {\n        initialSettings: {},\n        fallback: {\n          \"/api/services\": [],\n          \"/api/bookmarks\": [],\n          \"/api/widgets\": [],\n          \"/api/hash\": false,\n        },\n        ...(await serverSideTranslations(\"en\")),\n      },\n    };\n  }\n}\n\nfunction Index({ initialSettings, fallback }) {\n  const windowFocused = useWindowFocus();\n  const [stale, setStale] = useState(false);\n  const { data: errorsData } = useSWR(\"/api/validate\");\n  const { error: validateError } = errorsData || {};\n  const { data: hashData, mutate: mutateHash } = useSWR(\"/api/hash\");\n\n  useEffect(() => {\n    if (windowFocused) {\n      mutateHash();\n    }\n  }, [windowFocused, mutateHash]);\n\n  useEffect(() => {\n    if (hashData) {\n      if (typeof window !== \"undefined\") {\n        const previousHash = localStorage.getItem(\"hash\");\n\n        if (!previousHash) {\n          localStorage.setItem(\"hash\", hashData.hash);\n        }\n\n        if (previousHash && previousHash !== hashData.hash) {\n          setStale(true);\n          localStorage.setItem(\"hash\", hashData.hash);\n\n          fetch(\"/api/revalidate\").then((res) => {\n            if (res.ok) {\n              window.location.reload();\n            }\n          });\n        }\n      }\n    }\n  }, [hashData]);\n\n  if (validateError) {\n    return (\n      <div className=\"w-full h-screen container m-auto justify-center p-10 pointer-events-none\">\n        <div className=\"flex flex-col\">\n          <div className=\"basis-1/2 bg-theme-500 dark:bg-theme-600 text-theme-600 dark:text-theme-300 m-2 rounded-md font-mono shadow-md border-4 border-transparent\">\n            <div className=\"bg-rose-200 text-rose-800 dark:text-rose-200 dark:bg-rose-800 p-2 rounded-md font-bold\">\n              <BiError className=\"float-right w-6 h-6\" />\n              Error\n            </div>\n            <div className=\"p-2 text-theme-100 dark:text-theme-200\">\n              <pre className=\"opacity-50 font-bold pb-2\">{validateError}</pre>\n            </div>\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  if (stale) {\n    return (\n      <div className=\"flex items-center justify-center h-screen\">\n        <div className=\"w-24 h-24 border-2 border-theme-400 border-solid rounded-full animate-spin border-t-transparent\" />\n      </div>\n    );\n  }\n\n  if (errorsData && errorsData.length > 0) {\n    return (\n      <div className=\"w-full h-screen container m-auto justify-center p-10 pointer-events-none\">\n        <div className=\"flex flex-col\">\n          {errorsData.map((error, i) => (\n            <div\n              className=\"basis-1/2 bg-theme-500 dark:bg-theme-600 text-theme-600 dark:text-theme-300 m-2 rounded-md font-mono shadow-md border-4 border-transparent\"\n              key={i}\n            >\n              <div className=\"bg-amber-200 text-amber-800 dark:text-amber-200 dark:bg-amber-800 p-2 rounded-md font-bold\">\n                <BiError className=\"float-right w-6 h-6\" />\n                {error.config}\n              </div>\n              <div className=\"p-2 text-theme-100 dark:text-theme-200\">\n                <pre className=\"opacity-50 font-bold pb-2\">{error.reason}</pre>\n                <pre className=\"text-sm\">{error.mark.snippet}</pre>\n              </div>\n            </div>\n          ))}\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <SWRConfig value={{ fallback, fetcher: (resource, init) => fetch(resource, init).then((res) => res.json()) }}>\n      <ErrorBoundary>\n        <Home initialSettings={initialSettings} />\n      </ErrorBoundary>\n    </SWRConfig>\n  );\n}\n\nconst headerStyles = {\n  boxed:\n    \"m-5 mb-0 sm:m-9 sm:mb-0 rounded-md shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 bg-theme-100/20 dark:bg-white/5 p-3\",\n  underlined: \"m-5 mb-0 sm:m-9 sm:mb-1 border-b-2 pb-4 border-theme-800 dark:border-theme-200/50\",\n  clean: \"m-5 mb-0 sm:m-9 sm:mb-0\",\n  boxedWidgets: \"m-5 mb-0 sm:m-9 sm:mb-0 sm:mt-1\",\n};\n\nfunction getAllServices(services) {\n  function getServices(group) {\n    let nestedServices = [...group.services];\n    if (group.groups.length > 0) {\n      nestedServices = [...nestedServices, ...group.groups.map(getServices).flat()];\n    }\n    return nestedServices;\n  }\n\n  return [...services.map(getServices).flat()];\n}\n\nfunction Home({ initialSettings }) {\n  const { i18n } = useTranslation();\n  const { theme, setTheme } = useContext(ThemeContext);\n  const { color, setColor } = useContext(ColorContext);\n  const { settings, setSettings } = useContext(SettingsContext);\n  const { activeTab, setActiveTab } = useContext(TabContext);\n  const { asPath } = useRouter();\n\n  useEffect(() => {\n    setSettings(initialSettings);\n  }, [initialSettings, setSettings]);\n\n  const { data: services } = useSWR(\"/api/services\");\n  const { data: bookmarks } = useSWR(\"/api/bookmarks\");\n  const { data: widgets } = useSWR(\"/api/widgets\");\n\n  const servicesAndBookmarks = [...bookmarks.map((bg) => bg.bookmarks).flat(), ...getAllServices(services)].filter(\n    (i) => i?.href,\n  );\n\n  useEffect(() => {\n    const language = normalizeLanguage(settings.language);\n    if (language) {\n      i18n.changeLanguage(language);\n    }\n\n    if (settings.theme && theme !== settings.theme) {\n      setTheme(settings.theme);\n    }\n\n    if (settings.color && color !== settings.color) {\n      setColor(settings.color);\n    }\n  }, [i18n, settings, color, setColor, theme, setTheme]);\n\n  const [searching, setSearching] = useState(false);\n  const [searchString, setSearchString] = useState(\"\");\n  const headerStyle = settings?.headerStyle || \"underlined\";\n\n  useEffect(() => {\n    function handleKeyDown(e) {\n      if (e.target.tagName === \"BODY\" || e.target.id === \"inner_wrapper\") {\n        if (\n          (e.key.length === 1 &&\n            e.key.match(/(\\w|\\s|[à-ü]|[À-Ü]|[\\w\\u0430-\\u044f])/gi) &&\n            !(e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)) ||\n          // accented characters and the bang may require modifier keys\n          e.key.match(/([à-ü]|[À-Ü]|!)/g) ||\n          (e.key === \"v\" && (e.ctrlKey || e.metaKey))\n        ) {\n          setSearching(true);\n        } else if (e.key === \"Escape\") {\n          setSearchString(\"\");\n          setSearching(false);\n        }\n      }\n    }\n\n    document.addEventListener(\"keydown\", handleKeyDown);\n\n    return function cleanup() {\n      document.removeEventListener(\"keydown\", handleKeyDown);\n    };\n  });\n\n  const tabs = useMemo(\n    () => [\n      ...new Set(\n        Object.keys(settings.layout ?? {})\n          .map((groupName) => settings.layout[groupName]?.tab?.toString())\n          .filter((group) => group),\n      ),\n    ],\n    [settings.layout],\n  );\n\n  useEffect(() => {\n    if (!activeTab) {\n      const initialTab = asPath.substring(asPath.indexOf(\"#\") + 1);\n      setActiveTab(initialTab === \"/\" ? slugifyAndEncode(tabs[\"0\"]) : initialTab);\n    }\n  });\n\n  const servicesAndBookmarksGroups = useMemo(() => {\n    const tabGroupFilter = (g) => g && [activeTab, \"\"].includes(slugifyAndEncode(settings.layout?.[g.name]?.tab));\n    const undefinedGroupFilter = (g) => settings.layout?.[g.name] === undefined;\n\n    const layoutGroups = Object.keys(settings.layout ?? {})\n      .map((groupName) => services?.find((g) => g.name === groupName) ?? bookmarks?.find((b) => b.name === groupName))\n      .filter(tabGroupFilter);\n\n    if (!settings.layout && JSON.stringify(settings.layout) !== JSON.stringify(initialSettings.layout)) {\n      // wait for settings to populate (if different from initial settings), otherwise all the widgets will be requested initially even if we are on a single tab\n      return <div />;\n    }\n\n    const serviceGroups = services?.filter(tabGroupFilter).filter(undefinedGroupFilter);\n    const bookmarkGroups = bookmarks.filter(tabGroupFilter).filter(undefinedGroupFilter);\n\n    return (\n      <>\n        {tabs.length > 0 && (\n          <div key=\"tabs\" id=\"tabs\" className=\"m-5 sm:m-9 sm:mt-4 sm:mb-0\">\n            <ul\n              className={classNames(\n                \"sm:flex rounded-md bg-theme-100/20 dark:bg-white/5\",\n                settings.cardBlur !== undefined &&\n                  `backdrop-blur${settings.cardBlur.length ? \"-\" : \"\"}${settings.cardBlur}`,\n              )}\n              id=\"myTab\"\n              data-tabs-toggle=\"#myTabContent\"\n              role=\"tablist\"\n            >\n              {tabs.map((tab) => (\n                <Tab key={tab} tab={tab} />\n              ))}\n            </ul>\n          </div>\n        )}\n        {layoutGroups.length > 0 && (\n          <div key=\"layoutGroups\" id=\"layout-groups\" className=\"flex flex-wrap m-4 sm:m-8 sm:mt-4 items-start mb-2\">\n            {layoutGroups.map((group) =>\n              group.services ? (\n                <ServicesGroup\n                  key={group.name}\n                  group={group}\n                  layout={settings.layout?.[group.name]}\n                  maxGroupColumns={settings.fiveColumns ? 5 : settings.maxGroupColumns}\n                  disableCollapse={settings.disableCollapse}\n                  useEqualHeights={settings.useEqualHeights}\n                  groupsInitiallyCollapsed={settings.groupsInitiallyCollapsed}\n                />\n              ) : (\n                <BookmarksGroup\n                  key={group.name}\n                  bookmarks={group}\n                  layout={settings.layout?.[group.name]}\n                  disableCollapse={settings.disableCollapse}\n                  maxGroupColumns={settings.maxBookmarkGroupColumns ?? settings.maxGroupColumns}\n                  groupsInitiallyCollapsed={settings.groupsInitiallyCollapsed}\n                />\n              ),\n            )}\n          </div>\n        )}\n        {serviceGroups?.length > 0 && (\n          <div key=\"services\" id=\"services\" className=\"flex flex-wrap m-4 sm:m-8 sm:mt-4 items-start mb-2\">\n            {serviceGroups.map((group) => (\n              <ServicesGroup\n                key={group.name}\n                group={group}\n                layout={settings.layout?.[group.name]}\n                maxGroupColumns={settings.fiveColumns ? 5 : settings.maxGroupColumns}\n                disableCollapse={settings.disableCollapse}\n                groupsInitiallyCollapsed={settings.groupsInitiallyCollapsed}\n              />\n            ))}\n          </div>\n        )}\n        {bookmarkGroups?.length > 0 && (\n          <div key=\"bookmarks\" id=\"bookmarks\" className=\"flex flex-wrap m-4 sm:m-8 sm:mt-4 items-start mb-2\">\n            {bookmarkGroups.map((group) => (\n              <BookmarksGroup\n                key={group.name}\n                bookmarks={group}\n                layout={settings.layout?.[group.name]}\n                disableCollapse={settings.disableCollapse}\n                maxGroupColumns={settings.maxBookmarkGroupColumns ?? settings.maxGroupColumns}\n                groupsInitiallyCollapsed={settings.groupsInitiallyCollapsed}\n                bookmarksStyle={settings.bookmarksStyle}\n              />\n            ))}\n          </div>\n        )}\n      </>\n    );\n  }, [\n    tabs,\n    activeTab,\n    services,\n    bookmarks,\n    settings.layout,\n    settings.fiveColumns,\n    settings.maxGroupColumns,\n    settings.maxBookmarkGroupColumns,\n    settings.disableCollapse,\n    settings.useEqualHeights,\n    settings.cardBlur,\n    settings.groupsInitiallyCollapsed,\n    settings.bookmarksStyle,\n    initialSettings.layout,\n  ]);\n\n  return (\n    <>\n      <Head>\n        <title>{initialSettings.title || \"Homepage\"}</title>\n        <meta\n          name=\"description\"\n          content={\n            initialSettings.description ||\n            \"A highly customizable homepage (or startpage / application dashboard) with Docker and service API integrations.\"\n          }\n        />\n        {settings.disableIndexing && <meta name=\"robots\" content=\"noindex, nofollow\" />}\n        {settings.base && <base href={settings.base} />}\n        {settings.favicon ? (\n          <>\n            <link rel=\"icon\" href={settings.favicon} />\n            <link rel=\"apple-touch-icon\" sizes=\"180x180\" href={settings.favicon} />\n          </>\n        ) : (\n          <>\n            <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/apple-touch-icon.png?v=4\" />\n            <link rel=\"shortcut icon\" href=\"/homepage.ico\" />\n            <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/favicon-32x32.png?v=4\" />\n            <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/favicon-16x16.png?v=4\" />\n            <link rel=\"mask-icon\" href=\"/safari-pinned-tab.svg?v=4\" color=\"#1e9cd7\" />\n          </>\n        )}\n        <meta name=\"msapplication-TileColor\" content={themes[settings.color || \"slate\"][settings.theme || \"dark\"]} />\n        <meta name=\"theme-color\" content={themes[settings.color || \"slate\"][settings.theme || \"dark\"]} />\n        <meta name=\"color-scheme\" content=\"dark light\"></meta>\n      </Head>\n\n      <Script src=\"/api/config/custom.js\" />\n\n      <div\n        className={classNames(\n          settings.fullWidth ? \"\" : \"container\",\n          \"relative m-auto flex flex-col justify-start z-10 h-full min-h-screen\",\n        )}\n      >\n        <QuickLaunch\n          servicesAndBookmarks={servicesAndBookmarks}\n          searchString={searchString}\n          setSearchString={setSearchString}\n          isOpen={searching}\n          setSearching={setSearching}\n        />\n        <div\n          id=\"information-widgets\"\n          className={classNames(\n            \"flex flex-row flex-wrap justify-between z-20\",\n            headerStyles[headerStyle],\n            settings.cardBlur !== undefined &&\n              headerStyle === \"boxed\" &&\n              `backdrop-blur${settings.cardBlur.length ? \"-\" : \"\"}${settings.cardBlur}`,\n          )}\n        >\n          <div id=\"widgets-wrap\" className={classNames(\"flex flex-row w-full flex-wrap justify-between gap-x-2\")}>\n            {widgets && (\n              <>\n                {widgets\n                  .filter((widget) => !rightAlignedWidgets.includes(widget.type))\n                  .map((widget, i) => (\n                    <Widget\n                      key={i}\n                      widget={widget}\n                      style={{ header: headerStyle, isRightAligned: false, cardBlur: settings.cardBlur }}\n                    />\n                  ))}\n\n                <div\n                  id=\"information-widgets-right\"\n                  className={classNames(\n                    \"m-auto flex flex-wrap grow sm:basis-auto justify-between md:justify-end\",\n                    \"m-auto flex flex-wrap grow sm:basis-auto justify-between md:justify-end gap-x-2\",\n                  )}\n                >\n                  {widgets\n                    .filter((widget) => rightAlignedWidgets.includes(widget.type))\n                    .map((widget, i) => (\n                      <Widget\n                        key={i}\n                        widget={widget}\n                        style={{ header: headerStyle, isRightAligned: true, cardBlur: settings.cardBlur }}\n                      />\n                    ))}\n                </div>\n              </>\n            )}\n          </div>\n        </div>\n\n        {servicesAndBookmarksGroups}\n\n        <div id=\"footer\" className=\"flex flex-col mt-auto p-8 w-full\">\n          <div id=\"style\" className=\"flex w-full justify-end\">\n            {!settings?.color && <ColorToggle />}\n            <Revalidate />\n            {!settings.theme && <ThemeToggle />}\n          </div>\n\n          <div id=\"version\" className=\"flex mt-4 w-full justify-end\">\n            {!settings.hideVersion && <Version disableUpdateCheck={settings.disableUpdateCheck} />}\n          </div>\n        </div>\n      </div>\n    </>\n  );\n}\n\nexport default function Wrapper({ initialSettings, fallback }) {\n  const { theme } = useContext(ThemeContext);\n  const { color } = useContext(ColorContext);\n  let backgroundImage = \"\";\n  let opacity = initialSettings?.backgroundOpacity ?? 0;\n  let backgroundBlur = false;\n  let backgroundSaturate = false;\n  let backgroundBrightness = false;\n  if (initialSettings?.background) {\n    const bg = initialSettings.background;\n    if (typeof bg === \"object\") {\n      backgroundImage = bg.image || \"\";\n      if (bg.opacity !== undefined) {\n        opacity = 1 - bg.opacity / 100;\n      }\n      backgroundBlur = bg.blur !== undefined;\n      backgroundSaturate = bg.saturate !== undefined;\n      backgroundBrightness = bg.brightness !== undefined;\n    } else {\n      backgroundImage = bg;\n    }\n  }\n\n  useEffect(() => {\n    const html = document.documentElement;\n    const body = document.body;\n\n    html.classList.remove(\"dark\", \"scheme-dark\", \"scheme-light\");\n    html.classList.toggle(\"dark\", theme === \"dark\");\n    html.classList.add(theme === \"dark\" ? \"scheme-dark\" : \"scheme-light\");\n\n    const desiredThemeClass = `theme-${color || initialSettings.color || \"slate\"}`;\n    const themeClassesToRemove = Array.from(html.classList).filter(\n      (cls) => cls.startsWith(\"theme-\") && cls !== desiredThemeClass,\n    );\n    if (themeClassesToRemove.length) {\n      html.classList.remove(...themeClassesToRemove);\n    }\n    if (!html.classList.contains(desiredThemeClass)) {\n      html.classList.add(desiredThemeClass);\n    }\n\n    // Remove any previously applied inline styles\n    body.style.backgroundImage = \"\";\n    body.style.backgroundColor = \"\";\n    body.style.backgroundAttachment = \"\";\n  }, [backgroundImage, opacity, theme, color, initialSettings.color]);\n\n  return (\n    <>\n      {backgroundImage && (\n        <div\n          id=\"background\"\n          aria-hidden=\"true\"\n          style={{\n            backgroundImage: `linear-gradient(rgb(var(--bg-color) / ${opacity}), rgb(var(--bg-color) / ${opacity})), url('${backgroundImage}')`,\n          }}\n        />\n      )}\n      <div id=\"page_wrapper\" className=\"relative h-full\">\n        <div\n          id=\"inner_wrapper\"\n          tabIndex=\"-1\"\n          className={classNames(\n            \"w-full h-full overflow-auto\",\n            backgroundBlur &&\n              `backdrop-blur${initialSettings.background.blur?.length ? `-${initialSettings.background.blur}` : \"\"}`,\n            backgroundSaturate && `backdrop-saturate-${initialSettings.background.saturate}`,\n            backgroundBrightness && `backdrop-brightness-${initialSettings.background.brightness}`,\n          )}\n        >\n          <Index initialSettings={initialSettings} fallback={fallback} />\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/pages/robots.txt.js",
    "content": "import { getSettings } from \"utils/config/config\";\n\nexport async function getServerSideProps({ res }) {\n  const settings = getSettings();\n  const content = [\"User-agent: *\", !!settings.disableIndexing ? \"Disallow: /\" : \"Allow: /\"].join(\"\\n\");\n\n  res.setHeader(\"Content-Type\", \"text/plain\");\n  res.write(content);\n  res.end();\n\n  return {\n    props: {},\n  };\n}\n\nexport default function RobotsTxt() {\n  // placeholder component\n  return null;\n}\n"
  },
  {
    "path": "src/pages/site.webmanifest.jsx",
    "content": "import checkAndCopyConfig, { getSettings } from \"utils/config/config\";\nimport themes from \"utils/styles/themes\";\n\nexport async function getServerSideProps({ res }) {\n  checkAndCopyConfig(\"settings.yaml\");\n  const settings = getSettings();\n\n  const color = settings.color || \"slate\";\n  const theme = settings.theme || \"dark\";\n\n  const pwa = settings.pwa || {};\n\n  const manifest = {\n    name: settings.title || \"Homepage\",\n    short_name: settings.title || \"Homepage\",\n    icons: pwa.icons || [\n      {\n        src: \"/android-chrome-192x192.png?v=2\",\n        sizes: \"192x192\",\n        type: \"image/png\",\n      },\n      {\n        src: \"/android-chrome-512x512.png?v=2\",\n        sizes: \"512x512\",\n        type: \"image/png\",\n      },\n    ],\n    shortcuts: pwa.shortcuts,\n    theme_color: themes[color][theme],\n    background_color: themes[color][theme],\n    display: \"standalone\",\n    start_url: settings.startUrl || \"/\",\n  };\n\n  res.setHeader(\"Content-Type\", \"application/manifest+json\");\n  res.write(JSON.stringify(manifest));\n  res.end();\n\n  return {\n    props: {},\n  };\n}\n\nexport default function Webmanifest() {\n  return null;\n}\n"
  },
  {
    "path": "src/skeleton/bookmarks.yaml",
    "content": "---\n# For configuration options and examples, please see:\n# https://gethomepage.dev/configs/bookmarks\n\n- Developer:\n    - Github:\n        - abbr: GH\n          href: https://github.com/\n\n- Social:\n    - Reddit:\n        - abbr: RE\n          href: https://reddit.com/\n\n- Entertainment:\n    - YouTube:\n        - abbr: YT\n          href: https://youtube.com/\n"
  },
  {
    "path": "src/skeleton/custom.css",
    "content": ""
  },
  {
    "path": "src/skeleton/custom.js",
    "content": ""
  },
  {
    "path": "src/skeleton/docker.yaml",
    "content": "---\n# For configuration options and examples, please see:\n# https://gethomepage.dev/configs/docker/\n\n# my-docker:\n#   host: 127.0.0.1\n#   port: 2375\n\n# my-docker:\n#   socket: /var/run/docker.sock\n"
  },
  {
    "path": "src/skeleton/kubernetes.yaml",
    "content": "---\n# sample kubernetes config\n"
  },
  {
    "path": "src/skeleton/proxmox.yaml",
    "content": "---\n# pve:\n#   url: https://proxmox.host.or.ip:8006\n#   token: username@pam!Token ID\n#   secret: secret\n"
  },
  {
    "path": "src/skeleton/services.yaml",
    "content": "---\n# For configuration options and examples, please see:\n# https://gethomepage.dev/configs/services/\n\n- My First Group:\n    - My First Service:\n        href: http://localhost/\n        description: Homepage is awesome\n\n- My Second Group:\n    - My Second Service:\n        href: http://localhost/\n        description: Homepage is the best\n\n- My Third Group:\n    - My Third Service:\n        href: http://localhost/\n        description: Homepage is 😎\n"
  },
  {
    "path": "src/skeleton/settings.yaml",
    "content": "---\n# For configuration options and examples, please see:\n# https://gethomepage.dev/configs/settings/\n\nproviders:\n  openweathermap: openweathermapapikey\n  weatherapi: weatherapiapikey\n"
  },
  {
    "path": "src/skeleton/widgets.yaml",
    "content": "---\n# For configuration options and examples, please see:\n# https://gethomepage.dev/configs/info-widgets/\n\n- resources:\n    cpu: true\n    memory: true\n    disk: /\n\n- search:\n    provider: duckduckgo\n    target: _blank\n"
  },
  {
    "path": "src/styles/globals.css",
    "content": "@import 'tailwindcss';\n\n@config '../../tailwind.config.js';\n\n@theme {\n  --breakpoint-3xl: 112rem;\n}\n\n/*\n  The default border color has changed to `currentColor` in Tailwind CSS v4,\n  so we've added these compatibility styles to make sure everything still\n  looks the same as it did with Tailwind CSS v3.\n\n  If we ever want to remove these styles, we need to add an explicit border\n  color utility to any element that depends on these defaults.\n*/\n@layer base {\n  *,\n  ::after,\n  ::before,\n  ::backdrop,\n  ::file-selector-button {\n    border-color: var(--color-gray-200, currentColor);\n  }\n}\n\nhtml,\nbody,\n#__next {\n  height: 100%;\n  margin: 0;\n  padding: 0;\n  background-color: rgb(var(--bg-color));\n}\n\n#background {\n  position: fixed;\n  inset: 0;\n  z-index: 0;\n  background-size: cover;\n  background-position: center;\n  background-repeat: no-repeat;\n  background-attachment: scroll;\n  pointer-events: none;\n}\n\nhtml,\nbody {\n  font-family: Manrope, \"Manrope-Fallback\", Arial, sans-serif;\n  height: 100%;\n  text-rendering: optimizeLegibility;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  letter-spacing: 0.1px;\n  scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);\n}\n\n#page_wrapper {\n  width: 100%;\n  margin: 0;\n  padding: 0;\n}\n\n.light {\n  --bg-color: var(--color-50);\n  --scrollbar-thumb: rgb(var(--color-300));\n  --scrollbar-track: rgb(var(--color-200));\n}\n\n.dark {\n  --bg-color: var(--color-800);\n  --scrollbar-thumb: rgb(var(--color-600));\n  --scrollbar-track: rgb(var(--color-700));\n}\n\ndialog ::-webkit-scrollbar {\n  display: none;\n}\n\n::-webkit-scrollbar-track {\n  background-color: var(--scrollbar-track);\n}\n\n::-webkit-scrollbar-thumb {\n  background-color: var(--scrollbar-thumb);\n  border-radius: 0.25em;\n}\n\n::-webkit-details-marker {\n  display: none;\n}\n\n.chart + .chart {\n  margin-top: 2em;\n}\n\n.service-container + .chart {\n  margin-top: 2.5rem;\n  margin-bottom: .5rem;\n}\n"
  },
  {
    "path": "src/styles/manrope.css",
    "content": "@font-face {\n  font-family: \"Manrope-Fallback\";\n  font-size: 16px;\n  line-height: 1.6;\n  color: red;\n  visibility: visible;\n  word-spacing: -0.65px;\n  letter-spacing: 0.25px;\n  font-weight: 400;\n  src: local(\"Arial\");\n  font-display: swap;\n}\n\n@font-face {\n  font-family: \"Manrope\";\n  font-weight: 200 800;\n  font-style: normal;\n  src: local(\"Manrope\"), url(\"./font/Manrope.woff2\") format(\"woff2\"), url(\"./font/Manrope.ttf\") format(\"ttf\");\n  font-display: swap;\n}\n\n@font-face {\n  font-family: \"Manrope\";\n  font-weight: 200 800;\n  fint-style: italic;\n  src: local(\"Manrope\"), url(\"./font/Manrope.woff2\") format(\"woff2\"), url(\"./font/Manrope.ttf\") format(\"ttf\");\n  font-display: swap;\n}\n"
  },
  {
    "path": "src/styles/theme.css",
    "content": ".theme-white {\n  --color-50: 255 255 255;\n  --color-100: 120 120 120;\n  --color-200: 120 120 120;\n  --color-300: 120 120 120;\n  --color-400: 120 120 120;\n  --color-500: 60 60 60;\n  --color-600: 120 120 120;\n  --color-700: 40 40 40;\n  --color-800: 255 255 255;\n  --color-900: 120 120 120;\n\n  --color-logo-start: 128 128 128 / 20%;\n  --color-logo-stop: 128 128 128 / 40%;\n}\n\n.theme-white .bg-theme-100\\/20:not([class^=\"backdrop-blur\"]),\n.theme-white .dark\\:bg-white\\/5:not([class^=\"backdrop-blur\"]) {\n  background-color: rgb(245, 245, 245);\n}\n\n.theme-white .bg-theme-100\\/20:hover:not([class^=\"backdrop-blur\"]),\n.theme-white .dark\\:bg-white\\/5:hover:not([class^=\"backdrop-blur\"]) {\n  background-color: rgb(250, 250, 250);\n}\n\n.theme-white .text-theme-800 {\n  color: rgb(120, 120, 120);\n}\n\n.theme-slate {\n  --color-50: 248 250 252;\n  --color-100: 241 245 249;\n  --color-200: 226 232 240;\n  --color-300: 203 213 225;\n  --color-400: 148 163 184;\n  --color-500: 100 116 139;\n  --color-600: 71 85 105;\n  --color-700: 51 65 85;\n  --color-800: 30 41 59;\n  --color-900: 15 23 42;\n\n  --color-logo-start: 148 163 184;\n  --color-logo-stop: 51 65 85;\n}\n\n.theme-gray {\n  --color-50: 249 250 251;\n  --color-100: 243 244 246;\n  --color-200: 229 231 235;\n  --color-300: 209 213 219;\n  --color-400: 156 163 175;\n  --color-500: 107 114 128;\n  --color-600: 75 85 99;\n  --color-700: 55 65 81;\n  --color-800: 31 41 55;\n  --color-900: 17 24 39;\n\n  --color-logo-start: 156 163 175;\n  --color-logo-stop: 55 65 81;\n}\n\n.theme-zinc {\n  --color-50: 250 250 250;\n  --color-100: 244 244 245;\n  --color-200: 228 228 231;\n  --color-300: 212 212 216;\n  --color-400: 161 161 170;\n  --color-500: 113 113 122;\n  --color-600: 82 82 91;\n  --color-700: 63 63 70;\n  --color-800: 39 39 42;\n  --color-900: 24 24 27;\n\n  --color-logo-start: 161 161 170;\n  --color-logo-stop: 63 63 70;\n}\n\n.theme-neutral {\n  --color-50: 250 250 250;\n  --color-100: 245 245 245;\n  --color-200: 229 229 229;\n  --color-300: 212 212 212;\n  --color-400: 163 163 163;\n  --color-500: 115 115 115;\n  --color-600: 82 82 82;\n  --color-700: 64 64 64;\n  --color-800: 38 38 38;\n  --color-900: 23 23 23;\n\n  --color-logo-start: 163 163 163;\n  --color-logo-stop: 64 64 64;\n}\n\n.theme-stone {\n  --color-50: 250 250 249;\n  --color-100: 245 245 244;\n  --color-200: 231 229 228;\n  --color-300: 214 211 209;\n  --color-400: 168 162 158;\n  --color-500: 120 113 108;\n  --color-600: 87 83 78;\n  --color-700: 68 64 60;\n  --color-800: 41 37 36;\n  --color-900: 28 25 23;\n\n  --color-logo-start: 168 162 158;\n  --color-logo-stop: 68 64 60;\n}\n\n.theme-red {\n  --color-50: 254 242 242;\n  --color-100: 254 226 226;\n  --color-200: 254 202 202;\n  --color-300: 252 165 165;\n  --color-400: 248 113 113;\n  --color-500: 239 68 68;\n  --color-600: 220 38 38;\n  --color-700: 185 28 28;\n  --color-800: 153 27 27;\n  --color-900: 127 29 29;\n\n  --color-logo-start: 248 113 113;\n  --color-logo-stop: 185 28 28;\n}\n\n.theme-orange {\n  --color-50: 255 247 237;\n  --color-100: 255 237 213;\n  --color-200: 254 215 170;\n  --color-300: 253 186 116;\n  --color-400: 251 146 60;\n  --color-500: 249 115 22;\n  --color-600: 234 88 12;\n  --color-700: 194 65 12;\n  --color-800: 154 52 18;\n  --color-900: 124 45 18;\n\n  --color-logo-start: 251 146 60;\n  --color-logo-stop: 194 65 12;\n}\n\n.theme-amber {\n  --color-50: 255 251 235;\n  --color-100: 254 243 199;\n  --color-200: 253 230 138;\n  --color-300: 252 211 77;\n  --color-400: 251 191 36;\n  --color-500: 245 158 11;\n  --color-600: 217 119 6;\n  --color-700: 180 83 9;\n  --color-800: 146 64 14;\n  --color-900: 120 53 15;\n\n  --color-logo-start: 251 191 36;\n  --color-logo-stop: 180 83 9;\n}\n\n.theme-yellow {\n  --color-50: 254 252 232;\n  --color-100: 254 249 195;\n  --color-200: 254 240 138;\n  --color-300: 253 224 71;\n  --color-400: 250 204 21;\n  --color-500: 234 179 8;\n  --color-600: 202 138 4;\n  --color-700: 161 98 7;\n  --color-800: 133 77 14;\n  --color-900: 113 63 18;\n\n  --color-logo-start: 250 204 21;\n  --color-logo-stop: 161 98 7;\n}\n\n.theme-lime {\n  --color-50: 247 254 231;\n  --color-100: 236 252 203;\n  --color-200: 217 249 157;\n  --color-300: 190 242 100;\n  --color-400: 163 230 53;\n  --color-500: 132 204 22;\n  --color-600: 101 163 13;\n  --color-700: 77 124 15;\n  --color-800: 63 98 18;\n  --color-900: 54 83 20;\n\n  --color-logo-start: 163 230 53;\n  --color-logo-stop: 77 124 15;\n}\n\n.theme-green {\n  --color-50: 240 253 244;\n  --color-100: 220 252 231;\n  --color-200: 187 247 208;\n  --color-300: 134 239 172;\n  --color-400: 74 222 128;\n  --color-500: 34 197 94;\n  --color-600: 22 163 74;\n  --color-700: 21 128 61;\n  --color-800: 22 101 52;\n  --color-900: 20 83 45;\n\n  --color-logo-start: 74 222 128;\n  --color-logo-stop: 21 128 61;\n}\n\n.theme-emerald {\n  --color-50: 236 253 245;\n  --color-100: 209 250 229;\n  --color-200: 167 243 208;\n  --color-300: 110 231 183;\n  --color-400: 52 211 153;\n  --color-500: 16 185 129;\n  --color-600: 5 150 105;\n  --color-700: 4 120 87;\n  --color-800: 6 95 70;\n  --color-900: 6 78 59;\n\n  --color-logo-start: 52 211 153;\n  --color-logo-stop: 4 120 87;\n}\n\n.theme-teal {\n  --color-50: 240 253 250;\n  --color-100: 204 251 241;\n  --color-200: 153 246 228;\n  --color-300: 94 234 212;\n  --color-400: 45 212 191;\n  --color-500: 20 184 166;\n  --color-600: 13 148 136;\n  --color-700: 15 118 110;\n  --color-800: 17 94 89;\n  --color-900: 19 78 74;\n\n  --color-logo-start: 45 212 191;\n  --color-logo-stop: 15 118 110;\n}\n\n.theme-cyan {\n  --color-50: 236 254 255;\n  --color-100: 207 250 254;\n  --color-200: 165 243 252;\n  --color-300: 103 232 249;\n  --color-400: 34 211 238;\n  --color-500: 6 182 212;\n  --color-600: 8 145 178;\n  --color-700: 14 116 144;\n  --color-800: 21 94 117;\n  --color-900: 22 78 99;\n\n  --color-logo-start: 34 211 238;\n  --color-logo-stop: 14 116 144;\n}\n\n.theme-sky {\n  --color-50: 240 249 255;\n  --color-100: 224 242 254;\n  --color-200: 186 230 253;\n  --color-300: 125 211 252;\n  --color-400: 56 189 248;\n  --color-500: 14 165 233;\n  --color-600: 2 132 199;\n  --color-700: 3 105 161;\n  --color-800: 7 89 133;\n  --color-900: 12 74 110;\n\n  --color-logo-start: 56 189 248;\n  --color-logo-stop: 3 105 161;\n}\n\n.theme-blue {\n  --color-50: 239 246 255;\n  --color-100: 219 234 254;\n  --color-200: 191 219 254;\n  --color-300: 147 197 253;\n  --color-400: 96 165 250;\n  --color-500: 59 130 246;\n  --color-600: 37 99 235;\n  --color-700: 29 78 216;\n  --color-800: 30 64 175;\n  --color-900: 30 58 138;\n\n  --color-logo-start: 96 165 250;\n  --color-logo-stop: 29 78 216;\n}\n\n.theme-indigo {\n  --color-50: 238 242 255;\n  --color-100: 224 231 255;\n  --color-200: 199 210 254;\n  --color-300: 165 180 252;\n  --color-400: 129 140 248;\n  --color-500: 99 102 241;\n  --color-600: 79 70 229;\n  --color-700: 67 56 202;\n  --color-800: 55 48 163;\n  --color-900: 49 46 129;\n\n  --color-logo-start: 129 140 248;\n  --color-logo-stop: 67 56 202;\n}\n\n.theme-violet {\n  --color-50: 245 243 255;\n  --color-100: 237 233 254;\n  --color-200: 221 214 254;\n  --color-300: 196 181 253;\n  --color-400: 167 139 250;\n  --color-500: 139 92 246;\n  --color-600: 124 58 237;\n  --color-700: 109 40 217;\n  --color-800: 91 33 182;\n  --color-900: 76 29 149;\n\n  --color-logo-start: 167 139 250;\n  --color-logo-stop: 109 40 217;\n}\n\n.theme-purple {\n  --color-50: 250 245 255;\n  --color-100: 243 232 255;\n  --color-200: 233 213 255;\n  --color-300: 216 180 254;\n  --color-400: 192 132 252;\n  --color-500: 168 85 247;\n  --color-600: 147 51 234;\n  --color-700: 126 34 206;\n  --color-800: 107 33 168;\n  --color-900: 88 28 135;\n\n  --color-logo-start: 192 132 252;\n  --color-logo-stop: 126 34 206;\n}\n\n.theme-fuchsia {\n  --color-50: 253 244 255;\n  --color-100: 250 232 255;\n  --color-200: 245 208 254;\n  --color-300: 240 171 252;\n  --color-400: 232 121 249;\n  --color-500: 217 70 239;\n  --color-600: 192 38 211;\n  --color-700: 162 28 175;\n  --color-800: 134 25 143;\n  --color-900: 112 26 117;\n\n  --color-logo-start: 232 121 249;\n  --color-logo-stop: 162 28 175;\n}\n\n.theme-pink {\n  --color-50: 253 242 248;\n  --color-100: 252 231 243;\n  --color-200: 251 207 232;\n  --color-300: 249 168 212;\n  --color-400: 244 114 182;\n  --color-500: 236 72 153;\n  --color-600: 219 39 119;\n  --color-700: 190 24 93;\n  --color-800: 157 23 77;\n  --color-900: 131 24 67;\n\n  --color-logo-start: 244 114 182;\n  --color-logo-stop: 190 24 93;\n}\n\n.theme-rose {\n  --color-50: 255 241 242;\n  --color-100: 255 228 230;\n  --color-200: 254 205 211;\n  --color-300: 253 164 175;\n  --color-400: 251 113 133;\n  --color-500: 244 63 94;\n  --color-600: 225 29 72;\n  --color-700: 190 18 60;\n  --color-800: 159 18 57;\n  --color-900: 136 19 55;\n\n  --color-logo-start: 251 113 133;\n  --color-logo-stop: 190 18 60;\n}\n"
  },
  {
    "path": "src/test-utils/create-mock-res.js",
    "content": "import { vi } from \"vitest\";\n\nexport default function createMockRes() {\n  const res = {\n    statusCode: null,\n    body: null,\n    headers: {},\n  };\n\n  res.status = vi.fn((code) => {\n    res.statusCode = code;\n    return res;\n  });\n\n  res.json = vi.fn((body) => {\n    res.body = body;\n    return res;\n  });\n\n  res.send = vi.fn((body) => {\n    res.body = body;\n    return res;\n  });\n\n  res.end = vi.fn((body) => {\n    res.body = body;\n    return res;\n  });\n\n  res.setHeader = vi.fn((key, value) => {\n    res.headers[key] = value;\n    return res;\n  });\n\n  return res;\n}\n"
  },
  {
    "path": "src/test-utils/render-with-providers.jsx",
    "content": "import { render } from \"@testing-library/react\";\n\nimport { SettingsContext } from \"utils/contexts/settings\";\n\nexport function renderWithProviders(ui, { settings = {} } = {}) {\n  const value = {\n    settings,\n    // Most tests don't need to mutate settings; this keeps Container happy.\n    setSettings: () => {},\n  };\n\n  return render(<SettingsContext.Provider value={value}>{ui}</SettingsContext.Provider>);\n}\n"
  },
  {
    "path": "src/test-utils/widget-assertions.js",
    "content": "import { expect } from \"vitest\";\n\nexport function findServiceBlockByLabel(container, label) {\n  const blocks = Array.from(container.querySelectorAll(\".service-block\"));\n  return blocks.find((b) => b.textContent?.includes(label));\n}\n\nexport function expectBlockValue(container, label, value) {\n  const block = findServiceBlockByLabel(container, label);\n  expect(block, `missing block for ${label}`).toBeTruthy();\n  expect(block.textContent).toContain(String(value));\n}\n"
  },
  {
    "path": "src/test-utils/widget-config.js",
    "content": "import { expect } from \"vitest\";\n\nexport function expectWidgetConfigShape(widget) {\n  expect(widget).toBeTruthy();\n  expect(widget).toBeTypeOf(\"object\");\n\n  if (\"api\" in widget) {\n    expect(widget.api).toBeTypeOf(\"string\");\n    // Widget APIs are either service-backed (`{url}` template) or third-party API URLs.\n    expect(widget.api.includes(\"{url}\") || /^https?:\\/\\//.test(widget.api)).toBe(true);\n  }\n\n  if (\"proxyHandler\" in widget) {\n    expect(widget.proxyHandler).toBeTypeOf(\"function\");\n  }\n\n  if (\"allowedEndpoints\" in widget) {\n    expect(widget.allowedEndpoints).toBeInstanceOf(RegExp);\n  }\n\n  if (\"mappings\" in widget) {\n    expect(widget.mappings).toBeTruthy();\n    expect(widget.mappings).toBeTypeOf(\"object\");\n\n    for (const [name, mapping] of Object.entries(widget.mappings)) {\n      expect(name).toBeTruthy();\n      expect(mapping).toBeTruthy();\n      expect(mapping).toBeTypeOf(\"object\");\n\n      if (\"endpoint\" in mapping) {\n        expect(mapping.endpoint).toBeTypeOf(\"string\");\n        expect(mapping.endpoint.length).toBeGreaterThan(0);\n      }\n      if (\"map\" in mapping) {\n        const map = mapping.map;\n        const proxyName = widget.proxyHandler?.name ?? \"genericProxyHandler\";\n\n        // Most handlers treat `map` as a transform function. A small number of custom\n        // proxies treat it as an options object.\n        expect([\"function\", \"object\"].includes(typeof map)).toBe(true);\n\n        if (typeof map === \"object\") {\n          expect(map).not.toBeNull();\n          expect(Array.isArray(map)).toBe(false);\n          // Generic handlers will call `map(resultData)`, so they must never receive an object.\n          expect(proxyName).not.toBe(\"genericProxyHandler\");\n          expect(proxyName).not.toBe(\"credentialedProxyHandler\");\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/utils/config/api-response.js",
    "content": "import { promises as fs } from \"fs\";\nimport path from \"path\";\n\nimport yaml from \"js-yaml\";\n\nimport checkAndCopyConfig, { CONF_DIR, getSettings, substituteEnvironmentVars } from \"utils/config/config\";\nimport {\n  cleanServiceGroups,\n  findGroupByName,\n  servicesFromConfig,\n  servicesFromDocker,\n  servicesFromKubernetes,\n} from \"utils/config/service-helpers\";\nimport { cleanWidgetGroups, widgetsFromConfig } from \"utils/config/widget-helpers\";\n\n/**\n * Compares services by weight then by name.\n */\nfunction compareServices(service1, service2) {\n  const comp = service1.weight - service2.weight;\n  if (comp !== 0) {\n    return comp;\n  }\n  return service1.name.localeCompare(service2.name);\n}\n\nexport async function bookmarksResponse() {\n  checkAndCopyConfig(\"bookmarks.yaml\");\n\n  const bookmarksYaml = path.join(CONF_DIR, \"bookmarks.yaml\");\n  const rawFileContents = await fs.readFile(bookmarksYaml, \"utf8\");\n  const fileContents = substituteEnvironmentVars(rawFileContents);\n  const bookmarks = yaml.load(fileContents);\n\n  if (!bookmarks) return [];\n\n  let initialSettings;\n\n  try {\n    initialSettings = await getSettings();\n  } catch (e) {\n    console.error(\"Failed to load settings.yaml, please check for errors\");\n    if (e) console.error(e.toString());\n    initialSettings = {};\n  }\n\n  // map easy to write YAML objects into easy to consume JS arrays\n  const bookmarksArray = bookmarks.map((group) => ({\n    name: Object.keys(group)[0],\n    bookmarks: group[Object.keys(group)[0]].map((entries) => ({\n      name: Object.keys(entries)[0],\n      ...entries[Object.keys(entries)[0]][0],\n    })),\n  }));\n\n  const sortedGroups = [];\n  const unsortedGroups = [];\n  const definedLayouts = initialSettings.layout ? Object.keys(initialSettings.layout) : null;\n\n  bookmarksArray.forEach((group) => {\n    if (definedLayouts) {\n      const layoutIndex = definedLayouts.findIndex((layout) => layout === group.name);\n      if (layoutIndex > -1) sortedGroups[layoutIndex] = group;\n      else unsortedGroups.push(group);\n    } else {\n      unsortedGroups.push(group);\n    }\n  });\n\n  return [...sortedGroups.filter((g) => g), ...unsortedGroups];\n}\n\nexport async function widgetsResponse() {\n  let configuredWidgets;\n\n  try {\n    configuredWidgets = cleanWidgetGroups(await widgetsFromConfig());\n  } catch (e) {\n    console.error(\"Failed to load widgets, please check widgets.yaml for errors or remove example entries.\");\n    if (e) console.error(e);\n    configuredWidgets = [];\n  }\n\n  return configuredWidgets;\n}\n\nfunction convertLayoutGroupToGroup(name, layoutGroup) {\n  const group = { name, services: [], groups: [] };\n  if (layoutGroup) {\n    Object.entries(layoutGroup).forEach(([key, value]) => {\n      if (typeof value === \"object\") {\n        group.groups.push(convertLayoutGroupToGroup(key, value));\n      }\n    });\n  }\n  return group;\n}\n\nfunction mergeSubgroups(configuredGroups, mergedGroup) {\n  configuredGroups.forEach((group) => {\n    if (group.name === mergedGroup.name) {\n      group.services = mergedGroup.services;\n    } else if (group.groups) {\n      mergeSubgroups(group.groups, mergedGroup);\n    }\n  });\n}\n\nfunction ensureParentGroupExists(sortedGroups, configuredGroups, group, definedLayouts) {\n  // make sure the top level parent group exists in the sortedGroups array\n  const parentGroupName = group.parent;\n  const parentGroup = findGroupByName(configuredGroups, parentGroupName);\n  if (parentGroup && parentGroup.parent) {\n    ensureParentGroupExists(sortedGroups, configuredGroups, parentGroup, definedLayouts);\n  } else {\n    const parentGroupIndex = definedLayouts.findIndex((layout) => layout === parentGroupName);\n    if (parentGroupIndex > -1) {\n      sortedGroups[parentGroupIndex] = parentGroup;\n    }\n  }\n}\n\nfunction pruneEmptyGroups(groups) {\n  // remove any groups that have no services\n  return groups.filter((group) => {\n    if (group.services.length === 0 && group.groups.length === 0) {\n      return false;\n    }\n    if (group.groups.length > 0) {\n      group.groups = pruneEmptyGroups(group.groups);\n    }\n    return true;\n  });\n}\n\nfunction mergeLayoutGroupsIntoConfigured(configuredGroups, layoutGroups) {\n  for (const layoutGroup of layoutGroups) {\n    const existing = findGroupByName(configuredGroups, layoutGroup.name);\n    if (existing) {\n      if (layoutGroup.groups?.length) {\n        existing.groups ??= [];\n        for (const sub of layoutGroup.groups) {\n          const existingSub = findGroupByName(existing.groups, sub.name);\n          if (!existingSub) {\n            existing.groups.push(sub);\n          } else {\n            // recursive merge if needed\n            mergeLayoutGroupsIntoConfigured([existingSub], [sub]);\n          }\n        }\n      }\n    } else {\n      configuredGroups.push(layoutGroup);\n    }\n  }\n}\n\nexport async function servicesResponse() {\n  let discoveredDockerServices;\n  let discoveredKubernetesServices;\n  let configuredServices;\n  let initialSettings;\n\n  try {\n    discoveredDockerServices = cleanServiceGroups(await servicesFromDocker());\n    if (discoveredDockerServices?.length === 0) {\n      console.debug(\"No containers were found with homepage labels.\");\n    }\n  } catch (e) {\n    console.error(\"Failed to discover services, please check docker.yaml for errors or remove example entries.\");\n    if (e) console.error(e.toString());\n    discoveredDockerServices = [];\n  }\n\n  try {\n    discoveredKubernetesServices = cleanServiceGroups(await servicesFromKubernetes());\n  } catch (e) {\n    console.error(\"Failed to discover services, please check kubernetes.yaml for errors or remove example entries.\");\n    if (e) console.error(e.toString());\n    discoveredKubernetesServices = [];\n  }\n\n  try {\n    configuredServices = cleanServiceGroups(await servicesFromConfig());\n  } catch (e) {\n    console.error(\"Failed to load services.yaml, please check for errors\");\n    if (e) console.error(e.toString());\n    configuredServices = [];\n  }\n\n  try {\n    initialSettings = await getSettings();\n  } catch (e) {\n    console.error(\"Failed to load settings.yaml, please check for errors\");\n    if (e) console.error(e.toString());\n    initialSettings = {};\n  }\n\n  const mergedGroupsNames = [\n    ...new Set(\n      [\n        discoveredDockerServices.map((group) => group.name),\n        discoveredKubernetesServices.map((group) => group.name),\n        configuredServices.map((group) => group.name),\n      ].flat(),\n    ),\n  ];\n\n  const sortedGroups = [];\n  const unsortedGroups = [];\n  const definedLayouts = initialSettings.layout ? Object.keys(initialSettings.layout) : null;\n  if (definedLayouts) {\n    // this handles cases where groups are only defined in the settings.yaml layout and not in the services.yaml\n    const layoutGroups = Object.entries(initialSettings.layout).map(([key, value]) =>\n      convertLayoutGroupToGroup(key, value),\n    );\n    mergeLayoutGroupsIntoConfigured(configuredServices, layoutGroups);\n  }\n\n  mergedGroupsNames.forEach((groupName) => {\n    const discoveredDockerGroup = findGroupByName(discoveredDockerServices, groupName) || {\n      services: [],\n    };\n    const discoveredKubernetesGroup = findGroupByName(discoveredKubernetesServices, groupName) || {\n      services: [],\n    };\n    const configuredGroup = findGroupByName(configuredServices, groupName) || { services: [], groups: [] };\n\n    const mergedGroup = {\n      name: groupName,\n      services: [...discoveredDockerGroup.services, ...discoveredKubernetesGroup.services, ...configuredGroup.services]\n        .filter((service) => service)\n        .sort(compareServices),\n      groups: [...configuredGroup.groups],\n    };\n\n    if (definedLayouts) {\n      const layoutIndex = definedLayouts.findIndex((layout) => layout === mergedGroup.name);\n      if (layoutIndex > -1) sortedGroups[layoutIndex] = mergedGroup;\n      else if (configuredGroup.parent) {\n        // this is a nested group, so find the parent group and merge the services\n        mergeSubgroups(configuredServices, mergedGroup);\n        // make sure the top level parent group exists in the sortedGroups array\n        ensureParentGroupExists(sortedGroups, configuredServices, configuredGroup, definedLayouts);\n      } else unsortedGroups.push(mergedGroup);\n    } else if (configuredGroup.parent) {\n      // this is a nested group, so find the parent group and merge the services\n      mergeSubgroups(configuredServices, mergedGroup);\n    } else {\n      unsortedGroups.push(mergedGroup);\n    }\n  });\n\n  const allGroups = [...sortedGroups.filter((g) => g), ...unsortedGroups];\n  return pruneEmptyGroups(allGroups);\n}\n"
  },
  {
    "path": "src/utils/config/api-response.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { fs, yaml, config, widgetHelpers, serviceHelpers } = vi.hoisted(() => ({\n  fs: {\n    readFile: vi.fn(),\n  },\n  yaml: {\n    load: vi.fn(),\n  },\n  config: {\n    CONF_DIR: \"/conf\",\n    getSettings: vi.fn(),\n    substituteEnvironmentVars: vi.fn((s) => s),\n    default: vi.fn(),\n  },\n  widgetHelpers: {\n    widgetsFromConfig: vi.fn(),\n    cleanWidgetGroups: vi.fn(),\n  },\n  serviceHelpers: {\n    servicesFromDocker: vi.fn(),\n    servicesFromKubernetes: vi.fn(),\n    servicesFromConfig: vi.fn(),\n    cleanServiceGroups: vi.fn((g) => g),\n    findGroupByName: vi.fn(),\n  },\n}));\n\nvi.mock(\"fs\", () => ({\n  promises: fs,\n}));\n\nvi.mock(\"js-yaml\", () => ({\n  default: yaml,\n  ...yaml,\n}));\n\nvi.mock(\"utils/config/config\", () => config);\nvi.mock(\"utils/config/widget-helpers\", () => widgetHelpers);\nvi.mock(\"utils/config/service-helpers\", () => serviceHelpers);\n\nimport { bookmarksResponse, servicesResponse, widgetsResponse } from \"./api-response\";\n\ndescribe(\"utils/config/api-response\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"bookmarksResponse returns [] when bookmarks are missing\", async () => {\n    fs.readFile.mockResolvedValueOnce(\"ignored\");\n    yaml.load.mockReturnValueOnce(null);\n\n    const res = await bookmarksResponse();\n    expect(res).toEqual([]);\n    expect(config.getSettings).not.toHaveBeenCalled();\n  });\n\n  it(\"bookmarksResponse falls back when settings cannot be loaded\", async () => {\n    const errSpy = vi.spyOn(console, \"error\").mockImplementation(() => {});\n    fs.readFile.mockResolvedValueOnce(\"ignored\");\n    config.getSettings.mockRejectedValueOnce(new Error(\"bad settings\"));\n    yaml.load.mockReturnValueOnce([{ A: [{ LinkA: [{ href: \"a\" }] }] }, { B: [{ LinkB: [{ href: \"b\" }] }] }]);\n\n    const res = await bookmarksResponse();\n    expect(res.map((g) => g.name)).toEqual([\"A\", \"B\"]);\n    expect(errSpy).toHaveBeenCalled();\n\n    errSpy.mockRestore();\n  });\n\n  it(\"bookmarksResponse sorts groups based on settings layout\", async () => {\n    fs.readFile.mockResolvedValueOnce(\"ignored\");\n    config.getSettings.mockResolvedValueOnce({ layout: { B: {}, A: {} } });\n    yaml.load.mockReturnValueOnce([{ A: [{ LinkA: [{ href: \"a\" }] }] }, { B: [{ LinkB: [{ href: \"b\" }] }] }]);\n\n    const res = await bookmarksResponse();\n    expect(res.map((g) => g.name)).toEqual([\"B\", \"A\"]);\n  });\n\n  it(\"bookmarksResponse appends groups not present in the layout\", async () => {\n    fs.readFile.mockResolvedValueOnce(\"ignored\");\n    config.getSettings.mockResolvedValueOnce({ layout: { A: {} } });\n    yaml.load.mockReturnValueOnce([{ A: [{ LinkA: [{ href: \"a\" }] }] }, { C: [{ LinkC: [{ href: \"c\" }] }] }]);\n\n    const res = await bookmarksResponse();\n    expect(res.map((g) => g.name)).toEqual([\"A\", \"C\"]);\n  });\n\n  it(\"widgetsResponse returns sanitized configured widgets\", async () => {\n    widgetHelpers.widgetsFromConfig.mockResolvedValueOnce([{ type: \"search\", options: { url: \"x\" } }]);\n    widgetHelpers.cleanWidgetGroups.mockResolvedValueOnce([{ type: \"search\", options: { index: 0 } }]);\n\n    expect(await widgetsResponse()).toEqual([{ type: \"search\", options: { index: 0 } }]);\n  });\n\n  it(\"widgetsResponse returns [] when widgets cannot be loaded\", async () => {\n    const errSpy = vi.spyOn(console, \"error\").mockImplementation(() => {});\n    widgetHelpers.widgetsFromConfig.mockRejectedValueOnce(new Error(\"bad widgets\"));\n\n    expect(await widgetsResponse()).toEqual([]);\n    expect(errSpy).toHaveBeenCalled();\n\n    errSpy.mockRestore();\n  });\n\n  it(\"servicesResponse merges groups and sorts services by weight then name\", async () => {\n    // Minimal stubs for findGroupByName used within servicesResponse.\n    serviceHelpers.findGroupByName.mockImplementation((groups, name) => groups.find((g) => g.name === name) ?? null);\n\n    serviceHelpers.servicesFromDocker.mockResolvedValueOnce([\n      {\n        name: \"GroupA\",\n        services: [\n          { name: \"b\", weight: 200 },\n          { name: \"a\", weight: 200 },\n        ],\n        groups: [],\n      },\n    ]);\n    serviceHelpers.servicesFromKubernetes.mockResolvedValueOnce([\n      { name: \"GroupA\", services: [{ name: \"c\", weight: 100 }], groups: [] },\n    ]);\n    serviceHelpers.servicesFromConfig.mockResolvedValueOnce([\n      { name: \"GroupA\", services: [{ name: \"d\", weight: 50 }], groups: [] },\n      { name: \"Empty\", services: [], groups: [] },\n    ]);\n\n    config.getSettings.mockResolvedValueOnce({ layout: { GroupA: {}, GroupB: {} } });\n\n    const groups = await servicesResponse();\n    expect(groups.map((g) => g.name)).toEqual([\"GroupA\"]);\n    expect(groups[0].services.map((s) => s.name)).toEqual([\"d\", \"c\", \"a\", \"b\"]);\n  });\n\n  it(\"servicesResponse logs when no docker services are discovered\", async () => {\n    const debugSpy = vi.spyOn(console, \"debug\").mockImplementation(() => {});\n    serviceHelpers.findGroupByName.mockImplementation((groups, name) => groups.find((g) => g.name === name) ?? null);\n\n    serviceHelpers.servicesFromDocker.mockResolvedValueOnce([]);\n    serviceHelpers.servicesFromKubernetes.mockResolvedValueOnce([]);\n    serviceHelpers.servicesFromConfig.mockResolvedValueOnce([]);\n    config.getSettings.mockResolvedValueOnce({});\n\n    const groups = await servicesResponse();\n\n    expect(groups).toEqual([]);\n    expect(debugSpy).toHaveBeenCalledWith(\"No containers were found with homepage labels.\");\n\n    debugSpy.mockRestore();\n  });\n\n  it(\"servicesResponse tolerates discovery/load failures and returns [] when nothing can be loaded\", async () => {\n    const errSpy = vi.spyOn(console, \"error\").mockImplementation(() => {});\n    serviceHelpers.servicesFromDocker.mockRejectedValueOnce(new Error(\"docker bad\"));\n    serviceHelpers.servicesFromKubernetes.mockRejectedValueOnce(new Error(\"kube bad\"));\n    serviceHelpers.servicesFromConfig.mockRejectedValueOnce(new Error(\"config bad\"));\n    config.getSettings.mockRejectedValueOnce(new Error(\"settings bad\"));\n\n    const groups = await servicesResponse();\n    expect(groups).toEqual([]);\n    expect(errSpy).toHaveBeenCalled();\n\n    errSpy.mockRestore();\n  });\n\n  it(\"servicesResponse supports multi-level nested layout groups and ensures the top-level parent exists\", async () => {\n    serviceHelpers.findGroupByName.mockImplementation(function find(groups, name, parent) {\n      for (const group of groups ?? []) {\n        if (group.name === name) {\n          if (parent) group.parent = parent;\n          return group;\n        }\n        const found = find(group.groups, name, group.name);\n        if (found) return found;\n      }\n      return null;\n    });\n\n    serviceHelpers.servicesFromDocker.mockResolvedValueOnce([\n      { name: \"Child\", services: [{ name: \"svc\", weight: 1 }], groups: [] },\n    ]);\n    serviceHelpers.servicesFromKubernetes.mockResolvedValueOnce([]);\n    serviceHelpers.servicesFromConfig.mockResolvedValueOnce([{ name: \"Root\", services: [], groups: [] }]);\n\n    config.getSettings.mockResolvedValueOnce({ layout: { Root: { Top: { Child: {} } } } });\n\n    const groups = await servicesResponse();\n\n    expect(groups.map((g) => g.name)).toEqual([\"Root\"]);\n    expect(groups[0].groups[0].name).toBe(\"Top\");\n    expect(groups[0].groups[0].groups[0].name).toBe(\"Child\");\n    expect(groups[0].groups[0].groups[0].services).toEqual([{ name: \"svc\", weight: 1 }]);\n  });\n\n  it(\"servicesResponse merges discovered nested groups into their configured parent layout group\", async () => {\n    serviceHelpers.findGroupByName.mockImplementation(function find(groups, name, parent) {\n      for (const group of groups ?? []) {\n        if (group.name === name) {\n          if (parent) group.parent = parent;\n          return group;\n        }\n        const found = find(group.groups, name, group.name);\n        if (found) return found;\n      }\n      return null;\n    });\n\n    serviceHelpers.servicesFromDocker.mockResolvedValueOnce([\n      {\n        name: \"Child\",\n        services: [\n          { name: \"svcB\", weight: 50 },\n          { name: \"svcA\", weight: 10 },\n        ],\n        groups: [],\n      },\n    ]);\n    serviceHelpers.servicesFromKubernetes.mockResolvedValueOnce([]);\n    serviceHelpers.servicesFromConfig.mockResolvedValueOnce([\n      {\n        name: \"Top\",\n        services: [],\n        groups: [{ name: \"Child\", services: [], groups: [] }],\n      },\n    ]);\n\n    config.getSettings.mockResolvedValueOnce({ layout: { Top: { Child: {} } } });\n\n    const groups = await servicesResponse();\n\n    expect(groups.map((g) => g.name)).toEqual([\"Top\"]);\n    expect(groups[0].groups).toHaveLength(1);\n    expect(groups[0].groups[0].name).toBe(\"Child\");\n    expect(groups[0].groups[0].services.map((s) => s.name)).toEqual([\"svcA\", \"svcB\"]);\n  });\n\n  it(\"servicesResponse merges nested discovered groups into their configured parent when no layout is defined\", async () => {\n    serviceHelpers.findGroupByName.mockImplementation(function find(groups, name, parent) {\n      for (const group of groups ?? []) {\n        if (group.name === name) {\n          if (parent) group.parent = parent;\n          return group;\n        }\n        const found = find(group.groups, name, group.name);\n        if (found) return found;\n      }\n      return null;\n    });\n\n    serviceHelpers.servicesFromDocker.mockResolvedValueOnce([\n      { name: \"Child\", services: [{ name: \"svc\", weight: 1 }], groups: [] },\n    ]);\n    serviceHelpers.servicesFromKubernetes.mockResolvedValueOnce([]);\n    serviceHelpers.servicesFromConfig.mockResolvedValueOnce([\n      { name: \"Top\", services: [], groups: [{ name: \"Child\", services: [], groups: [] }] },\n    ]);\n    config.getSettings.mockResolvedValueOnce({});\n\n    const groups = await servicesResponse();\n\n    expect(groups.map((g) => g.name)).toEqual([\"Top\"]);\n    expect(groups[0].groups[0].name).toBe(\"Child\");\n    expect(groups[0].groups[0].services).toEqual([{ name: \"svc\", weight: 1 }]);\n  });\n});\n"
  },
  {
    "path": "src/utils/config/config.check-copy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { fs, yaml } = vi.hoisted(() => ({\n  fs: {\n    copyFileSync: vi.fn(),\n    existsSync: vi.fn(),\n    mkdirSync: vi.fn(),\n    readFileSync: vi.fn(),\n  },\n  yaml: {\n    load: vi.fn(),\n  },\n}));\n\nvi.mock(\"fs\", () => fs);\nvi.mock(\"js-yaml\", () => ({ default: yaml, ...yaml }));\n\ndescribe(\"utils/config/config checkAndCopyConfig\", () => {\n  const originalEnv = process.env;\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    vi.resetModules();\n    process.env = { ...originalEnv, HOMEPAGE_CONFIG_DIR: \"/conf\" };\n  });\n\n  it(\"returns false when it cannot create the config directory\", async () => {\n    const warnSpy = vi.spyOn(console, \"warn\").mockImplementation(() => {});\n    fs.existsSync.mockReturnValueOnce(false);\n    fs.mkdirSync.mockImplementationOnce(() => {\n      throw new Error(\"no perms\");\n    });\n\n    const mod = await import(\"./config\");\n    expect(mod.default(\"services.yaml\")).toBe(false);\n    expect(warnSpy).toHaveBeenCalled();\n\n    warnSpy.mockRestore();\n  });\n\n  it(\"copies the skeleton file when the config file does not exist\", async () => {\n    const infoSpy = vi.spyOn(console, \"info\").mockImplementation(() => {});\n    // dir exists\n    fs.existsSync.mockReturnValueOnce(true);\n    // config file missing\n    fs.existsSync.mockReturnValueOnce(false);\n\n    const mod = await import(\"./config\");\n    expect(mod.default(\"services.yaml\")).toBe(true);\n    expect(fs.copyFileSync).toHaveBeenCalled();\n    expect(infoSpy).toHaveBeenCalled();\n\n    infoSpy.mockRestore();\n  });\n\n  it(\"exits the process when copying the skeleton fails\", async () => {\n    const exitSpy = vi.spyOn(process, \"exit\").mockImplementation(() => {\n      throw new Error(\"exit\");\n    });\n    const errSpy = vi.spyOn(console, \"error\").mockImplementation(() => {});\n\n    fs.existsSync.mockReturnValueOnce(true);\n    fs.existsSync.mockReturnValueOnce(false);\n    fs.copyFileSync.mockImplementationOnce(() => {\n      throw new Error(\"copy failed\");\n    });\n\n    const mod = await import(\"./config\");\n    expect(() => mod.default(\"services.yaml\")).toThrow(\"exit\");\n    expect(exitSpy).toHaveBeenCalledWith(1);\n    expect(errSpy).toHaveBeenCalled();\n\n    exitSpy.mockRestore();\n    errSpy.mockRestore();\n  });\n\n  it(\"returns a parse error with config name when YAML is invalid\", async () => {\n    fs.existsSync.mockReturnValueOnce(true);\n    fs.existsSync.mockReturnValueOnce(true);\n    fs.readFileSync.mockReturnValueOnce(\"bad\");\n    yaml.load.mockImplementationOnce(() => {\n      throw Object.assign(new Error(\"yaml bad\"), { name: \"YAMLException\" });\n    });\n\n    const mod = await import(\"./config\");\n    const result = mod.default(\"services.yaml\");\n\n    expect(result).toEqual(expect.objectContaining({ name: \"YAMLException\", config: \"services.yaml\" }));\n  });\n});\n"
  },
  {
    "path": "src/utils/config/config.js",
    "content": "import { copyFileSync, existsSync, mkdirSync, readFileSync } from \"fs\";\nimport { join } from \"path\";\n\nimport yaml from \"js-yaml\";\nimport cache from \"memory-cache\";\n\nconst cacheKey = \"homepageEnvironmentVariables\";\nconst homepageVarPrefix = \"HOMEPAGE_VAR_\";\nconst homepageFilePrefix = \"HOMEPAGE_FILE_\";\n\nexport const CONF_DIR = process.env.HOMEPAGE_CONFIG_DIR\n  ? process.env.HOMEPAGE_CONFIG_DIR\n  : join(process.cwd(), \"config\");\n\nexport default function checkAndCopyConfig(config) {\n  // Ensure config directory exists\n  if (!existsSync(CONF_DIR)) {\n    try {\n      mkdirSync(CONF_DIR, { recursive: true });\n    } catch (e) {\n      console.warn(`Could not create config directory ${CONF_DIR}: ${e.message}`);\n      return false;\n    }\n  }\n\n  const configYaml = join(CONF_DIR, config);\n\n  // If the config file doesn't exist, try to copy the skeleton\n  if (!existsSync(configYaml)) {\n    const configSkeleton = join(process.cwd(), \"src\", \"skeleton\", config);\n    try {\n      copyFileSync(configSkeleton, configYaml);\n      console.info(\"%s was copied to the config folder\", config);\n    } catch (err) {\n      console.error(\"❌ Failed to initialize required config: %s\", configYaml);\n      console.error(\"Reason: %s\", err.message);\n      console.error(\"Hint: Make /app/config writable or manually place the config file.\");\n      process.exit(1);\n    }\n\n    return true;\n  }\n\n  try {\n    yaml.load(readFileSync(configYaml, \"utf8\"));\n    return true;\n  } catch (e) {\n    return { ...e, config };\n  }\n}\n\nfunction getCachedEnvironmentVars() {\n  let cachedVars = cache.get(cacheKey);\n  if (!cachedVars) {\n    // initialize cache\n    cachedVars = Object.entries(process.env).filter(\n      ([key]) => key.includes(homepageVarPrefix) || key.includes(homepageFilePrefix),\n    );\n    cache.put(cacheKey, cachedVars);\n  }\n  return cachedVars;\n}\n\nexport function substituteEnvironmentVars(str) {\n  let result = str;\n  if (result.includes(\"{{\")) {\n    // crude check if we have vars to replace\n    const cachedVars = getCachedEnvironmentVars();\n    cachedVars.forEach(([key, value]) => {\n      if (key.startsWith(homepageVarPrefix)) {\n        result = result.replaceAll(`{{${key}}}`, value);\n      } else if (key.startsWith(homepageFilePrefix)) {\n        const filename = value;\n        const fileContents = readFileSync(filename, \"utf8\");\n        result = result.replaceAll(`{{${key}}}`, fileContents);\n      }\n    });\n  }\n  return result;\n}\n\nexport function getSettings() {\n  checkAndCopyConfig(\"settings.yaml\");\n\n  const settingsYaml = join(CONF_DIR, \"settings.yaml\");\n  const rawFileContents = readFileSync(settingsYaml, \"utf8\");\n  const fileContents = substituteEnvironmentVars(rawFileContents);\n  const initialSettings = yaml.load(fileContents) ?? {};\n\n  if (initialSettings.layout) {\n    // support yaml list but old spec was object so convert to that\n    // see https://github.com/gethomepage/homepage/issues/1546\n    if (Array.isArray(initialSettings.layout)) {\n      const layoutItems = initialSettings.layout;\n      initialSettings.layout = {};\n      layoutItems.forEach((i) => {\n        const name = Object.keys(i)[0];\n        initialSettings.layout[name] = i[name];\n      });\n    }\n  }\n  return initialSettings;\n}\n"
  },
  {
    "path": "src/utils/config/config.test.js",
    "content": "import { mkdtempSync, writeFileSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport path from \"node:path\";\n\nimport cache from \"memory-cache\";\nimport { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\";\n\ndescribe(\"utils/config/config\", () => {\n  const originalEnv = process.env;\n\n  beforeEach(() => {\n    vi.resetModules();\n    process.env = { ...originalEnv };\n    cache.del(\"homepageEnvironmentVariables\");\n  });\n\n  afterEach(() => {\n    process.env = originalEnv;\n    cache.del(\"homepageEnvironmentVariables\");\n  });\n\n  it(\"substituteEnvironmentVars replaces HOMEPAGE_VAR_* placeholders\", async () => {\n    process.env.HOMEPAGE_VAR_FOO = \"bar\";\n\n    const mod = await import(\"./config\");\n    expect(mod.substituteEnvironmentVars(\"x {{HOMEPAGE_VAR_FOO}} y\")).toBe(\"x bar y\");\n  });\n\n  it(\"substituteEnvironmentVars replaces HOMEPAGE_FILE_* placeholders with file contents\", async () => {\n    const dir = mkdtempSync(path.join(tmpdir(), \"homepage-config-test-\"));\n    const secretPath = path.join(dir, \"secret.txt\");\n    writeFileSync(secretPath, \"secret\", \"utf8\");\n\n    process.env.HOMEPAGE_FILE_SECRET = secretPath;\n\n    const mod = await import(\"./config\");\n    expect(mod.substituteEnvironmentVars(\"token={{HOMEPAGE_FILE_SECRET}}\")).toBe(\"token=secret\");\n  });\n\n  it(\"getSettings reads from HOMEPAGE_CONFIG_DIR and converts layout list to an object\", async () => {\n    const dir = mkdtempSync(path.join(tmpdir(), \"homepage-settings-test-\"));\n    process.env.HOMEPAGE_CONFIG_DIR = dir;\n    process.env.HOMEPAGE_VAR_TITLE = \"MyTitle\";\n\n    // Create a minimal settings.yaml; checkAndCopyConfig will see it exists and won't copy skeleton.\n    writeFileSync(\n      path.join(dir, \"settings.yaml\"),\n      ['title: \"{{HOMEPAGE_VAR_TITLE}}\"', \"layout:\", \"  - GroupA:\", \"      style: row\"].join(\"\\n\"),\n      \"utf8\",\n    );\n\n    vi.resetModules(); // ensure CONF_DIR is computed from updated env\n    const mod = await import(\"./config\");\n\n    const settings = mod.getSettings();\n    expect(settings.title).toBe(\"MyTitle\");\n    expect(settings.layout).toEqual({ GroupA: { style: \"row\" } });\n  });\n});\n"
  },
  {
    "path": "src/utils/config/docker.js",
    "content": "import { readFileSync } from \"fs\";\nimport path from \"path\";\n\nimport yaml from \"js-yaml\";\n\nimport checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from \"utils/config/config\";\n\nexport function getDefaultDockerArgs(platform = process.platform) {\n  if (platform !== \"win32\" && platform !== \"darwin\") {\n    return { socketPath: \"/var/run/docker.sock\" };\n  }\n\n  return { host: \"127.0.0.1\" };\n}\n\nexport default function getDockerArguments(server) {\n  checkAndCopyConfig(\"docker.yaml\");\n\n  const configFile = path.join(CONF_DIR, \"docker.yaml\");\n  const rawConfigData = readFileSync(configFile, \"utf8\");\n  const configData = substituteEnvironmentVars(rawConfigData);\n  const servers = yaml.load(configData);\n\n  if (!server) {\n    return getDefaultDockerArgs();\n  }\n\n  if (servers[server]) {\n    if (servers[server].socket) {\n      return { conn: { socketPath: servers[server].socket }, swarm: !!servers[server].swarm };\n    }\n\n    if (servers[server].host) {\n      const res = {\n        conn: { host: servers[server].host },\n        swarm: !!servers[server].swarm,\n      };\n\n      if (servers[server].port) {\n        res.conn.port = servers[server].port;\n      }\n\n      if (servers[server].tls) {\n        res.conn.ca = readFileSync(path.join(CONF_DIR, servers[server].tls.caFile));\n        res.conn.cert = readFileSync(path.join(CONF_DIR, servers[server].tls.certFile));\n        res.conn.key = readFileSync(path.join(CONF_DIR, servers[server].tls.keyFile));\n        res.conn.protocol = \"https\";\n      }\n\n      if (servers[server].protocol) {\n        res.conn.protocol = servers[server].protocol;\n      }\n\n      if (servers[server].headers) {\n        res.conn.headers = servers[server].headers;\n      }\n\n      return res;\n    }\n\n    return servers[server];\n  }\n  return null;\n}\n"
  },
  {
    "path": "src/utils/config/docker.test.js",
    "content": "import { describe, expect, it, vi } from \"vitest\";\n\nconst { fs, yaml, config, checkAndCopyConfig } = vi.hoisted(() => ({\n  fs: {\n    readFileSync: vi.fn((filePath, encoding) => {\n      if (String(filePath).endsWith(\"/docker.yaml\") && encoding === \"utf8\") return \"docker-yaml\";\n      return Buffer.from(String(filePath));\n    }),\n  },\n  yaml: {\n    load: vi.fn(),\n  },\n  config: {\n    CONF_DIR: \"/conf\",\n    substituteEnvironmentVars: vi.fn((s) => s),\n  },\n  checkAndCopyConfig: vi.fn(),\n}));\n\nvi.mock(\"fs\", () => ({\n  readFileSync: fs.readFileSync,\n}));\n\nvi.mock(\"js-yaml\", () => ({\n  default: yaml,\n  ...yaml,\n}));\n\nvi.mock(\"utils/config/config\", () => ({\n  default: checkAndCopyConfig,\n  ...config,\n}));\n\nimport getDockerArguments, { getDefaultDockerArgs } from \"./docker\";\n\ndescribe(\"utils/config/docker\", () => {\n  it(\"getDefaultDockerArgs returns a socketPath on linux and host on darwin\", () => {\n    expect(getDefaultDockerArgs(\"linux\")).toEqual({ socketPath: \"/var/run/docker.sock\" });\n    expect(getDefaultDockerArgs(\"darwin\")).toEqual({ host: \"127.0.0.1\" });\n  });\n\n  it(\"returns default args when no server is given\", () => {\n    yaml.load.mockReturnValueOnce({});\n\n    const args = getDockerArguments();\n\n    expect(checkAndCopyConfig).toHaveBeenCalledWith(\"docker.yaml\");\n    // if running on linux, should return socketPath\n    if (process.platform !== \"win32\" && process.platform !== \"darwin\") {\n      expect(args).toEqual({ socketPath: \"/var/run/docker.sock\" });\n    } else {\n      // otherwise, should return host\n      expect(args).toEqual(expect.objectContaining({ host: expect.any(String) }));\n    }\n  });\n\n  it(\"returns socket config when server has a socket\", () => {\n    yaml.load.mockReturnValueOnce({\n      \"docker-local\": { socket: \"/tmp/docker.sock\", swarm: true },\n    });\n\n    const args = getDockerArguments(\"docker-local\");\n\n    expect(args).toEqual({ conn: { socketPath: \"/tmp/docker.sock\" }, swarm: true });\n  });\n\n  it(\"returns host/port/tls/protocol/headers config when provided\", () => {\n    yaml.load.mockReturnValueOnce({\n      remote: {\n        host: \"10.0.0.1\",\n        port: 2376,\n        swarm: false,\n        protocol: \"http\",\n        headers: { \"X-Test\": \"1\" },\n        tls: { caFile: \"ca.pem\", certFile: \"cert.pem\", keyFile: \"key.pem\" },\n      },\n    });\n\n    const args = getDockerArguments(\"remote\");\n\n    expect(args).toEqual(\n      expect.objectContaining({\n        swarm: false,\n        conn: expect.objectContaining({\n          host: \"10.0.0.1\",\n          port: 2376,\n          protocol: \"http\",\n          headers: { \"X-Test\": \"1\" },\n          ca: expect.any(Buffer),\n          cert: expect.any(Buffer),\n          key: expect.any(Buffer),\n        }),\n      }),\n    );\n  });\n\n  it(\"returns null when server is not configured\", () => {\n    yaml.load.mockReturnValueOnce({ other: { host: \"x\" } });\n    expect(getDockerArguments(\"missing\")).toBeNull();\n  });\n\n  it(\"returns the raw server config when it has no host/socket overrides\", () => {\n    yaml.load.mockReturnValueOnce({\n      raw: { swarm: true, something: \"else\" },\n    });\n\n    expect(getDockerArguments(\"raw\")).toEqual({ swarm: true, something: \"else\" });\n  });\n});\n"
  },
  {
    "path": "src/utils/config/kubernetes.js",
    "content": "import { readFileSync } from \"fs\";\nimport path from \"path\";\n\nimport { ApiextensionsV1Api, KubeConfig } from \"@kubernetes/client-node\";\nimport yaml from \"js-yaml\";\n\nimport checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from \"utils/config/config\";\n\nexport function getKubernetes() {\n  checkAndCopyConfig(\"kubernetes.yaml\");\n  const configFile = path.join(CONF_DIR, \"kubernetes.yaml\");\n  const rawConfigData = readFileSync(configFile, \"utf8\");\n  const configData = substituteEnvironmentVars(rawConfigData);\n  return yaml.load(configData);\n}\n\nexport const getKubeConfig = () => {\n  const kc = new KubeConfig();\n  const config = getKubernetes();\n\n  switch (config?.mode) {\n    case \"cluster\":\n      kc.loadFromCluster();\n      break;\n    case \"default\":\n      kc.loadFromDefault();\n      break;\n    case \"disabled\":\n    default:\n      return null;\n  }\n\n  return kc;\n};\n\nexport async function checkCRD(name, kc, logger) {\n  const apiExtensions = kc.makeApiClient(ApiextensionsV1Api);\n  const exist = await apiExtensions\n    .readCustomResourceDefinitionStatus({\n      name,\n    })\n    .then(() => true)\n    .catch(async (error) => {\n      if (error.statusCode === 403) {\n        logger.error(\n          \"Error checking if CRD %s exists. Make sure to add the following permission to your RBAC: %d %s %s\",\n          name,\n          error.statusCode,\n          error.body.message,\n        );\n      }\n      return false;\n    });\n\n  return exist;\n}\n\nexport const ANNOTATION_BASE = \"gethomepage.dev\";\nexport const ANNOTATION_WIDGET_BASE = `${ANNOTATION_BASE}/widget.`;\nexport const HTTPROUTE_API_GROUP = \"gateway.networking.k8s.io\";\nexport const HTTPROUTE_API_VERSION = \"v1\";\n"
  },
  {
    "path": "src/utils/config/kubernetes.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { fs, yaml, config, checkAndCopyConfig, kube, apiExt } = vi.hoisted(() => {\n  const apiExt = {\n    readCustomResourceDefinitionStatus: vi.fn(),\n  };\n\n  const kube = {\n    loadFromCluster: vi.fn(),\n    loadFromDefault: vi.fn(),\n    makeApiClient: vi.fn(() => apiExt),\n  };\n\n  return {\n    fs: {\n      readFileSync: vi.fn(() => \"kube-yaml\"),\n    },\n    yaml: {\n      load: vi.fn(),\n    },\n    config: {\n      CONF_DIR: \"/conf\",\n      substituteEnvironmentVars: vi.fn((s) => s),\n    },\n    checkAndCopyConfig: vi.fn(),\n    kube,\n    apiExt,\n  };\n});\n\nvi.mock(\"fs\", () => ({\n  readFileSync: fs.readFileSync,\n}));\n\nvi.mock(\"js-yaml\", () => ({\n  default: yaml,\n  ...yaml,\n}));\n\nvi.mock(\"utils/config/config\", () => ({\n  default: checkAndCopyConfig,\n  ...config,\n}));\n\nvi.mock(\"@kubernetes/client-node\", () => ({\n  ApiextensionsV1Api: class ApiextensionsV1Api {},\n  KubeConfig: class KubeConfig {\n    loadFromCluster() {\n      return kube.loadFromCluster();\n    }\n    loadFromDefault() {\n      return kube.loadFromDefault();\n    }\n    makeApiClient() {\n      return kube.makeApiClient();\n    }\n  },\n}));\n\nimport { checkCRD, getKubeConfig, getKubernetes } from \"./kubernetes\";\n\ndescribe(\"utils/config/kubernetes\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"getKubernetes loads and parses kubernetes.yaml\", () => {\n    yaml.load.mockReturnValueOnce({ mode: \"disabled\" });\n\n    expect(getKubernetes()).toEqual({ mode: \"disabled\" });\n    expect(checkAndCopyConfig).toHaveBeenCalledWith(\"kubernetes.yaml\");\n  });\n\n  it(\"getKubeConfig returns null when disabled\", () => {\n    yaml.load.mockReturnValueOnce({ mode: \"disabled\" });\n    expect(getKubeConfig()).toBeNull();\n  });\n\n  it(\"getKubeConfig loads from cluster/default based on mode\", () => {\n    yaml.load.mockReturnValueOnce({ mode: \"cluster\" });\n    const kc1 = getKubeConfig();\n    expect(kube.loadFromCluster).toHaveBeenCalled();\n    expect(kc1).not.toBeNull();\n\n    yaml.load.mockReturnValueOnce({ mode: \"default\" });\n    const kc2 = getKubeConfig();\n    expect(kube.loadFromDefault).toHaveBeenCalled();\n    expect(kc2).not.toBeNull();\n  });\n\n  it(\"checkCRD returns true when the CRD exists\", async () => {\n    apiExt.readCustomResourceDefinitionStatus.mockResolvedValueOnce({ ok: true });\n    const logger = { error: vi.fn() };\n\n    await expect(checkCRD(\"x.example\", kube, logger)).resolves.toBe(true);\n  });\n\n  it(\"checkCRD returns false and logs on 403\", async () => {\n    apiExt.readCustomResourceDefinitionStatus.mockRejectedValueOnce({\n      statusCode: 403,\n      body: { message: \"nope\" },\n    });\n    const logger = { error: vi.fn() };\n\n    await expect(checkCRD(\"x.example\", kube, logger)).resolves.toBe(false);\n    expect(logger.error).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/utils/config/proxmox.js",
    "content": "import { readFileSync } from \"fs\";\nimport path from \"path\";\n\nimport yaml from \"js-yaml\";\n\nimport checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from \"utils/config/config\";\n\nexport function getProxmoxConfig() {\n  checkAndCopyConfig(\"proxmox.yaml\");\n  const configFile = path.join(CONF_DIR, \"proxmox.yaml\");\n  const rawConfigData = readFileSync(configFile, \"utf8\");\n  const configData = substituteEnvironmentVars(rawConfigData);\n  return yaml.load(configData);\n}\n"
  },
  {
    "path": "src/utils/config/proxmox.test.js",
    "content": "import { describe, expect, it, vi } from \"vitest\";\n\nconst { fs, yaml, config, checkAndCopyConfig } = vi.hoisted(() => ({\n  fs: {\n    readFileSync: vi.fn(() => \"proxmox-yaml\"),\n  },\n  yaml: {\n    load: vi.fn(),\n  },\n  config: {\n    CONF_DIR: \"/conf\",\n    substituteEnvironmentVars: vi.fn((s) => s),\n  },\n  checkAndCopyConfig: vi.fn(),\n}));\n\nvi.mock(\"fs\", () => ({\n  readFileSync: fs.readFileSync,\n}));\n\nvi.mock(\"js-yaml\", () => ({\n  default: yaml,\n  ...yaml,\n}));\n\nvi.mock(\"utils/config/config\", () => ({\n  default: checkAndCopyConfig,\n  ...config,\n}));\n\nimport { getProxmoxConfig } from \"./proxmox\";\n\ndescribe(\"utils/config/proxmox\", () => {\n  it(\"loads and parses proxmox.yaml\", () => {\n    yaml.load.mockReturnValueOnce({ pve: { url: \"http://pve\" } });\n\n    expect(getProxmoxConfig()).toEqual({ pve: { url: \"http://pve\" } });\n    expect(checkAndCopyConfig).toHaveBeenCalledWith(\"proxmox.yaml\");\n    expect(fs.readFileSync).toHaveBeenCalledWith(\"/conf/proxmox.yaml\", \"utf8\");\n  });\n});\n"
  },
  {
    "path": "src/utils/config/service-helpers.js",
    "content": "import { promises as fs } from \"fs\";\nimport path from \"path\";\n\nimport Docker from \"dockerode\";\nimport yaml from \"js-yaml\";\n\nimport checkAndCopyConfig, { CONF_DIR, getSettings, substituteEnvironmentVars } from \"utils/config/config\";\nimport getDockerArguments from \"utils/config/docker\";\nimport { getKubeConfig } from \"utils/config/kubernetes\";\nimport * as shvl from \"utils/config/shvl\";\nimport kubernetes from \"utils/kubernetes/export\";\nimport createLogger from \"utils/logger\";\n\nconst logger = createLogger(\"service-helpers\");\n\nfunction parseServicesToGroups(services) {\n  if (!services) {\n    return [];\n  }\n\n  // map easy to write YAML objects into easy to consume JS arrays\n  return services.map((serviceGroup) => {\n    const name = Object.keys(serviceGroup)[0];\n    let groups = [];\n    const serviceGroupServices = [];\n    serviceGroup[name].forEach((entries) => {\n      const entryName = Object.keys(entries)[0];\n      if (!entries[entryName]) {\n        logger.warn(`Error parsing service \"${entryName}\" from config. Ensure required fields are present.`);\n        return;\n      }\n      if (Array.isArray(entries[entryName])) {\n        groups = groups.concat(parseServicesToGroups([{ [entryName]: entries[entryName] }]));\n      } else {\n        serviceGroupServices.push({\n          name: entryName,\n          ...entries[entryName],\n          weight: entries[entryName].weight ?? (serviceGroupServices.length + 1) * 100, // default weight\n          type: \"service\",\n        });\n      }\n    });\n    return {\n      name,\n      type: \"group\",\n      services: serviceGroupServices,\n      groups,\n    };\n  });\n}\n\nexport async function servicesFromConfig() {\n  checkAndCopyConfig(\"services.yaml\");\n\n  const servicesYaml = path.join(CONF_DIR, \"services.yaml\");\n  const rawFileContents = await fs.readFile(servicesYaml, \"utf8\");\n  const fileContents = substituteEnvironmentVars(rawFileContents);\n  const services = yaml.load(fileContents);\n  return parseServicesToGroups(services);\n}\n\nexport async function servicesFromDocker() {\n  checkAndCopyConfig(\"docker.yaml\");\n\n  const dockerYaml = path.join(CONF_DIR, \"docker.yaml\");\n  const rawDockerFileContents = await fs.readFile(dockerYaml, \"utf8\");\n  const dockerFileContents = substituteEnvironmentVars(rawDockerFileContents);\n  const servers = yaml.load(dockerFileContents);\n\n  if (!servers) {\n    return [];\n  }\n\n  const { instanceName } = getSettings();\n\n  const serviceServers = await Promise.all(\n    Object.keys(servers).map(async (serverName) => {\n      try {\n        const isSwarm = !!servers[serverName].swarm;\n        const docker = new Docker(getDockerArguments(serverName).conn);\n        const listProperties = { all: true };\n        const containers = await (isSwarm\n          ? docker.listServices(listProperties)\n          : docker.listContainers(listProperties));\n\n        // bad docker connections can result in a <Buffer ...> object?\n        // in any case, this ensures the result is the expected array\n        if (!Array.isArray(containers)) {\n          return { server: serverName, services: [] };\n        }\n\n        const discovered = containers.map((container) => {\n          let constructedService = null;\n          const containerLabels = isSwarm ? shvl.get(container, \"Spec.Labels\") : container.Labels;\n          const containerName = isSwarm ? shvl.get(container, \"Spec.Name\") : container.Names[0];\n\n          Object.keys(containerLabels).forEach((label) => {\n            if (label.startsWith(\"homepage.\")) {\n              let value = label.replace(\"homepage.\", \"\");\n              if (instanceName && value.startsWith(`instance.${instanceName}.`)) {\n                value = value.replace(`instance.${instanceName}.`, \"\");\n              } else if (value.startsWith(\"instance.\")) {\n                return;\n              }\n\n              if (!constructedService) {\n                constructedService = {\n                  container: containerName.replace(/^\\//, \"\"),\n                  server: serverName,\n                  weight: 0,\n                  type: \"service\",\n                };\n              }\n              let substitutedVal = substituteEnvironmentVars(containerLabels[label]);\n              if (value === \"widget.version\" || /^widgets\\[\\d+\\]\\.version$/.test(value)) {\n                substitutedVal = parseInt(substitutedVal, 10);\n              }\n              shvl.set(constructedService, value, substitutedVal);\n            }\n          });\n\n          if (constructedService && (!constructedService.name || !constructedService.group)) {\n            logger.error(\n              `Error constructing service using homepage labels for container '${containerName.replace(\n                /^\\//,\n                \"\",\n              )}'. Ensure required labels are present.`,\n            );\n            return null;\n          }\n\n          return constructedService;\n        });\n\n        return { server: serverName, services: discovered.filter((filteredService) => filteredService) };\n      } catch (e) {\n        logger.error(\"Error getting services from Docker server '%s': %s\", serverName, e);\n\n        // a server failed, but others may succeed\n        return { server: serverName, services: [] };\n      }\n    }),\n  );\n\n  const mappedServiceGroups = [];\n\n  serviceServers.forEach((server) => {\n    server.services.forEach((serverService) => {\n      let serverGroup = mappedServiceGroups.find((searchedGroup) => searchedGroup.name === serverService.group);\n      if (!serverGroup) {\n        mappedServiceGroups.push({\n          name: serverService.group,\n          services: [],\n        });\n        serverGroup = mappedServiceGroups[mappedServiceGroups.length - 1];\n      }\n\n      const { name: serviceName, group: serverServiceGroup, ...pushedService } = serverService;\n      const result = {\n        name: serviceName,\n        ...pushedService,\n      };\n\n      serverGroup.services.push(result);\n    });\n  });\n\n  return mappedServiceGroups;\n}\n\nexport async function servicesFromKubernetes() {\n  const { instanceName } = getSettings();\n\n  checkAndCopyConfig(\"kubernetes.yaml\");\n\n  try {\n    const kc = getKubeConfig();\n    if (!kc) {\n      return [];\n    }\n\n    // resource lists\n    const [ingressList, traefikIngressList, httpRouteList] = await Promise.all([\n      kubernetes.listIngress(),\n      kubernetes.listTraefikIngress(),\n      kubernetes.listHttpRoute(),\n    ]);\n\n    const resources = [...ingressList, ...traefikIngressList, ...httpRouteList];\n\n    /* c8 ignore next 3 -- resources is always an array once the spreads succeed */\n    if (!resources) {\n      return [];\n    }\n    const services = await Promise.all(\n      resources\n        .filter((resource) => kubernetes.isDiscoverable(resource, instanceName))\n        .map(async (resource) => kubernetes.constructedServiceFromResource(resource)),\n    );\n\n    // map service groups\n    const mappedServiceGroups = services.reduce((groups, serverService) => {\n      let serverGroup = groups.find((group) => group.name === serverService.group);\n\n      if (!serverGroup) {\n        serverGroup = {\n          name: serverService.group,\n          services: [],\n        };\n        groups.push(serverGroup);\n      }\n\n      const { name: serviceName, group: _, ...pushedService } = serverService;\n\n      serverGroup.services.push({\n        name: serviceName,\n        ...pushedService,\n      });\n\n      return groups;\n    }, []);\n\n    return mappedServiceGroups;\n  } catch (e) {\n    if (e) logger.error(e);\n    throw e;\n  }\n}\n\nexport function cleanServiceGroups(groups) {\n  return groups.map((serviceGroup) => ({\n    name: serviceGroup.name,\n    services: serviceGroup.services.map((service) => {\n      const cleanedService = { ...service };\n      if (cleanedService.showStats !== undefined) cleanedService.showStats = JSON.parse(cleanedService.showStats);\n      if (typeof service.weight === \"string\") {\n        const weight = parseInt(service.weight, 10);\n        if (Number.isNaN(weight)) {\n          cleanedService.weight = 0;\n        } else {\n          cleanedService.weight = weight;\n        }\n      }\n      if (typeof cleanedService.weight !== \"number\") {\n        cleanedService.weight = 0;\n      }\n      if (!cleanedService.widgets) cleanedService.widgets = [];\n      if (cleanedService.widget) {\n        cleanedService.widgets.push(cleanedService.widget);\n        delete cleanedService.widget;\n      }\n      cleanedService.widgets = cleanedService.widgets.map((widgetData, index) => {\n        // whitelisted set of keys to pass to the frontend\n        // alphabetical, grouped by widget(s)\n        const {\n          // all widgets\n          fields,\n          hideErrors,\n          highlight,\n          type,\n\n          // arcane\n          env,\n\n          // azuredevops\n          repositoryId,\n          userEmail,\n\n          // beszel\n          systemId,\n\n          // calendar\n          firstDayInWeek,\n          integrations,\n          maxEvents,\n          showTime,\n          previousDays,\n          view,\n          timezone,\n\n          // coinmarketcap\n          currency,\n          defaultinterval,\n          slugs,\n          symbols,\n\n          // crowdsec\n          limit24h,\n\n          // customapi\n          mappings,\n          display,\n\n          // deluge, qbittorrent\n          enableLeechProgress,\n          enableLeechSize,\n\n          // diskstation\n          volume,\n\n          // dispatcharr\n          enableActiveStreams,\n\n          // docker\n          container,\n          server,\n\n          // dockhand\n          environment,\n\n          // emby, jellyfin\n          enableBlocks,\n          enableNowPlaying,\n          enableMediaControl,\n\n          // emby, jellyfin, tautulli, tracearr\n          enableUser,\n          expandOneStreamToTwoRows,\n          showEpisodeNumber,\n\n          // frigate\n          enableRecentEvents,\n\n          // gamedig\n          gameToken,\n\n          // authentik, beszel, glances, immich, komga, mealie, netalertx, pihole, pfsense, speedtest\n          version,\n\n          // glances\n          chart,\n          metric,\n          pointsLimit,\n          diskUnits,\n\n          // glances, customapi, iframe, prometheusmetric\n          refreshInterval,\n\n          // hdhomerun\n          tuner,\n\n          // healthchecks\n          uuid,\n\n          // iframe\n          allowFullscreen,\n          allowPolicy,\n          allowScrolling,\n          classes,\n          loadingStrategy,\n          referrerPolicy,\n          src,\n\n          // jellystat\n          days,\n\n          // komodo\n          showSummary,\n          showStacks,\n\n          // kopia\n          snapshotHost,\n          snapshotPath,\n\n          // kubernetes\n          app,\n          namespace,\n          podSelector,\n\n          // lubelogger\n          vehicleID,\n\n          // mjpeg\n          fit,\n          stream,\n\n          // openmediavault\n          method,\n\n          // openwrt\n          interfaceName,\n\n          // opnsense, pfsense\n          wan,\n\n          // portainer\n          kubernetes,\n\n          // prometheusmetric\n          metrics,\n\n          // proxmox\n          node,\n\n          // proxmoxbackupserver\n          datastore,\n\n          // speedtest\n          bitratePrecision,\n\n          // sonarr, radarr\n          enableQueue,\n\n          // stocks\n          watchlist,\n          showUSMarketStatus,\n\n          // truenas\n          enablePools,\n          nasType,\n\n          // unifi\n          site,\n\n          // unraid\n          pool1,\n          pool2,\n          pool3,\n          pool4,\n\n          // vikunja\n          enableTaskList,\n\n          // wgeasy\n          threshold,\n\n          // yourspotify\n          interval,\n\n          // technitium\n          range,\n\n          // spoolman\n          spoolIds,\n\n          // grafana\n          alerts,\n        } = widgetData;\n\n        let fieldsList = fields;\n        if (typeof fields === \"string\") {\n          try {\n            fieldsList = JSON.parse(fields);\n          } catch (e) {\n            logger.error(\"Invalid fields list detected in config for service '%s'\", service.name);\n            fieldsList = null;\n          }\n        }\n\n        const widget = {\n          type,\n          fields: fieldsList || null,\n          hide_errors: hideErrors || false,\n          service_name: service.name,\n          service_group: serviceGroup.name,\n          index,\n        };\n\n        if (highlight) {\n          let parsedHighlight = highlight;\n          if (typeof highlight === \"string\") {\n            try {\n              parsedHighlight = JSON.parse(highlight);\n            } catch (e) {\n              logger.error(\"Invalid highlight configuration detected in config for service '%s'\", service.name);\n              parsedHighlight = null;\n            }\n          }\n          if (parsedHighlight && typeof parsedHighlight === \"object\") {\n            widget.highlight = parsedHighlight;\n          }\n        }\n\n        if (type === \"azuredevops\") {\n          if (userEmail) widget.userEmail = userEmail;\n          if (repositoryId) widget.repositoryId = repositoryId;\n        }\n\n        if (type === \"arcane\") {\n          if (env !== undefined) widget.env = env;\n        }\n\n        if (type === \"beszel\") {\n          if (systemId) widget.systemId = systemId;\n        }\n\n        if (type === \"coinmarketcap\") {\n          if (currency) widget.currency = currency;\n          if (symbols) widget.symbols = symbols;\n          if (slugs) widget.slugs = slugs;\n          if (defaultinterval) widget.defaultinterval = defaultinterval;\n        }\n\n        if (limit24h !== undefined) {\n          widget.limit24h = !!limit24h;\n        }\n\n        if (type === \"docker\") {\n          if (server) widget.server = server;\n          if (container) widget.container = container;\n        }\n        if (type === \"unifi\") {\n          if (site) widget.site = site;\n        }\n        if (type === \"portainer\") {\n          if (kubernetes) widget.kubernetes = !!JSON.parse(kubernetes);\n        }\n        if (type === \"proxmox\") {\n          if (node) widget.node = node;\n        }\n        if (type === \"proxmoxbackupserver\") {\n          if (datastore) widget.datastore = datastore;\n        }\n        if (type === \"komodo\") {\n          if (showSummary !== undefined) widget.showSummary = !!JSON.parse(showSummary);\n          if (showStacks !== undefined) widget.showStacks = !!JSON.parse(showStacks);\n        }\n        if (type === \"kubernetes\") {\n          if (namespace) widget.namespace = namespace;\n          if (app) widget.app = app;\n          if (podSelector) widget.podSelector = podSelector;\n        }\n        if (type === \"iframe\") {\n          if (src) widget.src = src;\n          if (classes) widget.classes = classes;\n          if (referrerPolicy) widget.referrerPolicy = referrerPolicy;\n          if (allowPolicy) widget.allowPolicy = allowPolicy;\n          if (allowFullscreen) widget.allowFullscreen = allowFullscreen;\n          if (loadingStrategy) widget.loadingStrategy = loadingStrategy;\n          if (allowScrolling) widget.allowScrolling = allowScrolling;\n          if (refreshInterval) widget.refreshInterval = refreshInterval;\n        }\n        if ([\"deluge\", \"qbittorrent\"].includes(type)) {\n          if (enableLeechProgress !== undefined) widget.enableLeechProgress = JSON.parse(enableLeechProgress);\n          if (enableLeechSize !== undefined) widget.enableLeechSize = JSON.parse(enableLeechSize);\n        }\n        if ([\"opnsense\", \"pfsense\"].includes(type)) {\n          if (wan) widget.wan = wan;\n        }\n        if ([\"emby\", \"jellyfin\"].includes(type)) {\n          if (enableMediaControl !== undefined) widget.enableMediaControl = !!JSON.parse(enableMediaControl);\n          if (enableBlocks !== undefined) widget.enableBlocks = JSON.parse(enableBlocks);\n          if (enableNowPlaying !== undefined) widget.enableNowPlaying = JSON.parse(enableNowPlaying);\n        }\n        if ([\"emby\", \"jellyfin\", \"tautulli\", \"tracearr\"].includes(type)) {\n          if (expandOneStreamToTwoRows !== undefined)\n            widget.expandOneStreamToTwoRows = !!JSON.parse(expandOneStreamToTwoRows);\n          if (showEpisodeNumber !== undefined) widget.showEpisodeNumber = !!JSON.parse(showEpisodeNumber);\n          if (enableUser !== undefined) widget.enableUser = !!JSON.parse(enableUser);\n        }\n        if (type === \"tracearr\") {\n          if (view !== undefined) widget.view = view;\n        }\n        if ([\"sonarr\", \"radarr\"].includes(type)) {\n          if (enableQueue !== undefined) widget.enableQueue = JSON.parse(enableQueue);\n        }\n        if (type === \"truenas\") {\n          if (enablePools !== undefined) widget.enablePools = JSON.parse(enablePools);\n          if (nasType !== undefined) widget.nasType = nasType;\n        }\n        if ([\"diskstation\", \"qnap\"].includes(type)) {\n          if (volume) widget.volume = volume;\n        }\n        if ([\"dispatcharr\"].includes(type)) {\n          if (enableActiveStreams) widget.enableActiveStreams = !!JSON.parse(enableActiveStreams);\n        }\n        if (type === \"gamedig\") {\n          if (gameToken) widget.gameToken = gameToken;\n        }\n        if (type === \"kopia\") {\n          if (snapshotHost) widget.snapshotHost = snapshotHost;\n          if (snapshotPath) widget.snapshotPath = snapshotPath;\n        }\n        if (\n          [\n            \"authentik\",\n            \"beszel\",\n            \"glances\",\n            \"immich\",\n            \"jellyfin\",\n            \"komga\",\n            \"mealie\",\n            \"netalertx\",\n            \"pfsense\",\n            \"pihole\",\n            \"speedtest\",\n            \"wgeasy\",\n            \"grafana\",\n            \"gluetun\",\n            \"vikunja\",\n          ].includes(type)\n        ) {\n          if (version) widget.version = parseInt(version, 10);\n        }\n        if (type === \"glances\") {\n          if (metric) widget.metric = metric;\n          if (chart !== undefined) {\n            widget.chart = chart;\n          } else {\n            widget.chart = true;\n          }\n          if (refreshInterval) widget.refreshInterval = refreshInterval;\n          if (pointsLimit) widget.pointsLimit = pointsLimit;\n          if (diskUnits) widget.diskUnits = diskUnits;\n        }\n        if (type === \"mjpeg\") {\n          if (stream) widget.stream = stream;\n          if (fit) widget.fit = fit;\n        }\n        if (type === \"openmediavault\") {\n          if (method) widget.method = method;\n        }\n        if (type === \"openwrt\") {\n          if (interfaceName) widget.interfaceName = interfaceName;\n        }\n        if (type === \"customapi\") {\n          if (mappings) widget.mappings = mappings;\n          if (display) widget.display = display;\n          if (refreshInterval) widget.refreshInterval = refreshInterval;\n        }\n        if (type === \"calendar\") {\n          if (integrations) {\n            if (Array.isArray(integrations)) {\n              widget.integrations = integrations.map((integration) => {\n                if (!integration || typeof integration !== \"object\") {\n                  return integration;\n                }\n                const { url, ...integrationWithoutUrl } = integration;\n                return integrationWithoutUrl;\n              });\n            } else {\n              widget.integrations = integrations;\n            }\n          }\n          if (firstDayInWeek) widget.firstDayInWeek = firstDayInWeek;\n          if (view) widget.view = view;\n          if (maxEvents) widget.maxEvents = maxEvents;\n          if (previousDays) widget.previousDays = previousDays;\n          if (showTime) widget.showTime = showTime;\n          if (timezone) widget.timezone = timezone;\n        }\n        if (type === \"dockhand\") {\n          if (environment) widget.environment = environment;\n        }\n        if (type === \"hdhomerun\") {\n          if (tuner !== undefined) widget.tuner = tuner;\n        }\n        if (type === \"healthchecks\") {\n          if (uuid !== undefined) widget.uuid = uuid;\n        }\n        if (type === \"speedtest\") {\n          if (bitratePrecision !== undefined) {\n            widget.bitratePrecision = parseInt(bitratePrecision, 10);\n          }\n        }\n        if (type === \"stocks\") {\n          if (watchlist) widget.watchlist = watchlist;\n          if (showUSMarketStatus) widget.showUSMarketStatus = showUSMarketStatus;\n        }\n        if (type === \"wgeasy\") {\n          if (threshold !== undefined) widget.threshold = parseInt(threshold, 10);\n        }\n        if (type === \"frigate\") {\n          if (enableRecentEvents !== undefined) widget.enableRecentEvents = enableRecentEvents;\n        }\n        if (type === \"technitium\") {\n          if (range !== undefined) widget.range = range;\n        }\n        if (type === \"lubelogger\") {\n          if (vehicleID !== undefined) widget.vehicleID = parseInt(vehicleID, 10);\n        }\n        if (type === \"vikunja\") {\n          if (enableTaskList !== undefined) widget.enableTaskList = !!enableTaskList;\n        }\n        if (type === \"prometheusmetric\") {\n          if (metrics) widget.metrics = metrics;\n          if (refreshInterval) widget.refreshInterval = refreshInterval;\n        }\n        if (type === \"spoolman\") {\n          if (spoolIds !== undefined) widget.spoolIds = spoolIds;\n        }\n        if (type === \"jellystat\") {\n          if (days !== undefined) widget.days = parseInt(days, 10);\n        }\n        if (type === \"grafana\") {\n          if (alerts) widget.alerts = alerts;\n        }\n        if (type === \"unraid\") {\n          if (pool1) widget.pool1 = pool1;\n          if (pool2) widget.pool2 = pool2;\n          if (pool3) widget.pool3 = pool3;\n          if (pool4) widget.pool4 = pool4;\n        }\n        if (type === \"yourspotify\") {\n          if (interval !== undefined) {\n            widget.interval = interval;\n          }\n        }\n        return widget;\n      });\n      return cleanedService;\n    }),\n    type: serviceGroup.type || \"group\",\n    groups: serviceGroup.groups ? cleanServiceGroups(serviceGroup.groups) : [],\n  }));\n}\n\nexport function findGroupByName(groups, name) {\n  // Deep search for a group by name. Using for loop allows for early return\n  for (let i = 0; i < groups.length; i += 1) {\n    const group = groups[i];\n    if (group.name === name) {\n      return group;\n    } else if (group.groups) {\n      const foundGroup = findGroupByName(group.groups, name);\n      if (foundGroup) {\n        foundGroup.parent = group.name;\n        return foundGroup;\n      }\n    }\n  }\n  return null;\n}\n\nexport async function getServiceItem(group, service) {\n  const configuredServices = await servicesFromConfig();\n\n  const serviceGroup = findGroupByName(configuredServices, group);\n  if (serviceGroup) {\n    const serviceEntry = serviceGroup.services.find((s) => s.name === service);\n    if (serviceEntry) return serviceEntry;\n  }\n\n  const discoveredServices = await servicesFromDocker();\n\n  const dockerServiceGroup = findGroupByName(discoveredServices, group);\n  if (dockerServiceGroup) {\n    const dockerServiceEntry = dockerServiceGroup.services.find((s) => s.name === service);\n    if (dockerServiceEntry) return dockerServiceEntry;\n  }\n\n  const kubernetesServices = await servicesFromKubernetes();\n  const kubernetesServiceGroup = findGroupByName(kubernetesServices, group);\n  if (kubernetesServiceGroup) {\n    const kubernetesServiceEntry = kubernetesServiceGroup.services.find((s) => s.name === service);\n    if (kubernetesServiceEntry) return kubernetesServiceEntry;\n  }\n\n  return false;\n}\n\nexport default async function getServiceWidget(group, service, index) {\n  const serviceItem = await getServiceItem(group, service);\n  if (serviceItem) {\n    const { widget, widgets } = serviceItem;\n    return index > -1 && widgets ? widgets[index] : widget;\n  }\n  return false;\n}\n"
  },
  {
    "path": "src/utils/config/service-helpers.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { state, fs, yaml, config, Docker, dockerCfg, kubeCfg, kubeApi } = vi.hoisted(() => {\n  const state = {\n    servicesYaml: null,\n    dockerYaml: null,\n    dockerContainers: [],\n    dockerContainersByServer: {},\n    dockerServicesByServer: {},\n    kubeConfig: null,\n    kubeServices: [],\n    logger: {\n      debug: vi.fn(),\n      info: vi.fn(),\n      warn: vi.fn(),\n      error: vi.fn(),\n    },\n  };\n\n  const fs = {\n    readFile: vi.fn(async (filePath) => {\n      if (String(filePath).endsWith(\"/services.yaml\")) return \"services\";\n      if (String(filePath).endsWith(\"/docker.yaml\")) return \"docker\";\n      return \"\";\n    }),\n  };\n\n  const yaml = {\n    load: vi.fn((contents) => {\n      if (contents === \"services\") return state.servicesYaml;\n      if (contents === \"docker\") return state.dockerYaml;\n      return null;\n    }),\n  };\n\n  const config = {\n    CONF_DIR: \"/conf\",\n    getSettings: vi.fn(() => ({ instanceName: undefined })),\n    substituteEnvironmentVars: vi.fn((s) => s),\n    default: vi.fn(),\n  };\n\n  const Docker = vi.fn((conn) => ({\n    listContainers: vi.fn(async () => state.dockerContainersByServer[conn?.serverName] ?? state.dockerContainers),\n    listServices: vi.fn(async () => state.dockerServicesByServer[conn?.serverName] ?? state.dockerContainers),\n  }));\n\n  const dockerCfg = {\n    default: vi.fn((serverName) => ({ conn: { serverName } })),\n  };\n\n  const kubeCfg = {\n    getKubeConfig: vi.fn(() => state.kubeConfig),\n  };\n\n  const kubeApi = {\n    listIngress: vi.fn(async () => []),\n    listTraefikIngress: vi.fn(async () => []),\n    listHttpRoute: vi.fn(async () => []),\n    isDiscoverable: vi.fn(() => true),\n    constructedServiceFromResource: vi.fn(async () => state.kubeServices.shift()),\n  };\n\n  return { state, fs, yaml, config, Docker, dockerCfg, kubeCfg, kubeApi };\n});\n\nvi.mock(\"fs\", () => ({\n  promises: fs,\n}));\n\nvi.mock(\"js-yaml\", () => ({\n  default: yaml,\n  ...yaml,\n}));\n\nvi.mock(\"utils/config/config\", () => config);\nvi.mock(\"dockerode\", () => ({ default: Docker }));\nvi.mock(\"utils/config/docker\", () => dockerCfg);\nvi.mock(\"utils/config/kubernetes\", () => kubeCfg);\nvi.mock(\"utils/kubernetes/export\", () => ({ default: kubeApi }));\n\nvi.mock(\"utils/logger\", () => ({\n  // Keep a stable logger instance so tests don't depend on module re-imports.\n  default: vi.fn(() => state.logger),\n}));\n\ndescribe(\"utils/config/service-helpers\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    state.servicesYaml = null;\n    state.dockerYaml = null;\n    state.dockerContainers = [];\n    state.dockerContainersByServer = {};\n    state.dockerServicesByServer = {};\n    state.kubeConfig = null;\n    state.kubeServices = [];\n    config.getSettings.mockReturnValue({ instanceName: undefined });\n  });\n\n  it(\"servicesFromConfig returns [] when services.yaml is empty\", async () => {\n    state.servicesYaml = null;\n\n    const mod = await import(\"./service-helpers\");\n    expect(await mod.servicesFromConfig()).toEqual([]);\n  });\n\n  it(\"servicesFromDocker returns [] when docker.yaml is empty\", async () => {\n    state.dockerYaml = null;\n\n    const mod = await import(\"./service-helpers\");\n    expect(await mod.servicesFromDocker()).toEqual([]);\n  });\n\n  it(\"servicesFromDocker tolerates non-array container responses from Docker\", async () => {\n    state.dockerYaml = { \"docker-local\": {} };\n    state.dockerContainersByServer[\"docker-local\"] = Buffer.from(\"bad docker response\");\n\n    const mod = await import(\"./service-helpers\");\n    const discovered = await mod.servicesFromDocker();\n\n    expect(discovered).toEqual([]);\n  });\n\n  it(\"servicesFromConfig parses nested groups, assigns default weights, and skips invalid entries\", async () => {\n    state.servicesYaml = [\n      {\n        Main: [\n          {\n            Child: [{ SvcA: { icon: \"a\" } }, { SvcB: { icon: \"b\", weight: 5 } }],\n          },\n          { SvcRoot: { icon: \"r\" } },\n          { BadSvc: null },\n        ],\n      },\n    ];\n\n    const mod = await import(\"./service-helpers\");\n    const groups = await mod.servicesFromConfig();\n\n    expect(groups).toHaveLength(1);\n    expect(groups[0].name).toBe(\"Main\");\n    expect(groups[0].type).toBe(\"group\");\n\n    // Root services live on the group; child groups are nested.\n    expect(groups[0].services.map((s) => ({ name: s.name, weight: s.weight }))).toEqual([\n      { name: \"SvcRoot\", weight: 100 },\n    ]);\n    expect(groups[0].groups).toHaveLength(1);\n    expect(groups[0].groups[0].name).toBe(\"Child\");\n    expect(groups[0].groups[0].services.map((s) => ({ name: s.name, weight: s.weight }))).toEqual([\n      { name: \"SvcA\", weight: 100 },\n      { name: \"SvcB\", weight: 5 },\n    ]);\n\n    expect(state.logger.warn).toHaveBeenCalled();\n  });\n\n  it(\"cleanServiceGroups normalizes weights, moves widget->widgets, and parses per-widget settings\", async () => {\n    const mod = await import(\"./service-helpers\");\n    const { cleanServiceGroups } = mod;\n\n    const rawGroups = [\n      {\n        name: \"Group\",\n        services: [\n          {\n            name: \"svc\",\n            showStats: \"true\",\n            weight: \"not-a-number\",\n            widgets: [\n              // Invalid fields/highlight should be dropped with a log message.\n              { type: \"iframe\", fields: \"{bad}\", highlight: \"{bad}\", src: \"https://example.com\" },\n              // Type-specific boolean parsing.\n              { type: \"portainer\", kubernetes: \"true\" },\n              { type: \"deluge\", enableLeechProgress: \"true\", enableLeechSize: \"false\" },\n            ],\n            // `widget` is appended after the `widgets` array.\n            widget: {\n              type: \"glances\",\n              metric: \"cpu\",\n              chart: false,\n              version: \"3\",\n              refreshInterval: 1500,\n              pointsLimit: 10,\n              diskUnits: \"gb\",\n              fields: '[\"cpu\"]',\n              highlight: '{\"level\":\"warning\"}',\n              hideErrors: true,\n            },\n          },\n          {\n            name: \"svc2\",\n            weight: {},\n            widget: { type: \"openwrt\", interfaceName: \"eth0\" },\n          },\n          {\n            name: \"svc3\",\n            weight: \"7\",\n            widget: { type: \"frigate\", enableRecentEvents: true },\n          },\n        ],\n        groups: [],\n      },\n    ];\n\n    const cleaned = cleanServiceGroups(rawGroups);\n    expect(cleaned).toHaveLength(1);\n    expect(cleaned[0].type).toBe(\"group\");\n    expect(cleaned[0].services).toHaveLength(3);\n\n    const svc = cleaned[0].services[0];\n    expect(svc.showStats).toBe(true);\n    expect(svc.weight).toBe(0);\n    expect(svc.widgets).toHaveLength(4);\n\n    // The last widget is the appended `widget` entry; it should carry service metadata.\n    const glancesWidget = svc.widgets[3];\n    expect(glancesWidget.type).toBe(\"glances\");\n    expect(glancesWidget.service_group).toBe(\"Group\");\n    expect(glancesWidget.service_name).toBe(\"svc\");\n    expect(glancesWidget.index).toBe(3);\n    expect(glancesWidget.hide_errors).toBe(true);\n    expect(glancesWidget.fields).toEqual([\"cpu\"]);\n    expect(glancesWidget.highlight).toEqual({ level: \"warning\" });\n    expect(glancesWidget.chart).toBe(false);\n    expect(glancesWidget.version).toBe(3);\n\n    // Type-specific parsing for other widgets.\n    expect(svc.widgets[1].kubernetes).toBe(true);\n    expect(svc.widgets[2].enableLeechProgress).toBe(true);\n    expect(svc.widgets[2].enableLeechSize).toBe(false);\n\n    const svc2 = cleaned[0].services[1];\n    expect(svc2.weight).toBe(0);\n    expect(svc2.widgets).toHaveLength(1);\n    expect(svc2.widgets[0]).toEqual(\n      expect.objectContaining({\n        type: \"openwrt\",\n        interfaceName: \"eth0\",\n        service_group: \"Group\",\n        service_name: \"svc2\",\n        index: 0,\n      }),\n    );\n\n    const svc3 = cleaned[0].services[2];\n    expect(svc3.weight).toBe(7);\n    expect(svc3.widgets[0]).toEqual(expect.objectContaining({ type: \"frigate\", enableRecentEvents: true }));\n\n    expect(state.logger.error).toHaveBeenCalled();\n  });\n\n  it(\"cleanServiceGroups applies widget-type specific mappings for commonly used widgets\", async () => {\n    const mod = await import(\"./service-helpers\");\n    const { cleanServiceGroups } = mod;\n\n    const rawGroups = [\n      {\n        name: \"Core\",\n        services: [\n          {\n            name: \"svc\",\n            weight: 100,\n            widgets: [\n              { type: \"azuredevops\", userEmail: \"u@example.com\", repositoryId: \"r\" },\n              { type: \"beszel\", version: \"2\", systemId: \"sys\" },\n              { type: \"coinmarketcap\", currency: \"USD\", symbols: \"BTC\", slugs: \"bitcoin\", defaultinterval: \"1d\" },\n              { type: \"crowdsec\", limit24h: \"true\" },\n              { type: \"docker\", server: \"docker-local\", container: \"c1\" },\n              { type: \"unifi\", site: \"Home\" },\n              { type: \"proxmox\", node: \"pve\" },\n              { type: \"proxmoxbackupserver\", datastore: \"ds\" },\n              { type: \"komodo\", showSummary: \"true\", showStacks: \"false\" },\n              { type: \"kubernetes\", namespace: \"default\", app: \"app\", podSelector: \"app=test\" },\n              {\n                type: \"iframe\",\n                src: \"https://example.com\",\n                allowFullscreen: true,\n                allowPolicy: \"geolocation\",\n                allowScrolling: false,\n                classes: \"x\",\n                loadingStrategy: \"lazy\",\n                referrerPolicy: \"no-referrer\",\n                refreshInterval: 1000,\n              },\n              { type: \"qbittorrent\", enableLeechProgress: \"true\", enableLeechSize: \"true\" },\n              { type: \"opnsense\", wan: \"wan1\" },\n              { type: \"emby\", enableBlocks: \"true\", enableNowPlaying: \"false\", enableMediaControl: \"true\" },\n              { type: \"tautulli\", expandOneStreamToTwoRows: \"true\", showEpisodeNumber: \"true\", enableUser: \"true\" },\n              { type: \"radarr\", enableQueue: \"true\" },\n              { type: \"truenas\", enablePools: \"true\", nasType: \"scale\" },\n              { type: \"qnap\", volume: \"vol1\" },\n              { type: \"dispatcharr\", enableActiveStreams: \"true\" },\n              { type: \"gamedig\", gameToken: \"t\" },\n              { type: \"kopia\", snapshotHost: \"h\", snapshotPath: \"/p\" },\n              { type: \"glances\", version: \"4\", metric: \"cpu\", refreshInterval: 2000, pointsLimit: 5, diskUnits: \"gb\" },\n              { type: \"mjpeg\", stream: \"s\", fit: \"contain\" },\n              { type: \"openmediavault\", method: \"foo.bar\" },\n              { type: \"customapi\", mappings: { x: 1 }, display: { y: 2 }, refreshInterval: 5000 },\n              {\n                type: \"calendar\",\n                integrations: [],\n                firstDayInWeek: \"monday\",\n                view: \"agenda\",\n                maxEvents: 10,\n                previousDays: 2,\n                showTime: true,\n                timezone: \"UTC\",\n              },\n              { type: \"dockhand\", environment: \"prod\" },\n              { type: \"hdhomerun\", tuner: 1 },\n              { type: \"healthchecks\", uuid: \"u\" },\n              { type: \"speedtest\", bitratePrecision: \"3\", version: \"1\" },\n              { type: \"stocks\", watchlist: \"AAPL\", showUSMarketStatus: true },\n              {\n                type: \"tracearr\",\n                expandOneStreamToTwoRows: \"true\",\n                showEpisodeNumber: \"true\",\n                enableUser: \"true\",\n                view: \"both\",\n              },\n              { type: \"wgeasy\", threshold: \"10\", version: \"1\" },\n              { type: \"technitium\", range: \"24h\" },\n              { type: \"lubelogger\", vehicleID: \"12\" },\n              { type: \"vikunja\", enableTaskList: true, version: \"1\" },\n              { type: \"prometheusmetric\", metrics: [], refreshInterval: 2500 },\n              { type: \"spoolman\", spoolIds: [1, 2] },\n              { type: \"jellystat\", days: \"7\" },\n              { type: \"grafana\", alerts: [] },\n              { type: \"unraid\", pool1: \"a\", pool2: \"b\", pool3: \"c\", pool4: \"d\" },\n              { type: \"yourspotify\", interval: \"daily\" },\n            ],\n          },\n        ],\n        groups: [],\n      },\n    ];\n\n    const cleaned = cleanServiceGroups(rawGroups);\n    const widgets = cleaned[0].services[0].widgets;\n\n    expect(widgets.find((w) => w.type === \"azuredevops\")).toEqual(\n      expect.objectContaining({ userEmail: \"u@example.com\", repositoryId: \"r\" }),\n    );\n    expect(widgets.find((w) => w.type === \"beszel\")).toEqual(expect.objectContaining({ version: 2, systemId: \"sys\" }));\n    expect(widgets.find((w) => w.type === \"crowdsec\")).toEqual(expect.objectContaining({ limit24h: true }));\n    expect(widgets.find((w) => w.type === \"docker\")).toEqual(\n      expect.objectContaining({ server: \"docker-local\", container: \"c1\" }),\n    );\n    expect(widgets.find((w) => w.type === \"komodo\")).toEqual(\n      expect.objectContaining({ showSummary: true, showStacks: false }),\n    );\n    expect(widgets.find((w) => w.type === \"kubernetes\")).toEqual(\n      expect.objectContaining({ namespace: \"default\", app: \"app\", podSelector: \"app=test\" }),\n    );\n    expect(widgets.find((w) => w.type === \"qnap\")).toEqual(expect.objectContaining({ volume: \"vol1\" }));\n    expect(widgets.find((w) => w.type === \"speedtest\")).toEqual(\n      expect.objectContaining({ bitratePrecision: 3, version: 1 }),\n    );\n    expect(widgets.find((w) => w.type === \"tracearr\")).toEqual(\n      expect.objectContaining({\n        expandOneStreamToTwoRows: true,\n        showEpisodeNumber: true,\n        enableUser: true,\n        view: \"both\",\n      }),\n    );\n    expect(widgets.find((w) => w.type === \"jellystat\")).toEqual(expect.objectContaining({ days: 7 }));\n    expect(widgets.find((w) => w.type === \"lubelogger\")).toEqual(expect.objectContaining({ vehicleID: 12 }));\n  });\n\n  it(\"cleanServiceGroups removes calendar integration urls from frontend widget payload\", async () => {\n    const mod = await import(\"./service-helpers\");\n    const { cleanServiceGroups } = mod;\n\n    const rawGroups = [\n      {\n        name: \"Core\",\n        services: [\n          {\n            name: \"Calendar\",\n            weight: 100,\n            widgets: [\n              {\n                type: \"calendar\",\n                integrations: [\n                  {\n                    type: \"ical\",\n                    name: \"EPL Fixtures\",\n                    url: \"https://calendar.google.com/calendar/ical/example/public/basic.ics\",\n                    color: \"purple\",\n                  },\n                ],\n              },\n            ],\n          },\n        ],\n        groups: [],\n      },\n    ];\n\n    const cleaned = cleanServiceGroups(rawGroups);\n    const calendarWidget = cleaned[0].services[0].widgets[0];\n    expect(calendarWidget.integrations).toEqual([\n      {\n        type: \"ical\",\n        name: \"EPL Fixtures\",\n        color: \"purple\",\n      },\n    ]);\n  });\n\n  it(\"findGroupByName deep-searches and annotates parent\", async () => {\n    const mod = await import(\"./service-helpers\");\n    const { findGroupByName } = mod;\n\n    const groups = [\n      {\n        name: \"Parent\",\n        groups: [{ name: \"Child\", services: [], groups: [] }],\n        services: [],\n      },\n    ];\n\n    const found = findGroupByName(groups, \"Child\");\n    expect(found.name).toBe(\"Child\");\n    expect(found.parent).toBe(\"Parent\");\n  });\n\n  it(\"getServiceItem prefers configured services over docker/kubernetes\", async () => {\n    // Service present in config -> should return early (no Docker init).\n    state.servicesYaml = [{ G: [{ S: { icon: \"x\" } }] }];\n\n    const mod = await import(\"./service-helpers\");\n    const serviceItem = await mod.getServiceItem(\"G\", \"S\");\n\n    expect(serviceItem).toEqual(expect.objectContaining({ name: \"S\", type: \"service\", icon: \"x\" }));\n    expect(Docker).not.toHaveBeenCalled();\n    expect(kubeCfg.getKubeConfig).not.toHaveBeenCalled();\n  });\n\n  it(\"getServiceItem falls back to docker then kubernetes\", async () => {\n    const mod = await import(\"./service-helpers\");\n\n    // Miss in config, hit in Docker.\n    state.servicesYaml = [{ G: [{ Other: { icon: \"nope\" } }] }];\n    state.dockerYaml = { \"docker-local\": {} };\n    state.dockerContainers = [\n      {\n        Names: [\"/c1\"],\n        Labels: {\n          \"homepage.group\": \"G\",\n          \"homepage.name\": \"S\",\n        },\n      },\n    ];\n\n    expect(await mod.getServiceItem(\"G\", \"S\")).toEqual(\n      expect.objectContaining({ name: \"S\", server: \"docker-local\", container: \"c1\" }),\n    );\n\n    // Miss in config, miss in Docker, hit in Kubernetes.\n    vi.resetModules();\n    state.servicesYaml = [{ G: [{ Other: { icon: \"nope\" } }] }];\n    state.dockerYaml = { \"docker-local\": {} };\n    state.dockerContainers = [];\n    state.kubeConfig = {}; // truthy => proceed\n    state.kubeServices = [{ name: \"S\", group: \"G\", type: \"service\" }];\n    kubeApi.listIngress.mockResolvedValueOnce([{}]);\n\n    const mod2 = await import(\"./service-helpers\");\n    expect(await mod2.getServiceItem(\"G\", \"S\")).toEqual(expect.objectContaining({ name: \"S\", type: \"service\" }));\n  });\n\n  it(\"getServiceItem returns false when the service cannot be found anywhere\", async () => {\n    state.servicesYaml = null;\n    state.dockerYaml = null;\n    state.kubeConfig = null;\n\n    const mod = await import(\"./service-helpers\");\n    expect(await mod.getServiceItem(\"MissingGroup\", \"MissingService\")).toBe(false);\n  });\n\n  it(\"getServiceWidget returns false when the widget cannot be found\", async () => {\n    state.servicesYaml = null;\n    state.dockerYaml = null;\n    state.kubeConfig = null;\n\n    const mod = await import(\"./service-helpers\");\n    expect(await mod.default(\"MissingGroup\", \"MissingService\", 0)).toBe(false);\n  });\n\n  it(\"getServiceWidget returns widget or widgets[index]\", async () => {\n    state.servicesYaml = [\n      {\n        G: [\n          {\n            S: { widget: { id: \"single\" }, widgets: [{ id: \"w0\" }, { id: \"w1\" }] },\n          },\n        ],\n      },\n    ];\n\n    const mod = await import(\"./service-helpers\");\n\n    expect(await mod.default(\"G\", \"S\", -1)).toEqual({ id: \"single\" });\n    expect(await mod.default(\"G\", \"S\", \"1\")).toEqual({ id: \"w1\" });\n  });\n\n  it(\"servicesFromDocker maps homepage labels to groups, filters instance-scoped labels, and parses widget version\", async () => {\n    config.getSettings.mockReturnValue({ instanceName: \"foo\" });\n\n    state.dockerYaml = {\n      \"docker-local\": {},\n      \"docker-swarm\": { swarm: true },\n    };\n\n    state.dockerContainersByServer[\"docker-local\"] = [\n      {\n        Names: [\"/c1\"],\n        Labels: {\n          \"homepage.group\": \"G\",\n          \"homepage.name\": \"Svc\",\n          \"homepage.href\": \"http://svc\",\n          \"homepage.widget.version\": \"3\",\n          \"homepage.instance.foo.description\": \"Desc\",\n          \"homepage.instance.bar.description\": \"Ignore\",\n        },\n      },\n      // Missing required labels -> should be skipped with an error.\n      {\n        Names: [\"/bad\"],\n        Labels: {\n          \"homepage.group\": \"G\",\n        },\n      },\n    ];\n\n    state.dockerServicesByServer[\"docker-swarm\"] = [\n      // Swarm service label format.\n      {\n        Spec: {\n          Name: \"swarm1\",\n          Labels: {\n            \"homepage.group\": \"G2\",\n            \"homepage.name\": \"SwarmSvc\",\n            \"homepage.widgets[0].version\": \"2\",\n          },\n        },\n      },\n    ];\n\n    const mod = await import(\"./service-helpers\");\n    const discoveredGroups = await mod.servicesFromDocker();\n\n    expect(discoveredGroups).toEqual(\n      expect.arrayContaining([\n        expect.objectContaining({\n          name: \"G\",\n          services: [\n            expect.objectContaining({\n              name: \"Svc\",\n              server: \"docker-local\",\n              container: \"c1\",\n              href: \"http://svc\",\n              description: \"Desc\",\n              widget: { version: 3 },\n            }),\n          ],\n        }),\n        expect.objectContaining({\n          name: \"G2\",\n          services: [\n            expect.objectContaining({\n              name: \"SwarmSvc\",\n              server: \"docker-swarm\",\n              container: \"swarm1\",\n              widgets: [{ version: 2 }],\n            }),\n          ],\n        }),\n      ]),\n    );\n\n    // The instance.bar.* labels should be ignored when instanceName=foo.\n    expect(JSON.stringify(discoveredGroups)).not.toContain(\"Ignore\");\n    expect(state.logger.error).toHaveBeenCalled();\n  });\n\n  it(\"servicesFromDocker tolerates per-server failures and still returns other results\", async () => {\n    state.dockerYaml = { \"docker-a\": {}, \"docker-b\": {} };\n\n    Docker.mockImplementationOnce(() => {\n      throw new Error(\"boom\");\n    });\n\n    state.dockerContainers = [{ Names: [\"/c1\"], Labels: { \"homepage.group\": \"G\", \"homepage.name\": \"Svc\" } }];\n\n    const mod = await import(\"./service-helpers\");\n    const discoveredGroups = await mod.servicesFromDocker();\n\n    expect(discoveredGroups).toEqual([\n      { name: \"G\", services: [expect.objectContaining({ name: \"Svc\", container: \"c1\" })] },\n    ]);\n    expect([\"docker-a\", \"docker-b\"]).toContain(discoveredGroups[0].services[0].server);\n    expect(state.logger.error).toHaveBeenCalled();\n  });\n\n  it(\"servicesFromKubernetes returns [] when kubernetes is not configured\", async () => {\n    state.kubeConfig = null;\n\n    const mod = await import(\"./service-helpers\");\n    expect(await mod.servicesFromKubernetes()).toEqual([]);\n  });\n\n  it(\"servicesFromKubernetes maps discoverable resources into service groups\", async () => {\n    config.getSettings.mockReturnValue({ instanceName: \"foo\" });\n    state.kubeConfig = {}; // truthy\n    kubeApi.listIngress.mockResolvedValueOnce([{ kind: \"Ingress\" }]);\n    kubeApi.isDiscoverable.mockReturnValueOnce(true);\n    state.kubeServices = [{ name: \"S\", group: \"G\", type: \"service\", href: \"http://k\" }];\n\n    const mod = await import(\"./service-helpers\");\n    const groups = await mod.servicesFromKubernetes();\n\n    expect(groups).toEqual([\n      {\n        name: \"G\",\n        services: [{ name: \"S\", type: \"service\", href: \"http://k\" }],\n      },\n    ]);\n    expect(kubeApi.isDiscoverable).toHaveBeenCalledWith({ kind: \"Ingress\" }, \"foo\");\n  });\n\n  it(\"servicesFromKubernetes logs and rethrows unexpected errors\", async () => {\n    state.kubeConfig = {}; // truthy\n    kubeApi.listIngress.mockRejectedValueOnce(new Error(\"boom\"));\n\n    const mod = await import(\"./service-helpers\");\n    await expect(mod.servicesFromKubernetes()).rejects.toThrow(\"boom\");\n    expect(state.logger.error).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/utils/config/shvl.js",
    "content": "/*\nCode primarely based on shvl repository: https://github.com/robinvdvleuten/shvl\n\nMIT License\n\nCopyright (c) Robin van der Vleuten <robin@webstronauts.co>\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n*/\n\nexport function get(object, path, def) {\n  // Split the path into keys and reduce the object to the target value\n  return (object = path.split(/[.[\\]]+/).reduce(function (obj, p) {\n    // Check each nested object to see if the key exists\n    return obj && obj[p] !== undefined ? obj[p] : undefined;\n  }, object)) === undefined\n    ? // If the final value is undefined, return the default value\n      def\n    : object; // Otherwise, return the value found\n}\n\nexport function set(obj, path, val) {\n  // Split the path into keys and filter out any empty strings\n  const keys = path.split(/[.[\\]]+/).filter(Boolean);\n\n  // Pop the last key to set the value later\n  const lastKey = keys.pop();\n\n  // Prevent setting dangerous keys like __proto__\n  if (/^(__proto__|constructor|prototype)$/.test(lastKey)) return obj;\n\n  // Reduce the object to the nested object where we want to set the value\n  keys.reduce((acc, key, i) => {\n    // Again, block dangerous keys\n    if (/^(__proto__|constructor|prototype)$/.test(key)) return {};\n\n    // Check if next key is an array index\n    const isIndex = /^\\d+$/.test(keys[i + 1]);\n\n    // If current key doesn't exist, initialise it as an array or object\n    acc[key] = Array.isArray(acc[key]) ? acc[key] : isIndex ? [] : acc[key] || {};\n\n    // Return nested object for next iteration\n    return acc[key];\n  }, obj)[lastKey] = val; // Finally set the value\n\n  return obj;\n}\n"
  },
  {
    "path": "src/utils/config/shvl.test.js",
    "content": "import { describe, expect, it } from \"vitest\";\n\nimport { get, set } from \"./shvl\";\n\ndescribe(\"utils/config/shvl\", () => {\n  it(\"get reads nested paths with arrays and returns default when missing\", () => {\n    const obj = { a: { b: [{ c: 1 }] } };\n\n    expect(get(obj, \"a.b[0].c\")).toBe(1);\n    expect(get(obj, \"a.b[1].c\", \"dflt\")).toBe(\"dflt\");\n  });\n\n  it(\"set creates nested objects/arrays as needed\", () => {\n    const obj = {};\n    set(obj, \"a.b[0].c\", 123);\n\n    expect(obj).toEqual({ a: { b: [{ c: 123 }] } });\n  });\n\n  it(\"set blocks prototype pollution\", () => {\n    const obj = {};\n    set(obj, \"__proto__.polluted\", true);\n    set(obj, \"a.__proto__.polluted\", true);\n    set(obj, \"constructor.prototype.polluted\", true);\n\n    expect(obj.polluted).toBeUndefined();\n    expect({}.polluted).toBeUndefined();\n    expect(Object.prototype.polluted).toBeUndefined();\n  });\n});\n"
  },
  {
    "path": "src/utils/config/widget-helpers.js",
    "content": "import { promises as fs } from \"fs\";\nimport path from \"path\";\n\nimport yaml from \"js-yaml\";\n\nimport checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from \"utils/config/config\";\n\nexport async function widgetsFromConfig() {\n  checkAndCopyConfig(\"widgets.yaml\");\n\n  const widgetsYaml = path.join(CONF_DIR, \"widgets.yaml\");\n  const rawFileContents = await fs.readFile(widgetsYaml, \"utf8\");\n  const fileContents = substituteEnvironmentVars(rawFileContents);\n  const widgets = yaml.load(fileContents);\n\n  if (!widgets) return [];\n\n  // map easy to write YAML objects into easy to consume JS arrays\n  const widgetsArray = widgets.map((group, index) => ({\n    type: Object.keys(group)[0],\n    options: {\n      index,\n      ...group[Object.keys(group)[0]],\n    },\n  }));\n  return widgetsArray;\n}\n\nexport async function cleanWidgetGroups(widgets) {\n  return widgets.map((widget, index) => {\n    const sanitizedOptions = widget.options;\n    const optionKeys = Object.keys(sanitizedOptions);\n\n    // delete private options from the sanitized options\n    [\"username\", \"password\", \"key\", \"apiKey\"].forEach((pO) => {\n      if (optionKeys.includes(pO)) {\n        delete sanitizedOptions[pO];\n      }\n    });\n\n    // delete url from the sanitized options if the widget is not a search or glances widget\n    if (widget.type !== \"search\" && widget.type !== \"glances\" && optionKeys.includes(\"url\")) {\n      delete sanitizedOptions.url;\n    }\n\n    return {\n      type: widget.type,\n      options: {\n        index,\n        ...sanitizedOptions,\n      },\n    };\n  });\n}\n\nexport async function getPrivateWidgetOptions(type, widgetIndex) {\n  const widgets = await widgetsFromConfig();\n\n  const privateOptions =\n    widgets.map((widget) => {\n      const { index, url, username, password, key, apiKey } = widget.options;\n\n      return {\n        type: widget.type,\n        options: {\n          index,\n          url,\n          username,\n          password,\n          key,\n          apiKey,\n        },\n      };\n    }) || {};\n\n  return type !== undefined && widgetIndex !== undefined\n    ? privateOptions.find((o) => o.type === type && o.options.index === parseInt(widgetIndex, 10))?.options\n    : privateOptions;\n}\n"
  },
  {
    "path": "src/utils/config/widget-helpers.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { fs, yaml, config } = vi.hoisted(() => ({\n  fs: {\n    readFile: vi.fn(),\n  },\n  yaml: {\n    load: vi.fn(),\n  },\n  config: {\n    CONF_DIR: \"/conf\",\n    substituteEnvironmentVars: vi.fn((s) => s),\n    default: vi.fn(),\n  },\n}));\n\nvi.mock(\"fs\", () => ({\n  promises: fs,\n}));\n\nvi.mock(\"js-yaml\", () => ({\n  default: yaml,\n  ...yaml,\n}));\n\nvi.mock(\"utils/config/config\", () => config);\n\nimport { cleanWidgetGroups, getPrivateWidgetOptions, widgetsFromConfig } from \"./widget-helpers\";\n\ndescribe(\"utils/config/widget-helpers\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"widgetsFromConfig maps YAML into a typed widgets array with indices\", async () => {\n    fs.readFile.mockResolvedValueOnce(\"ignored\");\n    yaml.load.mockReturnValueOnce([{ search: { provider: \"google\", url: \"http://x\", key: \"k\" } }]);\n\n    const widgets = await widgetsFromConfig();\n    expect(widgets).toEqual([\n      {\n        type: \"search\",\n        options: { index: 0, provider: \"google\", url: \"http://x\", key: \"k\" },\n      },\n    ]);\n  });\n\n  it(\"cleanWidgetGroups removes private options and hides url except for search/glances\", async () => {\n    const cleaned = await cleanWidgetGroups([\n      { type: \"search\", options: { index: 0, url: \"http://x\", username: \"u\", password: \"p\" } },\n      { type: \"something\", options: { index: 1, url: \"http://y\", key: \"k\", foo: 1 } },\n      { type: \"glances\", options: { index: 2, url: \"http://z\", apiKey: \"k\", bar: 2 } },\n    ]);\n\n    expect(cleaned[0].options.url).toBe(\"http://x\");\n    expect(cleaned[0].options.username).toBeUndefined();\n\n    expect(cleaned[1].options.url).toBeUndefined();\n    expect(cleaned[1].options.key).toBeUndefined();\n    expect(cleaned[1].options.foo).toBe(1);\n\n    expect(cleaned[2].options.url).toBe(\"http://z\");\n    expect(cleaned[2].options.apiKey).toBeUndefined();\n  });\n\n  it(\"getPrivateWidgetOptions returns private options for a specific widget\", async () => {\n    fs.readFile.mockResolvedValueOnce(\"ignored\");\n    yaml.load.mockReturnValueOnce([{ search: { url: \"http://x\", username: \"u\", password: \"p\", key: \"k\" } }]);\n\n    const options = await getPrivateWidgetOptions(\"search\", 0);\n    expect(options).toEqual(\n      expect.objectContaining({\n        index: 0,\n        url: \"http://x\",\n        username: \"u\",\n        password: \"p\",\n        key: \"k\",\n      }),\n    );\n\n    // And the full list when no args are provided\n    fs.readFile.mockResolvedValueOnce(\"ignored\");\n    yaml.load.mockReturnValueOnce([{ search: { url: \"http://x\", username: \"u\" } }]);\n    const all = await getPrivateWidgetOptions();\n    expect(Array.isArray(all)).toBe(true);\n    expect(all[0].options.url).toBe(\"http://x\");\n  });\n});\n"
  },
  {
    "path": "src/utils/contexts/color.jsx",
    "content": "import { createContext, useEffect, useMemo, useState } from \"react\";\n\nlet lastColor = false;\n\nconst getInitialColor = () => {\n  if (typeof window !== \"undefined\" && window.localStorage) {\n    const storedPrefs = window.localStorage.getItem(\"theme-color\");\n    if (typeof storedPrefs === \"string\") {\n      lastColor = storedPrefs;\n      return storedPrefs;\n    }\n  }\n\n  return \"slate\"; // slate as the default color;\n};\n\nexport const ColorContext = createContext();\n\nexport function ColorProvider({ initialTheme, children }) {\n  const [color, setColor] = useState(() => initialTheme ?? getInitialColor());\n\n  const rawSetColor = (rawColor) => {\n    const root = window.document.documentElement;\n\n    root.classList.remove(`theme-${lastColor}`);\n    root.classList.add(`theme-${rawColor}`);\n\n    localStorage.setItem(\"theme-color\", rawColor);\n\n    lastColor = rawColor;\n  };\n\n  useEffect(() => {\n    if (initialTheme !== undefined) setColor(initialTheme ?? getInitialColor());\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [initialTheme]);\n\n  useEffect(() => {\n    rawSetColor(color);\n  }, [color]);\n\n  const value = useMemo(() => ({ color, setColor }), [color]);\n\n  return <ColorContext.Provider value={value}>{children}</ColorContext.Provider>;\n}\n"
  },
  {
    "path": "src/utils/contexts/color.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { fireEvent, render, screen, waitFor } from \"@testing-library/react\";\nimport { useContext } from \"react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport { ColorContext, ColorProvider } from \"./color\";\n\nfunction Reader() {\n  const { color, setColor } = useContext(ColorContext);\n  return (\n    <div>\n      <div data-testid=\"value\">{color}</div>\n      <button type=\"button\" onClick={() => setColor(\"red\")}>\n        red\n      </button>\n    </div>\n  );\n}\n\ndescribe(\"utils/contexts/color\", () => {\n  it(\"initializes from localStorage and writes theme class + storage on updates\", async () => {\n    localStorage.setItem(\"theme-color\", \"blue\");\n    document.documentElement.className = \"\";\n\n    render(\n      <ColorProvider>\n        <Reader />\n      </ColorProvider>,\n    );\n\n    expect(screen.getByTestId(\"value\")).toHaveTextContent(\"blue\");\n    await waitFor(() => expect(document.documentElement.classList.contains(\"theme-blue\")).toBe(true));\n\n    fireEvent.click(screen.getByRole(\"button\", { name: \"red\" }));\n    await waitFor(() => expect(document.documentElement.classList.contains(\"theme-red\")).toBe(true));\n    expect(localStorage.getItem(\"theme-color\")).toBe(\"red\");\n  });\n\n  it(\"defaults to slate when localStorage is empty\", async () => {\n    localStorage.removeItem(\"theme-color\");\n    document.documentElement.className = \"\";\n\n    render(\n      <ColorProvider>\n        <Reader />\n      </ColorProvider>,\n    );\n\n    expect(screen.getByTestId(\"value\")).toHaveTextContent(\"slate\");\n    await waitFor(() => expect(document.documentElement.classList.contains(\"theme-slate\")).toBe(true));\n  });\n});\n"
  },
  {
    "path": "src/utils/contexts/settings.jsx",
    "content": "import { createContext, useEffect, useMemo, useState } from \"react\";\n\nexport const SettingsContext = createContext();\n\nexport function SettingsProvider({ initialSettings, children }) {\n  const [settings, setSettings] = useState(() => initialSettings ?? {});\n\n  useEffect(() => {\n    if (initialSettings !== undefined) setSettings(initialSettings ?? {});\n  }, [initialSettings]);\n\n  const value = useMemo(() => ({ settings, setSettings }), [settings]);\n\n  return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;\n}\n"
  },
  {
    "path": "src/utils/contexts/settings.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { fireEvent, render, screen } from \"@testing-library/react\";\nimport { useContext } from \"react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport { SettingsContext, SettingsProvider } from \"./settings\";\n\nfunction Reader() {\n  const { settings, setSettings } = useContext(SettingsContext);\n  return (\n    <div>\n      <div data-testid=\"value\">{JSON.stringify(settings)}</div>\n      <button type=\"button\" onClick={() => setSettings({ updated: true })}>\n        update\n      </button>\n    </div>\n  );\n}\n\ndescribe(\"utils/contexts/settings\", () => {\n  it(\"provides initial settings and allows updates\", () => {\n    render(\n      <SettingsProvider initialSettings={{ a: 1 }}>\n        <Reader />\n      </SettingsProvider>,\n    );\n\n    expect(screen.getByTestId(\"value\")).toHaveTextContent('{\"a\":1}');\n    fireEvent.click(screen.getByRole(\"button\", { name: \"update\" }));\n    expect(screen.getByTestId(\"value\")).toHaveTextContent('{\"updated\":true}');\n  });\n});\n"
  },
  {
    "path": "src/utils/contexts/tab.jsx",
    "content": "import { createContext, useEffect, useMemo, useState } from \"react\";\n\nexport const TabContext = createContext();\n\nexport function TabProvider({ initialTab, children }) {\n  const [activeTab, setActiveTab] = useState(() => initialTab ?? false);\n\n  useEffect(() => {\n    if (initialTab !== undefined) setActiveTab(initialTab ?? false);\n  }, [initialTab]);\n\n  const value = useMemo(() => ({ activeTab, setActiveTab }), [activeTab]);\n\n  return <TabContext.Provider value={value}>{children}</TabContext.Provider>;\n}\n"
  },
  {
    "path": "src/utils/contexts/tab.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { fireEvent, render, screen } from \"@testing-library/react\";\nimport { useContext } from \"react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport { TabContext, TabProvider } from \"./tab\";\n\nfunction Reader() {\n  const { activeTab, setActiveTab } = useContext(TabContext);\n  return (\n    <div>\n      <div data-testid=\"value\">{String(activeTab)}</div>\n      <button type=\"button\" onClick={() => setActiveTab(\"next\")}>\n        next\n      </button>\n    </div>\n  );\n}\n\ndescribe(\"utils/contexts/tab\", () => {\n  it(\"provides initial tab and allows updates\", () => {\n    render(\n      <TabProvider initialTab=\"first\">\n        <Reader />\n      </TabProvider>,\n    );\n\n    expect(screen.getByTestId(\"value\")).toHaveTextContent(\"first\");\n    fireEvent.click(screen.getByRole(\"button\", { name: \"next\" }));\n    expect(screen.getByTestId(\"value\")).toHaveTextContent(\"next\");\n  });\n});\n"
  },
  {
    "path": "src/utils/contexts/theme.jsx",
    "content": "import { createContext, useEffect, useMemo, useState } from \"react\";\n\nconst getInitialTheme = () => {\n  if (typeof window !== \"undefined\" && window.localStorage) {\n    const storedPrefs = window.localStorage.getItem(\"theme-mode\");\n    if (typeof storedPrefs === \"string\") {\n      return storedPrefs;\n    }\n\n    const userMedia = window.matchMedia(\"(prefers-color-scheme: dark)\");\n    if (userMedia.matches) {\n      return \"dark\";\n    }\n  }\n\n  return \"dark\"; // dark as the default mode\n};\n\nexport const ThemeContext = createContext();\n\nexport function ThemeProvider({ initialTheme, children }) {\n  const [theme, setTheme] = useState(() => initialTheme ?? getInitialTheme());\n\n  const rawSetTheme = (rawTheme) => {\n    const root = window.document.documentElement;\n    const isDark = rawTheme === \"dark\";\n\n    root.classList.remove(isDark ? \"light\" : \"dark\");\n    root.classList.add(rawTheme);\n\n    localStorage.setItem(\"theme-mode\", rawTheme);\n  };\n\n  useEffect(() => {\n    if (initialTheme !== undefined) setTheme(initialTheme ?? getInitialTheme());\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [initialTheme]);\n\n  useEffect(() => {\n    rawSetTheme(theme);\n  }, [theme]);\n\n  const value = useMemo(() => ({ theme, setTheme }), [theme]);\n\n  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;\n}\n"
  },
  {
    "path": "src/utils/contexts/theme.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen, waitFor } from \"@testing-library/react\";\nimport { useContext } from \"react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { ThemeContext, ThemeProvider } from \"./theme\";\n\nfunction Reader() {\n  const { theme } = useContext(ThemeContext);\n  return <div data-testid=\"value\">{theme}</div>;\n}\n\ndescribe(\"utils/contexts/theme\", () => {\n  it(\"initializes from localStorage and writes html classes\", async () => {\n    // jsdom doesn't implement matchMedia by default; ensure it exists for getInitialTheme.\n    window.matchMedia =\n      window.matchMedia || vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }));\n\n    localStorage.setItem(\"theme-mode\", \"light\");\n    document.documentElement.className = \"\";\n\n    render(\n      <ThemeProvider>\n        <Reader />\n      </ThemeProvider>,\n    );\n\n    expect(screen.getByTestId(\"value\")).toHaveTextContent(\"light\");\n    await waitFor(() => expect(document.documentElement.classList.contains(\"light\")).toBe(true));\n    expect(localStorage.getItem(\"theme-mode\")).toBe(\"light\");\n  });\n\n  it(\"falls back to prefers-color-scheme when localStorage is empty\", async () => {\n    const matchMedia = vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() }));\n    window.matchMedia = matchMedia;\n    localStorage.removeItem(\"theme-mode\");\n\n    render(\n      <ThemeProvider>\n        <Reader />\n      </ThemeProvider>,\n    );\n\n    expect(matchMedia).toHaveBeenCalledWith(\"(prefers-color-scheme: dark)\");\n    expect(screen.getByTestId(\"value\")).toHaveTextContent(\"dark\");\n  });\n\n  it(\"defaults to dark when prefers-color-scheme does not match\", async () => {\n    const matchMedia = vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }));\n    window.matchMedia = matchMedia;\n    localStorage.removeItem(\"theme-mode\");\n\n    render(\n      <ThemeProvider>\n        <Reader />\n      </ThemeProvider>,\n    );\n\n    expect(matchMedia).toHaveBeenCalledWith(\"(prefers-color-scheme: dark)\");\n    expect(screen.getByTestId(\"value\")).toHaveTextContent(\"dark\");\n    await waitFor(() => expect(localStorage.getItem(\"theme-mode\")).toBe(\"dark\"));\n  });\n});\n"
  },
  {
    "path": "src/utils/highlights.js",
    "content": "const DEFAULT_LEVEL_CLASSES = {\n  good: \"bg-emerald-500/40 text-emerald-950 dark:bg-emerald-900/60 dark:text-emerald-400\",\n  warn: \"bg-amber-300/30 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200\",\n  danger: \"bg-rose-700/45 text-rose-200 dark:bg-rose-950/70 dark:text-rose-400\",\n};\n\nconst normalizeFieldKeys = (fields, widgetType) => {\n  if (!fields || typeof fields !== \"object\") return {};\n\n  return Object.entries(fields).reduce((acc, [key, value]) => {\n    if (value === null || value === undefined) return acc;\n    if (typeof key !== \"string\") return acc;\n    const trimmedKey = key.trim();\n    if (trimmedKey === \"\") return acc;\n\n    acc[trimmedKey] = value;\n\n    if (widgetType && !trimmedKey.includes(\".\")) {\n      const namespacedKey = `${widgetType}.${trimmedKey}`;\n      if (!(namespacedKey in acc)) {\n        acc[namespacedKey] = value;\n      }\n    }\n\n    return acc;\n  }, {});\n};\n\nexport const buildHighlightConfig = (globalConfig, widgetConfig, widgetType) => {\n  const levels = {\n    ...DEFAULT_LEVEL_CLASSES,\n    ...(globalConfig?.levels || {}),\n    ...(widgetConfig?.levels || {}),\n  };\n\n  const { levels: _levels, ...fields } = widgetConfig || {};\n  const normalizedFields = normalizeFieldKeys(fields, widgetType);\n\n  const hasLevels = Object.values(levels).some(Boolean);\n  const hasFields = Object.keys(normalizedFields).length > 0;\n\n  if (!hasLevels && !hasFields) return null;\n\n  return { levels, fields: normalizedFields };\n};\n\nconst NUMERIC_OPERATORS = {\n  gt: (value, target) => value > target,\n  gte: (value, target) => value >= target,\n  lt: (value, target) => value < target,\n  lte: (value, target) => value <= target,\n  eq: (value, target) => value === target,\n  ne: (value, target) => value !== target,\n};\n\nconst STRING_OPERATORS = {\n  equals: (value, target, caseSensitive) =>\n    caseSensitive ? value === target : value.toLowerCase() === target.toLowerCase(),\n  includes: (value, target, caseSensitive) =>\n    caseSensitive ? value.includes(target) : value.toLowerCase().includes(target.toLowerCase()),\n  startsWith: (value, target, caseSensitive) =>\n    caseSensitive ? value.startsWith(target) : value.toLowerCase().startsWith(target.toLowerCase()),\n  endsWith: (value, target, caseSensitive) =>\n    caseSensitive ? value.endsWith(target) : value.toLowerCase().endsWith(target.toLowerCase()),\n};\n\nconst toNumber = (value) => {\n  if (typeof value === \"number\" && Number.isFinite(value)) return value;\n  if (typeof value === \"string\" && value.trim()) {\n    const trimmed = value.trim();\n    const candidate = Number(trimmed);\n    if (!Number.isNaN(candidate)) return candidate;\n  }\n  return undefined;\n};\n\nconst extractNumericToken = (value) => {\n  if (typeof value !== \"string\") return undefined;\n  const match = value.match(/[-+]?\\d[\\d\\s.,]*/);\n  if (!match) return undefined;\n\n  const token = match[0].trim();\n  if (!token) return undefined;\n\n  const prefix = value.slice(0, match.index).trim();\n  const suffix = value.slice((match.index ?? 0) + match[0].length).trim();\n  if (/\\d/.test(prefix) || /\\d/.test(suffix)) return undefined;\n\n  return token;\n};\n\nconst parseNumericValue = (value) => {\n  if (value === null || value === undefined) return undefined;\n  if (typeof value === \"number\" && Number.isFinite(value)) return value;\n\n  if (typeof value === \"string\") {\n    const trimmed = value.trim();\n    if (!trimmed) return undefined;\n\n    const direct = Number(trimmed);\n    if (!Number.isNaN(direct)) return direct;\n\n    const candidate = extractNumericToken(trimmed);\n    const numericString = candidate ?? trimmed;\n    const compact = numericString.replace(/\\s+/g, \"\");\n    if (!compact || !/^[-+]?[0-9.,]+$/.test(compact)) return undefined;\n\n    const commaCount = (compact.match(/,/g) || []).length;\n    const dotCount = (compact.match(/\\./g) || []).length;\n\n    if (commaCount && dotCount) {\n      const lastComma = compact.lastIndexOf(\",\");\n      const lastDot = compact.lastIndexOf(\".\");\n      if (lastComma > lastDot) {\n        const asDecimal = compact.replace(/\\./g, \"\").replace(/,/g, \".\");\n        const parsed = Number(asDecimal);\n        return Number.isNaN(parsed) ? undefined : parsed;\n      }\n      const asThousands = compact.replace(/,/g, \"\");\n      const parsed = Number(asThousands);\n      return Number.isNaN(parsed) ? undefined : parsed;\n    }\n\n    if (commaCount) {\n      const parts = compact.split(\",\");\n      if (commaCount === 1 && parts[1]?.length <= 2) {\n        const parsed = Number(compact.replace(\",\", \".\"));\n        if (!Number.isNaN(parsed)) return parsed;\n      }\n      const isGrouped = parts.length > 1 && parts.slice(1).every((part) => part.length === 3);\n      if (isGrouped) {\n        const parsed = Number(compact.replace(/,/g, \"\"));\n        if (!Number.isNaN(parsed)) return parsed;\n      }\n      return undefined;\n    }\n\n    if (dotCount) {\n      const parts = compact.split(\".\");\n      if (dotCount === 1 && parts[1]?.length <= 2) {\n        const parsed = Number(compact);\n        if (!Number.isNaN(parsed)) return parsed;\n      }\n      const isGrouped = parts.length > 1 && parts.slice(1).every((part) => part.length === 3);\n      if (isGrouped) {\n        const parsed = Number(compact.replace(/\\./g, \"\"));\n        if (!Number.isNaN(parsed)) return parsed;\n      }\n      const parsed = Number(compact);\n      return Number.isNaN(parsed) ? undefined : parsed;\n    }\n\n    const parsed = Number(compact);\n    return Number.isNaN(parsed) ? undefined : parsed;\n  }\n\n  if (typeof value === \"object\" && value !== null && \"props\" in value) {\n    return undefined;\n  }\n\n  return undefined;\n};\n\nconst evaluateNumericRule = (value, rule) => {\n  if (!rule || typeof rule !== \"object\") return false;\n  const operator = rule.when && NUMERIC_OPERATORS[rule.when];\n  const numericValue = toNumber(rule.value);\n  if (operator && numericValue !== undefined) {\n    const passes = operator(value, numericValue);\n    return rule.negate ? !passes : passes;\n  }\n\n  if (rule.when === \"between\") {\n    const min = toNumber(rule.min ?? rule.value?.min);\n    const max = toNumber(rule.max ?? rule.value?.max);\n    if (min === undefined && max === undefined) return false;\n    const lowerBound = min ?? Number.NEGATIVE_INFINITY;\n    const upperBound = max ?? Number.POSITIVE_INFINITY;\n    const passes = value >= lowerBound && value <= upperBound;\n    return rule.negate ? !passes : passes;\n  }\n\n  if (rule.when === \"outside\") {\n    const min = toNumber(rule.min ?? rule.value?.min);\n    const max = toNumber(rule.max ?? rule.value?.max);\n    if (min === undefined && max === undefined) return false;\n    const passes = value < (min ?? Number.NEGATIVE_INFINITY) || value > (max ?? Number.POSITIVE_INFINITY);\n    return rule.negate ? !passes : passes;\n  }\n\n  return false;\n};\n\nconst evaluateStringRule = (value, rule) => {\n  if (!rule || typeof rule !== \"object\") return false;\n  if (rule.when === \"regex\" && typeof rule.value === \"string\") {\n    try {\n      const flags = rule.flags || (rule.caseSensitive ? \"\" : \"i\");\n      const regex = new RegExp(rule.value, flags);\n      const passes = regex.test(value);\n      return rule.negate ? !passes : passes;\n    } catch (error) {\n      return false;\n    }\n  }\n\n  const operator = rule.when && STRING_OPERATORS[rule.when];\n  if (!operator || typeof rule.value !== \"string\") return false;\n  const passes = operator(value, rule.value, Boolean(rule.caseSensitive));\n  return rule.negate ? !passes : passes;\n};\n\nconst ensureArray = (value) => {\n  if (Array.isArray(value)) return value;\n  if (value === undefined || value === null) return [];\n  return [value];\n};\n\nconst findHighlightLevel = (ruleSet, numericValue, stringValue) => {\n  const { numeric, string, valueOnly } = ruleSet;\n\n  if (numeric && numericValue !== undefined) {\n    const numericRules = ensureArray(numeric);\n    const numericCandidates = Array.isArray(numericValue) ? numericValue : [numericValue];\n    for (const candidate of numericCandidates) {\n      for (const rule of numericRules) {\n        if (rule?.level && evaluateNumericRule(candidate, rule)) {\n          return { level: rule.level, source: \"numeric\", rule, valueOnly };\n        }\n      }\n    }\n  }\n\n  if (string && stringValue !== undefined) {\n    const stringRules = ensureArray(string);\n    for (const rule of stringRules) {\n      if (rule?.level && evaluateStringRule(stringValue, rule)) {\n        return { level: rule.level, source: \"string\", rule, valueOnly };\n      }\n    }\n  }\n\n  return null;\n};\n\nexport const evaluateHighlight = (fieldKey, value, highlightConfig) => {\n  if (!highlightConfig || !fieldKey) return null;\n  const { fields } = highlightConfig;\n  if (!fields || typeof fields !== \"object\") return null;\n\n  const ruleSet = fields[fieldKey];\n  if (!ruleSet) return null;\n\n  const numericValue = parseNumericValue(value);\n  let stringValue;\n  if (typeof value === \"string\") {\n    stringValue = value;\n  } else if (typeof value === \"number\" || typeof value === \"bigint\") {\n    stringValue = String(value);\n  } else if (typeof value === \"boolean\") {\n    stringValue = value ? \"true\" : \"false\";\n  }\n\n  const normalizedString = typeof stringValue === \"string\" ? stringValue.trim() : stringValue;\n\n  return findHighlightLevel(ruleSet, numericValue, normalizedString);\n};\n\nexport const getHighlightClass = (level, highlightConfig) => {\n  if (!level || !highlightConfig) return undefined;\n  return highlightConfig.levels?.[level];\n};\n\nexport const getDefaultHighlightLevels = () => DEFAULT_LEVEL_CLASSES;\n"
  },
  {
    "path": "src/utils/highlights.test.js",
    "content": "import { describe, expect, it } from \"vitest\";\n\nimport { buildHighlightConfig, evaluateHighlight, getHighlightClass } from \"./highlights\";\n\ndescribe(\"utils/highlights\", () => {\n  it(\"returns null when there are no levels and no fields to evaluate\", () => {\n    const cfg = buildHighlightConfig(\n      null,\n      {\n        levels: { good: null, warn: null, danger: null },\n      },\n      \"x\",\n    );\n\n    expect(cfg).toBeNull();\n  });\n\n  it(\"buildHighlightConfig merges levels and namespaces unqualified field keys\", () => {\n    const cfg = buildHighlightConfig(\n      { levels: { warn: \"global-warn\" } },\n      {\n        levels: { warn: \"widget-warn\", custom: \"widget-custom\" },\n        cpu: { numeric: { when: \"gt\", value: 80, level: \"warn\" } },\n      },\n      \"resources\",\n    );\n\n    expect(cfg).not.toBeNull();\n    expect(cfg.levels.warn).toBe(\"widget-warn\");\n    expect(cfg.levels.custom).toBe(\"widget-custom\");\n\n    // Field keys get normalized + namespaced.\n    expect(cfg.fields.cpu).toBeTruthy();\n    expect(cfg.fields[\"resources.cpu\"]).toBeTruthy();\n  });\n\n  it(\"normalizes field keys by trimming and skipping blank/null entries\", () => {\n    const cfg = buildHighlightConfig(\n      null,\n      {\n        levels: { good: null, warn: null, danger: null, custom: \"x\" },\n        \"  cpu  \": { numeric: { when: \"gt\", value: 1, level: \"custom\" } },\n        \"\": { numeric: { when: \"gt\", value: 1, level: \"danger\" } },\n        empty: null,\n      },\n      \"resources\",\n    );\n\n    expect(cfg.fields.cpu).toBeTruthy();\n    expect(cfg.fields[\"resources.cpu\"]).toBeTruthy();\n    expect(cfg.fields.empty).toBeUndefined();\n  });\n\n  it(\"evaluateHighlight returns matching numeric rule with valueOnly metadata\", () => {\n    const cfg = buildHighlightConfig(\n      null,\n      {\n        // valueOnly should propagate through the result so Block can apply styling.\n        cpu: { valueOnly: true, numeric: { when: \"gte\", value: 90, level: \"danger\" } },\n      },\n      \"resources\",\n    );\n\n    const hit = evaluateHighlight(\"resources.cpu\", \" 90 \", cfg);\n    expect(hit).toMatchObject({ level: \"danger\", source: \"numeric\", valueOnly: true });\n  });\n\n  it(\"evaluateHighlight stringifies booleans and applies case-sensitive string rules\", () => {\n    const cfg = buildHighlightConfig(null, {\n      enabled: { string: { when: \"equals\", value: \"true\", caseSensitive: true, level: \"good\" } },\n      suffix: { string: { when: \"endsWith\", value: \"World\", caseSensitive: true, level: \"warn\" } },\n    });\n\n    expect(evaluateHighlight(\"enabled\", true, cfg)).toMatchObject({ level: \"good\", source: \"string\" });\n    expect(evaluateHighlight(\"suffix\", \"HelloWorld\", cfg)).toMatchObject({ level: \"warn\", source: \"string\" });\n    expect(evaluateHighlight(\"suffix\", \"helloworld\", cfg)).toBeNull();\n  });\n\n  it(\"evaluateHighlight supports string rules (case-insensitive includes)\", () => {\n    const cfg = buildHighlightConfig(null, { status: { string: { when: \"includes\", value: \"down\", level: \"warn\" } } });\n\n    const hit = evaluateHighlight(\"status\", \"Service DOWN\", cfg);\n    expect(hit).toMatchObject({ level: \"warn\", source: \"string\" });\n  });\n\n  it(\"getHighlightClass returns configured class for a level\", () => {\n    const cfg = buildHighlightConfig({ levels: { danger: \"danger-class\" } }, {}, \"x\");\n    expect(getHighlightClass(\"danger\", cfg)).toBe(\"danger-class\");\n    expect(getHighlightClass(\"missing\", cfg)).toBeUndefined();\n  });\n\n  it(\"supports localized numeric parsing and between/outside operators (with negate)\", () => {\n    const cfg = buildHighlightConfig(null, {\n      temp: {\n        numeric: [\n          { when: \"between\", min: 1000.5, max: 1500.5, level: \"warn\" },\n          { when: \"outside\", value: { min: 1234.5, max: 2234.5 }, level: \"danger\", negate: true },\n        ],\n      },\n    });\n\n    // \"1.234,56\" should parse as 1234.56 and hit the between rule.\n    expect(evaluateHighlight(\"temp\", \"1.234,56\", cfg)).toMatchObject({ level: \"warn\", source: \"numeric\" });\n\n    // Negated outside => inside the range should match.\n    expect(evaluateHighlight(\"temp\", \"2.000,00\", cfg)).toMatchObject({ level: \"danger\", source: \"numeric\" });\n  });\n\n  it(\"supports numeric parsing for dot/comma thousands formats\", () => {\n    const cfg = buildHighlightConfig(null, {\n      num: { numeric: { when: \"eq\", value: 1234.56, level: \"good\" } },\n      grouped: { numeric: { when: \"eq\", value: 1234567, level: \"warn\" } },\n    });\n\n    expect(evaluateHighlight(\"num\", \"1,234.56\", cfg)).toMatchObject({ level: \"good\", source: \"numeric\" });\n    expect(evaluateHighlight(\"grouped\", \"1.234.567\", cfg)).toMatchObject({ level: \"warn\", source: \"numeric\" });\n  });\n\n  it(\"supports regex string rules, including invalid regex patterns (ignored)\", () => {\n    const cfg = buildHighlightConfig(null, {\n      status: {\n        string: [\n          { when: \"regex\", value: \"^up$\", level: \"good\" },\n          { when: \"regex\", value: \"(\", level: \"danger\" }, // invalid; should be ignored\n          { when: \"equals\", value: \"DOWN\", level: \"warn\", caseSensitive: true },\n        ],\n      },\n    });\n\n    expect(evaluateHighlight(\"status\", \"Up\", cfg)).toMatchObject({ level: \"good\", source: \"string\" });\n    expect(evaluateHighlight(\"status\", \"DOWN\", cfg)).toMatchObject({ level: \"warn\", source: \"string\" });\n    expect(evaluateHighlight(\"status\", \"Down\", cfg)).toBeNull();\n  });\n\n  it(\"parses numeric strings with commas/dots/spaces and supports stringified numeric rule values\", () => {\n    const cfg = buildHighlightConfig(null, {\n      // string numeric rule values go through toNumber()\n      gt: { numeric: { when: \"gt\", value: \"5\", level: \"warn\" } },\n      withUnitSuffix: { numeric: { when: \"gt\", value: 5, level: \"warn\" } },\n      withUnitPrefix: { numeric: { when: \"gt\", value: 5, level: \"warn\" } },\n      localizedUnitSuffix: { numeric: { when: \"gt\", value: 0.5, level: \"warn\" } },\n      commaGrouped: { numeric: { when: \"eq\", value: 1234, level: \"good\" } },\n      commaDecimal: { numeric: { when: \"eq\", value: 12.34, level: \"good\" } },\n      dotDecimal: { numeric: { when: \"eq\", value: 12.34, level: \"good\" } },\n      spaceGrouped: { numeric: { when: \"eq\", value: 1234, level: \"good\" } },\n    });\n\n    expect(evaluateHighlight(\"gt\", \"6\", cfg)).toMatchObject({ level: \"warn\", source: \"numeric\" });\n    expect(evaluateHighlight(\"withUnitSuffix\", \"5.2 ms\", cfg)).toMatchObject({ level: \"warn\", source: \"numeric\" });\n    expect(evaluateHighlight(\"withUnitPrefix\", \"ms 5.2\", cfg)).toMatchObject({ level: \"warn\", source: \"numeric\" });\n    expect(evaluateHighlight(\"localizedUnitSuffix\", \"0,71\\u202Fms\", cfg)).toMatchObject({\n      level: \"warn\",\n      source: \"numeric\",\n    });\n    expect(evaluateHighlight(\"commaGrouped\", \"1,234\", cfg)).toMatchObject({ level: \"good\", source: \"numeric\" });\n    expect(evaluateHighlight(\"commaDecimal\", \"12,34\", cfg)).toMatchObject({ level: \"good\", source: \"numeric\" });\n    // Include a space so Number(trimmed) fails and we exercise the dot parsing branch.\n    expect(evaluateHighlight(\"dotDecimal\", \"12 .34\", cfg)).toMatchObject({ level: \"good\", source: \"numeric\" });\n    expect(evaluateHighlight(\"spaceGrouped\", \"1 234\", cfg)).toMatchObject({ level: \"good\", source: \"numeric\" });\n  });\n\n  it(\"treats unparseable numeric formats as non-numeric\", () => {\n    const cfg = buildHighlightConfig(null, {\n      num: { numeric: { when: \"gt\", value: 0, level: \"warn\" } },\n    });\n\n    // Invalid comma grouping should not be treated as numeric.\n    expect(evaluateHighlight(\"num\", \"1,2,3\", cfg)).toBeNull();\n\n    // \"1.2.3\" is not a valid grouped or decimal number for our parser.\n    expect(evaluateHighlight(\"num\", \"1.2.3\", cfg)).toBeNull();\n\n    // Multiple numbers in one string should not be treated as a single numeric value.\n    expect(evaluateHighlight(\"num\", \"5/10 ms\", cfg)).toBeNull();\n\n    // JSX-ish values should not be treated as numeric.\n    expect(evaluateHighlight(\"num\", { props: { children: \"x\" } }, cfg)).toBeNull();\n  });\n\n  it(\"falls through numeric evaluation when numeric rules do not match\", () => {\n    const cfg = buildHighlightConfig(null, {\n      status: {\n        numeric: { when: \"gte\", value: 100, level: \"danger\" },\n        string: { when: \"includes\", value: \"ok\", level: \"good\" },\n      },\n    });\n\n    // Numeric rule doesn't match, string rule does.\n    expect(evaluateHighlight(\"status\", \"ok\", cfg)).toMatchObject({ level: \"good\", source: \"string\" });\n  });\n\n  it(\"stringifies numbers/bigints for string evaluation and ignores unknown numeric operators\", () => {\n    const cfg = buildHighlightConfig(null, {\n      // unknown numeric operator should not match\n      weird: { numeric: { when: \"nope\", value: 1, level: \"warn\" } },\n      // bigint should stringify to match a string rule\n      big: { string: { when: \"equals\", value: \"9007199254740993\", level: \"good\", caseSensitive: true } },\n    });\n\n    expect(evaluateHighlight(\"weird\", \"10\", cfg)).toBeNull();\n    expect(evaluateHighlight(\"big\", 9007199254740993n, cfg)).toMatchObject({ level: \"good\", source: \"string\" });\n  });\n});\n"
  },
  {
    "path": "src/utils/hooks/window-focus.js",
    "content": "import { useEffect, useState } from \"react\";\n\nconst hasFocus = () => typeof document !== \"undefined\" && document.hasFocus();\n\nconst useWindowFocus = () => {\n  const [focused, setFocused] = useState(hasFocus);\n\n  useEffect(() => {\n    setFocused(hasFocus());\n\n    const onFocus = () => setFocused(true);\n    const onBlur = () => setFocused(false);\n\n    window.addEventListener(\"focus\", onFocus);\n    window.addEventListener(\"blur\", onBlur);\n\n    return () => {\n      window.removeEventListener(\"focus\", onFocus);\n      window.removeEventListener(\"blur\", onBlur);\n    };\n  }, []);\n\n  return focused;\n};\n\nexport default useWindowFocus;\n"
  },
  {
    "path": "src/utils/hooks/window-focus.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen, waitFor } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport useWindowFocus from \"./window-focus\";\n\nfunction Fixture() {\n  const focused = useWindowFocus();\n  return <div data-testid=\"focused\">{String(focused)}</div>;\n}\n\ndescribe(\"utils/hooks/window-focus\", () => {\n  it(\"tracks focus/blur events\", async () => {\n    vi.spyOn(document, \"hasFocus\").mockReturnValue(true);\n\n    render(<Fixture />);\n\n    expect(screen.getByTestId(\"focused\")).toHaveTextContent(\"true\");\n\n    window.dispatchEvent(new Event(\"blur\"));\n    await waitFor(() => expect(screen.getByTestId(\"focused\")).toHaveTextContent(\"false\"));\n\n    window.dispatchEvent(new Event(\"focus\"));\n    await waitFor(() => expect(screen.getByTestId(\"focused\")).toHaveTextContent(\"true\"));\n  });\n});\n"
  },
  {
    "path": "src/utils/kubernetes/export.js",
    "content": "import listHttpRoute from \"utils/kubernetes/httproute-list\";\nimport listIngress from \"utils/kubernetes/ingress-list\";\nimport { constructedServiceFromResource, isDiscoverable } from \"utils/kubernetes/resource-helpers\";\nimport listTraefikIngress from \"utils/kubernetes/traefik-list\";\n\nconst kubernetes = {\n  listIngress,\n  listTraefikIngress,\n  listHttpRoute,\n  isDiscoverable,\n  constructedServiceFromResource,\n};\n\nexport default kubernetes;\n"
  },
  {
    "path": "src/utils/kubernetes/export.test.js",
    "content": "import { describe, expect, it, vi } from \"vitest\";\n\nconst { listIngress, listTraefikIngress, listHttpRoute, isDiscoverable, constructedServiceFromResource } = vi.hoisted(\n  () => ({\n    listIngress: vi.fn(),\n    listTraefikIngress: vi.fn(),\n    listHttpRoute: vi.fn(),\n    isDiscoverable: vi.fn(),\n    constructedServiceFromResource: vi.fn(),\n  }),\n);\n\nvi.mock(\"utils/kubernetes/ingress-list\", () => ({ default: listIngress }));\nvi.mock(\"utils/kubernetes/traefik-list\", () => ({ default: listTraefikIngress }));\nvi.mock(\"utils/kubernetes/httproute-list\", () => ({ default: listHttpRoute }));\nvi.mock(\"utils/kubernetes/resource-helpers\", () => ({ isDiscoverable, constructedServiceFromResource }));\n\nimport kubernetes from \"./export\";\n\ndescribe(\"utils/kubernetes/export\", () => {\n  it(\"re-exports kubernetes helper functions\", () => {\n    expect(kubernetes.listIngress).toBe(listIngress);\n    expect(kubernetes.listTraefikIngress).toBe(listTraefikIngress);\n    expect(kubernetes.listHttpRoute).toBe(listHttpRoute);\n    expect(kubernetes.isDiscoverable).toBe(isDiscoverable);\n    expect(kubernetes.constructedServiceFromResource).toBe(constructedServiceFromResource);\n  });\n});\n"
  },
  {
    "path": "src/utils/kubernetes/httproute-list.js",
    "content": "import { CoreV1Api, CustomObjectsApi } from \"@kubernetes/client-node\";\n\nimport { getKubeConfig, getKubernetes, HTTPROUTE_API_GROUP, HTTPROUTE_API_VERSION } from \"utils/config/kubernetes\";\nimport createLogger from \"utils/logger\";\n\nconst logger = createLogger(\"httproute-list\");\nconst kc = getKubeConfig();\n\nexport default async function listHttpRoute() {\n  const crd = kc.makeApiClient(CustomObjectsApi);\n  const core = kc.makeApiClient(CoreV1Api);\n  const { gateway } = getKubernetes();\n  let httpRouteList = [];\n\n  if (gateway) {\n    // httproutes\n    const getHttpRoutes = async (namespace) =>\n      crd\n        .listNamespacedCustomObject({\n          group: HTTPROUTE_API_GROUP,\n          version: HTTPROUTE_API_VERSION,\n          namespace,\n          plural: \"httproutes\",\n        })\n        .then((response) => {\n          return response.items;\n        })\n        .catch((error) => {\n          logger.error(\"Error getting httproutes: %d %s %s\", error.statusCode, error.body, error.response);\n          logger.debug(error);\n          return null;\n        });\n    // namespaces\n    const namespaces = await core\n      .listNamespace()\n      .then((response) => response.items.map((ns) => ns.metadata.name))\n      .catch((error) => {\n        logger.error(\"Error getting namespaces: %d %s %s\", error.statusCode, error.body, error.response);\n        logger.debug(error);\n        return null;\n      });\n\n    if (namespaces) {\n      const httpRouteListUnfiltered = await Promise.all(\n        namespaces.map(async (namespace) => {\n          const httpRoutes = await getHttpRoutes(namespace);\n          return httpRoutes;\n        }),\n      );\n\n      httpRouteList = httpRouteListUnfiltered.flat().filter((httpRoute) => httpRoute);\n    }\n  }\n  return httpRouteList;\n}\n"
  },
  {
    "path": "src/utils/kubernetes/httproute-list.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { state, getKubernetes, getKubeConfig, logger } = vi.hoisted(() => {\n  const state = {\n    enabled: true,\n    namespaces: [\"a\", \"b\"],\n    routesByNs: {\n      a: [{ metadata: { name: \"r1\" } }],\n      b: [{ metadata: { name: \"r2\" } }],\n    },\n    crd: {\n      listNamespacedCustomObject: vi.fn(async ({ namespace }) => ({ items: state.routesByNs[namespace] ?? [] })),\n    },\n    core: {\n      listNamespace: vi.fn(async () => ({ items: state.namespaces.map((n) => ({ metadata: { name: n } })) })),\n    },\n    kc: {\n      makeApiClient: vi.fn((Api) => (Api.name === \"CoreV1Api\" ? state.core : state.crd)),\n    },\n  };\n\n  return {\n    state,\n    getKubernetes: vi.fn(() => ({ gateway: state.enabled })),\n    getKubeConfig: vi.fn(() => state.kc),\n    logger: { error: vi.fn(), debug: vi.fn() },\n  };\n});\n\nvi.mock(\"@kubernetes/client-node\", () => ({\n  CoreV1Api: class CoreV1Api {},\n  CustomObjectsApi: class CustomObjectsApi {},\n}));\n\nvi.mock(\"utils/config/kubernetes\", () => ({\n  getKubeConfig,\n  getKubernetes,\n  HTTPROUTE_API_GROUP: \"gateway.networking.k8s.io\",\n  HTTPROUTE_API_VERSION: \"v1\",\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\ndescribe(\"utils/kubernetes/httproute-list\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    state.enabled = true;\n    state.namespaces = [\"a\", \"b\"];\n    state.routesByNs = {\n      a: [{ metadata: { name: \"r1\" } }],\n      b: [{ metadata: { name: \"r2\" } }],\n    };\n  });\n\n  it(\"returns an empty list when gateway discovery is disabled\", async () => {\n    state.enabled = false;\n    vi.resetModules();\n    const listHttpRoute = (await import(\"./httproute-list\")).default;\n\n    const result = await listHttpRoute();\n\n    expect(result).toEqual([]);\n  });\n\n  it(\"lists namespaces and aggregates httproutes\", async () => {\n    vi.resetModules();\n    const listHttpRoute = (await import(\"./httproute-list\")).default;\n\n    const result = await listHttpRoute();\n\n    expect(result.map((r) => r.metadata.name)).toEqual([\"r1\", \"r2\"]);\n    expect(state.core.listNamespace).toHaveBeenCalled();\n    expect(state.crd.listNamespacedCustomObject).toHaveBeenCalledTimes(2);\n  });\n\n  it(\"logs and returns [] when namespace listing fails\", async () => {\n    state.core.listNamespace.mockRejectedValueOnce({ statusCode: 500, body: \"boom\", response: \"resp\" });\n\n    vi.resetModules();\n    const listHttpRoute = (await import(\"./httproute-list\")).default;\n\n    const result = await listHttpRoute();\n\n    expect(result).toEqual([]);\n    expect(logger.error).toHaveBeenCalled();\n    expect(logger.debug).toHaveBeenCalled();\n  });\n\n  it(\"skips namespaces whose httproute queries fail\", async () => {\n    state.crd.listNamespacedCustomObject.mockImplementation(async ({ namespace }) => {\n      if (namespace === \"b\") throw { statusCode: 500, body: \"boom\", response: \"resp\" };\n      return { items: state.routesByNs[namespace] ?? [] };\n    });\n\n    vi.resetModules();\n    const listHttpRoute = (await import(\"./httproute-list\")).default;\n\n    const result = await listHttpRoute();\n\n    expect(result.map((r) => r.metadata.name)).toEqual([\"r1\"]);\n    expect(logger.error).toHaveBeenCalled();\n    expect(logger.debug).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/utils/kubernetes/ingress-list.js",
    "content": "import { NetworkingV1Api } from \"@kubernetes/client-node\";\n\nimport { getKubeConfig, getKubernetes } from \"utils/config/kubernetes\";\nimport createLogger from \"utils/logger\";\n\nconst logger = createLogger(\"ingress-list\");\nconst kc = getKubeConfig();\n\nexport default async function listIngress() {\n  const networking = kc.makeApiClient(NetworkingV1Api);\n  const { ingress = true } = getKubernetes();\n  let ingressList = [];\n\n  if (ingress) {\n    const ingressData = await networking\n      .listIngressForAllNamespaces()\n      .then((response) => response)\n      .catch((error) => {\n        logger.error(\"Error getting ingresses: %d %s %s\", error.statusCode, error.body, error.response);\n        logger.debug(error);\n        return null;\n      });\n    ingressList = ingressData?.items ?? [];\n  }\n  return ingressList;\n}\n"
  },
  {
    "path": "src/utils/kubernetes/ingress-list.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { state, getKubernetes, getKubeConfig, logger } = vi.hoisted(() => {\n  const state = {\n    ingressEnabled: true,\n    items: [],\n    throw: null,\n    networking: {\n      listIngressForAllNamespaces: vi.fn(async () => {\n        if (state.throw) throw state.throw;\n        return { items: state.items };\n      }),\n    },\n    kc: {\n      makeApiClient: vi.fn(() => state.networking),\n    },\n  };\n\n  return {\n    state,\n    getKubernetes: vi.fn(() => ({ ingress: state.ingressEnabled })),\n    getKubeConfig: vi.fn(() => state.kc),\n    logger: { error: vi.fn(), debug: vi.fn() },\n  };\n});\n\nvi.mock(\"@kubernetes/client-node\", () => ({\n  NetworkingV1Api: class NetworkingV1Api {},\n}));\n\nvi.mock(\"utils/config/kubernetes\", () => ({\n  getKubernetes,\n  getKubeConfig,\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\ndescribe(\"utils/kubernetes/ingress-list\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    state.ingressEnabled = true;\n    state.items = [];\n    state.throw = null;\n  });\n\n  it(\"returns an empty list when ingress discovery is disabled\", async () => {\n    state.ingressEnabled = false;\n    vi.resetModules();\n    const listIngress = (await import(\"./ingress-list\")).default;\n\n    const result = await listIngress();\n\n    expect(result).toEqual([]);\n    expect(state.networking.listIngressForAllNamespaces).not.toHaveBeenCalled();\n  });\n\n  it(\"returns items from listIngressForAllNamespaces\", async () => {\n    state.items = [{ metadata: { name: \"i1\" } }];\n    vi.resetModules();\n    const listIngress = (await import(\"./ingress-list\")).default;\n\n    const result = await listIngress();\n\n    expect(result).toEqual([{ metadata: { name: \"i1\" } }]);\n  });\n\n  it(\"returns an empty list on errors\", async () => {\n    state.throw = { statusCode: 500, body: \"nope\", response: \"x\" };\n    vi.resetModules();\n    const listIngress = (await import(\"./ingress-list\")).default;\n\n    const result = await listIngress();\n\n    expect(result).toEqual([]);\n    expect(logger.error).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/utils/kubernetes/resource-helpers.js",
    "content": "import { CustomObjectsApi } from \"@kubernetes/client-node\";\n\nimport { substituteEnvironmentVars } from \"utils/config/config\";\nimport {\n  ANNOTATION_BASE,\n  ANNOTATION_WIDGET_BASE,\n  getKubeConfig,\n  HTTPROUTE_API_GROUP,\n  HTTPROUTE_API_VERSION,\n} from \"utils/config/kubernetes\";\nimport * as shvl from \"utils/config/shvl\";\nimport createLogger from \"utils/logger\";\n\nconst logger = createLogger(\"resource-helpers\");\nconst kc = getKubeConfig();\n\nconst getSchemaFromGateway = async (parentRef) => {\n  const crd = kc.makeApiClient(CustomObjectsApi);\n  const schema = await crd\n    .getNamespacedCustomObject({\n      group: HTTPROUTE_API_GROUP,\n      version: HTTPROUTE_API_VERSION,\n      namespace: parentRef.namespace,\n      plural: \"gateways\",\n      name: parentRef.name,\n    })\n    .then((response) => {\n      const listener =\n        response.spec.listeners.find((l) => l.name === parentRef.sectionName) ?? response.spec.listeners[0];\n\n      return listener.protocol.toLowerCase();\n    })\n    .catch((error) => {\n      logger.error(\"Error getting gateways: %d %s %s\", error.statusCode, error.body, error.response);\n      logger.debug(error);\n\n      return \"http\";\n    });\n\n  return schema;\n};\n\nasync function getUrlFromHttpRoute(resource) {\n  let url = null;\n  const hasHostName = resource.spec?.hostnames;\n\n  if (hasHostName) {\n    if (resource.spec.rules[0].matches[0].path.type !== \"RegularExpression\") {\n      const urlHost = resource.spec.hostnames[0];\n      const urlPath = resource.spec.rules[0].matches[0].path.value;\n      const urlSchema = await getSchemaFromGateway(resource.spec.parentRefs[0]);\n      url = `${urlSchema}://${urlHost}${urlPath}`;\n    }\n  }\n\n  return url;\n}\n\nfunction getUrlFromIngress(resource) {\n  const urlHost = resource.spec.rules[0].host;\n  const urlPath = resource.spec.rules[0].http.paths[0].path;\n  const urlSchema = resource.spec.tls ? \"https\" : \"http\";\n\n  return `${urlSchema}://${urlHost}${urlPath}`;\n}\n\nasync function getUrlSchema(resource) {\n  const isHttpRoute = resource.kind === \"HTTPRoute\";\n  let urlSchema;\n  if (isHttpRoute) {\n    urlSchema = getUrlFromHttpRoute(resource);\n  } else {\n    urlSchema = getUrlFromIngress(resource);\n  }\n\n  return urlSchema;\n}\n\nexport function isDiscoverable(resource, instanceName) {\n  return (\n    resource.metadata.annotations &&\n    resource.metadata.annotations[`${ANNOTATION_BASE}/enabled`] === \"true\" &&\n    (!resource.metadata.annotations[`${ANNOTATION_BASE}/instance`] ||\n      resource.metadata.annotations[`${ANNOTATION_BASE}/instance`] === instanceName ||\n      `${ANNOTATION_BASE}/instance.${instanceName}` in resource.metadata.annotations)\n  );\n}\n\nexport async function constructedServiceFromResource(resource) {\n  let constructedService = {\n    app: resource.metadata.annotations[`${ANNOTATION_BASE}/app`] || resource.metadata.name,\n    namespace: resource.metadata.namespace,\n    href: resource.metadata.annotations[`${ANNOTATION_BASE}/href`] || (await getUrlSchema(resource)),\n    name: resource.metadata.annotations[`${ANNOTATION_BASE}/name`] || resource.metadata.name,\n    group: resource.metadata.annotations[`${ANNOTATION_BASE}/group`] || \"Kubernetes\",\n    weight: resource.metadata.annotations[`${ANNOTATION_BASE}/weight`] || \"0\",\n    icon: resource.metadata.annotations[`${ANNOTATION_BASE}/icon`] || \"\",\n    description: resource.metadata.annotations[`${ANNOTATION_BASE}/description`] || \"\",\n    external: false,\n    type: \"service\",\n  };\n  if (resource.metadata.annotations[`${ANNOTATION_BASE}/external`]) {\n    constructedService.external =\n      String(resource.metadata.annotations[`${ANNOTATION_BASE}/external`]).toLowerCase() === \"true\";\n  }\n  if (resource.metadata.annotations[`${ANNOTATION_BASE}/pod-selector`] !== undefined) {\n    constructedService.podSelector = resource.metadata.annotations[`${ANNOTATION_BASE}/pod-selector`];\n  }\n  if (resource.metadata.annotations[`${ANNOTATION_BASE}/ping`]) {\n    constructedService.ping = resource.metadata.annotations[`${ANNOTATION_BASE}/ping`];\n  }\n  if (resource.metadata.annotations[`${ANNOTATION_BASE}/siteMonitor`]) {\n    constructedService.siteMonitor = resource.metadata.annotations[`${ANNOTATION_BASE}/siteMonitor`];\n  }\n  if (resource.metadata.annotations[`${ANNOTATION_BASE}/statusStyle`]) {\n    constructedService.statusStyle = resource.metadata.annotations[`${ANNOTATION_BASE}/statusStyle`];\n  }\n\n  Object.keys(resource.metadata.annotations).forEach((annotation) => {\n    if (annotation.startsWith(ANNOTATION_WIDGET_BASE)) {\n      shvl.set(\n        constructedService,\n        annotation.replace(`${ANNOTATION_BASE}/`, \"\"),\n        resource.metadata.annotations[annotation],\n      );\n    }\n  });\n\n  try {\n    constructedService = JSON.parse(substituteEnvironmentVars(JSON.stringify(constructedService)));\n  } catch (e) {\n    logger.error(\"Error attempting k8s environment variable substitution.\");\n    logger.debug(e);\n  }\n\n  return constructedService;\n}\n"
  },
  {
    "path": "src/utils/kubernetes/resource-helpers.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { state, substituteEnvironmentVars, getKubeConfig, logger } = vi.hoisted(() => {\n  const state = {\n    gatewayProtocol: \"https\",\n  };\n\n  const substituteEnvironmentVars = vi.fn((raw) =>\n    raw.replaceAll(\"${DESC}\", process.env.DESC ?? \"\").replaceAll(\"${ICON}\", process.env.ICON ?? \"\"),\n  );\n\n  const crd = {\n    getNamespacedCustomObject: vi.fn(async () => ({\n      spec: { listeners: [{ name: \"web\", protocol: state.gatewayProtocol.toUpperCase() }] },\n    })),\n  };\n\n  const kc = {\n    makeApiClient: vi.fn(() => crd),\n  };\n\n  return {\n    state,\n    substituteEnvironmentVars,\n    getKubeConfig: vi.fn(() => kc),\n    logger: { error: vi.fn(), debug: vi.fn() },\n  };\n});\n\nvi.mock(\"@kubernetes/client-node\", () => ({\n  CustomObjectsApi: class CustomObjectsApi {},\n}));\n\nvi.mock(\"utils/config/config\", () => ({\n  substituteEnvironmentVars,\n}));\n\nvi.mock(\"utils/config/kubernetes\", () => ({\n  ANNOTATION_BASE: \"gethomepage.dev\",\n  ANNOTATION_WIDGET_BASE: \"gethomepage.dev/widget.\",\n  HTTPROUTE_API_GROUP: \"gateway.networking.k8s.io\",\n  HTTPROUTE_API_VERSION: \"v1\",\n  getKubeConfig,\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nimport { constructedServiceFromResource, isDiscoverable } from \"./resource-helpers\";\n\ndescribe(\"utils/kubernetes/resource-helpers\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    process.env.DESC = \"desc\";\n    process.env.ICON = \"mdi:test\";\n    state.gatewayProtocol = \"https\";\n  });\n\n  it(\"checks discoverability by annotations and instance\", () => {\n    const base = \"gethomepage.dev\";\n    const resource = { metadata: { annotations: { [`${base}/enabled`]: \"true\" } } };\n\n    expect(isDiscoverable(resource, \"x\")).toBe(true);\n    expect(isDiscoverable({ metadata: { annotations: { [`${base}/enabled`]: \"false\" } } }, \"x\")).toBe(false);\n    expect(\n      isDiscoverable({ metadata: { annotations: { [`${base}/enabled`]: \"true\", [`${base}/instance`]: \"x\" } } }, \"x\"),\n    ).toBe(true);\n    expect(\n      isDiscoverable({ metadata: { annotations: { [`${base}/enabled`]: \"true\", [`${base}/instance.y`]: \"1\" } } }, \"y\"),\n    ).toBe(true);\n  });\n\n  it(\"constructs a service from an ingress and applies widget annotations + env substitution\", async () => {\n    const base = \"gethomepage.dev\";\n    const resource = {\n      kind: \"Ingress\",\n      metadata: {\n        name: \"app\",\n        namespace: \"ns\",\n        annotations: {\n          [`${base}/external`]: \"TRUE\",\n          [`${base}/description`]: \"${DESC}\",\n          [`${base}/icon`]: \"${ICON}\",\n          [`${base}/pod-selector`]: \"app=test\",\n          [`${base}/ping`]: \"http://example.com/ping\",\n          [`${base}/siteMonitor`]: \"http://example.com/health\",\n          [`${base}/statusStyle`]: \"dot\",\n          [`${base}/widget.type`]: \"kubernetes\",\n          [`${base}/widget.url`]: \"http://x\",\n        },\n      },\n      spec: {\n        tls: [{}],\n        rules: [{ host: \"example.com\", http: { paths: [{ path: \"/app\" }] } }],\n      },\n    };\n\n    const service = await constructedServiceFromResource(resource);\n\n    expect(service.href).toBe(\"https://example.com/app\");\n    expect(service.external).toBe(true);\n    expect(service.description).toBe(\"desc\");\n    expect(service.icon).toBe(\"mdi:test\");\n    expect(service.podSelector).toBe(\"app=test\");\n    expect(service.ping).toBe(\"http://example.com/ping\");\n    expect(service.siteMonitor).toBe(\"http://example.com/health\");\n    expect(service.statusStyle).toBe(\"dot\");\n    expect(service.widget.type).toBe(\"kubernetes\");\n    expect(service.widget.url).toBe(\"http://x\");\n    expect(substituteEnvironmentVars).toHaveBeenCalled();\n  });\n\n  it(\"constructs a href from an HTTPRoute using the gateway listener protocol\", async () => {\n    const base = \"gethomepage.dev\";\n    const resource = {\n      kind: \"HTTPRoute\",\n      metadata: {\n        name: \"route\",\n        namespace: \"ns\",\n        annotations: {\n          [`${base}/enabled`]: \"true\",\n        },\n      },\n      spec: {\n        hostnames: [\"example.com\"],\n        parentRefs: [{ namespace: \"ns\", name: \"gw\", sectionName: \"web\" }],\n        rules: [\n          {\n            matches: [{ path: { type: \"PathPrefix\", value: \"/r\" } }],\n          },\n        ],\n      },\n    };\n\n    const service = await constructedServiceFromResource(resource);\n    expect(service.href).toBe(\"https://example.com/r\");\n  });\n\n  it(\"falls back to http when the gateway listener protocol cannot be resolved\", async () => {\n    const kc = getKubeConfig();\n    const crd = kc.makeApiClient();\n    crd.getNamespacedCustomObject.mockRejectedValueOnce({\n      statusCode: 500,\n      body: \"boom\",\n      response: \"resp\",\n    });\n\n    const base = \"gethomepage.dev\";\n    const resource = {\n      kind: \"HTTPRoute\",\n      metadata: {\n        name: \"route\",\n        namespace: \"ns\",\n        annotations: {\n          [`${base}/enabled`]: \"true\",\n        },\n      },\n      spec: {\n        hostnames: [\"example.com\"],\n        parentRefs: [{ namespace: \"ns\", name: \"gw\", sectionName: \"web\" }],\n        rules: [\n          {\n            matches: [{ path: { type: \"PathPrefix\", value: \"/r\" } }],\n          },\n        ],\n      },\n    };\n\n    const service = await constructedServiceFromResource(resource);\n    expect(service.href).toBe(\"http://example.com/r\");\n    expect(logger.error).toHaveBeenCalled();\n    expect(logger.debug).toHaveBeenCalled();\n  });\n\n  it(\"logs and recovers when environment substitution yields invalid json\", async () => {\n    substituteEnvironmentVars.mockImplementationOnce(() => \"{bad json\");\n\n    const base = \"gethomepage.dev\";\n    const resource = {\n      kind: \"Ingress\",\n      metadata: {\n        name: \"app\",\n        namespace: \"ns\",\n        annotations: {\n          [`${base}/enabled`]: \"true\",\n        },\n      },\n      spec: {\n        rules: [{ host: \"example.com\", http: { paths: [{ path: \"/app\" }] } }],\n      },\n    };\n\n    const service = await constructedServiceFromResource(resource);\n    expect(service.name).toBe(\"app\");\n    expect(logger.error).toHaveBeenCalledWith(\"Error attempting k8s environment variable substitution.\");\n    expect(logger.debug).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/utils/kubernetes/traefik-list.js",
    "content": "import { CustomObjectsApi } from \"@kubernetes/client-node\";\n\nimport { ANNOTATION_BASE, checkCRD, getKubeConfig, getKubernetes } from \"utils/config/kubernetes\";\nimport createLogger from \"utils/logger\";\n\nconst logger = createLogger(\"traefik-list\");\nconst kc = getKubeConfig();\n\nexport default async function listTraefikIngress() {\n  const { traefik } = getKubernetes();\n  const traefikList = [];\n\n  if (traefik) {\n    const crd = kc.makeApiClient(CustomObjectsApi);\n    const traefikContainoExists = await checkCRD(\"ingressroutes.traefik.containo.us\", kc, logger);\n    const traefikExists = await checkCRD(\"ingressroutes.traefik.io\", kc, logger);\n\n    const traefikIngressListContaino = await crd\n      .listClusterCustomObject({\n        group: \"traefik.containo.us\",\n        version: \"v1alpha1\",\n        plural: \"ingressroutes\",\n      })\n      .catch(async (error) => {\n        if (traefikContainoExists) {\n          logger.error(\n            \"Error getting traefik ingresses from traefik.containo.us: %d %s %s\",\n            error.statusCode,\n            error.body,\n            error.response,\n          );\n          logger.debug(error);\n        }\n\n        return [];\n      });\n\n    const traefikIngressListIo = await crd\n      .listClusterCustomObject({\n        group: \"traefik.io\",\n        version: \"v1alpha1\",\n        plural: \"ingressroutes\",\n      })\n      .catch(async (error) => {\n        if (traefikExists) {\n          logger.error(\n            \"Error getting traefik ingresses from traefik.io: %d %s %s\",\n            error.statusCode,\n            error.body,\n            error.response,\n          );\n          logger.debug(error);\n        }\n\n        return [];\n      });\n\n    const traefikIngressList = [...(traefikIngressListContaino?.items ?? []), ...(traefikIngressListIo?.items ?? [])];\n\n    if (traefikIngressList.length > 0) {\n      const traefikServices = traefikIngressList.filter(\n        (ingress) => ingress.metadata.annotations && ingress.metadata.annotations[`${ANNOTATION_BASE}/href`],\n      );\n      traefikList.push(...traefikServices);\n    }\n  }\n  return traefikList;\n}\n"
  },
  {
    "path": "src/utils/kubernetes/traefik-list.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { state, getKubernetes, getKubeConfig, checkCRD, logger } = vi.hoisted(() => {\n  const state = {\n    enabled: true,\n    containoItems: [],\n    ioItems: [],\n    crd: {\n      listClusterCustomObject: vi.fn(async ({ group }) => {\n        if (group === \"traefik.containo.us\") return { items: state.containoItems };\n        if (group === \"traefik.io\") return { items: state.ioItems };\n        return { items: [] };\n      }),\n    },\n    kc: {\n      makeApiClient: vi.fn(() => state.crd),\n    },\n  };\n\n  return {\n    state,\n    getKubernetes: vi.fn(() => ({ traefik: state.enabled })),\n    getKubeConfig: vi.fn(() => state.kc),\n    checkCRD: vi.fn(async () => true),\n    logger: { error: vi.fn(), debug: vi.fn() },\n  };\n});\n\nvi.mock(\"@kubernetes/client-node\", () => ({\n  CustomObjectsApi: class CustomObjectsApi {},\n}));\n\nvi.mock(\"utils/config/kubernetes\", () => ({\n  ANNOTATION_BASE: \"gethomepage.dev\",\n  checkCRD,\n  getKubeConfig,\n  getKubernetes,\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\ndescribe(\"utils/kubernetes/traefik-list\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    state.enabled = true;\n    state.containoItems = [];\n    state.ioItems = [];\n    state.crd.listClusterCustomObject.mockImplementation(async ({ group }) => {\n      if (group === \"traefik.containo.us\") return { items: state.containoItems };\n      if (group === \"traefik.io\") return { items: state.ioItems };\n      return { items: [] };\n    });\n    checkCRD.mockResolvedValue(true);\n  });\n\n  it(\"returns an empty list when traefik discovery is disabled\", async () => {\n    state.enabled = false;\n    vi.resetModules();\n    const listTraefikIngress = (await import(\"./traefik-list\")).default;\n\n    const result = await listTraefikIngress();\n\n    expect(result).toEqual([]);\n  });\n\n  it(\"filters and merges ingressroutes with homepage href annotations\", async () => {\n    state.containoItems = [\n      { metadata: { annotations: { \"gethomepage.dev/href\": \"http://a\" } } },\n      { metadata: { annotations: {} } },\n    ];\n    state.ioItems = [{ metadata: { annotations: { \"gethomepage.dev/href\": \"http://b\" } } }];\n    vi.resetModules();\n    const listTraefikIngress = (await import(\"./traefik-list\")).default;\n\n    const result = await listTraefikIngress();\n\n    expect(result).toHaveLength(2);\n    expect(result[0].metadata.annotations[\"gethomepage.dev/href\"]).toBe(\"http://a\");\n    expect(result[1].metadata.annotations[\"gethomepage.dev/href\"]).toBe(\"http://b\");\n    expect(checkCRD).toHaveBeenCalled();\n  });\n\n  it(\"logs errors when traefik CRDs exist and the API calls fail\", async () => {\n    const err = { statusCode: 500, body: \"nope\", response: \"nope\" };\n    state.crd.listClusterCustomObject.mockRejectedValue(err);\n\n    vi.resetModules();\n    const listTraefikIngress = (await import(\"./traefik-list\")).default;\n\n    const result = await listTraefikIngress();\n\n    expect(result).toEqual([]);\n    expect(logger.error).toHaveBeenCalled();\n    expect(logger.debug).toHaveBeenCalledWith(err);\n  });\n\n  it(\"suppresses API errors when the CRD is not installed\", async () => {\n    checkCRD.mockResolvedValue(false);\n    state.crd.listClusterCustomObject.mockRejectedValue({ statusCode: 500 });\n\n    vi.resetModules();\n    const listTraefikIngress = (await import(\"./traefik-list\")).default;\n\n    const result = await listTraefikIngress();\n\n    expect(result).toEqual([]);\n    expect(logger.error).not.toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/utils/kubernetes/utils.js",
    "content": "export function parseCpu(cpuStr) {\n  const unitLength = 1;\n  const base = Number.parseInt(cpuStr, 10);\n  const units = cpuStr.substring(cpuStr.length - unitLength);\n  if (Number.isNaN(Number(units))) {\n    switch (units) {\n      case \"n\":\n        return base / 1000000000;\n      case \"u\":\n        return base / 1000000;\n      case \"m\":\n        return base / 1000;\n      default:\n        return base;\n    }\n  } else {\n    return Number.parseInt(cpuStr, 10);\n  }\n}\n\nexport function parseMemory(memStr) {\n  const unitLength = memStr.substring(memStr.length - 1) === \"i\" ? 2 : 1;\n  const base = Number.parseInt(memStr, 10);\n  const units = memStr.substring(memStr.length - unitLength);\n  if (Number.isNaN(Number(units))) {\n    switch (units) {\n      case \"Ki\":\n        return base * 1000;\n      case \"K\":\n        return base * 1024;\n      case \"Mi\":\n        return base * 1000000;\n      case \"M\":\n        return base * 1024 * 1024;\n      case \"Gi\":\n        return base * 1000000000;\n      case \"G\":\n        return base * 1024 * 1024 * 1024;\n      default:\n        return base;\n    }\n  } else {\n    return Number.parseInt(memStr, 10);\n  }\n}\n"
  },
  {
    "path": "src/utils/kubernetes/utils.test.js",
    "content": "import { describe, expect, it } from \"vitest\";\n\nimport { parseCpu, parseMemory } from \"./utils\";\n\ndescribe(\"utils/kubernetes/utils\", () => {\n  it(\"parses cpu units into core values\", () => {\n    expect(parseCpu(\"500m\")).toBeCloseTo(0.5);\n    expect(parseCpu(\"250u\")).toBeCloseTo(0.00025);\n    expect(parseCpu(\"1000n\")).toBeCloseTo(0.000001);\n    expect(parseCpu(\"5x\")).toBe(5);\n    expect(parseCpu(\"2\")).toBe(2);\n  });\n\n  it(\"parses memory units into numeric values\", () => {\n    expect(parseMemory(\"1Gi\")).toBe(1000000000);\n    expect(parseMemory(\"1G\")).toBe(1024 * 1024 * 1024);\n    expect(parseMemory(\"1Mi\")).toBe(1000000);\n    expect(parseMemory(\"1M\")).toBe(1024 * 1024);\n    expect(parseMemory(\"1Ki\")).toBe(1000);\n    expect(parseMemory(\"1K\")).toBe(1024);\n    expect(parseMemory(\"3Ti\")).toBe(3);\n    expect(parseMemory(\"256\")).toBe(256);\n  });\n});\n"
  },
  {
    "path": "src/utils/layout/columns.js",
    "content": "export const columnMap = [\n  \"grid-cols-1 md:grid-cols-1 lg:grid-cols-1\",\n  \"grid-cols-1 md:grid-cols-1 lg:grid-cols-1\",\n  \"grid-cols-1 md:grid-cols-2 lg:grid-cols-2\",\n  \"grid-cols-1 md:grid-cols-2 lg:grid-cols-3\",\n  \"grid-cols-1 md:grid-cols-2 lg:grid-cols-4\",\n  \"grid-cols-1 md:grid-cols-2 lg:grid-cols-5\",\n  \"grid-cols-1 md:grid-cols-2 lg:grid-cols-6\",\n  \"grid-cols-1 md:grid-cols-2 lg:grid-cols-7\",\n  \"grid-cols-1 md:grid-cols-2 lg:grid-cols-8\",\n];\n"
  },
  {
    "path": "src/utils/layout/columns.test.js",
    "content": "import { describe, expect, it } from \"vitest\";\n\nimport { columnMap } from \"./columns\";\n\ndescribe(\"utils/layout/columns\", () => {\n  it(\"maps column counts to responsive grid classes\", () => {\n    expect(columnMap).toHaveLength(9);\n    expect(columnMap[1]).toContain(\"grid-cols-1\");\n    expect(columnMap[2]).toContain(\"md:grid-cols-2\");\n    expect(columnMap[8]).toContain(\"lg:grid-cols-8\");\n  });\n});\n"
  },
  {
    "path": "src/utils/logger.js",
    "content": "import { format as utilFormat } from \"node:util\";\n\nimport winston from \"winston\";\n\nimport checkAndCopyConfig, { CONF_DIR, getSettings } from \"utils/config/config\";\n\nlet winstonLogger;\n\nfunction combineMessageAndSplat() {\n  return {\n    transform: (info, opts) => {\n      // combine message and args if any\n\n      info.message = utilFormat(info.message, ...(info[Symbol.for(\"splat\")] || []));\n      return info;\n    },\n  };\n}\n\nfunction messageFormatter(logInfo) {\n  if (logInfo.label) {\n    if (logInfo.stack) {\n      return `[${logInfo.timestamp}] ${logInfo.level}: <${logInfo.label}> ${logInfo.stack}`;\n    }\n    return `[${logInfo.timestamp}] ${logInfo.level}: <${logInfo.label}> ${logInfo.message}`;\n  }\n\n  if (logInfo.stack) {\n    return `[${logInfo.timestamp}] ${logInfo.level}: ${logInfo.stack}`;\n  }\n  return `[${logInfo.timestamp}] ${logInfo.level}: ${logInfo.message}`;\n}\n\nfunction getConsoleLogger() {\n  return new winston.transports.Console({\n    format: winston.format.combine(\n      winston.format.errors({ stack: true }),\n      combineMessageAndSplat(),\n      winston.format.timestamp(),\n      winston.format.colorize(),\n      winston.format.printf(messageFormatter),\n    ),\n    handleExceptions: true,\n    handleRejections: true,\n  });\n}\n\nfunction getFileLogger() {\n  const settings = getSettings();\n  const logpath = settings.logpath || CONF_DIR;\n\n  return new winston.transports.File({\n    format: winston.format.combine(\n      winston.format.errors({ stack: true }),\n      combineMessageAndSplat(),\n      winston.format.timestamp(),\n      winston.format.printf(messageFormatter),\n    ),\n    filename: `${logpath}/logs/homepage.log`,\n    handleExceptions: true,\n    handleRejections: true,\n  });\n}\n\nfunction init() {\n  checkAndCopyConfig(\"settings.yaml\");\n  const configuredTargets = process.env.LOG_TARGETS || \"both\";\n  const loggingTransports = [];\n\n  switch (configuredTargets) {\n    case \"both\":\n      loggingTransports.push(getConsoleLogger(), getFileLogger());\n      break;\n    case \"stdout\":\n      loggingTransports.push(getConsoleLogger());\n      break;\n    case \"file\":\n      loggingTransports.push(getFileLogger());\n      break;\n    default:\n      loggingTransports.push(getConsoleLogger(), getFileLogger());\n  }\n\n  winstonLogger = winston.createLogger({\n    level: process.env.LOG_LEVEL || \"info\",\n    transports: loggingTransports,\n  });\n\n  // patch the console log mechanism to use our logger\n  const consoleMethods = [\"log\", \"debug\", \"info\", \"warn\", \"error\"];\n  consoleMethods.forEach((method) => {\n    // workaround for https://github.com/winstonjs/winston/issues/1591\n    switch (method) {\n      case \"log\":\n        console[method] = winstonLogger.info.bind(winstonLogger);\n        break;\n      default:\n        console[method] = winstonLogger[method].bind(winstonLogger);\n        break;\n    }\n  });\n}\n\nconst loggers = {};\n\nexport default function createLogger(label) {\n  if (!winstonLogger) {\n    init();\n  }\n\n  if (!loggers[label]) {\n    loggers[label] = winstonLogger.child({ label });\n  }\n\n  return loggers[label];\n}\n"
  },
  {
    "path": "src/utils/logger.test.js",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { state, winston, checkAndCopyConfig, getSettings } = vi.hoisted(() => {\n  const state = {\n    created: [],\n    lastCreateLoggerArgs: null,\n  };\n\n  function ConsoleTransport(opts) {\n    this.opts = opts;\n  }\n  function FileTransport(opts) {\n    this.opts = opts;\n  }\n\n  const createLogger = vi.fn((args) => {\n    state.lastCreateLoggerArgs = args;\n\n    const base = {\n      child: vi.fn(() => base),\n      debug: vi.fn(),\n      info: vi.fn(),\n      warn: vi.fn(),\n      error: vi.fn(),\n    };\n    state.created.push(base);\n    return base;\n  });\n\n  const winston = {\n    transports: { Console: ConsoleTransport, File: FileTransport },\n    format: {\n      combine: (...parts) => ({ parts }),\n      errors: () => ({}),\n      timestamp: () => ({}),\n      colorize: () => ({}),\n      printf: (fn) => fn,\n    },\n    createLogger,\n  };\n\n  return {\n    state,\n    winston,\n    checkAndCopyConfig: vi.fn(),\n    getSettings: vi.fn(() => ({ logpath: \"/tmp\" })),\n  };\n});\n\nvi.mock(\"winston\", () => ({ default: winston, ...winston }));\n\nvi.mock(\"utils/config/config\", () => ({\n  default: checkAndCopyConfig,\n  CONF_DIR: \"/conf\",\n  getSettings,\n}));\n\ndescribe(\"utils/logger\", () => {\n  const originalEnv = process.env;\n  const originalConsole = { ...console };\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    process.env = { ...originalEnv };\n  });\n\n  afterEach(() => {\n    // Restore patched console methods if init() ran.\n    Object.assign(console, originalConsole);\n  });\n\n  it(\"initializes winston on first createLogger() and caches per label\", async () => {\n    vi.resetModules();\n    process.env.LOG_TARGETS = \"stdout\";\n\n    const createLogger = (await import(\"./logger\")).default;\n\n    const a1 = createLogger(\"a\");\n    const a2 = createLogger(\"a\");\n    const b = createLogger(\"b\");\n\n    expect(checkAndCopyConfig).toHaveBeenCalledWith(\"settings.yaml\");\n    expect(winston.createLogger).toHaveBeenCalled();\n    expect(a1).toBe(a2);\n    expect(b).toBeDefined();\n  });\n\n  it(\"selects stdout/file/both transports based on LOG_TARGETS\", async () => {\n    vi.resetModules();\n    process.env.LOG_TARGETS = \"file\";\n\n    const createLogger = (await import(\"./logger\")).default;\n    createLogger(\"x\");\n\n    const transports = state.lastCreateLoggerArgs.transports;\n    expect(transports).toHaveLength(1);\n    expect(transports[0].opts.filename).toBe(\"/tmp/logs/homepage.log\");\n  });\n\n  it(\"defaults to both transports for unknown LOG_TARGETS and patches console methods\", async () => {\n    vi.resetModules();\n    process.env.LOG_TARGETS = \"wat\";\n\n    const createLogger = (await import(\"./logger\")).default;\n    const instance = createLogger(\"x\");\n\n    const transports = state.lastCreateLoggerArgs.transports;\n    expect(transports).toHaveLength(2);\n\n    console.log(\"hello\");\n    expect(instance.info).toHaveBeenCalledWith(\"hello\");\n  });\n\n  it(\"uses CONF_DIR as the default logpath when settings.logpath is not set\", async () => {\n    vi.resetModules();\n    process.env.LOG_TARGETS = \"file\";\n    getSettings.mockReturnValueOnce({});\n\n    const createLogger = (await import(\"./logger\")).default;\n    createLogger(\"x\");\n\n    const transports = state.lastCreateLoggerArgs.transports;\n    expect(transports[0].opts.filename).toBe(\"/conf/logs/homepage.log\");\n  });\n\n  it(\"formats messages and stacks through the printf formatter\", async () => {\n    vi.resetModules();\n    process.env.LOG_TARGETS = \"stdout\";\n    process.env.LOG_LEVEL = \"debug\";\n\n    const createLogger = (await import(\"./logger\")).default;\n    createLogger(\"x\");\n\n    expect(state.lastCreateLoggerArgs.level).toBe(\"debug\");\n\n    const [consoleTransport] = state.lastCreateLoggerArgs.transports;\n    const parts = consoleTransport.opts.format.parts;\n    const formatter = parts.find((p) => typeof p === \"function\");\n    const splat = parts.find((p) => p && typeof p.transform === \"function\");\n\n    const msg = formatter({\n      timestamp: \"t\",\n      level: \"info\",\n      label: \"x\",\n      message: \"hello\",\n    });\n    expect(msg).toBe(\"[t] info: <x> hello\");\n\n    const labelStackMsg = formatter({\n      timestamp: \"t\",\n      level: \"error\",\n      label: \"x\",\n      stack: \"STACK\",\n      message: \"ignored\",\n    });\n    expect(labelStackMsg).toBe(\"[t] error: <x> STACK\");\n\n    const stackMsg = formatter({\n      timestamp: \"t\",\n      level: \"error\",\n      stack: \"STACK\",\n      message: \"ignored\",\n    });\n    expect(stackMsg).toBe(\"[t] error: STACK\");\n\n    const plainMsg = formatter({\n      timestamp: \"t\",\n      level: \"info\",\n      message: \"hello\",\n    });\n    expect(plainMsg).toBe(\"[t] info: hello\");\n\n    const out = splat.transform(\n      {\n        message: \"Hello %s\",\n        [Symbol.for(\"splat\")]: [\"World\"],\n      },\n      {},\n    );\n    expect(out.message).toBe(\"Hello World\");\n  });\n});\n"
  },
  {
    "path": "src/utils/proxy/api-helpers.js",
    "content": "export function formatApiCall(url, args) {\n  const find = /\\{.*?\\}/g;\n  const replace = (match) => {\n    const key = match.replace(/\\{|\\}/g, \"\");\n    let value = args[key];\n    if (key === \"url\") {\n      value = value.replace(/\\/+$/, \"\"); // remove trailing slashes\n    }\n    return value?.toString() || \"\";\n  };\n\n  return url.replace(find, replace).replace(find, replace);\n}\n\nexport function getURLSearchParams(widget, endpoint) {\n  const params = new URLSearchParams({\n    group: widget.service_group,\n    service: widget.service_name,\n    index: widget.index,\n  });\n  if (endpoint) {\n    params.append(\"endpoint\", endpoint);\n  }\n  return params;\n}\n\nexport function formatProxyUrl(widget, endpoint, queryParams) {\n  const params = getURLSearchParams(widget, endpoint);\n  if (queryParams) {\n    params.append(\"query\", JSON.stringify(queryParams));\n  }\n  return `/api/services/proxy?${params.toString()}`;\n}\n\nexport function asJson(data) {\n  if (data?.length > 0) {\n    const json = JSON.parse(data.toString());\n    return json;\n  }\n  return data;\n}\n\nexport function jsonArrayTransform(data, transform) {\n  const json = asJson(data);\n  if (json instanceof Array) {\n    return transform(json);\n  }\n  return json;\n}\n\nexport function jsonArrayFilter(data, filter) {\n  return jsonArrayTransform(data, (items) => items.filter(filter));\n}\n\nexport function sanitizeErrorURL(errorURL) {\n  // Dont display sensitive params on frontend\n  const url = new URL(errorURL);\n  [\"apikey\", \"api_key\", \"token\", \"t\", \"access_token\", \"auth\"].forEach((key) => {\n    if (url.searchParams.has(key)) url.searchParams.set(key, \"***\");\n    if (url.hash.includes(key)) url.hash = url.hash.replace(new RegExp(`${key}=[^&]+`), `${key}=***`);\n  });\n  return url.toString();\n}\n"
  },
  {
    "path": "src/utils/proxy/api-helpers.test.js",
    "content": "import { describe, expect, it } from \"vitest\";\n\nimport {\n  asJson,\n  formatApiCall,\n  formatProxyUrl,\n  getURLSearchParams,\n  jsonArrayFilter,\n  jsonArrayTransform,\n  sanitizeErrorURL,\n} from \"./api-helpers\";\n\ndescribe(\"utils/proxy/api-helpers\", () => {\n  it(\"formatApiCall replaces placeholders and trims trailing slashes for {url}\", () => {\n    expect(formatApiCall(\"{url}/{endpoint}\", { url: \"http://localhost///\", endpoint: \"api\" })).toBe(\n      \"http://localhost/api\",\n    );\n  });\n\n  it(\"formatApiCall replaces repeated placeholders\", () => {\n    expect(formatApiCall(\"{a}-{a}-{missing}\", { a: \"x\" })).toBe(\"x-x-\");\n  });\n\n  it(\"getURLSearchParams includes group/service/index and optionally endpoint\", () => {\n    const widget = { service_group: \"g\", service_name: \"s\", index: \"0\" };\n\n    const withEndpoint = getURLSearchParams(widget, \"stats\");\n    expect(withEndpoint.get(\"group\")).toBe(\"g\");\n    expect(withEndpoint.get(\"service\")).toBe(\"s\");\n    expect(withEndpoint.get(\"index\")).toBe(\"0\");\n    expect(withEndpoint.get(\"endpoint\")).toBe(\"stats\");\n\n    const withoutEndpoint = getURLSearchParams(widget);\n    expect(withoutEndpoint.get(\"endpoint\")).toBeNull();\n  });\n\n  it(\"formatProxyUrl builds expected proxy URL and encodes query params\", () => {\n    const widget = { service_group: \"g\", service_name: \"s\", index: \"2\" };\n    const url = formatProxyUrl(widget, \"health\", { a: 1, b: \"x\" });\n\n    expect(url.startsWith(\"/api/services/proxy?\")).toBe(true);\n\n    const qs = url.split(\"?\")[1];\n    const params = new URLSearchParams(qs);\n    expect(params.get(\"group\")).toBe(\"g\");\n    expect(params.get(\"service\")).toBe(\"s\");\n    expect(params.get(\"index\")).toBe(\"2\");\n    expect(params.get(\"endpoint\")).toBe(\"health\");\n\n    expect(JSON.parse(params.get(\"query\"))).toEqual({ a: 1, b: \"x\" });\n  });\n\n  it(\"asJson parses JSON buffers and returns non-JSON values unchanged\", () => {\n    expect(asJson(Buffer.from(JSON.stringify({ ok: true })))).toEqual({ ok: true });\n    expect(asJson(Buffer.from(\"\"))).toEqual(Buffer.from(\"\"));\n    expect(asJson(null)).toBeNull();\n  });\n\n  it(\"jsonArrayTransform transforms arrays and returns non-arrays unchanged\", () => {\n    const data = Buffer.from(JSON.stringify([{ a: 1 }, { a: 2 }]));\n    expect(jsonArrayTransform(data, (items) => items.map((i) => i.a))).toEqual([1, 2]);\n\n    expect(jsonArrayTransform(Buffer.from(JSON.stringify({ ok: true })), () => \"nope\")).toEqual({ ok: true });\n  });\n\n  it(\"jsonArrayFilter filters arrays and returns non-arrays unchanged\", () => {\n    const data = Buffer.from(JSON.stringify([{ a: 1 }, { a: 2 }]));\n    expect(jsonArrayFilter(data, (item) => item.a > 1)).toEqual([{ a: 2 }]);\n  });\n\n  it(\"sanitizeErrorURL redacts sensitive query params and hash fragments\", () => {\n    const input = \"https://example.com/path?apikey=123&token=abc#access_token=xyz&other=1\";\n    const output = sanitizeErrorURL(input);\n\n    const url = new URL(output);\n    expect(url.searchParams.get(\"apikey\")).toBe(\"***\");\n    expect(url.searchParams.get(\"token\")).toBe(\"***\");\n    expect(url.hash).toContain(\"access_token=***\");\n    expect(url.hash).toContain(\"other=1\");\n  });\n\n  it(\"sanitizeErrorURL only redacts known keys\", () => {\n    const input = \"https://example.com/path?api_key=123&safe=ok#auth=abc&safe_hash=1\";\n    const output = sanitizeErrorURL(input);\n\n    const url = new URL(output);\n    expect(url.searchParams.get(\"api_key\")).toBe(\"***\");\n    expect(url.searchParams.get(\"safe\")).toBe(\"ok\");\n    expect(url.hash).toContain(\"auth=***\");\n    expect(url.hash).toContain(\"safe_hash=1\");\n  });\n});\n"
  },
  {
    "path": "src/utils/proxy/cookie-jar.js",
    "content": "import { Cookie, CookieJar } from \"tough-cookie\";\n\nconst cookieJar = new CookieJar();\n\nexport function setCookieHeader(url, params) {\n  // add cookie header, if we have one in the jar\n  const existingCookie = cookieJar.getCookieStringSync(url.toString());\n  if (existingCookie) {\n    params.headers = params.headers ?? {};\n    params.headers[params.cookieHeader ?? \"Cookie\"] = existingCookie;\n  }\n}\n\nexport function addCookieToJar(url, headers) {\n  let cookieHeader = headers[\"set-cookie\"];\n  if (headers instanceof Headers) {\n    cookieHeader = headers.get(\"set-cookie\");\n  }\n\n  if (!cookieHeader || cookieHeader.length === 0) return;\n\n  let cookies = null;\n  if (cookieHeader instanceof Array) {\n    cookies = cookieHeader.map((c) => {\n      const cookie = Cookie.parse(c);\n      cookie.setMaxAge(60 * 60);\n      return cookie;\n    });\n  } else {\n    const cookie = Cookie.parse(cookieHeader);\n    cookie.setMaxAge(60 * 60);\n    cookies = [cookie];\n  }\n\n  for (let i = 0; i < cookies.length; i += 1) {\n    cookieJar.setCookieSync(cookies[i], url.toString(), { ignoreError: true });\n  }\n}\n"
  },
  {
    "path": "src/utils/proxy/cookie-jar.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\ndescribe(\"utils/proxy/cookie-jar\", () => {\n  beforeEach(() => {\n    vi.resetModules();\n  });\n\n  it(\"adds cookies to the jar and sets Cookie header on subsequent requests\", async () => {\n    const { addCookieToJar, setCookieHeader } = await import(\"./cookie-jar\");\n\n    const url = new URL(\"http://example.test/path\");\n    addCookieToJar(url, { \"set-cookie\": [\"a=b; Path=/\"] });\n\n    const params = { headers: {} };\n    setCookieHeader(url, params);\n\n    expect(params.headers.Cookie).toContain(\"a=b\");\n  });\n\n  it(\"supports custom cookie header names via params.cookieHeader\", async () => {\n    const { addCookieToJar, setCookieHeader } = await import(\"./cookie-jar\");\n\n    const url = new URL(\"http://example2.test/path\");\n    addCookieToJar(url, { \"set-cookie\": [\"sid=1; Path=/\"] });\n\n    const params = { headers: {}, cookieHeader: \"X-Auth-Token\" };\n    setCookieHeader(url, params);\n\n    expect(params.headers[\"X-Auth-Token\"]).toContain(\"sid=1\");\n  });\n\n  it(\"supports Headers instances passed as response headers\", async () => {\n    const { addCookieToJar, setCookieHeader } = await import(\"./cookie-jar\");\n\n    const url = new URL(\"http://example3.test/path\");\n    const headers = new Headers();\n    headers.set(\"set-cookie\", \"c=d; Path=/\");\n    addCookieToJar(url, headers);\n\n    const params = { headers: {} };\n    setCookieHeader(url, params);\n\n    expect(params.headers.Cookie).toContain(\"c=d\");\n  });\n});\n"
  },
  {
    "path": "src/utils/proxy/handlers/credentialed.js",
    "content": "import { getSettings } from \"utils/config/config\";\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall, sanitizeErrorURL } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport validateWidgetData from \"utils/proxy/validate-widget-data\";\nimport widgets from \"widgets/widgets\";\n\nconst logger = createLogger(\"credentialedProxyHandler\");\n\nfunction basicAuthHeader(widget) {\n  return `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString(\"base64\")}`;\n}\n\nexport default async function credentialedProxyHandler(req, res, map) {\n  const { group, service, endpoint, index } = req.query;\n\n  if (group && service) {\n    const widget = await getServiceWidget(group, service, index);\n\n    if (!widget) {\n      logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n      return res.status(400).json({ error: \"Invalid proxy service type\" });\n    }\n\n    if (!widgets?.[widget.type]?.api) {\n      return res.status(403).json({ error: \"Service does not support API calls\" });\n    }\n\n    if (widget) {\n      const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));\n\n      const headers = {\n        \"Content-Type\": \"application/json\",\n        ...(widgets[widget.type].headers ?? {}),\n        ...(widget.headers ?? {}),\n        ...(req.extraHeaders ?? {}),\n      };\n\n      if (widget.type === \"stocks\") {\n        const { providers } = getSettings();\n        if (widget.provider === \"finnhub\" && providers?.finnhub) {\n          headers[\"X-Finnhub-Token\"] = `${providers?.finnhub}`;\n        }\n      } else if (widget.type === \"coinmarketcap\") {\n        headers[\"X-CMC_PRO_API_KEY\"] = `${widget.key}`;\n      } else if (widget.type === \"gotify\") {\n        headers[\"X-gotify-Key\"] = `${widget.key}`;\n      } else if (widget.type === \"checkmk\") {\n        headers[\"Accept\"] = `application/json`;\n        headers.Authorization = `Bearer ${widget.username} ${widget.password}`;\n      } else if (\n        [\n          \"argocd\",\n          \"authentik\",\n          \"cloudflared\",\n          \"ghostfolio\",\n          \"headscale\",\n          \"hoarder\",\n          \"karakeep\",\n          \"linkwarden\",\n          \"mealie\",\n          \"netalertx\",\n          \"pangolin\",\n          \"tailscale\",\n          \"tandoor\",\n          \"tracearr\",\n          \"pterodactyl\",\n          \"vikunja\",\n          \"firefly\",\n        ].includes(widget.type)\n      ) {\n        headers.Authorization = `Bearer ${widget.key}`;\n      } else if (widget.type === \"truenas\") {\n        if (widget.key) {\n          headers.Authorization = `Bearer ${widget.key}`;\n        } else {\n          headers.Authorization = basicAuthHeader(widget);\n        }\n      } else if (widget.type === \"proxmox\") {\n        headers.Authorization = `PVEAPIToken=${widget.username}=${widget.password}`;\n      } else if (widget.type === \"proxmoxbackupserver\") {\n        delete headers[\"Content-Type\"];\n        headers.Authorization = `PBSAPIToken=${widget.username}:${widget.password}`;\n      } else if ([\"autobrr\", \"jellystat\"].includes(widget.type)) {\n        headers[\"X-API-Token\"] = `${widget.key}`;\n      } else if (widget.type === \"tubearchivist\") {\n        headers.Authorization = `Token ${widget.key}`;\n      } else if (widget.type === \"miniflux\") {\n        headers[\"X-Auth-Token\"] = `${widget.key}`;\n      } else if (widget.type === \"nextcloud\") {\n        if (widget.key) {\n          headers[\"NC-Token\"] = `${widget.key}`;\n        } else {\n          headers.Authorization = basicAuthHeader(widget);\n        }\n      } else if (widget.type === \"paperlessngx\") {\n        if (widget.key) {\n          headers.Authorization = `Token ${widget.key}`;\n        } else {\n          headers.Authorization = basicAuthHeader(widget);\n        }\n      } else if (widget.type === \"azuredevops\") {\n        headers.Authorization = `Basic ${Buffer.from(`$:${widget.key}`).toString(\"base64\")}`;\n      } else if (widget.type === \"glances\") {\n        headers.Authorization = basicAuthHeader(widget);\n      } else if (widget.type === \"plantit\") {\n        headers.Key = `${widget.key}`;\n      } else if (widget.type === \"myspeed\") {\n        headers.Password = `${widget.password}`;\n      } else if (widget.type === \"esphome\") {\n        if (widget.username && widget.password) {\n          headers.Authorization = basicAuthHeader(widget);\n        } else if (widget.key) {\n          headers.Cookie = `authenticated=${widget.key}`;\n        }\n      } else if (widget.type === \"wgeasy\") {\n        if (widget.username && widget.password) {\n          headers.Authorization = basicAuthHeader(widget);\n        } else {\n          headers.Authorization = widget.password;\n        }\n      } else if (widget.type === \"trilium\") {\n        headers.Authorization = widget.key;\n      } else if (widget.type === \"gitlab\") {\n        headers[\"PRIVATE-TOKEN\"] = widget.key;\n      } else if (widget.type === \"speedtest\") {\n        if (widget.key) {\n          // v1 does not require a key\n          headers.Authorization = `Bearer ${widget.key}`;\n        }\n      } else {\n        headers[\"X-API-Key\"] = `${widget.key}`;\n      }\n\n      const [status, contentType, data] = await httpProxy(url, {\n        method: req.method,\n        withCredentials: true,\n        credentials: \"include\",\n        headers,\n      });\n\n      let resultData = data;\n\n      if (resultData.error?.url) {\n        resultData.error.url = sanitizeErrorURL(url);\n      }\n\n      if (status === 204 || status === 304) {\n        return res.status(status).end();\n      }\n\n      if (status >= 400) {\n        logger.error(\"HTTP Error %d calling %s\", status, url.toString());\n      }\n\n      if (status === 200) {\n        if (!validateWidgetData(widget, endpoint, resultData)) {\n          return res\n            .status(500)\n            .json({ error: { message: \"Invalid data\", url: sanitizeErrorURL(url), data: resultData } });\n        }\n        if (map) resultData = map(resultData);\n      }\n\n      if (contentType) res.setHeader(\"Content-Type\", contentType);\n      return res.status(status).send(resultData);\n    }\n  }\n\n  logger.debug(\"Invalid or missing proxy service type '%s' in group '%s'\", service, group);\n  return res.status(400).json({ error: \"Invalid proxy service type\" });\n}\n"
  },
  {
    "path": "src/utils/proxy/handlers/credentialed.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { httpProxy } = vi.hoisted(() => ({ httpProxy: vi.fn() }));\nconst { validateWidgetData } = vi.hoisted(() => ({ validateWidgetData: vi.fn(() => true) }));\nconst { getServiceWidget } = vi.hoisted(() => ({ getServiceWidget: vi.fn() }));\nconst { getSettings } = vi.hoisted(() => ({\n  getSettings: vi.fn(() => ({ providers: { finnhub: \"finnhub-token\" } })),\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => ({\n    debug: vi.fn(),\n    error: vi.fn(),\n  }),\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({ httpProxy }));\nvi.mock(\"utils/proxy/validate-widget-data\", () => ({ default: validateWidgetData }));\nvi.mock(\"utils/config/service-helpers\", () => ({ default: getServiceWidget }));\nvi.mock(\"utils/config/config\", () => ({ getSettings }));\n\n// Keep the widget registry minimal so the test doesn't import the whole widget graph.\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    coinmarketcap: { api: \"{url}/{endpoint}\" },\n    gotify: { api: \"{url}/{endpoint}\" },\n    plantit: { api: \"{url}/{endpoint}\" },\n    myspeed: { api: \"{url}/{endpoint}\" },\n    esphome: { api: \"{url}/{endpoint}\" },\n    wgeasy: { api: \"{url}/{endpoint}\" },\n    linkwarden: { api: \"{url}/api/v1/{endpoint}\" },\n    miniflux: { api: \"{url}/{endpoint}\" },\n    nextcloud: { api: \"{url}/ocs/v2.php/apps/serverinfo/api/v1/{endpoint}\" },\n    paperlessngx: { api: \"{url}/api/{endpoint}\" },\n    proxmox: { api: \"{url}/api2/json/{endpoint}\" },\n    truenas: { api: \"{url}/api/v2.0/{endpoint}\" },\n    proxmoxbackupserver: { api: \"{url}/api2/json/{endpoint}\" },\n    checkmk: { api: \"{url}/{endpoint}\" },\n    stocks: { api: \"{url}/{endpoint}\" },\n    speedtest: { api: \"{url}/{endpoint}\" },\n    tubearchivist: { api: \"{url}/{endpoint}\" },\n    autobrr: { api: \"{url}/{endpoint}\" },\n    jellystat: { api: \"{url}/{endpoint}\" },\n    trilium: { api: \"{url}/{endpoint}\" },\n    gitlab: { api: \"{url}/{endpoint}\" },\n    azuredevops: { api: \"{url}/{endpoint}\" },\n    glances: { api: \"{url}/{endpoint}\" },\n    withheaders: { api: \"{url}/{endpoint}\", headers: { \"X-Widget\": \"1\" } },\n  },\n}));\n\nimport credentialedProxyHandler from \"./credentialed\";\n\nfunction createMockRes() {\n  const res = {\n    headers: {},\n    statusCode: undefined,\n    body: undefined,\n    setHeader: (k, v) => {\n      res.headers[k] = v;\n    },\n    status: (code) => {\n      res.statusCode = code;\n      return res;\n    },\n    json: (data) => {\n      res.body = data;\n      return res;\n    },\n    send: (data) => {\n      res.body = data;\n      return res;\n    },\n    end: () => res,\n  };\n  return res;\n}\n\ndescribe(\"utils/proxy/handlers/credentialed\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    validateWidgetData.mockReturnValue(true);\n  });\n\n  it(\"returns 400 when group/service are missing\", async () => {\n    const req = { method: \"GET\", query: { endpoint: \"e\", index: 0 } };\n    const res = createMockRes();\n\n    await credentialedProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Invalid proxy service type\" });\n  });\n\n  it(\"returns 400 when the widget cannot be resolved\", async () => {\n    getServiceWidget.mockResolvedValue(false);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", endpoint: \"collections\", index: 0 } };\n    const res = createMockRes();\n\n    await credentialedProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Invalid proxy service type\" });\n  });\n\n  it(\"returns 403 when the widget type does not support API calls\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"noapi\", url: \"http://example\", key: \"token\" });\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", endpoint: \"collections\", index: 0 } };\n    const res = createMockRes();\n\n    await credentialedProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(403);\n    expect(res.body).toEqual({ error: \"Service does not support API calls\" });\n  });\n\n  it(\"uses Bearer auth for linkwarden widgets\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"linkwarden\", url: \"http://example\", key: \"token\" });\n    httpProxy.mockResolvedValue([200, \"application/json\", { ok: true }]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", endpoint: \"collections\", index: 0 } };\n    const res = createMockRes();\n\n    await credentialedProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalled();\n    const [, params] = httpProxy.mock.calls[0];\n    expect(params.headers.Authorization).toBe(\"Bearer token\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ ok: true });\n  });\n\n  it(\"uses NC-Token auth for nextcloud widgets when key is provided\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"nextcloud\", url: \"http://example\", key: \"nc-token\" });\n    httpProxy.mockResolvedValue([200, \"application/json\", { ok: true }]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", endpoint: \"status\", index: 0 } };\n    const res = createMockRes();\n\n    await credentialedProxyHandler(req, res);\n\n    const [, params] = httpProxy.mock.calls.at(-1);\n    expect(params.headers[\"NC-Token\"]).toBe(\"nc-token\");\n    expect(params.headers.Authorization).toBeUndefined();\n  });\n\n  it(\"uses basic auth for nextcloud when key is not provided\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"nextcloud\", url: \"http://example\", username: \"u\", password: \"p\" });\n    httpProxy.mockResolvedValue([200, \"application/json\", { ok: true }]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", endpoint: \"status\", index: 0 } };\n    const res = createMockRes();\n\n    await credentialedProxyHandler(req, res);\n\n    const [, params] = httpProxy.mock.calls.at(-1);\n    expect(params.headers.Authorization).toMatch(/^Basic /);\n  });\n\n  it(\"uses basic auth for truenas when key is not provided\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"truenas\", url: \"http://nas\", username: \"u\", password: \"p\" });\n    httpProxy.mockResolvedValue([200, \"application/json\", { ok: true }]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", endpoint: \"system/info\", index: 0 } };\n    const res = createMockRes();\n\n    await credentialedProxyHandler(req, res);\n\n    const [, params] = httpProxy.mock.calls.at(-1);\n    expect(params.headers.Authorization).toMatch(/^Basic /);\n  });\n\n  it(\"uses Bearer auth for truenas when key is provided\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"truenas\", url: \"http://nas\", key: \"k\" });\n    httpProxy.mockResolvedValue([200, \"application/json\", { ok: true }]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", endpoint: \"system/info\", index: 0 } };\n    const res = createMockRes();\n\n    await credentialedProxyHandler(req, res);\n\n    const [, params] = httpProxy.mock.calls.at(-1);\n    expect(params.headers.Authorization).toBe(\"Bearer k\");\n  });\n\n  it.each([\n    [{ type: \"paperlessngx\", url: \"http://x\", key: \"k\" }, { Authorization: \"Token k\" }],\n    [\n      { type: \"paperlessngx\", url: \"http://x\", username: \"u\", password: \"p\" },\n      { Authorization: expect.stringMatching(/^Basic /) },\n    ],\n  ])(\"sets paperlessngx auth mode for %o\", async (widget, expected) => {\n    getServiceWidget.mockResolvedValue(widget);\n    httpProxy.mockResolvedValue([200, \"application/json\", { ok: true }]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", endpoint: \"documents\", index: 0 } };\n    const res = createMockRes();\n\n    await credentialedProxyHandler(req, res);\n\n    const [, params] = httpProxy.mock.calls.at(-1);\n    expect(params.headers).toEqual(expect.objectContaining(expected));\n  });\n\n  it(\"uses basic auth for esphome when username/password are provided\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"esphome\", url: \"http://x\", username: \"u\", password: \"p\" });\n    httpProxy.mockResolvedValue([200, \"application/json\", { ok: true }]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", endpoint: \"e\", index: 0 } };\n    const res = createMockRes();\n\n    await credentialedProxyHandler(req, res);\n\n    const [, params] = httpProxy.mock.calls.at(-1);\n    expect(params.headers.Authorization).toMatch(/^Basic /);\n  });\n\n  it(\"uses basic auth for wgeasy when username/password are provided\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"wgeasy\", url: \"http://x\", username: \"u\", password: \"p\" });\n    httpProxy.mockResolvedValue([200, \"application/json\", { ok: true }]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", endpoint: \"e\", index: 0 } };\n    const res = createMockRes();\n\n    await credentialedProxyHandler(req, res);\n\n    const [, params] = httpProxy.mock.calls.at(-1);\n    expect(params.headers.Authorization).toMatch(/^Basic /);\n  });\n\n  it(\"covers additional auth/header modes for common widgets\", async () => {\n    const cases = [\n      [{ type: \"coinmarketcap\", url: \"http://x\", key: \"k\" }, { \"X-CMC_PRO_API_KEY\": \"k\" }],\n      [{ type: \"gotify\", url: \"http://x\", key: \"k\" }, { \"X-gotify-Key\": \"k\" }],\n      [{ type: \"plantit\", url: \"http://x\", key: \"k\" }, { Key: \"k\" }],\n      [{ type: \"myspeed\", url: \"http://x\", password: \"p\" }, { Password: \"p\" }],\n      [{ type: \"proxmox\", url: \"http://x\", username: \"u\", password: \"p\" }, { Authorization: \"PVEAPIToken=u=p\" }],\n      [{ type: \"autobrr\", url: \"http://x\", key: \"k\" }, { \"X-API-Token\": \"k\" }],\n      [{ type: \"jellystat\", url: \"http://x\", key: \"k\" }, { \"X-API-Token\": \"k\" }],\n      [{ type: \"tubearchivist\", url: \"http://x\", key: \"k\" }, { Authorization: \"Token k\" }],\n      [{ type: \"miniflux\", url: \"http://x\", key: \"k\" }, { \"X-Auth-Token\": \"k\" }],\n      [{ type: \"trilium\", url: \"http://x\", key: \"k\" }, { Authorization: \"k\" }],\n      [{ type: \"gitlab\", url: \"http://x\", key: \"k\" }, { \"PRIVATE-TOKEN\": \"k\" }],\n      [{ type: \"speedtest\", url: \"http://x\", key: \"k\" }, { Authorization: \"Bearer k\" }],\n      [\n        { type: \"azuredevops\", url: \"http://x\", key: \"k\" },\n        { Authorization: `Basic ${Buffer.from(\"$:k\").toString(\"base64\")}` },\n      ],\n      [\n        { type: \"glances\", url: \"http://x\", username: \"u\", password: \"p\" },\n        { Authorization: expect.stringMatching(/^Basic /) },\n      ],\n      [{ type: \"wgeasy\", url: \"http://x\", password: \"p\" }, { Authorization: \"p\" }],\n      [{ type: \"esphome\", url: \"http://x\", key: \"cookie\" }, { Cookie: \"authenticated=cookie\" }],\n    ];\n\n    for (const [widget, expected] of cases) {\n      getServiceWidget.mockResolvedValue(widget);\n      httpProxy.mockResolvedValue([200, \"application/json\", { ok: true }]);\n\n      const req = { method: \"GET\", query: { group: \"g\", service: \"s\", endpoint: \"e\", index: 0 } };\n      const res = createMockRes();\n\n      await credentialedProxyHandler(req, res);\n\n      const [, params] = httpProxy.mock.calls.at(-1);\n      expect(params.headers).toEqual(expect.objectContaining(expected));\n    }\n  });\n\n  it(\"merges registry/widget/request headers and falls back to X-API-Key for unknown types\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"withheaders\",\n      url: \"http://example\",\n      key: \"k\",\n      headers: { \"X-From-Widget\": \"2\" },\n    });\n    httpProxy.mockResolvedValue([200, \"application/json\", { ok: true }]);\n\n    const req = {\n      method: \"GET\",\n      query: { group: \"g\", service: \"s\", endpoint: \"collections\", index: 0 },\n      extraHeaders: { \"X-From-Req\": \"3\" },\n    };\n    const res = createMockRes();\n\n    await credentialedProxyHandler(req, res);\n\n    const [, params] = httpProxy.mock.calls.at(-1);\n    expect(params.headers).toEqual(\n      expect.objectContaining({\n        \"Content-Type\": \"application/json\",\n        \"X-Widget\": \"1\",\n        \"X-From-Widget\": \"2\",\n        \"X-From-Req\": \"3\",\n        \"X-API-Key\": \"k\",\n      }),\n    );\n  });\n\n  it(\"sets PBSAPIToken auth and removes content-type for proxmoxbackupserver\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"proxmoxbackupserver\",\n      url: \"http://pbs\",\n      username: \"u\",\n      password: \"p\",\n    });\n    httpProxy.mockResolvedValue([200, \"application/json\", { ok: true }]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", endpoint: \"nodes\", index: 0 } };\n    const res = createMockRes();\n\n    await credentialedProxyHandler(req, res);\n\n    const [, params] = httpProxy.mock.calls.at(-1);\n    expect(params.headers[\"Content-Type\"]).toBeUndefined();\n    expect(params.headers.Authorization).toBe(\"PBSAPIToken=u:p\");\n  });\n\n  it(\"uses checkmk's Bearer username password auth format\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"checkmk\", url: \"http://checkmk\", username: \"u\", password: \"p\" });\n    httpProxy.mockResolvedValue([200, \"application/json\", { ok: true }]);\n\n    const req = {\n      method: \"GET\",\n      query: { group: \"g\", service: \"s\", endpoint: \"domain-types/host_config/collections/all\", index: 0 },\n    };\n    const res = createMockRes();\n\n    await credentialedProxyHandler(req, res);\n\n    const [, params] = httpProxy.mock.calls.at(-1);\n    expect(params.headers.Accept).toBe(\"application/json\");\n    expect(params.headers.Authorization).toBe(\"Bearer u p\");\n  });\n\n  it(\"injects the configured finnhub provider token for stocks widgets\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"stocks\", url: \"http://stocks\", provider: \"finnhub\" });\n    httpProxy.mockResolvedValue([200, \"application/json\", { ok: true }]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", endpoint: \"quote\", index: 0 } };\n    const res = createMockRes();\n\n    await credentialedProxyHandler(req, res);\n\n    const [, params] = httpProxy.mock.calls.at(-1);\n    expect(params.headers[\"X-Finnhub-Token\"]).toBe(\"finnhub-token\");\n  });\n\n  it(\"sanitizes embedded query params when a downstream error contains a url\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"linkwarden\", url: \"http://example\", key: \"token\" });\n    httpProxy.mockResolvedValue([500, \"application/json\", { error: { message: \"oops\", url: \"http://bad\" } }]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", endpoint: \"collections?apikey=secret\", index: 0 } };\n    const res = createMockRes();\n\n    await credentialedProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body.error.url).toContain(\"apikey=***\");\n  });\n\n  it(\"ends the response for 204/304 statuses\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"linkwarden\", url: \"http://example\", key: \"token\" });\n    httpProxy.mockResolvedValue([204, \"application/json\", Buffer.from(\"\")]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", endpoint: \"collections\", index: 0 } };\n    const res = createMockRes();\n\n    await credentialedProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(204);\n  });\n\n  it(\"returns invalid data errors as 500 when validation fails on 200 responses\", async () => {\n    validateWidgetData.mockReturnValueOnce(false);\n    getServiceWidget.mockResolvedValue({ type: \"linkwarden\", url: \"http://example\", key: \"token\" });\n    httpProxy.mockResolvedValue([200, \"application/json\", { ok: true }]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", endpoint: \"collections\", index: 0 } };\n    const res = createMockRes();\n\n    await credentialedProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body.error.message).toBe(\"Invalid data\");\n    expect(res.body.error.url).toContain(\"http://example/api/v1/collections\");\n  });\n\n  it(\"applies the response mapping function when provided\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"linkwarden\", url: \"http://example\", key: \"token\" });\n    httpProxy.mockResolvedValue([200, \"application/json\", { ok: true, value: 1 }]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", endpoint: \"collections\", index: 0 } };\n    const res = createMockRes();\n\n    await credentialedProxyHandler(req, res, (data) => ({ ok: data.ok, v: data.value }));\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ ok: true, v: 1 });\n  });\n});\n"
  },
  {
    "path": "src/utils/proxy/handlers/generic.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall, sanitizeErrorURL } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport validateWidgetData from \"utils/proxy/validate-widget-data\";\nimport widgets from \"widgets/widgets\";\n\nconst logger = createLogger(\"genericProxyHandler\");\n\nexport default async function genericProxyHandler(req, res, map) {\n  const { group, service, endpoint, index } = req.query;\n\n  if (group && service) {\n    const widget = await getServiceWidget(group, service, index);\n\n    if (!widgets?.[widget.type]?.api) {\n      return res.status(403).json({ error: \"Service does not support API calls\" });\n    }\n\n    if (widget) {\n      // if there are more than one question marks, replace others to &\n      let urlString = formatApiCall(widgets[widget.type].api, { endpoint, ...widget }).replace(/(?<=\\?.*)\\?/g, \"&\");\n      if (widget.type === \"customapi\" && widget.url?.endsWith(\"/\")) {\n        urlString += \"/\"; // Ensure we dont lose the trailing slash for custom API calls\n      }\n      const url = new URL(urlString);\n\n      const headers = {\n        ...(widgets[widget.type].headers ?? {}),\n        ...(widget.headers ?? {}),\n        ...(req.extraHeaders ?? {}),\n      };\n\n      if (widget.username && widget.password) {\n        headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString(\"base64\")}`;\n      }\n\n      const params = {\n        method: widget.method ?? req.method,\n        headers,\n      };\n      if (req.body) {\n        params.body = req.body;\n      } else if (widget.requestBody) {\n        if (typeof widget.requestBody === \"object\") {\n          params.body = JSON.stringify(widget.requestBody);\n        } else {\n          params.body = widget.requestBody;\n        }\n      }\n\n      const [status, contentType, data] = await httpProxy(url, params);\n\n      let resultData = data;\n\n      if (resultData.error?.url) {\n        resultData.error.url = sanitizeErrorURL(url);\n      }\n\n      if (status === 200) {\n        if (!validateWidgetData(widget, endpoint, resultData)) {\n          return res\n            .status(status)\n            .json({ error: { message: \"Invalid data\", url: sanitizeErrorURL(url), data: resultData } });\n        }\n        if (map) resultData = map(resultData);\n      }\n\n      if (contentType) res.setHeader(\"Content-Type\", contentType);\n\n      if (status === 204 || status === 304) {\n        return res.status(status).end();\n      }\n\n      if (status >= 400) {\n        logger.debug(\n          \"HTTP Error %d calling %s//%s%s%s...\",\n          status,\n          url.protocol,\n          url.hostname,\n          url.port ? `:${url.port}` : \"\",\n          url.pathname,\n        );\n        return res.status(status).json({\n          error: {\n            message: \"HTTP Error\",\n            url: sanitizeErrorURL(url),\n            data: Buffer.isBuffer(resultData) ? Buffer.from(resultData).toString() : resultData,\n          },\n        });\n      }\n\n      return res.status(status).send(resultData);\n    }\n  }\n\n  logger.debug(\"Invalid or missing proxy service type '%s' in group '%s'\", service, group);\n  return res.status(400).json({ error: \"Invalid proxy service type\" });\n}\n"
  },
  {
    "path": "src/utils/proxy/handlers/generic.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, validateWidgetData, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  validateWidgetData: vi.fn(() => true),\n  logger: { debug: vi.fn() },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\nvi.mock(\"utils/proxy/validate-widget-data\", () => ({\n  default: validateWidgetData,\n}));\n\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    testservice: {\n      api: \"{url}/{endpoint}\",\n    },\n    customapi: {\n      api: \"{url}/{endpoint}\",\n    },\n  },\n}));\n\nimport genericProxyHandler from \"./generic\";\n\ndescribe(\"utils/proxy/handlers/generic\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    validateWidgetData.mockReturnValue(true);\n  });\n\n  it(\"returns 403 when the service widget type does not define an API mapping\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"missing\",\n      url: \"http://example\",\n    });\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"svc\", endpoint: \"api\", index: \"0\" } };\n    const res = createMockRes();\n\n    await genericProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(403);\n    expect(res.body).toEqual({ error: \"Service does not support API calls\" });\n  });\n\n  it(\"replaces extra '?' characters in the endpoint with '&'\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"testservice\",\n      url: \"http://example\",\n    });\n\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"ok\")]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"svc\", endpoint: \"x?a=1?b=2\", index: \"0\" } };\n    const res = createMockRes();\n\n    await genericProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(1);\n    expect(httpProxy.mock.calls[0][0].toString()).toBe(\"http://example/x?a=1&b=2\");\n    expect(res.statusCode).toBe(200);\n  });\n\n  it(\"preserves trailing slash for customapi widgets when widget.url ends with /\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"customapi\",\n      url: \"http://example/\",\n    });\n\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"ok\")]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"svc\", endpoint: \"path\", index: \"0\" } };\n    const res = createMockRes();\n\n    await genericProxyHandler(req, res);\n\n    expect(httpProxy.mock.calls[0][0].toString()).toBe(\"http://example/path/\");\n  });\n\n  it(\"uses widget.requestBody as a string when req.body is not provided\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"testservice\",\n      url: \"http://example\",\n      method: \"POST\",\n      requestBody: \"raw-body\",\n    });\n\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"ok\")]);\n\n    const req = { method: \"POST\", query: { group: \"g\", service: \"svc\", endpoint: \"api\", index: \"0\" } };\n    const res = createMockRes();\n\n    await genericProxyHandler(req, res);\n\n    expect(httpProxy.mock.calls[0][1].body).toBe(\"raw-body\");\n  });\n\n  it(\"uses requestBody and basic auth headers when provided\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"testservice\",\n      url: \"http://example\",\n      method: \"POST\",\n      username: \"u\",\n      password: \"p\",\n      requestBody: { hello: \"world\" },\n    });\n\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"ok\")]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"svc\", endpoint: \"api\", index: \"0\" } };\n    const res = createMockRes();\n\n    await genericProxyHandler(req, res);\n\n    expect(httpProxy.mock.calls[0][1].method).toBe(\"POST\");\n    expect(httpProxy.mock.calls[0][1].headers.Authorization).toMatch(/^Basic /);\n    expect(httpProxy.mock.calls[0][1].body).toBe(JSON.stringify({ hello: \"world\" }));\n  });\n\n  it(\"sanitizes error urls embedded in successful payloads\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"testservice\",\n      url: \"http://example\",\n    });\n    httpProxy.mockResolvedValueOnce([\n      200,\n      \"application/json\",\n      {\n        error: {\n          url: \"http://upstream.example/?apikey=secret\",\n        },\n      },\n    ]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"svc\", endpoint: \"api?apikey=secret\", index: \"0\" } };\n    const res = createMockRes();\n\n    await genericProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body.error.url).toContain(\"apikey=***\");\n  });\n\n  it(\"returns an Invalid data error when validation fails\", async () => {\n    validateWidgetData.mockReturnValue(false);\n    getServiceWidget.mockResolvedValue({\n      type: \"testservice\",\n      url: \"http://example\",\n    });\n\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", { bad: true }]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"svc\", endpoint: \"api\", index: \"0\" } };\n    const res = createMockRes();\n\n    await genericProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body.error.message).toBe(\"Invalid data\");\n  });\n\n  it(\"uses string requestBody as-is and prefers req.body over widget.requestBody\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"testservice\",\n      url: \"http://example\",\n      requestBody: '{\"a\":1}',\n    });\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"ok\")]);\n\n    const req = {\n      method: \"POST\",\n      body: \"override-body\",\n      query: { group: \"g\", service: \"svc\", endpoint: \"api\", index: \"0\" },\n    };\n    const res = createMockRes();\n\n    await genericProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(1);\n    expect(httpProxy.mock.calls[0][1].body).toBe(\"override-body\");\n  });\n\n  it(\"ends the response for 204/304 statuses\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"testservice\",\n      url: \"http://example\",\n    });\n    httpProxy.mockResolvedValueOnce([204, \"application/json\", Buffer.from(\"\")]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"svc\", endpoint: \"api\", index: \"0\" } };\n    const res = createMockRes();\n\n    await genericProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(204);\n    expect(res.end).toHaveBeenCalled();\n  });\n\n  it(\"returns an HTTP Error object for status>=400 and stringifies buffer data\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"testservice\",\n      url: \"http://example\",\n    });\n    httpProxy.mockResolvedValueOnce([500, \"application/json\", Buffer.from(\"fail\")]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"svc\", endpoint: \"api?apikey=secret\", index: \"0\" } };\n    const res = createMockRes();\n\n    await genericProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body.error.message).toBe(\"HTTP Error\");\n    expect(res.body.error.url).toContain(\"apikey=***\");\n    expect(res.body.error.data).toBe(\"fail\");\n  });\n\n  it(\"returns 400 when group/service are missing\", async () => {\n    const req = { method: \"GET\", query: { endpoint: \"api\", index: \"0\" } };\n    const res = createMockRes();\n\n    await genericProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Invalid proxy service type\" });\n    expect(logger.debug).toHaveBeenCalled();\n  });\n\n  it(\"applies the response mapping function when provided\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"testservice\",\n      url: \"http://example\",\n    });\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", { ok: true }]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"svc\", endpoint: \"api\", index: \"0\" } };\n    const res = createMockRes();\n\n    await genericProxyHandler(req, res, (data) => ({ mapped: data.ok }));\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ mapped: true });\n  });\n});\n"
  },
  {
    "path": "src/utils/proxy/handlers/jsonrpc.js",
    "content": "import { JSONRPCClient, JSONRPCErrorException } from \"json-rpc-2.0\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst logger = createLogger(\"jsonrpcProxyHandler\");\n\nexport async function sendJsonRpcRequest(url, method, params, widget) {\n  const headers = {\n    \"content-type\": \"application/json\",\n    accept: \"application/json\",\n  };\n\n  if (widget?.username && widget?.password) {\n    headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString(\"base64\")}`;\n  }\n\n  if (widget?.key) {\n    headers.Authorization = `Bearer ${widget.key}`;\n  }\n\n  const client = new JSONRPCClient(async (rpcRequest) => {\n    const body = JSON.stringify(rpcRequest);\n    const httpRequestParams = {\n      method: \"POST\",\n      headers,\n      body,\n    };\n\n    const [status, contentType, data] = await httpProxy(url, httpRequestParams);\n    if (status === 200) {\n      const json = JSON.parse(data.toString());\n\n      if (json.id === null) {\n        json.id = 1;\n      }\n\n      // in order to get access to the underlying error object in the JSON response\n      // you must set `result` equal to undefined\n      if (json.error && json.result === null) {\n        json.result = undefined;\n      }\n      return client.receive(json);\n    }\n\n    return Promise.reject(data?.error ? data : new Error(data.toString()));\n  });\n\n  try {\n    const response = await client.request(method, params);\n    return [200, \"application/json\", JSON.stringify(response)];\n  } catch (e) {\n    if (e instanceof JSONRPCErrorException) {\n      logger.debug(\"Error calling JSONPRC endpoint: %s.  %s\", url, e.message);\n      return [200, \"application/json\", JSON.stringify({ result: null, error: { code: e.code, message: e.message } })];\n    }\n\n    logger.warn(\"Error calling JSONPRC endpoint: %s.  %s\", url, e);\n    return [500, \"application/json\", JSON.stringify({ result: null, error: { code: 2, message: e.toString() } })];\n  }\n}\n\nexport default async function jsonrpcProxyHandler(req, res) {\n  const { group, service, endpoint: method, index } = req.query;\n\n  if (group && service) {\n    const widget = await getServiceWidget(group, service, index);\n    const api = widgets?.[widget.type]?.api;\n\n    const [, mapping] = Object.entries(widgets?.[widget.type]?.mappings).find(([, value]) => value.endpoint === method);\n    const params = mapping?.params ?? null;\n\n    if (!api) {\n      return res.status(403).json({ error: \"Service does not support API calls\" });\n    }\n\n    if (widget) {\n      const url = formatApiCall(api, { ...widget });\n\n      const [status, , data] = await sendJsonRpcRequest(url, method, params, widget);\n      return res.status(status).end(data);\n    }\n  }\n\n  logger.debug(\"Invalid or missing proxy service type '%s' in group '%s'\", service, group);\n  return res.status(400).json({ error: \"Invalid proxy service type\" });\n}\n"
  },
  {
    "path": "src/utils/proxy/handlers/jsonrpc.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  logger: { debug: vi.fn(), warn: vi.fn() },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    rpcwidget: {\n      api: \"{url}/jsonrpc\",\n      mappings: {\n        list: { endpoint: \"test.method\", params: [1, 2] },\n      },\n    },\n    missingapi: {\n      mappings: {\n        list: { endpoint: \"test.method\", params: [1, 2] },\n      },\n    },\n  },\n}));\n\ndescribe(\"utils/proxy/handlers/jsonrpc sendJsonRpcRequest\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"sends a JSON-RPC request and returns the response\", async () => {\n    const { sendJsonRpcRequest } = await import(\"./jsonrpc\");\n    httpProxy.mockImplementationOnce(async (_url, params) => {\n      const req = JSON.parse(params.body);\n      return [\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ jsonrpc: \"2.0\", id: req.id, result: { ok: true } })),\n      ];\n    });\n\n    const [status, contentType, data] = await sendJsonRpcRequest(\"http://rpc\", \"test.method\", [1], {\n      username: \"u\",\n      password: \"p\",\n    });\n\n    expect(status).toBe(200);\n    expect(contentType).toBe(\"application/json\");\n    expect(JSON.parse(data)).toEqual({ ok: true });\n    expect(httpProxy).toHaveBeenCalledTimes(1);\n    expect(httpProxy.mock.calls[0][1].headers.Authorization).toMatch(/^Basic /);\n  });\n\n  it(\"maps JSON-RPC error responses into a result=null error object\", async () => {\n    const { sendJsonRpcRequest } = await import(\"./jsonrpc\");\n    httpProxy.mockImplementationOnce(async (_url, params) => {\n      const req = JSON.parse(params.body);\n      return [\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ jsonrpc: \"2.0\", id: req.id, result: null, error: { code: 123, message: \"bad\" } })),\n      ];\n    });\n\n    const [status, , data] = await sendJsonRpcRequest(\"http://rpc\", \"test.method\", null, { key: \"token\" });\n\n    expect(status).toBe(200);\n    expect(JSON.parse(data)).toEqual({ result: null, error: { code: 123, message: \"bad\" } });\n    expect(httpProxy.mock.calls[0][1].headers.Authorization).toBe(\"Bearer token\");\n  });\n\n  it(\"prefers Bearer auth when both basic credentials and a key are provided\", async () => {\n    const { sendJsonRpcRequest } = await import(\"./jsonrpc\");\n    httpProxy.mockImplementationOnce(async (_url, params) => {\n      const req = JSON.parse(params.body);\n      return [\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ jsonrpc: \"2.0\", id: req.id, result: { ok: true } })),\n      ];\n    });\n\n    const [, , data] = await sendJsonRpcRequest(\"http://rpc\", \"test.method\", null, {\n      username: \"u\",\n      password: \"p\",\n      key: \"token\",\n    });\n\n    expect(JSON.parse(data)).toEqual({ ok: true });\n    expect(httpProxy.mock.calls[0][1].headers.Authorization).toBe(\"Bearer token\");\n  });\n\n  it(\"maps transport/parse failures into a JSON-RPC style error response\", async () => {\n    const { sendJsonRpcRequest } = await import(\"./jsonrpc\");\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"not-json\")]);\n\n    const [status, , data] = await sendJsonRpcRequest(\"http://rpc\", \"test.method\", null, { key: \"token\" });\n\n    expect(status).toBe(200);\n    expect(JSON.parse(data)).toEqual({\n      result: null,\n      error: { code: expect.any(Number), message: expect.any(String) },\n    });\n    expect(logger.debug).toHaveBeenCalled();\n  });\n\n  it(\"normalizes id=null responses so the client can still receive a result\", async () => {\n    const { sendJsonRpcRequest } = await import(\"./jsonrpc\");\n    httpProxy.mockImplementationOnce(async (_url, params) => {\n      const req = JSON.parse(params.body);\n      expect(req.id).toBe(1);\n      return [200, \"application/json\", Buffer.from(JSON.stringify({ jsonrpc: \"2.0\", id: null, result: { ok: true } }))];\n    });\n\n    const [status, , data] = await sendJsonRpcRequest(\"http://rpc\", \"test.method\", null, { key: \"token\" });\n\n    expect(status).toBe(200);\n    expect(JSON.parse(data)).toEqual({ ok: true });\n  });\n});\n\ndescribe(\"utils/proxy/handlers/jsonrpc proxy handler\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"looks up the widget, applies mappings, and returns JSON-RPC data\", async () => {\n    const { default: jsonrpcProxyHandler } = await import(\"./jsonrpc\");\n\n    getServiceWidget.mockResolvedValue({ type: \"rpcwidget\", url: \"http://rpc\", key: \"token\" });\n    httpProxy.mockImplementationOnce(async (_url, params) => {\n      const req = JSON.parse(params.body);\n      return [\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ jsonrpc: \"2.0\", id: req.id, result: { method: req.method, params: req.params } })),\n      ];\n    });\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", endpoint: \"test.method\", index: 0 } };\n    const res = createMockRes();\n\n    await jsonrpcProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    const json = JSON.parse(res.body);\n    expect(json).toEqual({ method: \"test.method\", params: [1, 2] });\n  });\n\n  it(\"returns 403 when the widget does not support API calls\", async () => {\n    const { default: jsonrpcProxyHandler } = await import(\"./jsonrpc\");\n\n    getServiceWidget.mockResolvedValue({ type: \"missingapi\", url: \"http://rpc\" });\n    const req = { method: \"GET\", query: { group: \"g\", service: \"s\", endpoint: \"test.method\", index: 0 } };\n    const res = createMockRes();\n\n    await jsonrpcProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(403);\n    expect(res.body).toEqual({ error: \"Service does not support API calls\" });\n  });\n\n  it(\"returns 400 for invalid requests without group/service\", async () => {\n    const { default: jsonrpcProxyHandler } = await import(\"./jsonrpc\");\n\n    const req = { method: \"GET\", query: { endpoint: \"test.method\" } };\n    const res = createMockRes();\n\n    await jsonrpcProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Invalid proxy service type\" });\n  });\n});\n\ndescribe(\"utils/proxy/handlers/jsonrpc unexpected errors\", () => {\n  it(\"returns 500 when the JSON-RPC client throws a non-JSONRPCErrorException\", async () => {\n    vi.resetModules();\n    vi.doMock(\"json-rpc-2.0\", () => {\n      class JSONRPCErrorException extends Error {\n        constructor(message, code) {\n          super(message);\n          this.code = code;\n        }\n      }\n\n      class JSONRPCClient {\n        constructor() {}\n\n        receive() {}\n\n        async request() {\n          throw new Error(\"boom\");\n        }\n      }\n\n      return { JSONRPCClient, JSONRPCErrorException };\n    });\n\n    const { sendJsonRpcRequest } = await import(\"./jsonrpc\");\n    const [status, , data] = await sendJsonRpcRequest(\"http://rpc\", \"test.method\", null, { key: \"token\" });\n\n    expect(status).toBe(500);\n    expect(JSON.parse(data)).toEqual({ result: null, error: { code: 2, message: \"Error: boom\" } });\n    expect(logger.warn).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/utils/proxy/handlers/synology.js",
    "content": "import cache from \"memory-cache\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { asJson, formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst INFO_ENDPOINT = \"{url}/webapi/query.cgi?api=SYNO.API.Info&version=1&method=query\";\nconst AUTH_ENDPOINT =\n  \"{url}/webapi/{path}?api=SYNO.API.Auth&version={maxVersion}&method=login&account={username}&passwd={password}&session=DownloadStation&format=cookie\";\nconst AUTH_API_NAME = \"SYNO.API.Auth\";\n\nconst proxyName = \"synologyProxyHandler\";\nconst logger = createLogger(proxyName);\n\nasync function login(loginUrl) {\n  const [status, contentType, data] = await httpProxy(loginUrl);\n  if (status !== 200) {\n    return [status, contentType, data];\n  }\n\n  const json = asJson(data);\n  if (json?.success !== true) {\n    // from page 16: https://global.download.synology.com/download/Document/Software/DeveloperGuide/Os/DSM/All/enu/DSM_Login_Web_API_Guide_enu.pdf\n    /*\n      Code Description\n      400  No such account or incorrect password\n      401  Account disabled\n      402  Permission denied\n      403  2-step verification code required\n      404  Failed to authenticate 2-step verification code\n    */\n    let message = \"Authentication failed.\";\n    if (json?.error?.code >= 403) message += \" 2FA enabled.\";\n    logger.warn(\"Unable to login.  Code: %d\", json?.error?.code);\n    return [401, \"application/json\", JSON.stringify({ code: json?.error?.code, message })];\n  }\n\n  return [status, contentType, data];\n}\n\nasync function getApiInfo(serviceWidget, apiName, serviceName) {\n  const cacheKey = `${proxyName}__${apiName}__${serviceName}`;\n  let { cgiPath, maxVersion } = cache.get(cacheKey) ?? {};\n  if (cgiPath && maxVersion) {\n    return [cgiPath, maxVersion];\n  }\n\n  const infoUrl = formatApiCall(INFO_ENDPOINT, serviceWidget);\n\n  const [status, contentType, data] = await httpProxy(infoUrl);\n\n  if (status === 200) {\n    try {\n      const json = asJson(data);\n      if (json?.data?.[apiName]) {\n        cgiPath = json.data[apiName].path;\n        maxVersion = json.data[apiName].maxVersion;\n        logger.debug(\n          `Detected ${serviceWidget.type}: apiName '${apiName}', cgiPath '${cgiPath}', and maxVersion ${maxVersion}`,\n        );\n        cache.put(cacheKey, { cgiPath, maxVersion });\n        return [cgiPath, maxVersion];\n      }\n    } catch {\n      logger.warn(`Error ${status} obtaining ${apiName} info`);\n    }\n  }\n\n  return [null, null];\n}\n\nasync function handleUnsuccessfulResponse(serviceWidget, url, serviceName) {\n  logger.debug(`Attempting login to ${serviceWidget.type}`);\n\n  const [apiPath, maxVersion] = await getApiInfo(serviceWidget, AUTH_API_NAME, serviceName);\n\n  const authArgs = { path: apiPath ?? \"entry.cgi\", maxVersion: maxVersion ?? 7, ...serviceWidget };\n  const loginUrl = formatApiCall(AUTH_ENDPOINT, authArgs);\n\n  const [status, contentType, data] = await login(loginUrl);\n  if (status !== 200) {\n    return [status, contentType, data];\n  }\n\n  return httpProxy(url);\n}\n\nfunction toError(url, synologyError) {\n  // commeon codes (100 => 199) from:\n  // https://global.download.synology.com/download/Document/Software/DeveloperGuide/Os/DSM/All/enu/DSM_Login_Web_API_Guide_enu.pdf\n  const code = synologyError.error?.code ?? synologyError.error ?? synologyError.code ?? 100;\n  const error = { code };\n  switch (code) {\n    case 102:\n      error.error = \"The requested API does not exist.\";\n      break;\n\n    case 103:\n      error.error = \"The requested method does not exist.\";\n      break;\n\n    case 104:\n      error.error = \"The requested version does not support the functionality.\";\n      break;\n\n    case 105:\n      error.error = \"The logged in session does not have permission.\";\n      break;\n\n    case 106:\n      error.error = \"Session timeout.\";\n      break;\n\n    case 107:\n      error.error = \"Session interrupted by duplicated login.\";\n      break;\n\n    case 119:\n      error.error = \"Invalid session or SID not found.\";\n      break;\n\n    default:\n      error.error = synologyError.message ?? \"Unknown error.\";\n      break;\n  }\n  logger.warn(`Unable to call ${url}.  code: ${code}, error: ${error.error}.`);\n  return error;\n}\n\nexport default async function synologyProxyHandler(req, res) {\n  const { group, service, endpoint, index } = req.query;\n\n  if (!group || !service) {\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const serviceWidget = await getServiceWidget(group, service, index);\n  if (!serviceWidget) {\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n  const widget = widgets?.[serviceWidget.type];\n  const mapping = widget?.mappings?.[endpoint];\n  if (!widget.api || !mapping) {\n    return res.status(403).json({ error: \"Service does not support API calls\" });\n  }\n\n  const [cgiPath, maxVersion] = await getApiInfo(serviceWidget, mapping.apiName, service);\n  if (!cgiPath || !maxVersion) {\n    return res.status(400).json({ error: `Unrecognized API name: ${mapping.apiName}` });\n  }\n\n  const url = formatApiCall(widget.api, {\n    apiName: mapping.apiName,\n    apiMethod: mapping.apiMethod,\n    cgiPath,\n    maxVersion,\n    ...serviceWidget,\n  });\n  let [status, contentType, data] = await httpProxy(url);\n  if (status !== 200) {\n    logger.debug(\"Error %d calling url %s\", status, url);\n    if (contentType) res.setHeader(\"Content-Type\", contentType);\n    return res.status(status).send(data);\n  }\n\n  let json = asJson(data);\n  if (json?.success !== true) {\n    logger.debug(`Attempting login to ${serviceWidget.type}`);\n    [status, contentType, data] = await handleUnsuccessfulResponse(serviceWidget, url, service);\n    json = asJson(data);\n  }\n\n  if (json.success !== true) {\n    data = toError(url, json);\n    status = 500;\n  }\n  if (contentType) res.setHeader(\"Content-Type\", contentType);\n  return res.status(status).send(data);\n}\n"
  },
  {
    "path": "src/utils/proxy/handlers/synology.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {\n  const store = new Map();\n  return {\n    httpProxy: vi.fn(),\n    getServiceWidget: vi.fn(),\n    cache: {\n      get: vi.fn((k) => store.get(k)),\n      put: vi.fn((k, v) => store.set(k, v)),\n      del: vi.fn((k) => store.delete(k)),\n      _reset: () => store.clear(),\n    },\n    logger: { debug: vi.fn(), warn: vi.fn() },\n  };\n});\n\nvi.mock(\"memory-cache\", () => ({\n  default: cache,\n  ...cache,\n}));\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    synology: {\n      api: \"{url}/webapi/{cgiPath}?api={apiName}&version={maxVersion}&method={apiMethod}\",\n      mappings: {\n        download: { apiName: \"SYNO.DownloadStation2.Task\", apiMethod: \"list\" },\n      },\n    },\n  },\n}));\n\nimport synologyProxyHandler from \"./synology\";\n\ndescribe(\"utils/proxy/handlers/synology\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    cache._reset();\n  });\n\n  it(\"returns 400 when group/service are missing\", async () => {\n    const req = { query: { endpoint: \"download\", index: \"0\" } };\n    const res = createMockRes();\n\n    await synologyProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Invalid proxy service type\" });\n  });\n\n  it(\"returns 400 when the widget cannot be resolved\", async () => {\n    getServiceWidget.mockResolvedValue(false);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"download\", index: \"0\" } };\n    const res = createMockRes();\n\n    await synologyProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Invalid proxy service type\" });\n  });\n\n  it(\"returns 403 when the endpoint is not mapped\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"synology\", url: \"http://nas\", username: \"u\", password: \"p\" });\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"nope\", index: \"0\" } };\n    const res = createMockRes();\n\n    await synologyProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(403);\n    expect(res.body).toEqual({ error: \"Service does not support API calls\" });\n  });\n\n  it(\"calls the mapped API when api info is available and success is true\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"synology\", url: \"http://nas\", username: \"u\", password: \"p\" });\n\n    httpProxy\n      // info query\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ data: { \"SYNO.DownloadStation2.Task\": { path: \"entry.cgi\", maxVersion: 2 } } })),\n      ])\n      // api call\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ success: true, data: { ok: true } })),\n      ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"download\", index: \"0\" } };\n    const res = createMockRes();\n\n    await synologyProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(2);\n    expect(httpProxy.mock.calls[1][0]).toContain(\"/webapi/entry.cgi?api=SYNO.DownloadStation2.Task\");\n    expect(res.statusCode).toBe(200);\n    expect(JSON.parse(res.body.toString()).data.ok).toBe(true);\n  });\n\n  it(\"caches api info lookups to avoid repeated query calls\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"synology\", url: \"http://nas\", username: \"u\", password: \"p\" });\n\n    httpProxy\n      // first call info query\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ data: { \"SYNO.DownloadStation2.Task\": { path: \"entry.cgi\", maxVersion: 2 } } })),\n      ])\n      // first call api\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ success: true }))])\n      // second call api only (info should be cached)\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ success: true }))]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"download\", index: \"0\" } };\n    const res1 = createMockRes();\n    const res2 = createMockRes();\n\n    await synologyProxyHandler(req, res1);\n    await synologyProxyHandler(req, res2);\n\n    expect(httpProxy).toHaveBeenCalledTimes(3);\n    // second invocation should not re-fetch api info\n    expect(httpProxy.mock.calls[2][0]).toContain(\"/webapi/entry.cgi?api=SYNO.DownloadStation2.Task\");\n  });\n\n  it(\"returns non-200 proxy responses as-is (with content-type)\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"synology\", url: \"http://nas\", username: \"u\", password: \"p\" });\n\n    httpProxy\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ data: { \"SYNO.DownloadStation2.Task\": { path: \"entry.cgi\", maxVersion: 2 } } })),\n      ])\n      .mockResolvedValueOnce([503, \"text/plain\", Buffer.from(\"nope\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"download\", index: \"0\" } };\n    const res = createMockRes();\n\n    await synologyProxyHandler(req, res);\n\n    expect(res.headers[\"Content-Type\"]).toBe(\"text/plain\");\n    expect(res.statusCode).toBe(503);\n    expect(res.body).toEqual(Buffer.from(\"nope\"));\n  });\n\n  it(\"returns 400 when the API name is unrecognized\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"synology\", url: \"http://nas\", username: \"u\", password: \"p\" });\n\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ data: {} }))]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"download\", index: \"0\" } };\n    const res = createMockRes();\n\n    await synologyProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Unrecognized API name: SYNO.DownloadStation2.Task\" });\n  });\n\n  it(\"logs a warning when API info returns invalid JSON and treats the API name as unrecognized\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"synology\", url: \"http://nas\", username: \"u\", password: \"p\" });\n\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"{not json\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"download\", index: \"0\" } };\n    const res = createMockRes();\n\n    await synologyProxyHandler(req, res);\n\n    expect(logger.warn).toHaveBeenCalled();\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Unrecognized API name: SYNO.DownloadStation2.Task\" });\n  });\n\n  it(\"includes a 2FA hint when authentication fails with a 403+ error code\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"synology\", url: \"http://nas\", username: \"u\", password: \"p\" });\n\n    httpProxy\n      // info query for mapping api name\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(\n          JSON.stringify({\n            data: {\n              \"SYNO.DownloadStation2.Task\": { path: \"entry.cgi\", maxVersion: 2 },\n              \"SYNO.API.Auth\": { path: \"auth.cgi\", maxVersion: 7 },\n            },\n          }),\n        ),\n      ])\n      // api call returns success false -> triggers login\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ success: false, error: { code: 106 } })),\n      ])\n      // info query for auth api name\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ data: { \"SYNO.API.Auth\": { path: \"auth.cgi\", maxVersion: 7 } } })),\n      ])\n      // login returns success false with 2fa-required code\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ success: false, error: { code: 403 } })),\n      ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"download\", index: \"0\" } };\n    const res = createMockRes();\n\n    await synologyProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual(expect.objectContaining({ code: 403, error: expect.stringContaining(\"2FA\") }));\n  });\n\n  it(\"handles non-200 login responses and surfaces a synology error code\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"synology\", url: \"http://nas\", username: \"u\", password: \"p\" });\n\n    httpProxy\n      // info query for mapping api name\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(\n          JSON.stringify({\n            data: {\n              \"SYNO.DownloadStation2.Task\": { path: \"entry.cgi\", maxVersion: 2 },\n              \"SYNO.API.Auth\": { path: \"auth.cgi\", maxVersion: 7 },\n            },\n          }),\n        ),\n      ])\n      // api call returns success false -> triggers login\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ success: false, error: { code: 106 } })),\n      ])\n      // info query for auth api name\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ data: { \"SYNO.API.Auth\": { path: \"auth.cgi\", maxVersion: 7 } } })),\n      ])\n      // login is non-200 => login() returns early\n      .mockResolvedValueOnce([\n        503,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ success: false, error: { code: 103 } })),\n      ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"download\", index: \"0\" } };\n    const res = createMockRes();\n\n    await synologyProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ code: 103, error: \"The requested method does not exist.\" });\n  });\n\n  it(\"attempts login and retries when the initial response is unsuccessful\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"synology\", url: \"http://nas\", username: \"u\", password: \"p\" });\n\n    httpProxy\n      // info query for mapping api name\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(\n          JSON.stringify({\n            data: {\n              \"SYNO.DownloadStation2.Task\": { path: \"entry.cgi\", maxVersion: 2 },\n              \"SYNO.API.Auth\": { path: \"auth.cgi\", maxVersion: 7 },\n            },\n          }),\n        ),\n      ])\n      // api call returns success false\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ success: false, error: { code: 106 } })),\n      ])\n      // info query for auth api name\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ data: { \"SYNO.API.Auth\": { path: \"auth.cgi\", maxVersion: 7 } } })),\n      ])\n      // login success\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ success: true }))])\n      // retry still fails\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ success: false, error: { code: 106 } })),\n      ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"download\", index: \"0\" } };\n    const res = createMockRes();\n\n    await synologyProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ code: 106, error: \"Session timeout.\" });\n  });\n\n  it.each([\n    [102, \"The requested API does not exist.\"],\n    [104, \"The requested version does not support the functionality.\"],\n    [105, \"The logged in session does not have permission.\"],\n    [107, \"Session interrupted by duplicated login.\"],\n    [119, \"Invalid session or SID not found.\"],\n  ])(\"maps synology error code %s to a friendly error\", async (code, expected) => {\n    getServiceWidget.mockResolvedValue({ type: \"synology\", url: \"http://nas\", username: \"u\", password: \"p\" });\n\n    httpProxy\n      // info query for mapping api name\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(\n          JSON.stringify({\n            data: {\n              \"SYNO.DownloadStation2.Task\": { path: \"entry.cgi\", maxVersion: 2 },\n              \"SYNO.API.Auth\": { path: \"auth.cgi\", maxVersion: 7 },\n            },\n          }),\n        ),\n      ])\n      // api call returns success false -> triggers login\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ success: false, error: { code } })),\n      ])\n      // info query for auth api name\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ data: { \"SYNO.API.Auth\": { path: \"auth.cgi\", maxVersion: 7 } } })),\n      ])\n      // login success\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ success: true }))])\n      // retry still fails with the same code\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ success: false, error: { code } })),\n      ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"download\", index: \"0\" } };\n    const res = createMockRes();\n\n    await synologyProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ code, error: expected });\n  });\n});\n"
  },
  {
    "path": "src/utils/proxy/http.js",
    "content": "import dns from \"node:dns\";\nimport net from \"node:net\";\nimport { createUnzip, constants as zlibConstants } from \"node:zlib\";\n\nimport { http, https } from \"follow-redirects\";\nimport cache from \"memory-cache\";\n\nimport { sanitizeErrorURL } from \"./api-helpers\";\nimport { addCookieToJar, setCookieHeader } from \"./cookie-jar\";\n\nimport createLogger from \"utils/logger\";\n\nconst logger = createLogger(\"httpProxy\");\n\nfunction addCookieHandler(url, params) {\n  setCookieHeader(url, params);\n\n  // handle cookies during redirects\n  params.beforeRedirect = (options, responseInfo) => {\n    addCookieToJar(options.href, responseInfo.headers);\n    setCookieHeader(options.href, options);\n  };\n}\n\nfunction handleRequest(requestor, url, params) {\n  return new Promise((resolve, reject) => {\n    addCookieHandler(url, params);\n    if (params?.body) {\n      params.headers = params.headers ?? {};\n      params.headers[\"content-length\"] = Buffer.byteLength(params.body);\n    }\n\n    const request = requestor.request(url, params, (response) => {\n      const data = [];\n      const contentEncoding = response.headers[\"content-encoding\"]?.trim().toLowerCase();\n\n      let responseContent = response;\n      if (contentEncoding === \"gzip\" || contentEncoding === \"deflate\") {\n        // https://github.com/request/request/blob/3c0cddc7c8eb60b470e9519da85896ed7ee0081e/request.js#L1018-L1025\n        // Be more lenient with decoding compressed responses, in case of invalid gzip responses that are still accepted\n        // by common browsers.\n        responseContent = createUnzip({\n          flush: zlibConstants.Z_SYNC_FLUSH,\n          finishFlush: zlibConstants.Z_SYNC_FLUSH,\n        });\n\n        // zlib errors\n        responseContent.on(\"error\", (e) => {\n          if (e) logger.error(e);\n          responseContent = response; // fallback\n        });\n        response.pipe(responseContent);\n      }\n\n      responseContent.on(\"data\", (chunk) => {\n        data.push(chunk);\n      });\n\n      responseContent.on(\"end\", () => {\n        addCookieToJar(url, response.headers);\n        resolve([response.statusCode, response.headers[\"content-type\"], Buffer.concat(data), response.headers]);\n      });\n    });\n\n    request.on(\"error\", (error) => {\n      reject([500, error]);\n    });\n\n    if (params?.body) {\n      request.write(params.body);\n    }\n\n    request.end();\n  });\n}\n\nexport function httpsRequest(url, params) {\n  return handleRequest(https, url, params);\n}\n\nexport function httpRequest(url, params) {\n  return handleRequest(http, url, params);\n}\n\nexport async function cachedRequest(url, duration = 5, ua = \"homepage\") {\n  const cached = cache.get(url);\n\n  if (cached) {\n    return cached;\n  }\n\n  const options = {\n    headers: {\n      \"User-Agent\": ua,\n      Accept: \"application/json\",\n    },\n  };\n  let [, , data] = await httpProxy(url, options);\n  if (Buffer.isBuffer(data)) {\n    try {\n      data = JSON.parse(Buffer.from(data).toString());\n    } catch (e) {\n      logger.debug(\"Error parsing cachedRequest data for %s: %s %s\", url, Buffer.from(data).toString(), e);\n      data = Buffer.from(data).toString();\n    }\n  }\n  cache.put(url, data, duration * 1000 * 60);\n  return data;\n}\n\n// Custom DNS lookup that falls back to Node.js c-ares resolver (dns.resolve)\n// when system getaddrinfo (dns.lookup) fails with ENOTFOUND/EAI_NONAME.\n// Fixes DNS resolution issues with Alpine/musl libc in k8s\nconst FALLBACK_CODES = new Set([\"ENOTFOUND\", \"EAI_NONAME\"]);\n\nfunction homepageDNSLookupFn() {\n  const normalizeOptions = (options) => {\n    if (typeof options === \"number\") {\n      return { family: options, all: false, lookupOptions: { family: options } };\n    }\n\n    const normalized = options ?? {};\n    return {\n      family: normalized.family,\n      all: Boolean(normalized.all),\n      lookupOptions: normalized,\n    };\n  };\n\n  return (hostname, options, callback) => {\n    // Handle case where options is the callback (2-argument form)\n    if (typeof options === \"function\") {\n      callback = options;\n      options = {};\n    }\n\n    const { family, all, lookupOptions } = normalizeOptions(options);\n    const sendResponse = (addr, fam) => {\n      if (all) {\n        let addresses = addr;\n        if (!Array.isArray(addresses)) {\n          addresses = [{ address: addresses, family: fam }];\n        } else if (addresses.length && typeof addresses[0] === \"string\") {\n          addresses = addresses.map((a) => ({ address: a, family: fam }));\n        }\n\n        callback(null, addresses);\n      } else {\n        callback(null, addr, fam);\n      }\n    };\n\n    // If hostname is already an IP address, return it directly\n    const ipVersion = net.isIP(hostname);\n    if (ipVersion) {\n      sendResponse(hostname, ipVersion);\n      return;\n    }\n\n    // Try dns.lookup first (preserves /etc/hosts behavior)\n    dns.lookup(hostname, lookupOptions, (lookupErr, address, lookupFamily) => {\n      if (!lookupErr) {\n        sendResponse(address, lookupFamily);\n        return;\n      }\n\n      // ENOTFOUND or EAI_NONAME will try fallback, otherwise return error here\n      if (!FALLBACK_CODES.has(lookupErr.code)) {\n        callback(lookupErr);\n        return;\n      }\n\n      const finalize = (addresses, resolvedFamily) => {\n        // Finalize the resolution and call the callback\n        if (!addresses || addresses.length === 0) {\n          const err = new Error(`No addresses found for hostname: ${hostname}`);\n          err.code = \"ENOTFOUND\";\n          callback(err);\n          return;\n        }\n\n        logger.debug(\"DNS fallback to c-ares resolver succeeded for %s\", hostname);\n\n        sendResponse(addresses, resolvedFamily);\n      };\n\n      const resolveOnce = (fn, resolvedFamily, onFail) => {\n        // attempt resolution with a specific resolver\n        fn(hostname, (err, addresses) => {\n          if (!err) {\n            finalize(addresses, resolvedFamily);\n            return;\n          }\n          onFail(err);\n        });\n      };\n\n      const handleFallbackFailure = (resolveErr) => {\n        // handle final fallback failure with full context\n        logger.debug(\n          \"DNS fallback failed for %s: lookup error=%s, resolve error=%s\",\n          hostname,\n          lookupErr.code,\n          resolveErr?.code,\n        );\n        callback(resolveErr || lookupErr);\n      };\n\n      // Fallback to c-ares (dns.resolve*). If family isn't specified, try v4 then v6.\n      if (family === 6) {\n        resolveOnce(dns.resolve6, 6, handleFallbackFailure);\n        return;\n      }\n\n      if (family === 4) {\n        resolveOnce(dns.resolve4, 4, handleFallbackFailure);\n        return;\n      }\n\n      resolveOnce(dns.resolve4, 4, () => {\n        resolveOnce(dns.resolve6, 6, handleFallbackFailure);\n      });\n    });\n  };\n}\n\nexport async function httpProxy(url, params = {}) {\n  const constructedUrl = new URL(url);\n  const disableIpv6 = process.env.HOMEPAGE_PROXY_DISABLE_IPV6 === \"true\";\n  const agentOptions = {\n    ...(disableIpv6 ? { family: 4, autoSelectFamily: false } : { autoSelectFamilyAttemptTimeout: 500 }),\n    lookup: homepageDNSLookupFn(),\n  };\n\n  let request = null;\n  if (constructedUrl.protocol === \"https:\") {\n    request = httpsRequest(constructedUrl, {\n      agent: new https.Agent({ ...agentOptions, rejectUnauthorized: false }),\n      ...params,\n    });\n  } else {\n    request = httpRequest(constructedUrl, {\n      agent: new http.Agent(agentOptions),\n      ...params,\n    });\n  }\n\n  try {\n    const [status, contentType, data, responseHeaders] = await request;\n    return [status, contentType, data, responseHeaders, params];\n  } catch (err) {\n    const rawError = Array.isArray(err) ? err[1] : err;\n    logger.error(\n      \"Error calling %s//%s%s%s...\",\n      constructedUrl.protocol,\n      constructedUrl.hostname,\n      constructedUrl.port ? `:${constructedUrl.port}` : \"\",\n      constructedUrl.pathname,\n    );\n    if (err) logger.error(err);\n    return [\n      500,\n      \"application/json\",\n      {\n        error: {\n          message: rawError?.message ?? \"Unknown error\",\n          url: sanitizeErrorURL(url),\n          rawError,\n        },\n      },\n      null,\n    ];\n  }\n}\n"
  },
  {
    "path": "src/utils/proxy/http.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { state, cache, logger, dns, net, cookieJar } = vi.hoisted(() => ({\n  state: {\n    response: {\n      statusCode: 200,\n      headers: { \"content-type\": \"application/json\" },\n      body: Buffer.from(\"\"),\n    },\n    error: null,\n    lastAgentOptions: null,\n    lastRequestParams: null,\n    lastWrittenBody: null,\n  },\n  cache: {\n    get: vi.fn(),\n    put: vi.fn(),\n  },\n  logger: {\n    debug: vi.fn(),\n    error: vi.fn(),\n  },\n  dns: {\n    lookup: vi.fn(),\n    resolve4: vi.fn(),\n    resolve6: vi.fn(),\n  },\n  net: {\n    isIP: vi.fn(),\n  },\n  cookieJar: {\n    addCookieToJar: vi.fn(),\n    setCookieHeader: vi.fn(),\n  },\n}));\n\nvi.mock(\"node:dns\", () => ({\n  default: dns,\n}));\n\nvi.mock(\"node:net\", () => ({\n  default: net,\n}));\n\nvi.mock(\"follow-redirects\", async () => {\n  const { EventEmitter } = await import(\"node:events\");\n  const { Readable } = await import(\"node:stream\");\n\n  function Agent(opts) {\n    this.opts = opts;\n  }\n\n  function makeRequest() {\n    return (url, params, cb) => {\n      const req = new EventEmitter();\n      state.lastRequestParams = params;\n      state.lastWrittenBody = null;\n      req.write = vi.fn((chunk) => {\n        state.lastWrittenBody = chunk;\n      });\n      req.end = vi.fn(() => {\n        state.lastAgentOptions = params?.agent?.opts ?? null;\n        if (state.error) {\n          req.emit(\"error\", state.error);\n          return;\n        }\n\n        const res = new Readable({\n          read() {\n            this.push(state.response.body);\n            this.push(null);\n          },\n        });\n        res.statusCode = state.response.statusCode;\n        res.headers = state.response.headers;\n        cb(res);\n      });\n      return req;\n    };\n  }\n\n  return {\n    http: { request: makeRequest(), Agent },\n    https: { request: makeRequest(), Agent },\n  };\n});\n\nvi.mock(\"memory-cache\", () => ({\n  default: cache,\n}));\n\nvi.mock(\"./cookie-jar\", () => cookieJar);\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\ndescribe(\"utils/proxy/http cachedRequest\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    state.error = null;\n    state.response = {\n      statusCode: 200,\n      headers: { \"content-type\": \"application/json\" },\n      body: Buffer.from(\"\"),\n    };\n    state.lastAgentOptions = null;\n    state.lastRequestParams = null;\n    state.lastWrittenBody = null;\n    vi.resetModules();\n  });\n\n  it(\"returns cached values without calling httpProxy\", async () => {\n    cache.get.mockReturnValueOnce({ ok: true });\n    const httpMod = await import(\"./http\");\n    const spy = vi.spyOn(httpMod, \"httpProxy\");\n\n    const data = await httpMod.cachedRequest(\"http://example.com\");\n\n    expect(data).toEqual({ ok: true });\n    expect(spy).not.toHaveBeenCalled();\n  });\n\n  it(\"parses json buffer responses and caches the result\", async () => {\n    cache.get.mockReturnValueOnce(null);\n    state.response.body = Buffer.from('{\"a\":1}');\n    const httpMod = await import(\"./http\");\n\n    const data = await httpMod.cachedRequest(\"http://example.com/data\", 1, \"ua\");\n\n    expect(data).toEqual({ a: 1 });\n    expect(cache.put).toHaveBeenCalledWith(\"http://example.com/data\", { a: 1 }, 1 * 1000 * 60);\n  });\n\n  it(\"falls back to string when cachedRequest cannot parse json\", async () => {\n    cache.get.mockReturnValueOnce(null);\n    state.response.body = Buffer.from(\"not-json\");\n    const httpMod = await import(\"./http\");\n\n    const data = await httpMod.cachedRequest(\"http://example.com/data\", 1, \"ua\");\n\n    expect(data).toBe(\"not-json\");\n    expect(logger.debug).toHaveBeenCalled();\n  });\n});\n\ndescribe(\"utils/proxy/http homepageDNSLookupFn\", () => {\n  const getLookupFn = async () => {\n    const httpMod = await import(\"./http\");\n    await httpMod.httpProxy(\"http://example.com\");\n    expect(state.lastAgentOptions?.lookup).toEqual(expect.any(Function));\n    return state.lastAgentOptions.lookup;\n  };\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    state.error = null;\n    state.lastAgentOptions = null;\n    net.isIP.mockReturnValue(0);\n    dns.lookup.mockImplementation((hostname, options, cb) => cb(null, \"127.0.0.1\", 4));\n    dns.resolve4.mockImplementation((hostname, cb) => cb(null, [\"127.0.0.1\"]));\n    dns.resolve6.mockImplementation((hostname, cb) => cb(null, [\"::1\"]));\n    vi.resetModules();\n  });\n\n  it(\"short-circuits when hostname is already an IP (all=false)\", async () => {\n    const lookup = await getLookupFn();\n    net.isIP.mockReturnValueOnce(4);\n    const cb = vi.fn();\n\n    lookup(\"1.2.3.4\", cb);\n\n    expect(dns.lookup).not.toHaveBeenCalled();\n    expect(cb).toHaveBeenCalledWith(null, \"1.2.3.4\", 4);\n  });\n\n  it(\"short-circuits when hostname is already an IP (all=true)\", async () => {\n    const lookup = await getLookupFn();\n    net.isIP.mockReturnValueOnce(6);\n    const cb = vi.fn();\n\n    lookup(\"::1\", { all: true }, cb);\n\n    expect(dns.lookup).not.toHaveBeenCalled();\n    expect(cb).toHaveBeenCalledWith(null, [{ address: \"::1\", family: 6 }]);\n  });\n\n  it(\"uses dns.lookup when it succeeds (2-argument form)\", async () => {\n    const lookup = await getLookupFn();\n    const cb = vi.fn();\n\n    dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(null, \"10.0.0.1\", 4));\n    lookup(\"example.com\", cb);\n\n    expect(dns.lookup).toHaveBeenCalledWith(\"example.com\", {}, expect.any(Function));\n    expect(dns.resolve4).not.toHaveBeenCalled();\n    expect(dns.resolve6).not.toHaveBeenCalled();\n    expect(cb).toHaveBeenCalledWith(null, \"10.0.0.1\", 4);\n  });\n\n  it(\"does not fall back for non-ENOTFOUND/EAI_NONAME lookup errors\", async () => {\n    const lookup = await getLookupFn();\n    const cb = vi.fn();\n    const err = Object.assign(new Error(\"temporary\"), { code: \"EAI_AGAIN\" });\n    dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(err));\n\n    lookup(\"example.com\", { all: true }, cb);\n\n    expect(dns.resolve4).not.toHaveBeenCalled();\n    expect(dns.resolve6).not.toHaveBeenCalled();\n    expect(cb).toHaveBeenCalledWith(err);\n  });\n\n  it(\"falls back to resolve4 when lookup fails with ENOTFOUND and family=4\", async () => {\n    const lookup = await getLookupFn();\n    const cb = vi.fn();\n    const lookupErr = Object.assign(new Error(\"not found\"), { code: \"ENOTFOUND\" });\n\n    dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));\n    dns.resolve4.mockImplementationOnce((hostname, resolveCb) => resolveCb(null, [\"1.1.1.1\"]));\n\n    lookup(\"example.com\", { family: 4, all: true }, cb);\n\n    expect(dns.resolve4).toHaveBeenCalledWith(\"example.com\", expect.any(Function));\n    expect(dns.resolve6).not.toHaveBeenCalled();\n    expect(cb).toHaveBeenCalledWith(null, [{ address: \"1.1.1.1\", family: 4 }]);\n    expect(logger.debug).toHaveBeenCalledWith(\"DNS fallback to c-ares resolver succeeded for %s\", \"example.com\");\n  });\n\n  it(\"falls back to resolve6 when lookup fails with ENOTFOUND and family=6\", async () => {\n    const lookup = await getLookupFn();\n    const cb = vi.fn();\n    const lookupErr = Object.assign(new Error(\"not found\"), { code: \"ENOTFOUND\" });\n\n    dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));\n    dns.resolve6.mockImplementationOnce((hostname, resolveCb) => resolveCb(null, [\"::1\"]));\n\n    lookup(\"example.com\", 6, cb);\n\n    expect(dns.lookup).toHaveBeenCalledWith(\"example.com\", { family: 6 }, expect.any(Function));\n    expect(dns.resolve4).not.toHaveBeenCalled();\n    expect(dns.resolve6).toHaveBeenCalledWith(\"example.com\", expect.any(Function));\n    expect(cb).toHaveBeenCalledWith(null, [\"::1\"], 6);\n  });\n\n  it(\"tries resolve4 then resolve6 when lookup fails and no family is specified\", async () => {\n    const lookup = await getLookupFn();\n    const cb = vi.fn();\n    const lookupErr = Object.assign(new Error(\"not found\"), { code: \"ENOTFOUND\" });\n\n    dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));\n    dns.resolve4.mockImplementationOnce((hostname, resolveCb) =>\n      resolveCb(Object.assign(new Error(\"v4 failed\"), { code: \"EAI_FAIL\" })),\n    );\n    dns.resolve6.mockImplementationOnce((hostname, resolveCb) => resolveCb(null, [\"::1\"]));\n\n    lookup(\"example.com\", { all: true }, cb);\n\n    expect(dns.resolve4).toHaveBeenCalledWith(\"example.com\", expect.any(Function));\n    expect(dns.resolve6).toHaveBeenCalledWith(\"example.com\", expect.any(Function));\n    expect(cb).toHaveBeenCalledWith(null, [{ address: \"::1\", family: 6 }]);\n  });\n\n  it(\"returns ENOTFOUND when fallback resolver returns no addresses\", async () => {\n    const lookup = await getLookupFn();\n    const cb = vi.fn();\n    const lookupErr = Object.assign(new Error(\"not found\"), { code: \"ENOTFOUND\" });\n\n    dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));\n    dns.resolve4.mockImplementationOnce((hostname, resolveCb) => resolveCb(null, []));\n\n    lookup(\"example.com\", { family: 4, all: true }, cb);\n\n    const err = cb.mock.calls[0][0];\n    expect(err).toBeInstanceOf(Error);\n    expect(err.code).toBe(\"ENOTFOUND\");\n    expect(dns.resolve6).not.toHaveBeenCalled();\n  });\n\n  it(\"returns resolve error when fallback resolver fails\", async () => {\n    const lookup = await getLookupFn();\n    const cb = vi.fn();\n    const lookupErr = Object.assign(new Error(\"not found\"), { code: \"ENOTFOUND\" });\n    const resolveErr = Object.assign(new Error(\"resolver down\"), { code: \"EAI_FAIL\" });\n\n    dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));\n    dns.resolve4.mockImplementationOnce((hostname, resolveCb) => resolveCb(resolveErr));\n\n    lookup(\"example.com\", { family: 4, all: true }, cb);\n\n    expect(cb).toHaveBeenCalledWith(resolveErr);\n    expect(logger.debug).toHaveBeenCalledWith(\n      \"DNS fallback failed for %s: lookup error=%s, resolve error=%s\",\n      \"example.com\",\n      \"ENOTFOUND\",\n      \"EAI_FAIL\",\n    );\n  });\n});\n\ndescribe(\"utils/proxy/http httpProxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    state.error = null;\n    state.response = {\n      statusCode: 200,\n      headers: { \"content-type\": \"application/json\" },\n      body: Buffer.from(\"ok\"),\n    };\n    state.lastAgentOptions = null;\n    state.lastRequestParams = null;\n    state.lastWrittenBody = null;\n    process.env.HOMEPAGE_PROXY_DISABLE_IPV6 = \"\";\n    vi.resetModules();\n  });\n\n  it(\"sets content-length and writes request bodies\", async () => {\n    const httpMod = await import(\"./http\");\n    const body = \"abc\";\n\n    const [status] = await httpMod.httpProxy(\"http://example.com\", { method: \"POST\", body, headers: {} });\n\n    expect(status).toBe(200);\n    expect(state.lastRequestParams.headers[\"content-length\"]).toBe(3);\n    expect(state.lastWrittenBody).toBe(body);\n  });\n\n  it(\"installs a beforeRedirect hook and updates the cookie jar\", async () => {\n    const httpMod = await import(\"./http\");\n    await httpMod.httpProxy(\"http://example.com\");\n\n    expect(state.lastRequestParams.beforeRedirect).toEqual(expect.any(Function));\n    expect(cookieJar.setCookieHeader).toHaveBeenCalled();\n    expect(cookieJar.addCookieToJar).toHaveBeenCalled();\n  });\n\n  it(\"updates cookies during redirects via beforeRedirect\", async () => {\n    const httpMod = await import(\"./http\");\n    await httpMod.httpProxy(\"http://example.com\");\n\n    state.lastRequestParams.beforeRedirect(\n      { href: \"http://example.com/redirect\" },\n      { headers: { \"set-cookie\": [\"a=b\"] } },\n    );\n\n    expect(cookieJar.addCookieToJar).toHaveBeenCalledWith(\"http://example.com/redirect\", { \"set-cookie\": [\"a=b\"] });\n    expect(cookieJar.setCookieHeader).toHaveBeenCalledWith(\"http://example.com/redirect\", expect.any(Object));\n  });\n\n  it(\"supports gzip-compressed responses\", async () => {\n    const { gzipSync } = await import(\"node:zlib\");\n    state.response.headers[\"content-encoding\"] = \"gzip\";\n    state.response.body = gzipSync(Buffer.from(\"hello\"));\n\n    const httpMod = await import(\"./http\");\n    const [, , data] = await httpMod.httpProxy(\"http://example.com\");\n\n    expect(Buffer.from(data).toString()).toBe(\"hello\");\n  });\n\n  it(\"logs when gzip decoding emits an error\", async () => {\n    const { PassThrough } = await import(\"node:stream\");\n\n    vi.doMock(\"node:zlib\", async () => {\n      const actual = await vi.importActual(\"node:zlib\");\n      return {\n        ...actual,\n        createUnzip: () => {\n          const pt = new PassThrough();\n          pt.on(\"pipe\", () => {\n            queueMicrotask(() => {\n              pt.emit(\"error\", new Error(\"bad gzip\"));\n              pt.end();\n            });\n          });\n          return pt;\n        },\n      };\n    });\n\n    vi.resetModules();\n    const httpMod = await import(\"./http\");\n\n    state.response.headers[\"content-encoding\"] = \"gzip\";\n    state.response.body = Buffer.from(\"hello\");\n\n    await httpMod.httpProxy(\"http://example.com\");\n\n    expect(logger.error).toHaveBeenCalled();\n\n    vi.unmock(\"node:zlib\");\n  });\n\n  it(\"applies strict IPv4 agent options when HOMEPAGE_PROXY_DISABLE_IPV6 is true\", async () => {\n    process.env.HOMEPAGE_PROXY_DISABLE_IPV6 = \"true\";\n    const httpMod = await import(\"./http\");\n\n    await httpMod.httpProxy(\"http://example.com\");\n\n    expect(state.lastAgentOptions.family).toBe(4);\n    expect(state.lastAgentOptions.autoSelectFamily).toBe(false);\n  });\n\n  it(\"uses the https agent with rejectUnauthorized=false for https:// URLs\", async () => {\n    const httpMod = await import(\"./http\");\n\n    await httpMod.httpProxy(\"https://example.com\");\n\n    expect(state.lastAgentOptions.rejectUnauthorized).toBe(false);\n  });\n\n  it(\"returns a sanitized error response when the request fails\", async () => {\n    state.error = Object.assign(new Error(\"boom\"), { code: \"EHOSTUNREACH\" });\n    const httpMod = await import(\"./http\");\n\n    const [status, contentType, data] = await httpMod.httpProxy(\"http://example.com/?apikey=secret\");\n\n    expect(status).toBe(500);\n    expect(contentType).toBe(\"application/json\");\n    expect(data.error.message).toBe(\"boom\");\n    expect(data.error.url).toContain(\"apikey=***\");\n  });\n});\n"
  },
  {
    "path": "src/utils/proxy/use-widget-api.js",
    "content": "import useSWR from \"swr\";\n\nimport { formatProxyUrl } from \"./api-helpers\";\n\nexport default function useWidgetAPI(widget, ...options) {\n  const config = {};\n  if (options && options[1]?.refreshInterval) {\n    config.refreshInterval = options[1].refreshInterval;\n  }\n  let url = formatProxyUrl(widget, ...options);\n  if (options[0] === \"\") {\n    url = null;\n  }\n  const { data, error, mutate } = useSWR(url, config);\n  // make the data error the top-level error\n  return { data, error: data?.error ?? error, mutate };\n}\n"
  },
  {
    "path": "src/utils/proxy/use-widget-api.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));\n\nvi.mock(\"swr\", () => ({\n  default: useSWR,\n}));\n\nimport useWidgetAPI from \"./use-widget-api\";\n\ndescribe(\"utils/proxy/use-widget-api\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"formats the proxy url and passes refreshInterval when provided in options\", () => {\n    useSWR.mockReturnValue({ data: { ok: true }, error: undefined, mutate: \"m\" });\n\n    const widget = { service_group: \"g\", service_name: \"s\", index: 0 };\n    const result = useWidgetAPI(widget, \"status\", { refreshInterval: 123, foo: \"bar\" });\n\n    expect(useSWR).toHaveBeenCalledWith(\n      expect.stringContaining(\"/api/services/proxy?\"),\n      expect.objectContaining({ refreshInterval: 123 }),\n    );\n    expect(result.data).toEqual({ ok: true });\n    expect(result.error).toBeUndefined();\n    expect(result.mutate).toBe(\"m\");\n  });\n\n  it(\"returns data.error as the top-level error\", () => {\n    const dataError = { message: \"nope\" };\n    useSWR.mockReturnValue({ data: { error: dataError }, error: undefined, mutate: vi.fn() });\n\n    const widget = { service_group: \"g\", service_name: \"s\", index: 0 };\n    const result = useWidgetAPI(widget, \"status\", {});\n\n    expect(result.error).toBe(dataError);\n  });\n\n  it(\"disables the request when endpoint is an empty string\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: undefined, mutate: vi.fn() });\n\n    const widget = { service_group: \"g\", service_name: \"s\", index: 0 };\n    useWidgetAPI(widget, \"\");\n\n    expect(useSWR).toHaveBeenCalledWith(null, {});\n  });\n});\n"
  },
  {
    "path": "src/utils/proxy/validate-widget-data.js",
    "content": "import createLogger from \"utils/logger\";\nimport widgets from \"widgets/widgets\";\n\nconst logger = createLogger(\"validateWidgetData\");\n\nexport default function validateWidgetData(widget, endpoint, data) {\n  let valid = true;\n  let dataParsed = data;\n  let error;\n  let mapping;\n  if (Buffer.isBuffer(data)) {\n    try {\n      dataParsed = JSON.parse(data);\n    } catch (e) {\n      try {\n        // try once more stripping whitespace\n        dataParsed = JSON.parse(data.toString().replace(/\\s/g, \"\"));\n      } catch (e2) {\n        error = e || e2;\n        valid = false;\n      }\n    }\n  }\n\n  if (dataParsed && Object.entries(dataParsed).length) {\n    const mappings = widgets[widget.type]?.mappings;\n    if (mappings) {\n      mapping = Object.values(mappings).find((m) => m.endpoint === endpoint);\n      mapping?.validate?.forEach((key) => {\n        if (dataParsed[key] === undefined) {\n          valid = false;\n        }\n      });\n    }\n  }\n\n  if (!valid) {\n    logger.error(\n      `Invalid data for widget '${widget.type}' endpoint '${endpoint}':\\nExpected:${mapping?.validate}\\nParse error: ${\n        error ?? \"none\"\n      }\\nData: ${JSON.stringify(data)}`,\n    );\n  }\n\n  return valid;\n}\n"
  },
  {
    "path": "src/utils/proxy/validate-widget-data.test.js",
    "content": "import { describe, expect, it, vi } from \"vitest\";\n\nconst { loggerError } = vi.hoisted(() => ({\n  loggerError: vi.fn(),\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => ({\n    error: loggerError,\n  }),\n}));\n\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    test: {\n      mappings: {\n        foo: {\n          endpoint: \"foo\",\n          validate: [\"a\", \"b\"],\n        },\n      },\n    },\n  },\n}));\n\nimport validateWidgetData from \"./validate-widget-data\";\n\ndescribe(\"utils/proxy/validate-widget-data\", () => {\n  it(\"returns false when buffer JSON cannot be parsed\", () => {\n    expect(validateWidgetData({ type: \"test\" }, \"foo\", Buffer.from(\"not json\"))).toBe(false);\n    expect(loggerError).toHaveBeenCalled();\n  });\n\n  it(\"retries parsing after stripping whitespace (e.g. vertical tab) and validates required keys\", () => {\n    // JSON.parse allows only a subset of whitespace; vertical tab triggers a parse error.\n    const data = Buffer.from(`{\\u000B\"a\": 1, \"b\": 2}`);\n    expect(validateWidgetData({ type: \"test\" }, \"foo\", data)).toBe(true);\n  });\n\n  it(\"returns false when required validate keys are missing\", () => {\n    expect(validateWidgetData({ type: \"test\" }, \"foo\", Buffer.from(JSON.stringify({ a: 1 })))).toBe(false);\n    expect(loggerError).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/utils/styles/themes.js",
    "content": "const themes = {\n  white: {\n    light: \"#ffffff\",\n    iconStart: \"#ffffff\",\n    iconEnd: \"#282828\",\n    dark: \"#ffffff\",\n  },\n  slate: {\n    light: \"#f8fafc\",\n    iconStart: \"#94a3b8\",\n    iconEnd: \"#334155\",\n    dark: \"#1e293b\",\n  },\n  gray: {\n    light: \"#f9fafb\",\n    iconStart: \"#9ca3af\",\n    iconEnd: \"#374151\",\n    dark: \"#1f2937\",\n  },\n  zinc: {\n    light: \"#fafafa\",\n    iconStart: \"#a1a1aa\",\n    iconEnd: \"#3f3f46\",\n    dark: \"#27272a\",\n  },\n  neutral: {\n    light: \"#fafafa\",\n    iconStart: \"#a3a3a3\",\n    iconEnd: \"#404040\",\n    dark: \"#262626\",\n  },\n  stone: {\n    light: \"#fafaf9\",\n    iconStart: \"#a8a29e\",\n    iconEnd: \"#44403c\",\n    dark: \"#292524\",\n  },\n  red: {\n    light: \"#fef2f2\",\n    iconStart: \"#f87171\",\n    iconEnd: \"#b91c1c\",\n    dark: \"#991b1b\",\n  },\n  orange: {\n    light: \"#fff7ed\",\n    iconStart: \"#fb923c\",\n    iconEnd: \"#c2410c\",\n    dark: \"#9a3412\",\n  },\n  amber: {\n    light: \"#fffbeb\",\n    iconStart: \"#fbbf24\",\n    iconEnd: \"#b45309\",\n    dark: \"#92400e\",\n  },\n  yellow: {\n    light: \"#fefce8\",\n    iconStart: \"#facc15\",\n    iconEnd: \"#a16207\",\n    dark: \"#854d0e\",\n  },\n  lime: {\n    light: \"#f7fee7\",\n    iconStart: \"#a3e635\",\n    iconEnd: \"#4d7c0f\",\n    dark: \"#3f6212\",\n  },\n  green: {\n    light: \"#f0fdf4\",\n    iconStart: \"#4ade80\",\n    iconEnd: \"#15803d\",\n    dark: \"#166534\",\n  },\n  emerald: {\n    light: \"#ecfdf5\",\n    iconStart: \"#34d399\",\n    iconEnd: \"#047857\",\n    dark: \"#065f46\",\n  },\n  teal: {\n    light: \"#f0fdfa\",\n    iconStart: \"#2dd4bf\",\n    iconEnd: \"#0f766e\",\n    dark: \"#115e59\",\n  },\n  cyan: {\n    light: \"#ecfeff\",\n    iconStart: \"#22d3ee\",\n    iconEnd: \"#0e7490\",\n    dark: \"#155e75\",\n  },\n  sky: {\n    light: \"#f0f9ff\",\n    iconStart: \"#38bdf8\",\n    iconEnd: \"#0369a1\",\n    dark: \"#075985\",\n  },\n  blue: {\n    light: \"#eff6ff\",\n    iconStart: \"#60a5fa\",\n    iconEnd: \"#1d4ed8\",\n    dark: \"#1e40af\",\n  },\n  indigo: {\n    light: \"#eef2ff\",\n    iconStart: \"#818cf8\",\n    iconEnd: \"#4338ca\",\n    dark: \"#3730a3\",\n  },\n  violet: {\n    light: \"#f5f3ff\",\n    iconStart: \"#a78bfa\",\n    iconEnd: \"#6d28d9\",\n    dark: \"#5b21b6\",\n  },\n  purple: {\n    light: \"#faf5ff\",\n    iconStart: \"#c084fc\",\n    iconEnd: \"#7e22ce\",\n    dark: \"#6b21a8\",\n  },\n  fuchsia: {\n    light: \"#fdf4ff\",\n    iconStart: \"#e879f9\",\n    iconEnd: \"#a21caf\",\n    dark: \"#86198f\",\n  },\n  pink: {\n    light: \"#fdf2f8\",\n    iconStart: \"#f472b6\",\n    iconEnd: \"#be185d\",\n    dark: \"#9d174d\",\n  },\n  rose: {\n    light: \"#fff1f2\",\n    iconStart: \"#fb7185\",\n    iconEnd: \"#be123c\",\n    dark: \"#9f1239\",\n  },\n};\n\nexport default themes;\n"
  },
  {
    "path": "src/utils/styles/themes.test.js",
    "content": "import { describe, expect, it } from \"vitest\";\n\nimport themes from \"./themes\";\n\ndescribe(\"utils/styles/themes\", () => {\n  it(\"contains expected theme palettes\", () => {\n    expect(themes).toHaveProperty(\"slate\");\n    expect(themes.slate).toEqual(\n      expect.objectContaining({\n        light: expect.stringMatching(/^#[0-9a-f]{6}$/i),\n        dark: expect.stringMatching(/^#[0-9a-f]{6}$/i),\n        iconStart: expect.stringMatching(/^#[0-9a-f]{6}$/i),\n        iconEnd: expect.stringMatching(/^#[0-9a-f]{6}$/i),\n      }),\n    );\n  });\n});\n"
  },
  {
    "path": "src/utils/weather/condition-map.js",
    "content": "// Code primarely based on Flame's ConditionMap class\n// https://github.com/pawelmalak/flame/blob/master/client/src/components/UI/Icons/WeatherIcon/IconMapping.ts\n\nimport * as Icons from \"react-icons/wi\";\n\nconst conditions = [\n  {\n    code: 1000,\n    icon: {\n      day: Icons.WiDaySunny,\n      night: Icons.WiNightClear,\n    },\n  },\n  {\n    code: 1003,\n    icon: {\n      day: Icons.WiDayCloudy,\n      night: Icons.WiNightPartlyCloudy,\n    },\n  },\n  {\n    code: 1006,\n    icon: {\n      day: Icons.WiDayCloudy,\n      night: Icons.WiNightCloudy,\n    },\n  },\n  {\n    code: 1009,\n    icon: {\n      day: Icons.WiDayCloudy,\n      night: Icons.WiNightCloudy,\n    },\n  },\n  {\n    code: 1030,\n    icon: {\n      day: Icons.WiDayFog,\n      night: Icons.WiNightFog,\n    },\n  },\n  {\n    code: 1063,\n    icon: {\n      day: Icons.WiDayRain,\n      night: Icons.WiNightRain,\n    },\n  },\n  {\n    code: 1066,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightSnow,\n    },\n  },\n  {\n    code: 1069,\n    icon: {\n      day: Icons.WiDayRainMix,\n      night: Icons.WiNightRainMix,\n    },\n  },\n  {\n    code: 1072,\n    icon: {\n      day: Icons.WiDaySleet,\n      night: Icons.WiNightSleet,\n    },\n  },\n  {\n    code: 1087,\n    icon: {\n      day: Icons.WiDayThunderstorm,\n      night: Icons.WiNightThunderstorm,\n    },\n  },\n  {\n    code: 1114,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightSnow,\n    },\n  },\n  {\n    code: 1117,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightSnow,\n    },\n  },\n  {\n    code: 1135,\n    icon: {\n      day: Icons.WiDayFog,\n      night: Icons.WiNightFog,\n    },\n  },\n  {\n    code: 1147,\n    icon: {\n      day: Icons.WiDayFog,\n      night: Icons.WiNightFog,\n    },\n  },\n  {\n    code: 1150,\n    icon: {\n      day: Icons.WiDayRain,\n      night: Icons.WiNightRain,\n    },\n  },\n  {\n    code: 1153,\n    icon: {\n      day: Icons.WiDayRain,\n      night: Icons.WiNightRain,\n    },\n  },\n  {\n    code: 1168,\n    icon: {\n      day: Icons.WiDaySleet,\n      night: Icons.WiNightSleet,\n    },\n  },\n  {\n    code: 1171,\n    icon: {\n      day: Icons.WiDaySleet,\n      night: Icons.WiNightSleet,\n    },\n  },\n  {\n    code: 1180,\n    icon: {\n      day: Icons.WiDayRain,\n      night: Icons.WiNightRain,\n    },\n  },\n  {\n    code: 1183,\n    icon: {\n      day: Icons.WiDayRain,\n      night: Icons.WiNightRain,\n    },\n  },\n  {\n    code: 1186,\n    icon: {\n      day: Icons.WiDayRain,\n      night: Icons.WiNightRain,\n    },\n  },\n  {\n    code: 1189,\n    icon: {\n      day: Icons.WiDayRain,\n      night: Icons.WiNightRain,\n    },\n  },\n  {\n    code: 1192,\n    icon: {\n      day: Icons.WiDayRain,\n      night: Icons.WiNightRain,\n    },\n  },\n  {\n    code: 1195,\n    icon: {\n      day: Icons.WiDayRain,\n      night: Icons.WiNightRain,\n    },\n  },\n  {\n    code: 1198,\n    icon: {\n      day: Icons.WiDaySleet,\n      night: Icons.WiNightSleet,\n    },\n  },\n  {\n    code: 1201,\n    icon: {\n      day: Icons.WiDaySleet,\n      night: Icons.WiNightSleet,\n    },\n  },\n  {\n    code: 1204,\n    icon: {\n      day: Icons.WiDayRainMix,\n      night: Icons.WiNightRainMix,\n    },\n  },\n  {\n    code: 1207,\n    icon: {\n      day: Icons.WiDayRainMix,\n      night: Icons.WiNightRainMix,\n    },\n  },\n  {\n    code: 1210,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightSnow,\n    },\n  },\n  {\n    code: 1213,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightSnow,\n    },\n  },\n  {\n    code: 1216,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightSnow,\n    },\n  },\n  {\n    code: 1219,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightSnow,\n    },\n  },\n  {\n    code: 1222,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightSnow,\n    },\n  },\n  {\n    code: 1225,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightSnow,\n    },\n  },\n  {\n    code: 1237,\n    icon: {\n      day: Icons.WiDayHail,\n      night: Icons.WiNightHail,\n    },\n  },\n  {\n    code: 1240,\n    icon: {\n      day: Icons.WiDayRain,\n      night: Icons.WiNightRain,\n    },\n  },\n  {\n    code: 1243,\n    icon: {\n      day: Icons.WiDayRain,\n      night: Icons.WiNightRain,\n    },\n  },\n  {\n    code: 1246,\n    icon: {\n      day: Icons.WiDayRain,\n      night: Icons.WiNightRain,\n    },\n  },\n  {\n    code: 1249,\n    icon: {\n      day: Icons.WiDayRainMix,\n      night: Icons.WiNightRainMix,\n    },\n  },\n  {\n    code: 1252,\n    icon: {\n      day: Icons.WiDayRainMix,\n      night: Icons.WiNightRainMix,\n    },\n  },\n  {\n    code: 1255,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightSnow,\n    },\n  },\n  {\n    code: 1258,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightSnow,\n    },\n  },\n  {\n    code: 1261,\n    icon: {\n      day: Icons.WiDayHail,\n      night: Icons.WiNightHail,\n    },\n  },\n  {\n    code: 1264,\n    icon: {\n      day: Icons.WiDayHail,\n      night: Icons.WiNightHail,\n    },\n  },\n  {\n    code: 1273,\n    icon: {\n      day: Icons.WiDayThunderstorm,\n      night: Icons.WiNightThunderstorm,\n    },\n  },\n  {\n    code: 1276,\n    icon: {\n      day: Icons.WiDayThunderstorm,\n      night: Icons.WiNightThunderstorm,\n    },\n  },\n  {\n    code: 1279,\n    icon: {\n      day: Icons.WiDayThunderstorm,\n      night: Icons.WiNightThunderstorm,\n    },\n  },\n  {\n    code: 1282,\n    icon: {\n      day: Icons.WiDayThunderstorm,\n      night: Icons.WiNightThunderstorm,\n    },\n  },\n];\n\nexport default function mapIcon(weatherStatusCode, timeOfDay) {\n  const mapping = conditions.find((condition) => condition.code === weatherStatusCode);\n\n  if (mapping) {\n    if (timeOfDay === \"day\") {\n      return mapping.icon.day;\n    }\n\n    if (timeOfDay === \"night\") {\n      return mapping.icon.night;\n    }\n  }\n\n  return Icons.WiDaySunny;\n}\n"
  },
  {
    "path": "src/utils/weather/condition-map.test.js",
    "content": "import * as Icons from \"react-icons/wi\";\nimport { describe, expect, it } from \"vitest\";\n\nimport mapIcon from \"./condition-map\";\n\ndescribe(\"utils/weather/condition-map\", () => {\n  it(\"maps known condition codes to day/night icons\", () => {\n    expect(mapIcon(1000, \"day\")).toBe(Icons.WiDaySunny);\n    expect(mapIcon(1000, \"night\")).toBe(Icons.WiNightClear);\n  });\n\n  it(\"falls back to a default icon for unknown codes\", () => {\n    expect(mapIcon(999999, \"day\")).toBe(Icons.WiDaySunny);\n  });\n});\n"
  },
  {
    "path": "src/utils/weather/openmeteo-condition-map.js",
    "content": "import * as Icons from \"react-icons/wi\";\n\n// see https://open-meteo.com/en/docs\n\nconst conditions = [\n  {\n    code: 1,\n    icon: {\n      day: Icons.WiDayCloudy,\n      night: Icons.WiNightAltCloudy,\n    },\n  },\n  {\n    code: 2,\n    icon: {\n      day: Icons.WiDayCloudy,\n      night: Icons.WiNightAltCloudy,\n    },\n  },\n  {\n    code: 3,\n    icon: {\n      day: Icons.WiDayCloudy,\n      night: Icons.WiNightAltCloudy,\n    },\n  },\n  {\n    code: 45,\n    icon: {\n      day: Icons.WiDayFog,\n      night: Icons.WiNightFog,\n    },\n  },\n  {\n    code: 48,\n    icon: {\n      day: Icons.WiDayFog,\n      night: Icons.WiNightFog,\n    },\n  },\n  {\n    code: 51,\n    icon: {\n      day: Icons.WiDaySprinkle,\n      night: Icons.WiNightAltSprinkle,\n    },\n  },\n  {\n    code: 53,\n    icon: {\n      day: Icons.WiDaySprinkle,\n      night: Icons.WiNightAltSprinkle,\n    },\n  },\n  {\n    code: 55,\n    icon: {\n      day: Icons.WiDaySprinkle,\n      night: Icons.WiNightAltSprinkle,\n    },\n  },\n  {\n    code: 56,\n    icon: {\n      day: Icons.WiDaySleet,\n      night: Icons.WiNightAltSleet,\n    },\n  },\n  {\n    code: 57,\n    icon: {\n      day: Icons.WiDaySleet,\n      night: Icons.WiNightAltSleet,\n    },\n  },\n  {\n    code: 61,\n    icon: {\n      day: Icons.WiDayShowers,\n      night: Icons.WiNightAltShowers,\n    },\n  },\n  {\n    code: 63,\n    icon: {\n      day: Icons.WiDayShowers,\n      night: Icons.WiNightAltShowers,\n    },\n  },\n  {\n    code: 65,\n    icon: {\n      day: Icons.WiDayShowers,\n      night: Icons.WiNightAltShowers,\n    },\n  },\n  {\n    code: 66,\n    icon: {\n      day: Icons.WiDaySleet,\n      night: Icons.WiNightAltSleet,\n    },\n  },\n  {\n    code: 67,\n    icon: {\n      day: Icons.WiDaySleet,\n      night: Icons.WiNightAltSleet,\n    },\n  },\n  {\n    code: 71,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightAltSnow,\n    },\n  },\n  {\n    code: 73,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightAltSnow,\n    },\n  },\n  {\n    code: 75,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightAltSnow,\n    },\n  },\n  {\n    code: 77,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightAltSnow,\n    },\n  },\n  {\n    code: 80,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightAltSnow,\n    },\n  },\n  {\n    code: 81,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightAltSnow,\n    },\n  },\n  {\n    code: 82,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightAltSnow,\n    },\n  },\n  {\n    code: 85,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightAltSnow,\n    },\n  },\n  {\n    code: 86,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightAltSnow,\n    },\n  },\n  {\n    code: 95,\n    icon: {\n      day: Icons.WiDayThunderstorm,\n      night: Icons.WiNightAltThunderstorm,\n    },\n  },\n  {\n    code: 96,\n    icon: {\n      day: Icons.WiDayThunderstorm,\n      night: Icons.WiNightAltThunderstorm,\n    },\n  },\n  {\n    code: 99,\n    icon: {\n      day: Icons.WiDayThunderstorm,\n      night: Icons.WiNightAltThunderstorm,\n    },\n  },\n];\n\nexport default function mapIcon(weatherStatusCode, timeOfDay) {\n  const mapping = conditions.find((condition) => condition.code === weatherStatusCode);\n\n  if (mapping) {\n    if (timeOfDay === \"day\") {\n      return mapping.icon.day;\n    }\n\n    if (timeOfDay === \"night\") {\n      return mapping.icon.night;\n    }\n  }\n\n  return Icons.WiDaySunny;\n}\n"
  },
  {
    "path": "src/utils/weather/openmeteo-condition-map.test.js",
    "content": "import * as Icons from \"react-icons/wi\";\nimport { describe, expect, it } from \"vitest\";\n\nimport mapIcon from \"./openmeteo-condition-map\";\n\ndescribe(\"utils/weather/openmeteo-condition-map\", () => {\n  it(\"maps known condition codes to day/night icons\", () => {\n    expect(mapIcon(95, \"day\")).toBe(Icons.WiDayThunderstorm);\n    expect(mapIcon(95, \"night\")).toBe(Icons.WiNightAltThunderstorm);\n  });\n\n  it(\"falls back to a default icon for unknown codes\", () => {\n    expect(mapIcon(999999, \"day\")).toBe(Icons.WiDaySunny);\n  });\n});\n"
  },
  {
    "path": "src/utils/weather/owm-condition-map.js",
    "content": "import * as Icons from \"react-icons/wi\";\n\nconst conditions = [\n  {\n    code: 200,\n    icon: {\n      day: Icons.WiDayStormShowers,\n      night: Icons.WiNightAltStormShowers,\n    },\n  },\n  {\n    code: 201,\n    icon: {\n      day: Icons.WiDayThunderstorm,\n      night: Icons.WiNightAltThunderstorm,\n    },\n  },\n  {\n    code: 202,\n    icon: {\n      day: Icons.WiDayThunderstorm,\n      night: Icons.WiNightAltThunderstorm,\n    },\n  },\n  {\n    code: 210,\n    icon: {\n      day: Icons.WiDayStormShowers,\n      night: Icons.WiNightAltStormShowers,\n    },\n  },\n  {\n    code: 211,\n    icon: {\n      day: Icons.WiDayThunderstorm,\n      night: Icons.WiNightAltThunderstorm,\n    },\n  },\n  {\n    code: 212,\n    icon: {\n      day: Icons.WiDayThunderstorm,\n      night: Icons.WiNightAltThunderstorm,\n    },\n  },\n  {\n    code: 221,\n    icon: {\n      day: Icons.WiDayThunderstorm,\n      night: Icons.WiNightAltThunderstorm,\n    },\n  },\n  {\n    code: 230,\n    icon: {\n      day: Icons.WiDayStormShowers,\n      night: Icons.WiNightAltStormShowers,\n    },\n  },\n  {\n    code: 231,\n    icon: {\n      day: Icons.WiDayStormShowers,\n      night: Icons.WiNightAltStormShowers,\n    },\n  },\n  {\n    code: 232,\n    icon: {\n      day: Icons.WiDayThunderstorm,\n      night: Icons.WiNightAltThunderstorm,\n    },\n  },\n\n  {\n    code: 300,\n    icon: {\n      day: Icons.WiDaySprinkle,\n      night: Icons.WiNightAltSprinkle,\n    },\n  },\n  {\n    code: 301,\n    icon: {\n      day: Icons.WiDaySprinkle,\n      night: Icons.WiNightAltSprinkle,\n    },\n  },\n  {\n    code: 302,\n    icon: {\n      day: Icons.WiDayRain,\n      night: Icons.WiNightAltRain,\n    },\n  },\n  {\n    code: 310,\n    icon: {\n      day: Icons.WiDaySprinkle,\n      night: Icons.WiNightAltSprinkle,\n    },\n  },\n  {\n    code: 311,\n    icon: {\n      day: Icons.WiDayRain,\n      night: Icons.WiNightAltRain,\n    },\n  },\n  {\n    code: 312,\n    icon: {\n      day: Icons.WiDayRain,\n      night: Icons.WiNightAltRain,\n    },\n  },\n  {\n    code: 313,\n    icon: {\n      day: Icons.WiDayShowers,\n      night: Icons.WiNightAltShowers,\n    },\n  },\n  {\n    code: 314,\n    icon: {\n      day: Icons.WiDayShowers,\n      night: Icons.WiNightAltShowers,\n    },\n  },\n  {\n    code: 321,\n    icon: {\n      day: Icons.WiDayShowers,\n      night: Icons.WiNightAltShowers,\n    },\n  },\n\n  {\n    code: 500,\n    icon: {\n      day: Icons.WiDayRain,\n      night: Icons.WiNightAltRain,\n    },\n  },\n  {\n    code: 501,\n    icon: {\n      day: Icons.WiDayRain,\n      night: Icons.WiNightAltRain,\n    },\n  },\n  {\n    code: 502,\n    icon: {\n      day: Icons.WiDayRain,\n      night: Icons.WiNightAltRain,\n    },\n  },\n  {\n    code: 503,\n    icon: {\n      day: Icons.WiDayRain,\n      night: Icons.WiNightAltRain,\n    },\n  },\n  {\n    code: 504,\n    icon: {\n      day: Icons.WiDayRain,\n      night: Icons.WiNightAltRain,\n    },\n  },\n  {\n    code: 511,\n    icon: {\n      day: Icons.WiDaySleet,\n      night: Icons.WiNightAltSleet,\n    },\n  },\n  {\n    code: 520,\n    icon: {\n      day: Icons.WiDayShowers,\n      night: Icons.WiNightAltShowers,\n    },\n  },\n  {\n    code: 521,\n    icon: {\n      day: Icons.WiDayShowers,\n      night: Icons.WiNightAltShowers,\n    },\n  },\n  {\n    code: 522,\n    icon: {\n      day: Icons.WiDayShowers,\n      night: Icons.WiNightAltShowers,\n    },\n  },\n  {\n    code: 531,\n    icon: {\n      day: Icons.WiDayShowers,\n      night: Icons.WiNightAltShowers,\n    },\n  },\n\n  {\n    code: 600,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightAltSnow,\n    },\n  },\n  {\n    code: 601,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightAltSnow,\n    },\n  },\n  {\n    code: 602,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightAltSnow,\n    },\n  },\n  {\n    code: 611,\n    icon: {\n      day: Icons.WiDaySleet,\n      night: Icons.WiNightAltSleet,\n    },\n  },\n  {\n    code: 612,\n    icon: {\n      day: Icons.WiDaySleet,\n      night: Icons.WiNightAltSleet,\n    },\n  },\n  {\n    code: 613,\n    icon: {\n      day: Icons.WiDaySleet,\n      night: Icons.WiNightAltSleet,\n    },\n  },\n  {\n    code: 615,\n    icon: {\n      day: Icons.WiDayRainMix,\n      night: Icons.WiNightAltRainMix,\n    },\n  },\n  {\n    code: 616,\n    icon: {\n      day: Icons.WiDayRainMix,\n      night: Icons.WiNightAltRainMix,\n    },\n  },\n  {\n    code: 620,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightAltSnow,\n    },\n  },\n  {\n    code: 621,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightAltSnow,\n    },\n  },\n  {\n    code: 622,\n    icon: {\n      day: Icons.WiDaySnow,\n      night: Icons.WiNightAltSnow,\n    },\n  },\n\n  {\n    code: 701,\n    icon: {\n      day: Icons.WiDayFog,\n      night: Icons.WiNightFog,\n    },\n  },\n  {\n    code: 711,\n    icon: {\n      day: Icons.WiSmoke,\n      night: Icons.WiSmoke,\n    },\n  },\n  {\n    code: 721,\n    icon: {\n      day: Icons.WiDayHaze,\n      night: Icons.WiWindy,\n    },\n  },\n  {\n    code: 731,\n    icon: {\n      day: Icons.WiDust,\n      night: Icons.WiDust,\n    },\n  },\n  {\n    code: 741,\n    icon: {\n      day: Icons.WiDayFog,\n      night: Icons.WiNightFog,\n    },\n  },\n  {\n    code: 751,\n    icon: {\n      day: Icons.WiDust,\n      night: Icons.WiDust,\n    },\n  },\n  {\n    code: 761,\n    icon: {\n      day: Icons.WiSandstorm,\n      night: Icons.WiSandstorm,\n    },\n  },\n  {\n    code: 762,\n    icon: {\n      day: Icons.WiDust,\n      night: Icons.WiDust,\n    },\n  },\n  {\n    code: 771,\n    icon: {\n      day: Icons.WiStrongWind,\n      night: Icons.WiStrongWind,\n    },\n  },\n\n  {\n    code: 781,\n    icon: {\n      day: Icons.WiTornado,\n      night: Icons.WiTornado,\n    },\n  },\n\n  {\n    code: 800,\n    icon: {\n      day: Icons.WiDaySunny,\n      night: Icons.WiNightClear,\n    },\n  },\n\n  {\n    code: 801,\n    icon: {\n      day: Icons.WiDayCloudy,\n      night: Icons.WiNightAltCloudy,\n    },\n  },\n  {\n    code: 802,\n    icon: {\n      day: Icons.WiDayCloudy,\n      night: Icons.WiNightAltCloudy,\n    },\n  },\n  {\n    code: 803,\n    icon: {\n      day: Icons.WiDayCloudy,\n      night: Icons.WiNightAltCloudy,\n    },\n  },\n  {\n    code: 804,\n    icon: {\n      day: Icons.WiCloudy,\n      night: Icons.WiCloudy,\n    },\n  },\n];\n\nexport default function mapIcon(weatherStatusCode, timeOfDay) {\n  const mapping = conditions.find((condition) => condition.code === weatherStatusCode);\n\n  if (mapping) {\n    if (timeOfDay === \"day\") {\n      return mapping.icon.day;\n    }\n\n    if (timeOfDay === \"night\") {\n      return mapping.icon.night;\n    }\n  }\n\n  return Icons.WiDaySunny;\n}\n"
  },
  {
    "path": "src/utils/weather/owm-condition-map.test.js",
    "content": "import * as Icons from \"react-icons/wi\";\nimport { describe, expect, it } from \"vitest\";\n\nimport mapIcon from \"./owm-condition-map\";\n\ndescribe(\"utils/weather/owm-condition-map\", () => {\n  it(\"maps known condition codes to day/night icons\", () => {\n    expect(mapIcon(804, \"day\")).toBe(Icons.WiCloudy);\n    expect(mapIcon(500, \"night\")).toBe(Icons.WiNightAltRain);\n  });\n\n  it(\"falls back to a default icon for unknown codes\", () => {\n    expect(mapIcon(999999, \"day\")).toBe(Icons.WiDaySunny);\n  });\n});\n"
  },
  {
    "path": "src/widgets/adguard/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: adguardData, error: adguardError } = useWidgetAPI(widget, \"stats\");\n\n  if (adguardError) {\n    return <Container service={service} error={adguardError} />;\n  }\n\n  if (!adguardData) {\n    return (\n      <Container service={service}>\n        <Block label=\"adguard.queries\" />\n        <Block label=\"adguard.blocked\" />\n        <Block label=\"adguard.filtered\" />\n        <Block label=\"adguard.latency\" />\n      </Container>\n    );\n  }\n\n  const filtered =\n    adguardData.num_replaced_safebrowsing + adguardData.num_replaced_safesearch + adguardData.num_replaced_parental;\n\n  return (\n    <Container service={service}>\n      <Block label=\"adguard.queries\" value={t(\"common.number\", { value: adguardData.num_dns_queries })} />\n      <Block label=\"adguard.blocked\" value={t(\"common.number\", { value: adguardData.num_blocked_filtering })} />\n      <Block label=\"adguard.filtered\" value={t(\"common.number\", { value: filtered })} />\n      <Block\n        label=\"adguard.latency\"\n        value={t(\"common.ms\", { value: adguardData.avg_processing_time * 1000, style: \"unit\", unit: \"millisecond\" })}\n        highlightValue={adguardData.avg_processing_time * 1000}\n      />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/adguard/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/adguard/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"adguard\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"adguard.queries\")).toBeInTheDocument();\n    expect(screen.getByText(\"adguard.blocked\")).toBeInTheDocument();\n    expect(screen.getByText(\"adguard.filtered\")).toBeInTheDocument();\n    expect(screen.getByText(\"adguard.latency\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"adguard\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n  });\n\n  it(\"renders computed filtered and latency values\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        num_dns_queries: 100,\n        num_blocked_filtering: 20,\n        num_replaced_safebrowsing: 1,\n        num_replaced_safesearch: 2,\n        num_replaced_parental: 3,\n        avg_processing_time: 0.01,\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"adguard\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"100\")).toBeInTheDocument();\n    expect(screen.getByText(\"20\")).toBeInTheDocument();\n    expect(screen.getByText(\"6\")).toBeInTheDocument(); // filtered sum\n    expect(screen.getByText(\"10\")).toBeInTheDocument(); // 0.01s -> 10ms\n  });\n});\n"
  },
  {
    "path": "src/widgets/adguard/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/control/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    stats: {\n      endpoint: \"stats\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/adguard/widget.test.js",
    "content": "import { describe, expect, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"adguard widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n    expect(widget.mappings?.stats?.endpoint).toBe(\"stats\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/apcups/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n  const { data, error } = useWidgetAPI(widget);\n\n  if (error) {\n    return <Container service={service} error={error} />;\n  }\n\n  if (!data) {\n    return (\n      <Container service={service}>\n        <Block label=\"apcups.status\" />\n        <Block label=\"apcups.load\" />\n        <Block label=\"apcups.bcharge\" />\n        <Block label=\"apcups.timeleft\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"apcups.status\" value={data.status} />\n      <Block label=\"apcups.load\" value={data.load} />\n      <Block label=\"apcups.bcharge\" value={data.bcharge} />\n      <Block label=\"apcups.timeleft\" value={data.timeleft} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/apcups/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/apcups/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"apcups\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"apcups.status\")).toBeInTheDocument();\n    expect(screen.getByText(\"apcups.load\")).toBeInTheDocument();\n    expect(screen.getByText(\"apcups.bcharge\")).toBeInTheDocument();\n    expect(screen.getByText(\"apcups.timeleft\")).toBeInTheDocument();\n  });\n\n  it(\"renders values when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { status: \"ONLINE\", load: \"12\", bcharge: \"99\", timeleft: \"30\" },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"apcups\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"ONLINE\")).toBeInTheDocument();\n    expect(screen.getByText(\"12\")).toBeInTheDocument();\n    expect(screen.getByText(\"99\")).toBeInTheDocument();\n    expect(screen.getByText(\"30\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/apcups/proxy.js",
    "content": "import { Buffer } from \"node:buffer\";\nimport net from \"node:net\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\n\nconst logger = createLogger(\"apcupsProxyHandler\");\n\nfunction parseResponse(buffer) {\n  let ptr = 0;\n  const output = [];\n  while (ptr < buffer.length) {\n    const lineLen = buffer.readUInt16BE(ptr);\n    const asciiData = buffer.toString(\"ascii\", ptr + 2, lineLen + ptr + 2);\n    output.push(asciiData);\n    ptr += 2 + lineLen;\n  }\n\n  return output;\n}\n\nfunction statusAsJSON(statusOutput) {\n  return statusOutput?.reduce((output, line) => {\n    if (!line || line.startsWith(\"END APC\")) return output;\n    const [key, value] = line.trim().split(\":\");\n    const newOutput = { ...output };\n    newOutput[key.trim()] = value?.trim();\n    return newOutput;\n  }, {});\n}\n\nasync function getStatus(host = \"127.0.0.1\", port = 3551) {\n  return new Promise((resolve, reject) => {\n    const socket = new net.Socket();\n    socket.setTimeout(5000);\n    socket.connect({ host, port });\n\n    const response = [];\n\n    socket.on(\"connect\", () => {\n      const CMD = \"status\";\n      logger.debug(`Connecting to ${host}:${port}`);\n      const buffer = Buffer.alloc(CMD.length + 2);\n      buffer.writeUInt16BE(CMD.length, 0);\n      buffer.write(CMD, 2);\n      socket.write(buffer);\n    });\n\n    socket.on(\"data\", (data) => {\n      response.push(data);\n\n      if (data.readUInt16BE(data.length - 2) === 0) {\n        try {\n          const buffer = Buffer.concat(response);\n          const output = parseResponse(buffer);\n          resolve(output);\n        } catch (e) {\n          reject(e);\n        }\n        socket.end();\n      }\n    });\n\n    socket.on(\"error\", (err) => {\n      socket.destroy();\n      reject(err);\n    });\n    socket.on(\"timeout\", () => {\n      socket.destroy();\n      reject(new Error(\"socket timeout\"));\n    });\n    socket.on(\"end\", () => {\n      logger.debug(\"socket end\");\n    });\n    socket.on(\"close\", () => {\n      logger.debug(\"socket closed\");\n    });\n  });\n}\n\nexport default async function apcupsProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const url = new URL(widget.url);\n  const data = {};\n\n  try {\n    const statusData = await getStatus(url.hostname, url.port);\n    const jsonData = statusAsJSON(statusData);\n\n    data.status = jsonData.STATUS;\n    data.load = jsonData.LOADPCT;\n    data.bcharge = jsonData.BCHARGE;\n    data.timeleft = jsonData.TIMELEFT;\n  } catch (e) {\n    logger.error(e);\n    return res.status(500).json({ error: e.message });\n  }\n\n  return res.status(200).send(data);\n}\n"
  },
  {
    "path": "src/widgets/apcups/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nfunction encodeLine(line) {\n  const buf = Buffer.alloc(2 + line.length);\n  buf.writeUInt16BE(line.length, 0);\n  buf.write(line, 2, \"ascii\");\n  return buf;\n}\n\nconst { getServiceWidget, logger } = vi.hoisted(() => ({\n  getServiceWidget: vi.fn(),\n  logger: { debug: vi.fn(), error: vi.fn() },\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"node:net\", () => {\n  class FakeSocket {\n    constructor() {\n      this._handlers = new Map();\n    }\n    setTimeout() {}\n    connect() {\n      queueMicrotask(() => this._emit(\"connect\"));\n    }\n    on(event, cb) {\n      const set = this._handlers.get(event) ?? new Set();\n      set.add(cb);\n      this._handlers.set(event, set);\n    }\n    write() {\n      const response = Buffer.concat([\n        encodeLine(\"STATUS : ONLINE\"),\n        encodeLine(\"LOADPCT : 10.0\"),\n        encodeLine(\"BCHARGE : 99.0\"),\n        encodeLine(\"TIMELEFT : 12.3\"),\n        encodeLine(\"END APC\"),\n        Buffer.from([0x00, 0x00]),\n      ]);\n      queueMicrotask(() => this._emit(\"data\", response));\n    }\n    end() {}\n    destroy() {}\n    _emit(event, payload) {\n      const set = this._handlers.get(event);\n      if (!set) return;\n      set.forEach((cb) => cb(payload));\n    }\n  }\n\n  return {\n    default: {\n      Socket: FakeSocket,\n    },\n  };\n});\n\nimport apcupsProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/apcups/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"parses the APCUPSD status response into JSON\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://127.0.0.1:3551\" });\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await apcupsProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({\n      status: \"ONLINE\",\n      load: \"10.0\",\n      bcharge: \"99.0\",\n      timeleft: \"12.3\",\n    });\n  });\n});\n"
  },
  {
    "path": "src/widgets/apcups/widget.js",
    "content": "import apcupsProxyHandler from \"./proxy\";\n\nconst widget = {\n  proxyHandler: apcupsProxyHandler,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/apcups/widget.test.js",
    "content": "import { describe, expect, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"apcups widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n    // apcups talks TCP directly, so it does not use an `{url}/...` API template.\n    expect(widget.api).toBeUndefined();\n  });\n});\n"
  },
  {
    "path": "src/widgets/arcane/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst MAX_FIELDS = 4;\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  if (!widget.fields) {\n    widget.fields = [\"running\", \"stopped\", \"total\", \"image_updates\"];\n  } else if (widget.fields.length > MAX_FIELDS) {\n    widget.fields = widget.fields.slice(0, MAX_FIELDS);\n  }\n\n  if (widget?.env == null || widget.env === \"\") {\n    return <Container service={service} error={t(\"arcane.environment_required\")} />;\n  }\n\n  const { data: containers, error: containersError } = useWidgetAPI(widget, \"containers\");\n  const { data: images, error: imagesError } = useWidgetAPI(widget, \"images\");\n  const { data: updates, error: updatesError } = useWidgetAPI(widget, \"updates\");\n\n  const error =\n    containersError ?? imagesError ?? updatesError ?? containers?.detail ?? images?.detail ?? updates?.detail;\n  if (error) {\n    return <Container service={service} error={error} />;\n  }\n\n  if (!containers || !images || !updates) {\n    return (\n      <Container service={service}>\n        <Block label=\"docker.running\" field=\"arcane.running\" />\n        <Block label=\"dockhand.stopped\" field=\"arcane.stopped\" />\n        <Block label=\"dockhand.total\" field=\"arcane.total\" />\n        <Block label=\"arcane.images\" field=\"arcane.images\" />\n        <Block label=\"resources.used\" field=\"arcane.images_used\" />\n        <Block label=\"arcane.images_unused\" field=\"arcane.images_unused\" />\n        <Block label=\"arcane.image_updates\" field=\"arcane.image_updates\" />\n      </Container>\n    );\n  }\n\n  const runningContainers = containers?.runningContainers ?? 0;\n  const totalContainers = containers?.totalContainers ?? 0;\n  const stoppedContainers = containers?.stoppedContainers ?? 0;\n  const totalImages = images?.totalImages ?? 0;\n  const imagesInuse = images?.imagesInuse ?? 0;\n  const imagesUnused = images?.imagesUnused ?? 0;\n  const imagesWithUpdates = updates?.imagesWithUpdates ?? 0;\n\n  return (\n    <Container service={service}>\n      <Block label=\"docker.running\" field=\"arcane.running\" value={t(\"common.number\", { value: runningContainers })} />\n      <Block label=\"dockhand.stopped\" field=\"arcane.stopped\" value={t(\"common.number\", { value: stoppedContainers })} />\n      <Block label=\"dockhand.total\" field=\"arcane.total\" value={t(\"common.number\", { value: totalContainers })} />\n      <Block label=\"arcane.images\" field=\"arcane.images\" value={t(\"common.number\", { value: totalImages })} />\n      <Block label=\"resources.used\" field=\"arcane.images_used\" value={t(\"common.number\", { value: imagesInuse })} />\n      <Block\n        label=\"arcane.images_unused\"\n        field=\"arcane.images_unused\"\n        value={t(\"common.number\", { value: imagesUnused })}\n      />\n      <Block\n        label=\"arcane.image_updates\"\n        field=\"arcane.image_updates\"\n        value={t(\"common.number\", { value: imagesWithUpdates })}\n      />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/arcane/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/arcane/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"shows an environment required error when env is missing\", () => {\n    renderWithProviders(<Component service={{ widget: { type: \"arcane\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(useWidgetAPI).not.toHaveBeenCalled();\n    expect(screen.getByText(\"arcane.environment_required\")).toBeInTheDocument();\n  });\n\n  it(\"shows an error when API calls return detail errors\", () => {\n    useWidgetAPI.mockImplementation(() => ({ data: { detail: \"Specific API error\" }, error: undefined }));\n\n    renderWithProviders(<Component service={{ widget: { type: \"arcane\", env: \"prod\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"Specific API error\")).toBeInTheDocument();\n  });\n\n  it(\"renders placeholders while loading data\", () => {\n    useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"arcane\", env: \"prod\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    // defaults to the first four fields when none are provided\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"docker.running\")).toBeInTheDocument();\n    expect(screen.getByText(\"dockhand.stopped\")).toBeInTheDocument();\n    expect(screen.getByText(\"dockhand.total\")).toBeInTheDocument();\n    expect(screen.getByText(\"arcane.image_updates\")).toBeInTheDocument();\n  });\n\n  it(\"truncates custom fields to the max allowed\", () => {\n    useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));\n\n    const service = {\n      widget: { type: \"arcane\", env: \"prod\", fields: [\"running\", \"stopped\", \"total\", \"images\", \"images_unused\"] },\n    };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    // sliced to first four entries\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"docker.running\")).toBeInTheDocument();\n    expect(screen.getByText(\"dockhand.stopped\")).toBeInTheDocument();\n    expect(screen.getByText(\"dockhand.total\")).toBeInTheDocument();\n    expect(screen.getByText(\"arcane.images\")).toBeInTheDocument();\n    expect(screen.queryByText(\"arcane.images_unused\")).toBeNull();\n  });\n\n  it(\"renders error UI when any widget call fails\", () => {\n    useWidgetAPI.mockImplementation((widget, endpoint) => {\n      if (endpoint === \"containers\") {\n        return { data: undefined, error: { message: \"boom\" } };\n      }\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"arcane\", env: \"prod\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(0);\n    expect(screen.getByText(\"boom\")).toBeInTheDocument();\n  });\n\n  it(\"renders values when API data is available\", () => {\n    useWidgetAPI.mockImplementation((widget, endpoint) => {\n      if (endpoint === \"containers\") {\n        return { data: { runningContainers: 3, totalContainers: 5, stoppedContainers: 2 }, error: undefined };\n      }\n      if (endpoint === \"images\") {\n        return { data: { totalImages: 7, imagesInuse: 4, imagesUnused: 3 }, error: undefined };\n      }\n      if (endpoint === \"updates\") {\n        return { data: { imagesWithUpdates: 2 }, error: undefined };\n      }\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"arcane\", env: \"prod\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expectBlockValue(container, \"docker.running\", 3);\n    expectBlockValue(container, \"dockhand.stopped\", 2);\n    expectBlockValue(container, \"dockhand.total\", 5);\n    expectBlockValue(container, \"arcane.image_updates\", 2);\n  });\n\n  it(\"falls back to zero when counts are missing\", () => {\n    useWidgetAPI.mockImplementation(() => ({ data: {}, error: undefined }));\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"arcane\", env: \"prod\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expectBlockValue(container, \"docker.running\", 0);\n    expectBlockValue(container, \"dockhand.stopped\", 0);\n    expectBlockValue(container, \"dockhand.total\", 0);\n    expectBlockValue(container, \"arcane.image_updates\", 0);\n  });\n});\n"
  },
  {
    "path": "src/widgets/arcane/widget.js",
    "content": "import { asJson } from \"utils/proxy/api-helpers\";\nimport credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    containers: {\n      endpoint: \"environments/{env}/containers/counts\",\n      map: (data) => asJson(data).data,\n    },\n    images: {\n      endpoint: \"environments/{env}/images/counts\",\n      map: (data) => asJson(data).data,\n    },\n    updates: {\n      endpoint: \"environments/{env}/image-updates/summary\",\n      map: (data) => asJson(data).data,\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/arcane/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"arcane widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/argocd/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  if (!widget.fields) {\n    widget.fields = [\"apps\", \"synced\", \"outOfSync\", \"healthy\"];\n  }\n\n  const MAX_ALLOWED_FIELDS = 4;\n  if (widget.fields.length > MAX_ALLOWED_FIELDS) {\n    widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);\n  }\n\n  const { data: appsData, error: appsError } = useWidgetAPI(widget, \"applications\");\n\n  const appCounts = widget.fields.map((status) => {\n    if (status === \"apps\") {\n      return { status, count: appsData?.items?.length };\n    }\n    const count = appsData?.items?.filter(\n      (item) =>\n        item.status?.sync?.status.toLowerCase() === status.toLowerCase() ||\n        item.status?.health?.status.toLowerCase() === status.toLowerCase(),\n    ).length;\n    return { status, count };\n  });\n\n  if (appsError) {\n    return <Container service={service} error={appsError} />;\n  }\n\n  if (!appsData) {\n    return (\n      <Container service={service}>\n        {appCounts.map((a) => (\n          <Block label={`argocd.${a.status}`} key={a.status} />\n        ))}\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      {appCounts.map((a) => (\n        <Block label={`argocd.${a.status}`} key={a.status} value={a.count} />\n      ))}\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/argocd/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/argocd/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"defaults and truncates widget.fields to 4 and renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"argocd\", fields: [\"apps\", \"synced\", \"outOfSync\", \"healthy\", \"extra\"] } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"apps\", \"synced\", \"outOfSync\", \"healthy\"]);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"argocd.apps\")).toBeInTheDocument();\n    expect(screen.getByText(\"argocd.synced\")).toBeInTheDocument();\n    expect(screen.getByText(\"argocd.outOfSync\")).toBeInTheDocument();\n    expect(screen.getByText(\"argocd.healthy\")).toBeInTheDocument();\n  });\n\n  it(\"renders counts when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        items: [\n          { status: { sync: { status: \"Synced\" }, health: { status: \"Healthy\" } } },\n          { status: { sync: { status: \"OutOfSync\" }, health: { status: \"Degraded\" } } },\n          { status: { sync: { status: \"Synced\" }, health: { status: \"Healthy\" } } },\n        ],\n      },\n      error: undefined,\n    });\n\n    const service = { widget: { type: \"argocd\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    // Default widget fields: apps/synced/outOfSync/healthy => all 4 should be visible.\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n\n    expectBlockValue(container, \"argocd.apps\", 3);\n    expectBlockValue(container, \"argocd.synced\", 2);\n    expectBlockValue(container, \"argocd.outOfSync\", 1);\n    expectBlockValue(container, \"argocd.healthy\", 2);\n  });\n});\n"
  },
  {
    "path": "src/widgets/argocd/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/v1/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    applications: {\n      endpoint: \"applications\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/argocd/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"argocd widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/atsumeru/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { data: infoData, error: infoError } = useWidgetAPI(widget, \"info\");\n\n  if (infoError) {\n    return <Container service={service} error={infoError} />;\n  }\n\n  if (!infoData) {\n    return (\n      <Container service={service}>\n        <Block label=\"atsumeru.series\" />\n        <Block label=\"atsumeru.archives\" />\n        <Block label=\"atsumeru.chapters\" />\n        <Block label=\"atsumeru.categories\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"atsumeru.series\" value={t(\"common.number\", { value: infoData.stats.total_series })} />\n      <Block label=\"atsumeru.archives\" value={t(\"common.number\", { value: infoData.stats.total_archives })} />\n      <Block label=\"atsumeru.chapters\" value={t(\"common.number\", { value: infoData.stats.total_chapters })} />\n      <Block label=\"atsumeru.categories\" value={t(\"common.number\", { value: infoData.stats.total_categories })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/atsumeru/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/atsumeru/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"atsumeru\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"atsumeru.series\")).toBeInTheDocument();\n    expect(screen.getByText(\"atsumeru.archives\")).toBeInTheDocument();\n    expect(screen.getByText(\"atsumeru.chapters\")).toBeInTheDocument();\n    expect(screen.getByText(\"atsumeru.categories\")).toBeInTheDocument();\n  });\n\n  it(\"renders values when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { stats: { total_series: 1, total_archives: 2, total_chapters: 3, total_categories: 4 } },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"atsumeru\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"1\")).toBeInTheDocument();\n    expect(screen.getByText(\"2\")).toBeInTheDocument();\n    expect(screen.getByText(\"3\")).toBeInTheDocument();\n    expect(screen.getByText(\"4\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/atsumeru/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/server/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    info: {\n      endpoint: \"info\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/atsumeru/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"atsumeru widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/audiobookshelf/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n  const { data: librariesData, error: librariesError } = useWidgetAPI(widget, \"libraries\");\n\n  if (librariesError) {\n    return <Container service={service} error={librariesError} />;\n  }\n\n  if (!librariesData) {\n    return (\n      <Container service={service}>\n        <Block label=\"audiobookshelf.podcasts\" />\n        <Block label=\"audiobookshelf.podcastsDuration\" />\n        <Block label=\"audiobookshelf.books\" />\n        <Block label=\"audiobookshelf.booksDuration\" />\n      </Container>\n    );\n  }\n\n  const podcastLibraries = librariesData.filter((l) => l.mediaType === \"podcast\");\n  const bookLibraries = librariesData.filter((l) => l.mediaType === \"book\");\n\n  const totalPodcasts = podcastLibraries.reduce((total, pL) => parseInt(pL.stats?.totalItems, 10) + total, 0);\n  const totalBooks = bookLibraries.reduce((total, bL) => parseInt(bL.stats?.totalItems, 10) + total, 0);\n\n  const totalPodcastsDuration = podcastLibraries.reduce((total, pL) => parseFloat(pL.stats?.totalDuration) + total, 0);\n  const totalBooksDuration = bookLibraries.reduce((total, bL) => parseFloat(bL.stats?.totalDuration) + total, 0);\n\n  return (\n    <Container service={service}>\n      <Block label=\"audiobookshelf.podcasts\" value={t(\"common.number\", { value: totalPodcasts })} />\n      <Block\n        label=\"audiobookshelf.podcastsDuration\"\n        value={t(\"common.duration\", {\n          value: totalPodcastsDuration,\n        })}\n      />\n      <Block label=\"audiobookshelf.books\" value={t(\"common.number\", { value: totalBooks })} />\n      <Block\n        label=\"audiobookshelf.booksDuration\"\n        value={t(\"common.duration\", {\n          value: totalBooksDuration,\n        })}\n      />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/audiobookshelf/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/audiobookshelf/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"audiobookshelf\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"audiobookshelf.podcasts\")).toBeInTheDocument();\n    expect(screen.getByText(\"audiobookshelf.podcastsDuration\")).toBeInTheDocument();\n    expect(screen.getByText(\"audiobookshelf.books\")).toBeInTheDocument();\n    expect(screen.getByText(\"audiobookshelf.booksDuration\")).toBeInTheDocument();\n  });\n\n  it(\"aggregates totals across libraries by mediaType\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: [\n        { mediaType: \"podcast\", stats: { totalItems: \"2\", totalDuration: \"100\" } },\n        { mediaType: \"podcast\", stats: { totalItems: \"1\", totalDuration: \"200\" } },\n        { mediaType: \"book\", stats: { totalItems: \"4\", totalDuration: \"300\" } },\n      ],\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"audiobookshelf\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expectBlockValue(container, \"audiobookshelf.podcasts\", 3);\n    expectBlockValue(container, \"audiobookshelf.podcastsDuration\", 300);\n    expectBlockValue(container, \"audiobookshelf.books\", 4);\n    expectBlockValue(container, \"audiobookshelf.booksDuration\", 300);\n  });\n});\n"
  },
  {
    "path": "src/widgets/audiobookshelf/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst proxyName = \"audiobookshelfProxyHandler\";\nconst logger = createLogger(proxyName);\n\nasync function retrieveFromAPI(url, key) {\n  const headers = {\n    \"content-type\": \"application/json\",\n    Authorization: `Bearer ${key}`,\n  };\n\n  const [status, , data] = await httpProxy(url, { headers });\n\n  if (status !== 200) {\n    throw new Error(`Error getting data from Audiobookshelf: ${status}. Data: ${data.toString()}`);\n  }\n\n  return JSON.parse(Buffer.from(data).toString());\n}\n\nexport default async function audiobookshelfProxyHandler(req, res) {\n  const { group, service, endpoint, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  if (!widget.key) {\n    logger.debug(\"Invalid or missing key for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Missing widget key\" });\n  }\n\n  const apiURL = widgets[widget.type].api;\n\n  try {\n    const url = new URL(formatApiCall(apiURL, { endpoint, ...widget }));\n    const libraryData = await retrieveFromAPI(url, widget.key);\n\n    const libraryStats = await Promise.all(\n      libraryData.libraries.map(async (l) => {\n        const stats = await retrieveFromAPI(\n          new URL(formatApiCall(apiURL, { endpoint: `libraries/${l.id}/stats`, ...widget })),\n          widget.key,\n        );\n        return {\n          ...l,\n          stats,\n        };\n      }),\n    );\n\n    return res.status(200).send(libraryStats);\n  } catch (e) {\n    if (e) logger.error(e);\n    return res.status(500).send({ error: { message: e.message } });\n  }\n}\n"
  },
  {
    "path": "src/widgets/audiobookshelf/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  logger: { debug: vi.fn(), error: vi.fn() },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    audiobookshelf: {\n      api: \"{url}/api/{endpoint}\",\n    },\n  },\n}));\n\nimport audiobookshelfProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/audiobookshelf/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"retrieves libraries and per-library stats\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"audiobookshelf\", url: \"http://abs\", key: \"k\" });\n\n    httpProxy\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(\n          JSON.stringify({\n            libraries: [\n              { id: \"l1\", name: \"A\" },\n              { id: \"l2\", name: \"B\" },\n            ],\n          }),\n        ),\n      ])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ total: 1 }))])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ total: 2 }))]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"libraries\", index: \"0\" } };\n    const res = createMockRes();\n\n    await audiobookshelfProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(3);\n    expect(httpProxy.mock.calls[0][1].headers.Authorization).toBe(\"Bearer k\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual([\n      { id: \"l1\", name: \"A\", stats: { total: 1 } },\n      { id: \"l2\", name: \"B\", stats: { total: 2 } },\n    ]);\n  });\n});\n"
  },
  {
    "path": "src/widgets/audiobookshelf/widget.js",
    "content": "import audiobookshelfProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: audiobookshelfProxyHandler,\n\n  mappings: {\n    libraries: {\n      endpoint: \"libraries\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/audiobookshelf/widget.test.js",
    "content": "import { describe, expect, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"audiobookshelf widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n    expect(widget.mappings?.libraries?.endpoint).toBe(\"libraries\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/authentik/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: usersData, error: usersError } = useWidgetAPI(widget, \"users\");\n\n  const loginsEndpoint = widget.version === 2 ? \"loginv2\" : \"login\";\n  const { data: loginsData, error: loginsError } = useWidgetAPI(widget, loginsEndpoint);\n\n  const failedLoginsEndpoint = widget.version === 2 ? \"login_failedv2\" : \"login_failed\";\n  const { data: failedLoginsData, error: failedLoginsError } = useWidgetAPI(widget, failedLoginsEndpoint);\n\n  if (usersError || loginsError || failedLoginsError) {\n    const finalError = usersError ?? loginsError ?? failedLoginsError;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!usersData || !loginsData || !failedLoginsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"authentik.users\" />\n        <Block label=\"authentik.loginsLast24H\" />\n        <Block label=\"authentik.failedLoginsLast24H\" />\n      </Container>\n    );\n  }\n\n  let loginsLast24H;\n  let failedLoginsLast24H;\n  switch (widget.version) {\n    case 1:\n      const yesterday = new Date(Date.now()).setHours(-24);\n      loginsLast24H = loginsData.reduce(\n        (total, current) => (current.x_cord >= yesterday ? total + current.y_cord : total),\n        0,\n      );\n      failedLoginsLast24H = failedLoginsData.reduce(\n        (total, current) => (current.x_cord >= yesterday ? total + current.y_cord : total),\n        0,\n      );\n      break;\n    case 2:\n      loginsLast24H =\n        loginsData.reduce?.(\n          (total, current) => (current?.count && current?.action === \"login\" ? total + current.count : total),\n          0,\n        ) || 0;\n      failedLoginsLast24H =\n        failedLoginsData.reduce?.((total, current) => (current?.count ? total + current.count : total), 0) || 0;\n      break;\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"authentik.users\" value={t(\"common.number\", { value: usersData.pagination.count })} />\n      <Block label=\"authentik.loginsLast24H\" value={t(\"common.number\", { value: loginsLast24H })} />\n      <Block label=\"authentik.failedLoginsLast24H\" value={t(\"common.number\", { value: failedLoginsLast24H })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/authentik/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/authentik/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  afterEach(() => {\n    vi.useRealTimers();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"authentik\", version: 2 } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"authentik.users\")).toBeInTheDocument();\n    expect(screen.getByText(\"authentik.loginsLast24H\")).toBeInTheDocument();\n    expect(screen.getByText(\"authentik.failedLoginsLast24H\")).toBeInTheDocument();\n  });\n\n  it(\"computes v2 login/failed counts from action data\", () => {\n    useWidgetAPI.mockImplementation((widget, endpoint) => {\n      if (endpoint === \"users\") return { data: { pagination: { count: 10 } }, error: undefined };\n      if (endpoint === \"loginv2\")\n        return {\n          data: [\n            { action: \"login\", count: 2 },\n            { action: \"logout\", count: 9 },\n          ],\n          error: undefined,\n        };\n      if (endpoint === \"login_failedv2\") return { data: [{ count: 3 }, { count: null }], error: undefined };\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"authentik\", version: 2 } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expectBlockValue(container, \"authentik.users\", 10);\n    expectBlockValue(container, \"authentik.loginsLast24H\", 2);\n    expectBlockValue(container, \"authentik.failedLoginsLast24H\", 3);\n  });\n\n  it(\"computes v1 login/failed counts for entries within the last 24h window\", () => {\n    vi.useFakeTimers();\n    vi.setSystemTime(new Date(\"2026-01-02T00:00:00Z\"));\n\n    const now = Date.now();\n    const oneHourAgo = now - 60 * 60 * 1000;\n    const twentyFiveHoursAgo = now - 25 * 60 * 60 * 1000;\n\n    useWidgetAPI.mockImplementation((widget, endpoint) => {\n      if (endpoint === \"users\") return { data: { pagination: { count: 5 } }, error: undefined };\n      if (endpoint === \"login\")\n        return {\n          data: [\n            { x_cord: oneHourAgo, y_cord: 2 },\n            { x_cord: twentyFiveHoursAgo, y_cord: 100 },\n          ],\n          error: undefined,\n        };\n      if (endpoint === \"login_failed\")\n        return {\n          data: [\n            { x_cord: oneHourAgo, y_cord: 1 },\n            { x_cord: twentyFiveHoursAgo, y_cord: 50 },\n          ],\n          error: undefined,\n        };\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"authentik\", version: 1 } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expectBlockValue(container, \"authentik.users\", 5);\n    expectBlockValue(container, \"authentik.loginsLast24H\", 2);\n    expectBlockValue(container, \"authentik.failedLoginsLast24H\", 1);\n  });\n});\n"
  },
  {
    "path": "src/widgets/authentik/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/v3/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    users: {\n      endpoint: \"core/users/?page_size=1\",\n    },\n    login: {\n      endpoint: \"events/events/per_month/?action=login\",\n    },\n    loginv2: {\n      endpoint: \"events/events/volume/?action=login&&history_days=1\",\n    },\n    login_failed: {\n      endpoint: \"events/events/per_month/?action=login_failed\",\n    },\n    login_failedv2: {\n      endpoint: \"events/events/volume/?action=login_failed&&history_days=1\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/authentik/widget.test.js",
    "content": "import { describe, expect, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"authentik widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n    expect(widget.api).toContain(\"/api/v3/\");\n    expect(widget.mappings?.users?.endpoint).toContain(\"core/users\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/autobrr/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: statsData, error: statsError } = useWidgetAPI(widget, \"stats\");\n  const { data: filtersData, error: filtersError } = useWidgetAPI(widget, \"filters\");\n  const { data: indexersData, error: indexersError } = useWidgetAPI(widget, \"indexers\");\n\n  if (statsError || filtersError || indexersError) {\n    const finalError = statsError ?? filtersError ?? indexersError;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!statsData || !filtersData || !indexersData) {\n    return (\n      <Container service={service}>\n        <Block label=\"autobrr.approvedPushes\" />\n        <Block label=\"autobrr.rejectedPushes\" />\n        <Block label=\"autobrr.filters\" />\n        <Block label=\"autobrr.indexers\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"autobrr.approvedPushes\" value={t(\"common.number\", { value: statsData.push_approved_count })} />\n      <Block label=\"autobrr.rejectedPushes\" value={t(\"common.number\", { value: statsData.push_rejected_count })} />\n      <Block label=\"autobrr.filters\" value={t(\"common.number\", { value: filtersData.length })} />\n      <Block label=\"autobrr.indexers\" value={t(\"common.number\", { value: indexersData.length })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/autobrr/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/autobrr/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"autobrr\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"autobrr.approvedPushes\")).toBeInTheDocument();\n    expect(screen.getByText(\"autobrr.rejectedPushes\")).toBeInTheDocument();\n    expect(screen.getByText(\"autobrr.filters\")).toBeInTheDocument();\n    expect(screen.getByText(\"autobrr.indexers\")).toBeInTheDocument();\n  });\n\n  it(\"renders values when loaded\", () => {\n    useWidgetAPI.mockImplementation((widget, endpoint) => {\n      if (endpoint === \"stats\") return { data: { push_approved_count: 1, push_rejected_count: 2 }, error: undefined };\n      if (endpoint === \"filters\") return { data: [{}, {}], error: undefined };\n      if (endpoint === \"indexers\") return { data: [{}], error: undefined };\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"autobrr\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expectBlockValue(container, \"autobrr.approvedPushes\", 1);\n    expectBlockValue(container, \"autobrr.rejectedPushes\", 2);\n    expectBlockValue(container, \"autobrr.filters\", 2);\n    expectBlockValue(container, \"autobrr.indexers\", 1);\n  });\n});\n"
  },
  {
    "path": "src/widgets/autobrr/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    stats: {\n      endpoint: \"release/stats\",\n      validate: [\"push_approved_count\", \"push_rejected_count\"],\n    },\n    filters: {\n      endpoint: \"filters\",\n    },\n    indexers: {\n      endpoint: \"release/indexers\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/autobrr/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"autobrr widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/azuredevops/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const { userEmail, repositoryId } = widget;\n  const includePR = userEmail !== undefined && repositoryId !== undefined;\n  const { data: prData, error: prError } = useWidgetAPI(widget, includePR ? \"pr\" : null);\n  const { data: pipelineData, error: pipelineError } = useWidgetAPI(widget, \"pipeline\");\n\n  if (pipelineError || (includePR && (prError || prData?.errorCode !== undefined))) {\n    let finalError = pipelineError ?? prError;\n    if (includePR && prData?.errorCode !== null) {\n      // pr call failed possibly with more specific message\n      finalError = { message: prData?.message ?? \"Error communicating with Azure API\" };\n    }\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!pipelineData || !Array.isArray(pipelineData.value) || (includePR && !prData)) {\n    return (\n      <Container service={service}>\n        <Block label=\"azuredevops.result\" />\n        <Block label=\"azuredevops.totalPrs\" />\n        <Block label=\"azuredevops.myPrs\" />\n        <Block label=\"azuredevops.approved\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      {pipelineData.value[0].result ? (\n        <Block label=\"azuredevops.result\" value={t(`azuredevops.${pipelineData.value[0].result.toString()}`)} />\n      ) : (\n        <Block label=\"azuredevops.status\" value={t(`azuredevops.${pipelineData.value[0].status.toString()}`)} />\n      )}\n\n      {includePR && <Block label=\"azuredevops.totalPrs\" value={t(\"common.number\", { value: prData.count })} />}\n      {includePR && (\n        <Block\n          label=\"azuredevops.myPrs\"\n          value={t(\"common.number\", {\n            value: prData.value?.filter((item) => item.createdBy.uniqueName.toLowerCase() === userEmail.toLowerCase())\n              .length,\n          })}\n        />\n      )}\n      {includePR && (\n        <Block\n          label=\"azuredevops.approved\"\n          value={t(\"common.number\", {\n            value: prData.value\n              ?.filter((item) => item.createdBy.uniqueName.toLowerCase() === userEmail.toLowerCase())\n              .filter((item) => item.reviewers.some((reviewer) => [5, 10].includes(reviewer.vote))).length,\n          })}\n        />\n      )}\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/azuredevops/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/azuredevops/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"azuredevops\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"azuredevops.result\")).toBeInTheDocument();\n    expect(screen.getByText(\"azuredevops.totalPrs\")).toBeInTheDocument();\n    expect(screen.getByText(\"azuredevops.myPrs\")).toBeInTheDocument();\n    expect(screen.getByText(\"azuredevops.approved\")).toBeInTheDocument();\n  });\n\n  it(\"renders pipeline result without PR blocks when includePR is false\", () => {\n    useWidgetAPI.mockImplementation((widget, endpoint) => {\n      if (endpoint === null) return { data: undefined, error: undefined };\n      if (endpoint === \"pipeline\")\n        return { data: { value: [{ result: \"succeeded\", status: \"completed\" }] }, error: undefined };\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"azuredevops\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(1);\n    expectBlockValue(container, \"azuredevops.result\", \"azuredevops.succeeded\");\n  });\n\n  it(\"renders pipeline status and PR aggregates when includePR is true\", () => {\n    useWidgetAPI.mockImplementation((widget, endpoint) => {\n      if (endpoint === \"pipeline\") return { data: { value: [{ status: \"inProgress\" }] }, error: undefined };\n      if (endpoint === \"pr\")\n        return {\n          data: {\n            count: 3,\n            value: [\n              { createdBy: { uniqueName: \"me@example.com\" }, reviewers: [{ vote: 5 }] },\n              { createdBy: { uniqueName: \"me@example.com\" }, reviewers: [{ vote: 0 }] },\n              { createdBy: { uniqueName: \"other@example.com\" }, reviewers: [{ vote: 10 }] },\n            ],\n          },\n          error: undefined,\n        };\n      return { data: undefined, error: undefined };\n    });\n\n    const service = {\n      widget: { type: \"azuredevops\", userEmail: \"me@example.com\", repositoryId: \"repo1\" },\n    };\n\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expectBlockValue(container, \"azuredevops.status\", \"azuredevops.inProgress\");\n    expectBlockValue(container, \"azuredevops.totalPrs\", 3);\n    expectBlockValue(container, \"azuredevops.myPrs\", 2);\n    expectBlockValue(container, \"azuredevops.approved\", 1);\n  });\n\n  it(\"renders PR error message when PR call returns an errorCode\", () => {\n    useWidgetAPI.mockImplementation((widget, endpoint) => {\n      if (endpoint === \"pipeline\") return { data: { value: [{ result: \"succeeded\" }] }, error: undefined };\n      if (endpoint === \"pr\") return { data: { errorCode: 1, message: \"Bad PR\" }, error: undefined };\n      return { data: undefined, error: undefined };\n    });\n\n    const service = { widget: { type: \"azuredevops\", userEmail: \"me@example.com\", repositoryId: \"repo1\" } };\n    renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"Bad PR\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/azuredevops/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"https://dev.azure.com/{organization}/{project}/_apis/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    pr: {\n      endpoint: \"git/repositories/{repositoryId}/pullrequests\",\n    },\n\n    pipeline: {\n      endpoint: \"build/Builds?branchName={branchName}&definitions={definitionId}\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/azuredevops/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"azuredevops widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/backrest/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst BACKREST_DEFAULT_FIELDS = [\"num_success_latest\", \"num_failure_latest\", \"num_failure_30\", \"bytes_added_30\"];\nconst MAX_ALLOWED_FIELDS = 4;\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data, error } = useWidgetAPI(widget, \"summary\");\n\n  if (error) {\n    return <Container service={service} error={error} />;\n  }\n\n  if (!widget.fields?.length) {\n    widget.fields = BACKREST_DEFAULT_FIELDS;\n  } else if (widget.fields.length > MAX_ALLOWED_FIELDS) {\n    widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);\n  }\n\n  if (!data) {\n    return (\n      <Container service={service}>\n        <Block label=\"backrest.num_plans\" />\n        <Block label=\"backrest.num_success_latest\" />\n        <Block label=\"backrest.num_failure_latest\" />\n        <Block label=\"backrest.num_success_30\" />\n        <Block label=\"backrest.num_failure_30\" />\n        <Block label=\"backrest.bytes_added_30\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"backrest.num_plans\" value={t(\"common.number\", { value: data.numPlans })} />\n      <Block label=\"backrest.num_success_latest\" value={t(\"common.number\", { value: data.numSuccessLatest })} />\n      <Block label=\"backrest.num_failure_latest\" value={t(\"common.number\", { value: data.numFailureLatest })} />\n      <Block label=\"backrest.num_success_30\" value={t(\"common.number\", { value: data.numSuccess30Days })} />\n      <Block label=\"backrest.num_failure_30\" value={t(\"common.number\", { value: data.numFailure30Days })} />\n      <Block label=\"backrest.bytes_added_30\" value={t(\"common.bytes\", { value: data.bytesAdded30Days })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/backrest/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/backrest/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"defaults widget.fields and filters placeholders down to 4 blocks while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"backrest\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\n      \"num_success_latest\",\n      \"num_failure_latest\",\n      \"num_failure_30\",\n      \"bytes_added_30\",\n    ]);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n\n    expect(screen.getByText(\"backrest.num_success_latest\")).toBeInTheDocument();\n    expect(screen.getByText(\"backrest.num_failure_latest\")).toBeInTheDocument();\n    expect(screen.getByText(\"backrest.num_failure_30\")).toBeInTheDocument();\n    expect(screen.getByText(\"backrest.bytes_added_30\")).toBeInTheDocument();\n    expect(screen.queryByText(\"backrest.num_plans\")).toBeNull();\n  });\n\n  it(\"truncates widget.fields to 4\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = {\n      widget: { type: \"backrest\", fields: [\"a\", \"b\", \"c\", \"d\", \"e\"] },\n    };\n\n    renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"a\", \"b\", \"c\", \"d\"]);\n  });\n\n  it(\"renders values and respects field filtering\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        numPlans: 10,\n        numSuccessLatest: 1,\n        numFailureLatest: 2,\n        numSuccess30Days: 3,\n        numFailure30Days: 4,\n        bytesAdded30Days: 500,\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"backrest\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    // Default fields exclude num_plans and num_success_30\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.queryByText(\"backrest.num_plans\")).toBeNull();\n    expect(screen.queryByText(\"backrest.num_success_30\")).toBeNull();\n\n    expect(screen.getByText(\"1\")).toBeInTheDocument();\n    expect(screen.getByText(\"2\")).toBeInTheDocument();\n    expect(screen.getByText(\"4\")).toBeInTheDocument();\n    expect(screen.getByText(\"500\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/backrest/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { asJson, formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst proxyName = \"backrestProxyHandler\";\nconst logger = createLogger(proxyName);\n\nexport function sumField(plans, field) {\n  return plans.reduce((sum, plan) => {\n    const num = Number(plan[field]);\n    return sum + (Number.isNaN(num) ? 0 : num);\n  }, 0);\n}\n\nexport function buildResponse(plans) {\n  const numSuccess30Days = sumField(plans, \"backupsSuccessLast30days\");\n  const numFailure30Days = sumField(plans, \"backupsFailed30days\");\n  const bytesAdded30Days = sumField(plans, \"bytesAddedLast30days\");\n\n  var numSuccessLatest = 0;\n  var numFailureLatest = 0;\n\n  plans.forEach((plan) => {\n    const statuses = plan?.recentBackups?.status;\n    // See https://github.com/garethgeorge/backrest/blob/4357295a17cb2e71639473c9929a060c4dd1b624/proto/v1/operations.proto#L78-L87\n    if (Array.isArray(statuses) && statuses.length > 0) {\n      if (statuses[0] === \"STATUS_SUCCESS\") {\n        numSuccessLatest++;\n      } else if (statuses[0] === \"STATUS_ERROR\") {\n        numFailureLatest++;\n      }\n    }\n  });\n\n  return {\n    numPlans: plans.length,\n    numSuccess30Days,\n    numFailure30Days,\n    numSuccessLatest,\n    numFailureLatest,\n    bytesAdded30Days,\n  };\n}\n\nexport default async function backrestProxyHandler(req, res) {\n  const { group, service, endpoint, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const headers = {\n    \"content-type\": \"application/json\",\n  };\n\n  if (widget.username && widget.password) {\n    headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString(\"base64\")}`;\n  }\n\n  const { api } = widgets[widget.type];\n  const url = new URL(formatApiCall(api, { endpoint, ...widget }));\n\n  try {\n    const [status, contentType, data] = await httpProxy(url, {\n      method: \"POST\",\n      body: JSON.stringify({}),\n      headers,\n    });\n\n    if (status !== 200) {\n      logger.error(\"Error getting data from Backrest: %d.  Data: %s\", status, data);\n      return res.status(500).send({ error: { message: \"Error getting data from Backrest\", url, data } });\n    }\n\n    if (contentType) res.setHeader(\"Content-Type\", \"application/json\");\n    const plans = asJson(data).planSummaries;\n    if (!Array.isArray(plans)) {\n      logger.error(\"Invalid plans data: %s\", JSON.stringify(plans));\n      return res.status(500).send({ error: { message: \"Invalid plans data\", url, data } });\n    }\n    const response = buildResponse(plans);\n    return res.status(status).send(response);\n  } catch (error) {\n    logger.error(\"Exception calling Backrest API: %s\", error.message);\n    return res.status(500).json({ error: \"Backrest API Error\", message: error.message });\n  }\n}\n"
  },
  {
    "path": "src/widgets/backrest/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  logger: {\n    debug: vi.fn(),\n    error: vi.fn(),\n  },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    backrest: {\n      api: \"{url}/v1.Backrest/{endpoint}\",\n    },\n  },\n}));\n\nimport backrestProxyHandler, { buildResponse } from \"./proxy\";\n\ndescribe(\"backrest proxy buildResponse\", () => {\n  it(\"aggregates plan metrics and latest status counts\", () => {\n    const plans = [\n      {\n        backupsSuccessLast30days: 3,\n        backupsFailed30days: 1,\n        bytesAddedLast30days: 1000,\n        recentBackups: { status: [\"STATUS_SUCCESS\"] },\n      },\n      {\n        backupsSuccessLast30days: 2,\n        backupsFailed30days: 0,\n        bytesAddedLast30days: 500,\n        recentBackups: { status: [\"STATUS_ERROR\"] },\n      },\n      {\n        backupsSuccessLast30days: \"not-a-number\",\n        backupsFailed30days: 4,\n        bytesAddedLast30days: 250,\n        recentBackups: { status: [] },\n      },\n    ];\n\n    expect(buildResponse(plans)).toEqual({\n      numPlans: 3,\n      numSuccess30Days: 5,\n      numFailure30Days: 5,\n      numSuccessLatest: 1,\n      numFailureLatest: 1,\n      bytesAdded30Days: 1750,\n    });\n  });\n});\n\ndescribe(\"widgets/backrest/proxy handler\", () => {\n  beforeEach(() => {\n    httpProxy.mockReset();\n    getServiceWidget.mockReset();\n    vi.clearAllMocks();\n  });\n\n  it(\"returns 400 when the query is missing group or service\", async () => {\n    const req = { query: { service: \"svc\" } };\n    const res = createMockRes();\n\n    await backrestProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Invalid proxy service type\" });\n    expect(getServiceWidget).not.toHaveBeenCalled();\n  });\n\n  it(\"returns 400 when the widget cannot be resolved\", async () => {\n    getServiceWidget.mockResolvedValue(null);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\", endpoint: \"GetSummaryDashboard\" } };\n    const res = createMockRes();\n\n    await backrestProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Invalid proxy service type\" });\n  });\n\n  it(\"calls the Backrest API with basic auth and returns the aggregated summary\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"backrest\",\n      url: \"http://backrest/\",\n      username: \"u\",\n      password: \"p\",\n    });\n\n    httpProxy.mockResolvedValueOnce([\n      200,\n      \"application/json\",\n      Buffer.from(\n        JSON.stringify({\n          planSummaries: [\n            {\n              backupsSuccessLast30days: 1,\n              backupsFailed30days: 0,\n              bytesAddedLast30days: 10,\n              recentBackups: { status: [] },\n            },\n            {\n              backupsSuccessLast30days: 0,\n              backupsFailed30days: 1,\n              bytesAddedLast30days: 5,\n              recentBackups: { status: [\"STATUS_ERROR\"] },\n            },\n          ],\n        }),\n      ),\n    ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\", endpoint: \"GetSummaryDashboard\" } };\n    const res = createMockRes();\n\n    await backrestProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(1);\n    expect(httpProxy.mock.calls[0][0].toString()).toBe(\"http://backrest/v1.Backrest/GetSummaryDashboard\");\n    expect(httpProxy.mock.calls[0][1]).toEqual(\n      expect.objectContaining({\n        method: \"POST\",\n        body: \"{}\",\n        headers: expect.objectContaining({\n          \"content-type\": \"application/json\",\n          Authorization: `Basic ${Buffer.from(\"u:p\").toString(\"base64\")}`,\n        }),\n      }),\n    );\n\n    expect(res.headers[\"Content-Type\"]).toBe(\"application/json\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({\n      numPlans: 2,\n      numSuccess30Days: 1,\n      numFailure30Days: 1,\n      numSuccessLatest: 0,\n      numFailureLatest: 1,\n      bytesAdded30Days: 15,\n    });\n  });\n\n  it(\"returns 500 when Backrest responds non-200\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"backrest\", url: \"http://backrest\" });\n    httpProxy.mockResolvedValueOnce([401, \"application/json\", Buffer.from(\"nope\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\", endpoint: \"GetSummaryDashboard\" } };\n    const res = createMockRes();\n\n    await backrestProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual(\n      expect.objectContaining({\n        error: expect.objectContaining({\n          message: \"Error getting data from Backrest\",\n          data: expect.any(Buffer),\n        }),\n      }),\n    );\n  });\n\n  it(\"returns 500 when the plans payload is invalid\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"backrest\", url: \"http://backrest\" });\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ planSummaries: {} }))]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\", endpoint: \"GetSummaryDashboard\" } };\n    const res = createMockRes();\n\n    await backrestProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual(\n      expect.objectContaining({\n        error: expect.objectContaining({\n          message: \"Invalid plans data\",\n        }),\n      }),\n    );\n  });\n\n  it(\"returns 500 when httpProxy throws\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"backrest\", url: \"http://backrest\" });\n    httpProxy.mockRejectedValueOnce(new Error(\"boom\"));\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\", endpoint: \"GetSummaryDashboard\" } };\n    const res = createMockRes();\n\n    await backrestProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual(expect.objectContaining({ error: \"Backrest API Error\", message: \"boom\" }));\n  });\n});\n"
  },
  {
    "path": "src/widgets/backrest/widget.js",
    "content": "import backrestProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/v1.Backrest/{endpoint}\",\n  proxyHandler: backrestProxyHandler,\n\n  mappings: {\n    summary: {\n      endpoint: \"GetSummaryDashboard\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/backrest/widget.test.js",
    "content": "import { describe, expect, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"backrest widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n    expect(widget.api).toContain(\"/v1.Backrest/\");\n    expect(widget.mappings?.summary?.endpoint).toBe(\"GetSummaryDashboard\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/bazarr/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: episodesData, error: episodesError } = useWidgetAPI(widget, \"episodes\");\n  const { data: moviesData, error: moviesError } = useWidgetAPI(widget, \"movies\");\n\n  if (moviesError || episodesError) {\n    const finalError = moviesError ?? episodesError;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!episodesData || !moviesData) {\n    return (\n      <Container service={service}>\n        <Block label=\"bazarr.missingEpisodes\" />\n        <Block label=\"bazarr.missingMovies\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"bazarr.missingEpisodes\" value={t(\"common.number\", { value: episodesData.total })} />\n      <Block label=\"bazarr.missingMovies\" value={t(\"common.number\", { value: moviesData.total })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/bazarr/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/bazarr/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"bazarr\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"bazarr.missingEpisodes\")).toBeInTheDocument();\n    expect(screen.getByText(\"bazarr.missingMovies\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when either endpoint errors\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: { message: \"episodes bad\" } })\n      .mockReturnValueOnce({ data: undefined, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"bazarr\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n  });\n\n  it(\"renders counts when loaded\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: { total: 11 }, error: undefined })\n      .mockReturnValueOnce({ data: { total: 22 }, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"bazarr\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"11\")).toBeInTheDocument();\n    expect(screen.getByText(\"22\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/bazarr/widget.js",
    "content": "import { asJson } from \"utils/proxy/api-helpers\";\nimport genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}/wanted?apikey={key}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    movies: {\n      endpoint: \"movies\",\n      map: (data) => ({\n        total: asJson(data).total,\n      }),\n    },\n    episodes: {\n      endpoint: \"episodes\",\n      map: (data) => ({\n        total: asJson(data).total,\n      }),\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/bazarr/widget.test.js",
    "content": "import { describe, expect, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"bazarr widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n    expect(widget.api).toContain(\"apikey={key}\");\n\n    const moviesMapping = widget.mappings?.movies;\n    expect(moviesMapping?.endpoint).toBe(\"movies\");\n    expect(moviesMapping?.map?.('{\"total\":123}')).toEqual({ total: 123 });\n  });\n});\n"
  },
  {
    "path": "src/widgets/beszel/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n  const { systemId } = widget;\n\n  const { data: systems, error: systemsError } = useWidgetAPI(widget, \"systems\");\n\n  const MAX_ALLOWED_FIELDS = 4;\n  if (!widget.fields?.length > 0) {\n    widget.fields = systemId ? [\"name\", \"status\", \"cpu\", \"memory\"] : [\"systems\", \"up\"];\n  }\n  if (widget.fields?.length > MAX_ALLOWED_FIELDS) {\n    widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);\n  }\n\n  let system = null;\n  let finalError = systemsError;\n\n  if (systems && !systems.items) {\n    finalError = { message: \"No items returned from beszel API\" };\n  } else if (systems && systems.items && systemId) {\n    system = systems.items.find((item) => item.id === systemId || item.name === systemId);\n    if (!system) {\n      finalError = { message: `System with id ${systemId} not found` };\n    }\n  }\n\n  if (finalError) {\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!systems) {\n    return (\n      <Container service={service}>\n        <Block label=\"beszel.systems\" />\n        <Block label=\"beszel.up\" />\n      </Container>\n    );\n  }\n\n  if (system) {\n    return (\n      <Container service={service}>\n        <Block label=\"beszel.name\" value={system.name} />\n        <Block label=\"beszel.status\" value={t(`beszel.${system.status}`)} />\n        <Block label=\"beszel.updated\" value={t(\"common.relativeDate\", { value: system.updated })} />\n        <Block\n          label=\"beszel.cpu\"\n          value={t(\"common.percent\", { value: system.info.cpu, maximumFractionDigits: 2 })}\n          highlightValue={system.info.cpu}\n        />\n        <Block\n          label=\"beszel.memory\"\n          value={t(\"common.percent\", { value: system.info.mp, maximumFractionDigits: 2 })}\n          highlightValue={system.info.mp}\n        />\n        <Block\n          label=\"beszel.disk\"\n          value={t(\"common.percent\", { value: system.info.dp, maximumFractionDigits: 2 })}\n          highlightValue={system.info.dp}\n        />\n        <Block\n          label=\"beszel.network\"\n          value={t(\"common.byterate\", { value: system.info.bb, maximumFractionDigits: 2 })}\n          highlightValue={system.info.bb}\n        />\n      </Container>\n    );\n  }\n\n  const upTotal = systems.items.filter((item) => item.status === \"up\").length;\n\n  return (\n    <Container service={service}>\n      <Block label=\"beszel.systems\" value={systems.totalItems} />\n      <Block label=\"beszel.up\" value={`${upTotal} / ${systems.totalItems}`} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/beszel/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/beszel/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading (systems view)\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"beszel\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"systems\", \"up\"]);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"beszel.systems\")).toBeInTheDocument();\n    expect(screen.getByText(\"beszel.up\")).toBeInTheDocument();\n  });\n\n  it(\"renders system totals when loaded (systems view)\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        totalItems: 3,\n        items: [{ status: \"up\" }, { status: \"down\" }, { status: \"up\" }],\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"beszel\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"beszel.systems\", 3);\n    expectBlockValue(container, \"beszel.up\", \"2 / 3\");\n  });\n\n  it(\"renders selected system details and filters to 4 default fields\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        totalItems: 1,\n        items: [\n          {\n            id: \"sys1\",\n            name: \"MySystem\",\n            status: \"up\",\n            updated: 123,\n            info: { cpu: 10, mp: 20, dp: 30, b: 40 },\n          },\n        ],\n      },\n      error: undefined,\n    });\n\n    const service = { widget: { type: \"beszel\", systemId: \"sys1\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"name\", \"status\", \"cpu\", \"memory\"]);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n\n    expectBlockValue(container, \"beszel.name\", \"MySystem\");\n    expectBlockValue(container, \"beszel.status\", \"beszel.up\");\n    expectBlockValue(container, \"beszel.cpu\", 10);\n    expectBlockValue(container, \"beszel.memory\", 20);\n    expect(screen.queryByText(\"beszel.updated\")).toBeNull();\n  });\n\n  it(\"renders optional fields\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        totalItems: 1,\n        items: [\n          {\n            id: \"sys1\",\n            name: \"MySystem\",\n            status: \"up\",\n            updated: 123,\n            info: { cpu: 10, mp: 20, dp: 30, b: 40, bb: 14.5 },\n          },\n        ],\n      },\n      error: undefined,\n    });\n\n    const service = {\n      widget: { type: \"beszel\", systemId: \"sys1\", fields: [\"name\", \"disk\", \"network\"] },\n    };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"name\", \"disk\", \"network\"]);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expectBlockValue(container, \"beszel.name\", \"MySystem\");\n    expectBlockValue(container, \"beszel.disk\", 30);\n    expectBlockValue(container, \"beszel.network\", 14.5);\n  });\n\n  it(\"renders error when systemId is not found\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { totalItems: 1, items: [{ id: \"sys1\", name: \"MySystem\", status: \"up\", info: {} }] },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"beszel\", systemId: \"missing\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"System with id missing not found\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/beszel/proxy.js",
    "content": "import cache from \"memory-cache\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst proxyName = \"beszelProxyHandler\";\nconst tokenCacheKey = `${proxyName}__token`;\nconst logger = createLogger(proxyName);\n\nasync function login(loginUrl, username, password, service) {\n  const authResponse = await httpProxy(loginUrl, {\n    method: \"POST\",\n    body: JSON.stringify({ identity: username, password }),\n    headers: {\n      \"Content-Type\": \"application/json\",\n    },\n  });\n\n  const status = authResponse[0];\n  let data = authResponse[2];\n  try {\n    data = JSON.parse(Buffer.from(authResponse[2]).toString());\n\n    if (status === 200) {\n      cache.put(`${tokenCacheKey}.${service}`, data.token);\n    }\n  } catch (e) {\n    logger.error(`Error ${status} logging into beszel`, JSON.stringify(authResponse[2]));\n  }\n  return [status, data.token ?? data];\n}\n\nexport default async function beszelProxyHandler(req, res) {\n  const { group, service, endpoint, index } = req.query;\n\n  if (group && service) {\n    const widget = await getServiceWidget(group, service, index);\n\n    if (!widgets?.[widget.type]?.api) {\n      return res.status(403).json({ error: \"Service does not support API calls\" });\n    }\n\n    if (widget) {\n      const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));\n      let authEndpointVersion = \"authv1\";\n      if (widget.version === 2) authEndpointVersion = \"authv2\";\n      const loginUrl = formatApiCall(widgets[widget.type].api, {\n        endpoint: widgets[widget.type].mappings[authEndpointVersion].endpoint,\n        ...widget,\n      });\n\n      let status;\n      let data;\n\n      let token = cache.get(`${tokenCacheKey}.${service}`);\n      if (!token) {\n        [status, token] = await login(loginUrl, widget.username, widget.password, service);\n        if (status !== 200) {\n          logger.debug(`HTTP ${status} logging into Beszel: ${JSON.stringify(token)}`);\n          return res.status(status).send(token);\n        }\n      }\n\n      [status, , data] = await httpProxy(url, {\n        method: \"GET\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n          Authorization: `Bearer ${token}`,\n        },\n      });\n\n      const badRequest = [400, 403].includes(status);\n      const text = data.toString(\"utf-8\");\n      let isEmpty = false;\n\n      try {\n        const json = JSON.parse(text);\n        isEmpty = Array.isArray(json.items) && json.items.length === 0;\n      } catch (err) {\n        logger.debug(\"Failed to parse Beszel response JSON:\", err);\n      }\n\n      if (badRequest || isEmpty) {\n        if (badRequest) {\n          logger.debug(`HTTP ${status} retrieving data from Beszel, logging in and trying again.`);\n        } else {\n          logger.debug(`Received empty list from Beszel, logging in and trying again.`);\n        }\n        cache.del(`${tokenCacheKey}.${service}`);\n        [status, token] = await login(loginUrl, widget.username, widget.password, service);\n\n        if (status !== 200) {\n          logger.debug(`HTTP ${status} logging into Beszel: ${JSON.stringify(data)}`);\n          return res.status(status).send(data);\n        }\n\n        [status, , data] = await httpProxy(url, {\n          method: \"GET\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n            Authorization: `Bearer ${token}`,\n          },\n        });\n      }\n\n      if (status !== 200) {\n        return res.status(status).send(data);\n      }\n\n      return res.send(data);\n    }\n  }\n\n  return res.status(400).json({ error: \"Invalid proxy service type\" });\n}\n"
  },
  {
    "path": "src/widgets/beszel/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {\n  const store = new Map();\n\n  return {\n    httpProxy: vi.fn(),\n    getServiceWidget: vi.fn(),\n    cache: {\n      get: vi.fn((k) => store.get(k)),\n      put: vi.fn((k, v) => store.set(k, v)),\n      del: vi.fn((k) => store.delete(k)),\n      _reset: () => store.clear(),\n    },\n    logger: {\n      debug: vi.fn(),\n      error: vi.fn(),\n    },\n  };\n});\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nvi.mock(\"memory-cache\", () => ({\n  default: cache,\n  ...cache,\n}));\n\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    beszel: {\n      api: \"{url}/{endpoint}\",\n      mappings: {\n        authv1: { endpoint: \"api/auth\" },\n        authv2: { endpoint: \"api/auth/v2\" },\n      },\n    },\n  },\n}));\n\nimport beszelProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/beszel/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    cache._reset();\n  });\n\n  it(\"logs in when token is missing and uses Bearer token for requests\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"beszel\",\n      url: \"http://beszel\",\n      username: \"u\",\n      password: \"p\",\n    });\n\n    httpProxy\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ token: \"t1\" }))])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ items: [1] }))]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"items\", index: \"0\" } };\n    const res = createMockRes();\n\n    await beszelProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(2);\n    expect(httpProxy.mock.calls[0][0]).toBe(\"http://beszel/api/auth\");\n    expect(httpProxy.mock.calls[1][0].toString()).toBe(\"http://beszel/items\");\n    expect(httpProxy.mock.calls[1][1]).toEqual({\n      method: \"GET\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n        Authorization: \"Bearer t1\",\n      },\n    });\n    expect(res.send).toHaveBeenCalledWith(Buffer.from(JSON.stringify({ items: [1] })));\n  });\n\n  it(\"retries after receiving an empty list by clearing cache and logging in again\", async () => {\n    cache.put(\"beszelProxyHandler__token.svc\", \"old\");\n\n    getServiceWidget.mockResolvedValue({\n      type: \"beszel\",\n      url: \"http://beszel\",\n      username: \"u\",\n      password: \"p\",\n    });\n\n    httpProxy\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ items: [] }))])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ token: \"new\" }))])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ items: [1] }))]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"items\", index: \"0\" } };\n    const res = createMockRes();\n\n    await beszelProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(3);\n    expect(httpProxy.mock.calls[0][1].headers.Authorization).toBe(\"Bearer old\");\n    expect(httpProxy.mock.calls[1][0]).toBe(\"http://beszel/api/auth\");\n    expect(httpProxy.mock.calls[2][1].headers.Authorization).toBe(\"Bearer new\");\n    expect(res.send).toHaveBeenCalledWith(Buffer.from(JSON.stringify({ items: [1] })));\n  });\n});\n"
  },
  {
    "path": "src/widgets/beszel/widget.js",
    "content": "import beszelProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: beszelProxyHandler,\n\n  mappings: {\n    authv1: {\n      endpoint: \"admins/auth-with-password\",\n    },\n    authv2: {\n      endpoint: \"collections/_superusers/auth-with-password\",\n    },\n    systems: {\n      endpoint: \"collections/systems/records?page=1&perPage=500&sort=%2Bcreated\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/beszel/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"beszel widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/booklore/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { data: bookloreData, error: bookloreError } = useWidgetAPI(widget);\n\n  if (bookloreError) {\n    return <Container service={service} error={bookloreError} />;\n  }\n\n  if (!bookloreData) {\n    return (\n      <Container service={service}>\n        <Block label=\"booklore.libraries\" />\n        <Block label=\"booklore.books\" />\n        <Block label=\"booklore.reading\" />\n        <Block label=\"booklore.finished\" />\n      </Container>\n    );\n  }\n\n  const stats = {\n    libraries: bookloreData.libraries ?? 0,\n    books: bookloreData.books ?? 0,\n    reading: bookloreData.reading ?? 0,\n    finished: bookloreData.finished ?? 0,\n  };\n\n  return (\n    <Container service={service}>\n      <Block label=\"booklore.libraries\" value={t(\"common.number\", { value: stats.libraries })} />\n      <Block label=\"booklore.books\" value={t(\"common.number\", { value: stats.books })} />\n      <Block label=\"booklore.reading\" value={t(\"common.number\", { value: stats.reading })} />\n      <Block label=\"booklore.finished\" value={t(\"common.number\", { value: stats.finished })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/booklore/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/booklore/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"booklore\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"booklore.libraries\")).toBeInTheDocument();\n    expect(screen.getByText(\"booklore.books\")).toBeInTheDocument();\n    expect(screen.getByText(\"booklore.reading\")).toBeInTheDocument();\n    expect(screen.getByText(\"booklore.finished\")).toBeInTheDocument();\n  });\n\n  it(\"renders values with nullish fallback defaults\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { libraries: 1, books: 2, finished: 4 }, // reading missing -> 0\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"booklore\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"1\")).toBeInTheDocument();\n    expect(screen.getByText(\"2\")).toBeInTheDocument();\n    expect(screen.getByText(\"0\")).toBeInTheDocument();\n    expect(screen.getByText(\"4\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/booklore/proxy.js",
    "content": "import cache from \"memory-cache\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst proxyName = \"bookloreProxyHandler\";\nconst sessionTokenCacheKey = `${proxyName}__sessionToken`;\nconst logger = createLogger(proxyName);\n\nasync function login(widget, service) {\n  if (!widget.username || !widget.password) {\n    logger.debug(\"Missing credentials for Booklore service '%s'\", service);\n    return { accessToken: false };\n  }\n\n  const api = widgets?.[widget.type]?.api;\n  const loginUrl = new URL(formatApiCall(api, { ...widget, endpoint: \"auth/login\" }));\n\n  const [status, , data] = await httpProxy(loginUrl, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n      accept: \"application/json\",\n    },\n    body: JSON.stringify({\n      username: widget.username,\n      password: widget.password,\n    }),\n  });\n\n  if (status !== 200) {\n    logger.debug(\"Booklore login failed for service '%s' with status %d\", service, status);\n    return { accessToken: false };\n  }\n\n  try {\n    const { accessToken } = JSON.parse(data.toString());\n\n    if (accessToken) {\n      // access tokens are valid for ~10 hours; refresh 1 minute early.\n      cache.put(`${sessionTokenCacheKey}.${service}`, accessToken, 10 * 60 * 60 * 1000 - 60 * 1000);\n      return { accessToken };\n    }\n  } catch (e) {\n    logger.error(\"Unable to login to Booklore API: %s\", e);\n  }\n\n  return { accessToken: false };\n}\n\nasync function apiCall(widget, endpoint, service) {\n  const cacheKey = `${sessionTokenCacheKey}.${service}`;\n  let accessToken = cache.get(cacheKey);\n\n  if (!accessToken) {\n    ({ accessToken } = await login(widget, service));\n  }\n\n  if (!accessToken) {\n    return { status: 401, data: null };\n  }\n\n  const headers = {\n    accept: \"application/json\",\n    Authorization: `Bearer ${accessToken}`,\n  };\n\n  const url = new URL(formatApiCall(widgets[widget.type].api, { ...widget, endpoint }));\n  let [status, , data] = await httpProxy(url, {\n    method: \"GET\",\n    headers,\n  });\n\n  if (status === 401 || status === 403) {\n    logger.debug(\"Booklore API rejected the request, attempting to obtain new session token\");\n    const refreshedToken = (await login(widget, service)).accessToken;\n    if (!refreshedToken) {\n      return { status, data: null };\n    }\n    headers.Authorization = `Bearer ${refreshedToken}`;\n    [status, , data] = await httpProxy(url, {\n      method: \"GET\",\n      headers,\n    });\n  }\n\n  if (status !== 200) {\n    logger.error(\"Error getting data from Booklore: %s status %d. Data: %s\", url, status, data);\n    return { status, data: null };\n  }\n\n  try {\n    return { status, data: JSON.parse(data.toString()) };\n  } catch (e) {\n    logger.error(\"Error parsing Booklore response: %s\", e);\n  }\n\n  return { status, data: null };\n}\n\nfunction summarizeStatuses(books = []) {\n  return books.reduce(\n    (accumulator, book) => {\n      const status = (book?.readStatus || \"\").toString().toUpperCase();\n      if (status === \"READING\") accumulator.reading += 1;\n      else if (status === \"READ\") accumulator.finished += 1;\n      return accumulator;\n    },\n    { reading: 0, finished: 0 },\n  );\n}\n\nexport default async function bookloreProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  if (!widget.username || !widget.password) {\n    logger.debug(\"Missing credentials for Booklore widget in service '%s'\", service);\n    return res.status(400).json({ error: \"Missing Booklore credentials\" });\n  }\n\n  const { data: librariesData, status: librariesStatus } = await apiCall(widget, \"libraries\", service);\n\n  if (librariesStatus !== 200 || !Array.isArray(librariesData)) {\n    return res.status(librariesStatus || 500).send(librariesData || { error: \"Error fetching libraries\" });\n  }\n\n  const { data: booksData, status: booksStatus } = await apiCall(widget, \"books\", service);\n\n  if (booksStatus !== 200 || !Array.isArray(booksData)) {\n    return res.status(booksStatus || 500).send(booksData || { error: \"Error fetching books\" });\n  }\n\n  const { reading, finished } = summarizeStatuses(booksData);\n\n  return res.status(200).send({\n    libraries: librariesData.length,\n    books: booksData.length,\n    reading,\n    finished,\n  });\n}\n"
  },
  {
    "path": "src/widgets/booklore/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {\n  const store = new Map();\n\n  return {\n    httpProxy: vi.fn(),\n    getServiceWidget: vi.fn(),\n    cache: {\n      get: vi.fn((k) => store.get(k)),\n      put: vi.fn((k, v) => store.set(k, v)),\n      del: vi.fn((k) => store.delete(k)),\n      _reset: () => store.clear(),\n    },\n    logger: {\n      debug: vi.fn(),\n      error: vi.fn(),\n    },\n  };\n});\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nvi.mock(\"memory-cache\", () => ({\n  default: cache,\n  ...cache,\n}));\n\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    booklore: {\n      api: \"{url}/{endpoint}\",\n    },\n  },\n}));\n\nimport bookloreProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/booklore/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    cache._reset();\n  });\n\n  it(\"returns 400 when Booklore credentials are missing\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"booklore\", url: \"http://booklore\" });\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await bookloreProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Missing Booklore credentials\" });\n  });\n\n  it(\"logs in and summarizes libraries and book statuses\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"booklore\",\n      url: \"http://booklore\",\n      username: \"u\",\n      password: \"p\",\n    });\n\n    const books = [{ readStatus: \"reading\" }, { readStatus: \"read\" }, { readStatus: \"READ\" }, { readStatus: \"other\" }];\n\n    httpProxy\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ accessToken: \"tok\" }))])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify([{ id: 1 }, { id: 2 }]))])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify(books))]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await bookloreProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(3);\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({\n      libraries: 2,\n      books: 4,\n      reading: 1,\n      finished: 2,\n    });\n  });\n});\n"
  },
  {
    "path": "src/widgets/booklore/widget.js",
    "content": "import bookloreProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/api/v1/{endpoint}\",\n  proxyHandler: bookloreProxyHandler,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/booklore/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"booklore widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/caddy/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n  const { data: resultData, error: resultError } = useWidgetAPI(widget, \"upstreams\");\n\n  if (resultError) {\n    return <Container service={service} error={resultError} />;\n  }\n\n  if (!resultData) {\n    return (\n      <Container service={service}>\n        <Block label=\"caddy.upstreams\" />\n        <Block label=\"caddy.requests\" />\n        <Block label=\"caddy.requests_failed\" />\n      </Container>\n    );\n  }\n\n  const upstreams = resultData.length;\n  const requests = resultData.reduce((acc, val) => acc + val.num_requests, 0);\n  const requestsFailed = resultData.reduce((acc, val) => acc + val.fails, 0);\n\n  return (\n    <Container service={service}>\n      <Block label=\"caddy.upstreams\" value={t(\"common.number\", { value: upstreams })} />\n      <Block label=\"caddy.requests\" value={t(\"common.number\", { value: requests })} />\n      <Block label=\"caddy.requests_failed\" value={t(\"common.number\", { value: requestsFailed })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/caddy/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/caddy/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"caddy\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"caddy.upstreams\")).toBeInTheDocument();\n    expect(screen.getByText(\"caddy.requests\")).toBeInTheDocument();\n    expect(screen.getByText(\"caddy.requests_failed\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"caddy\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"computes upstream/request totals when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: [\n        { num_requests: 10, fails: 1 },\n        { num_requests: 5, fails: 2 },\n      ],\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"caddy\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"caddy.upstreams\", 2);\n    expectBlockValue(container, \"caddy.requests\", 15);\n    expectBlockValue(container, \"caddy.requests_failed\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/caddy/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    upstreams: {\n      endpoint: \"reverse_proxy/upstreams\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/caddy/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"caddy widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/calendar/agenda.jsx",
    "content": "import classNames from \"classnames\";\nimport { DateTime } from \"luxon\";\nimport { useTranslation } from \"next-i18next\";\n\nimport Event, { compareDateTimezone } from \"./event\";\n\nexport default function Agenda({ service, colorVariants, events, showDate }) {\n  const { widget } = service;\n  const { t } = useTranslation();\n\n  if (!showDate) {\n    return <div className=\" text-center\" />;\n  }\n\n  const eventsArray = Object.keys(events)\n    .filter(\n      (eventKey) =>\n        showDate.minus({ days: widget?.previousDays ?? 0 }).startOf(\"day\").ts <=\n        events[eventKey].date?.startOf(\"day\").ts,\n    )\n    .map((eventKey) => events[eventKey])\n    .sort((a, b) => a.date - b.date)\n    .slice(0, widget?.maxEvents ?? 10);\n\n  if (!eventsArray.length) {\n    return (\n      <div className=\"text-center\">\n        <div className=\"pl-2 pr-2\">\n          <div className={classNames(\"flex flex-col\", !eventsArray.length && !events.length && \"animate-pulse\")}>\n            <Event\n              key=\"no-event\"\n              event={{\n                title: t(\"calendar.noEventsToday\"),\n                date: DateTime.now(),\n                color: \"gray\",\n              }}\n              colorVariants={colorVariants}\n            />\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  const days = Array.from(new Set(eventsArray.map((e) => e.date.startOf(\"day\").ts)));\n  const eventsByDay = days.map((d) => eventsArray.filter((e) => e.date.startOf(\"day\").ts === d));\n\n  return (\n    <div className=\"pl-1 pr-1 pb-1\">\n      <div className={classNames(\"flex flex-col\", !eventsArray.length && !events.length && \"animate-pulse\")}>\n        {eventsByDay.map((eventsDay, i) => (\n          <div key={days[i]}>\n            {eventsDay.map((event, j) => (\n              <Event\n                key={`event-agenda-${event.title}-${event.date}-${event.additional}`}\n                event={event}\n                colorVariants={colorVariants}\n                showDate={j === 0}\n                showTime={widget?.showTime && compareDateTimezone(showDate, event)}\n              />\n            ))}\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/widgets/calendar/agenda.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { DateTime } from \"luxon\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nconst { EventStub, compareDateTimezoneStub } = vi.hoisted(() => ({\n  EventStub: vi.fn(({ event, showDate, showTime }) => (\n    <div data-testid=\"event\" data-showdate={showDate ? \"1\" : \"0\"} data-showtime={showTime ? \"1\" : \"0\"}>\n      {event.title}\n    </div>\n  )),\n  compareDateTimezoneStub: vi.fn(\n    (date, event) => date.startOf(\"day\").toISODate() === event.date.startOf(\"day\").toISODate(),\n  ),\n}));\n\nvi.mock(\"./event\", () => ({\n  default: EventStub,\n  compareDateTimezone: compareDateTimezoneStub,\n}));\n\nimport Agenda from \"./agenda\";\n\ndescribe(\"widgets/calendar/agenda\", () => {\n  it(\"renders an empty placeholder when showDate is not set\", () => {\n    const { container } = render(<Agenda service={{ widget: {} }} colorVariants={{}} events={{}} showDate={null} />);\n    expect(container.textContent).toBe(\"\");\n  });\n\n  it(\"renders a no-events placeholder when there are no events in range\", () => {\n    render(<Agenda service={{ widget: {} }} colorVariants={{}} events={{}} showDate={DateTime.now()} />);\n    expect(screen.getByText(\"calendar.noEventsToday\")).toBeInTheDocument();\n    expect(EventStub).toHaveBeenCalled();\n  });\n\n  it(\"filters by previousDays, sorts, and enforces maxEvents\", () => {\n    const showDate = DateTime.local(2099, 1, 2).startOf(\"day\");\n    const service = { widget: { previousDays: 0, maxEvents: 2, showTime: true } };\n\n    const events = {\n      old: { title: \"Old\", date: DateTime.local(2099, 1, 1, 0, 0), color: \"gray\" },\n      a: { title: \"A\", date: DateTime.local(2099, 1, 2, 10, 0), color: \"gray\" },\n      b: { title: \"B\", date: DateTime.local(2099, 1, 3, 10, 0), color: \"gray\" },\n      c: { title: \"C\", date: DateTime.local(2099, 1, 4, 10, 0), color: \"gray\" },\n    };\n\n    render(<Agenda service={service} colorVariants={{}} events={events} showDate={showDate} />);\n\n    // Old is filtered out, C is sliced out by maxEvents.\n    expect(screen.queryByText(\"Old\")).toBeNull();\n    expect(screen.getByText(\"A\")).toBeInTheDocument();\n    expect(screen.getByText(\"B\")).toBeInTheDocument();\n    expect(screen.queryByText(\"C\")).toBeNull();\n\n    const renderedEvents = screen.getAllByTestId(\"event\");\n    expect(renderedEvents).toHaveLength(2);\n\n    // showTime is only true for the selected day.\n    const [first, second] = renderedEvents;\n    expect(first).toHaveAttribute(\"data-showtime\", \"1\");\n    expect(second).toHaveAttribute(\"data-showtime\", \"0\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/calendar/component.jsx",
    "content": "import Container from \"components/services/widget/container\";\nimport { DateTime } from \"luxon\";\nimport { useTranslation } from \"next-i18next\";\nimport dynamic from \"next/dynamic\";\nimport { useContext, useEffect, useMemo, useState } from \"react\";\nimport { SettingsContext } from \"utils/contexts/settings\";\n\nimport Agenda from \"./agenda\";\nimport Monthly from \"./monthly\";\n\nconst colorVariants = {\n  // https://tailwindcss.com/docs/content-configuration#dynamic-class-names\n  amber: \"bg-amber-500\",\n  blue: \"bg-blue-500\",\n  cyan: \"bg-cyan-500\",\n  emerald: \"bg-emerald-500\",\n  fuchsia: \"bg-fuchsia-500\",\n  gray: \"bg-gray-500\",\n  green: \"bg-green-500\",\n  indigo: \"bg-indigo-500\",\n  lime: \"bg-lime-500\",\n  neutral: \"bg-neutral-500\",\n  orange: \"bg-orange-500\",\n  pink: \"bg-pink-500\",\n  purple: \"bg-purple-500\",\n  red: \"bg-red-500\",\n  rose: \"bg-rose-500\",\n  sky: \"bg-sky-500\",\n  slate: \"bg-slate-500\",\n  stone: \"bg-stone-500\",\n  teal: \"bg-teal-500\",\n  violet: \"bg-violet-500\",\n  white: \"bg-white-500\",\n  yellow: \"bg-yellow-500\",\n  zinc: \"bg-zinc-500\",\n};\n\nexport default function Component({ service }) {\n  const { widget } = service;\n  const { i18n } = useTranslation();\n  const [showDate, setShowDate] = useState(null);\n  const [events, setEvents] = useState({});\n  const nowDate = DateTime.now().setLocale(i18n.language);\n  const currentDate = widget?.timezone ? nowDate.setZone(widget?.timezone).startOf(\"day\") : nowDate;\n  const { settings } = useContext(SettingsContext);\n\n  useEffect(() => {\n    if (!showDate) {\n      setShowDate(currentDate);\n    }\n  }, [showDate, currentDate]);\n\n  // params for API fetch\n  const params = useMemo(() => {\n    const constructedParams = {\n      start: \"\",\n      end: \"\",\n      unmonitored: false,\n    };\n\n    if (showDate) {\n      constructedParams.start = showDate.minus({ months: 3 }).toFormat(\"yyyy-MM-dd\");\n      constructedParams.end = showDate.plus({ months: 3 }).toFormat(\"yyyy-MM-dd\");\n    }\n\n    return constructedParams;\n  }, [showDate]);\n\n  // Load active integrations\n  const integrations = useMemo(\n    () =>\n      widget.integrations\n        ?.filter((integration) => integration?.type)\n        .map((integration) => ({\n          // Include the extension so Vite/Vitest can statically validate the import base.\n          service: dynamic(\n            () =>\n              import(\n                /* webpackExclude: /\\.test\\.jsx$/ */\n                `./integrations/${integration.type}.jsx`\n              ),\n          ),\n          widget: { ...widget, ...integration },\n        })) ?? [],\n    [widget],\n  );\n\n  return (\n    <Container service={service}>\n      <div className=\"flex flex-col w-full\">\n        <div className=\"sticky top-0\">\n          {integrations.map((integration) => {\n            const Integration = integration.service;\n            const key = `integration-${integration.widget.type}-${integration.widget.service_name}-${integration.widget.service_group}-${integration.widget.name}`;\n\n            return (\n              <Integration\n                key={key}\n                config={integration.widget}\n                params={params}\n                setEvents={setEvents}\n                hideErrors={settings.hideErrors}\n                timezone={widget?.timezone}\n                className=\"fixed bottom-0 left-0 bg-red-500 w-screen h-12\"\n              />\n            );\n          })}\n        </div>\n        {(!widget?.view || widget?.view === \"monthly\") && (\n          <Monthly\n            key={`monthly-${showDate?.toFormat(\"yyyy-MM-dd\")}`}\n            service={service}\n            colorVariants={colorVariants}\n            events={events}\n            showDate={showDate}\n            setShowDate={setShowDate}\n            currentDate={currentDate}\n            className=\"flex\"\n          />\n        )}\n        {widget?.view === \"agenda\" && (\n          <Agenda\n            key={`agenda-${showDate?.toFormat(\"yyyy-MM-dd\")}`}\n            service={service}\n            colorVariants={colorVariants}\n            events={events}\n            showDate={showDate}\n            setShowDate={setShowDate}\n            className=\"flex\"\n          />\n        )}\n      </div>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/calendar/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen, waitFor } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nvi.mock(\"next/dynamic\", () => ({\n  default: () => (props) => (\n    <div\n      data-testid=\"calendar-integration\"\n      data-type={props.config.type}\n      data-start={props.params.start}\n      data-end={props.params.end}\n      data-timezone={props.timezone || \"\"}\n    />\n  ),\n}));\n\nvi.mock(\"./monthly\", () => ({\n  default: ({ showDate }) => <div data-testid=\"calendar-monthly\" data-show={showDate?.toISODate?.() || \"\"} />,\n}));\n\nvi.mock(\"./agenda\", () => ({\n  default: ({ showDate }) => <div data-testid=\"calendar-agenda\" data-show={showDate?.toISODate?.() || \"\"} />,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/calendar/component\", () => {\n  it(\"renders monthly view by default\", async () => {\n    renderWithProviders(<Component service={{ widget: { type: \"calendar\", integrations: [] } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByTestId(\"calendar-monthly\")).toBeInTheDocument();\n    expect(screen.queryByTestId(\"calendar-agenda\")).toBeNull();\n\n    // showDate is set asynchronously in an effect; ensure it eventually resolves to a date string.\n    await waitFor(() => {\n      expect(screen.getByTestId(\"calendar-monthly\").getAttribute(\"data-show\")).not.toBe(\"\");\n    });\n  });\n\n  it(\"renders agenda view when configured\", async () => {\n    renderWithProviders(<Component service={{ widget: { type: \"calendar\", view: \"agenda\", integrations: [] } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByTestId(\"calendar-agenda\")).toBeInTheDocument();\n    expect(screen.queryByTestId(\"calendar-monthly\")).toBeNull();\n\n    await waitFor(() => {\n      expect(screen.getByTestId(\"calendar-agenda\").getAttribute(\"data-show\")).not.toBe(\"\");\n    });\n  });\n\n  it(\"loads configured integrations and passes calculated params\", async () => {\n    renderWithProviders(\n      <Component\n        service={{\n          widget: {\n            type: \"calendar\",\n            timezone: \"UTC\",\n            integrations: [\n              {\n                type: \"sonarr\",\n                name: \"Sonarr\",\n                service_group: \"Media\",\n                service_name: \"Sonarr\",\n              },\n            ],\n          },\n        }}\n      />,\n      { settings: { hideErrors: false } },\n    );\n\n    const integration = screen.getByTestId(\"calendar-integration\");\n    expect(integration.getAttribute(\"data-type\")).toBe(\"sonarr\");\n    expect(integration.getAttribute(\"data-timezone\")).toBe(\"UTC\");\n\n    await waitFor(() => {\n      // start/end should be yyyy-MM-dd after showDate is set.\n      expect(integration.getAttribute(\"data-start\")).toMatch(/^\\d{4}-\\d{2}-\\d{2}$/);\n      expect(integration.getAttribute(\"data-end\")).toMatch(/^\\d{4}-\\d{2}-\\d{2}$/);\n    });\n  });\n});\n"
  },
  {
    "path": "src/widgets/calendar/event.jsx",
    "content": "import classNames from \"classnames\";\nimport { DateTime } from \"luxon\";\nimport { useTranslation } from \"next-i18next\";\nimport { useState } from \"react\";\nimport { IoMdCheckmarkCircleOutline } from \"react-icons/io\";\n\nexport default function Event({ event, colorVariants, showDate = false, showTime = false, showDateColumn = true }) {\n  const [hover, setHover] = useState(false);\n  const { i18n } = useTranslation();\n\n  const children = (\n    <>\n      {showDateColumn && (\n        <span className=\"ml-2 w-12\">\n          <span>\n            {(showDate || showTime) &&\n              event.date\n                .setLocale(i18n.language)\n                .toLocaleString(showTime ? DateTime.TIME_24_SIMPLE : { month: \"short\", day: \"numeric\" })}\n          </span>\n        </span>\n      )}\n      <span className=\"ml-2 h-2 w-2\">\n        <span className={classNames(\"block w-2 h-2 rounded-sm\", colorVariants[event.color] ?? \"gray\")} />\n      </span>\n      <div className=\"ml-2 h-5 text-left relative truncate\" style={{ width: \"70%\" }}>\n        <div className=\"absolute mt-0.5 text-xs\">{hover && event.additional ? event.additional : event.title}</div>\n      </div>\n      {event.isCompleted && (\n        <span className=\"text-xs mr-1 ml-auto z-10\">\n          <IoMdCheckmarkCircleOutline />\n        </span>\n      )}\n    </>\n  );\n  const className =\n    \"flex flex-row text-theme-700 dark:text-theme-200 items-center text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1\";\n  const key = `event-${event.title}-${event.date}-${event.additional}`;\n  return event.url ? (\n    <a\n      className={classNames(className, \"hover:bg-theme-300/50 dark:hover:bg-theme-800/20\")}\n      onMouseEnter={() => setHover(!hover)}\n      onMouseLeave={() => setHover(!hover)}\n      key={key}\n      href={event.url}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n    >\n      {children}\n    </a>\n  ) : (\n    <div className={className} onMouseEnter={() => setHover(!hover)} onMouseLeave={() => setHover(!hover)} key={key}>\n      {children}\n    </div>\n  );\n}\nexport const compareDateTimezone = (date, event) =>\n  date.startOf(\"day\").toISODate() === event.date.startOf(\"day\").toISODate();\n"
  },
  {
    "path": "src/widgets/calendar/event.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { fireEvent, render, screen } from \"@testing-library/react\";\nimport { DateTime } from \"luxon\";\nimport { describe, expect, it } from \"vitest\";\n\nimport Event, { compareDateTimezone } from \"./event\";\n\ndescribe(\"widgets/calendar/event\", () => {\n  it(\"renders an anchor when a url is provided and toggles additional text on hover\", () => {\n    const date = DateTime.fromISO(\"2099-01-01T13:00:00.000Z\").setZone(\"utc\");\n\n    render(\n      <Event\n        event={{\n          title: \"Primary\",\n          additional: \"More info\",\n          date,\n          color: \"gray\",\n          url: \"https://example.com\",\n          isCompleted: true,\n        }}\n        colorVariants={{ gray: \"bg-gray-500\" }}\n        showDate\n        showTime\n      />,\n    );\n\n    const link = screen.getByRole(\"link\", { name: /primary/i });\n    expect(link).toHaveAttribute(\"href\", \"https://example.com\");\n    expect(link).toHaveAttribute(\"target\", \"_blank\");\n    expect(link).toHaveAttribute(\"rel\", \"noopener noreferrer\");\n\n    // time is rendered when showTime=true\n    expect(link.textContent).toContain(\"13:00\");\n\n    // default shows title, hover shows `additional`\n    expect(screen.getByText(\"Primary\")).toBeInTheDocument();\n    expect(screen.queryByText(\"More info\")).toBeNull();\n\n    fireEvent.mouseEnter(link);\n    expect(screen.getByText(\"More info\")).toBeInTheDocument();\n\n    fireEvent.mouseLeave(link);\n    expect(screen.getByText(\"Primary\")).toBeInTheDocument();\n\n    // completed icon from react-icons renders an SVG\n    expect(link.querySelector(\"svg\")).toBeTruthy();\n  });\n\n  it(\"compareDateTimezone matches dates by day\", () => {\n    const day = DateTime.fromISO(\"2099-01-01T00:00:00.000Z\").setZone(\"utc\");\n    expect(compareDateTimezone(day, { date: DateTime.fromISO(\"2099-01-01T23:59:00.000Z\").setZone(\"utc\") })).toBe(true);\n    expect(compareDateTimezone(day, { date: DateTime.fromISO(\"2099-01-02T00:00:00.000Z\").setZone(\"utc\") })).toBe(false);\n  });\n});\n"
  },
  {
    "path": "src/widgets/calendar/integrations/ical.jsx",
    "content": "import ICAL from \"ical.js\";\nimport { DateTime } from \"luxon\";\nimport { useTranslation } from \"next-i18next\";\nimport { useEffect } from \"react\";\n\nimport Error from \"../../../components/services/widget/error\";\nimport useWidgetAPI from \"../../../utils/proxy/use-widget-api\";\n\nfunction simpleHash(str) {\n  let hash = 0;\n  const prime = 31;\n\n  for (let i = 0; i < str.length; i++) {\n    hash = (hash * prime + str.charCodeAt(i)) % 2_147_483_647;\n  }\n\n  return Math.abs(hash).toString(36);\n}\n\nexport default function Integration({ config, params, setEvents, hideErrors, timezone }) {\n  const { t } = useTranslation();\n  const { data: icalData, error: icalError } = useWidgetAPI(config, config.name, {\n    refreshInterval: 300000, // 5 minutes\n  });\n\n  useEffect(() => {\n    const { showName = false } = config?.params || {};\n    let events = [];\n\n    if (!icalError && icalData && !icalData.error) {\n      if (!icalData.data) {\n        icalData.error = { message: `'${config.name}': ${t(\"calendar.errorWhenLoadingData\")}` };\n        return;\n      }\n\n      const jCal = ICAL.parse(icalData.data);\n      const vCalendar = new ICAL.Component(jCal);\n\n      const buildEvent = (event, type) => {\n        return {\n          id: event.getFirstPropertyValue(\"uid\"),\n          type,\n          title: event.getFirstPropertyValue(\"summary\"),\n          rrule: event.getFirstPropertyValue(\"rrule\"),\n          dtstart:\n            event.getFirstPropertyValue(\"dtstart\") ||\n            event.getFirstPropertyValue(\"due\") ||\n            event.getFirstPropertyValue(\"completed\") ||\n            ICAL.Time.now(), // handles events without a date\n          dtend:\n            event.getFirstPropertyValue(\"dtend\") ||\n            event.getFirstPropertyValue(\"due\") ||\n            event.getFirstPropertyValue(\"completed\") ||\n            ICAL.Time.now(), // handles events without a date\n          location: event.getFirstPropertyValue(\"location\"),\n          status: event.getFirstPropertyValue(\"status\"),\n          url: event.getFirstPropertyValue(\"url\"),\n        };\n      };\n\n      const getEvents = () => {\n        const vEvents = vCalendar.getAllSubcomponents(\"vevent\").map((event) => buildEvent(event, \"vevent\"));\n\n        const vTodos = vCalendar.getAllSubcomponents(\"vtodo\").map((todo) => buildEvent(todo, \"vtodo\"));\n\n        return [...vEvents, ...vTodos];\n      };\n\n      events = getEvents();\n      if (events.length === 0) {\n        icalData.error = { message: `'${config.name}': ${t(\"calendar.noEventsFound\")}` };\n      }\n    }\n\n    const startDate = DateTime.fromISO(params.start);\n    const endDate = DateTime.fromISO(params.end);\n\n    if (icalError || events.length === 0 || !startDate.isValid || !endDate.isValid) {\n      return;\n    }\n\n    const rangeStart = ICAL.Time.fromJSDate(startDate.toJSDate());\n    const rangeEnd = ICAL.Time.fromJSDate(endDate.toJSDate());\n\n    const getOcurrencesFromRange = (event) => {\n      if (!event.rrule) {\n        if (event.dtstart.compare(rangeStart) >= 0 && event.dtend.compare(rangeEnd) <= 0) {\n          return [event.dtstart];\n        }\n\n        return [];\n      }\n\n      const iterator = event.rrule.iterator(event.dtstart);\n\n      const occurrences = [];\n      for (let next = iterator.next(); next && next.compare(rangeEnd) < 0; next = iterator.next()) {\n        if (next.compare(rangeStart) < 0) {\n          continue;\n        }\n\n        occurrences.push(next.clone());\n      }\n\n      return occurrences;\n    };\n\n    const eventsToAdd = [];\n    events.forEach((event) => {\n      const occurrences = getOcurrencesFromRange(event);\n\n      occurrences.forEach((icalDate) => {\n        const date = icalDate.toJSDate();\n\n        const occurrenceTimestamp = date.getTime();\n        const eventIdentifier =\n          event.id ??\n          simpleHash(\n            `${event.title ?? \"\"}-${event.type ?? \"\"}-${event.status ?? \"\"}-${event.url ?? \"\"}-${event.location ?? \"\"}`,\n          );\n        const hash = simpleHash(`${eventIdentifier}-${occurrenceTimestamp}`);\n\n        let title = event.title;\n        if (showName) {\n          title = `${config.name}: ${title}`;\n        }\n\n        const getIsCompleted = () => {\n          if (event.type === \"vtodo\") {\n            return event.status === \"COMPLETED\";\n          }\n\n          return DateTime.fromJSDate(date) < DateTime.now();\n        };\n\n        eventsToAdd[hash] = {\n          title,\n          date: DateTime.fromJSDate(date),\n          color: config?.color ?? \"zinc\",\n          isCompleted: getIsCompleted(),\n          additional: event.location,\n          type: \"ical\",\n          url: event.url,\n        };\n      });\n    });\n\n    setEvents((prevEvents) => ({ ...prevEvents, ...eventsToAdd }));\n  }, [icalData, icalError, config, params, setEvents, timezone, t]);\n\n  const error = icalError ?? icalData?.error;\n  return error && !hideErrors && <Error error={{ message: `${config.type}: ${error.message ?? error}` }} />;\n}\n"
  },
  {
    "path": "src/widgets/calendar/integrations/ical.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, waitFor } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Integration from \"./ical\";\n\ndescribe(\"widgets/calendar/integrations/ical\", () => {\n  it(\"adds parsed events within the date range\", async () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          \"BEGIN:VCALENDAR\",\n          \"VERSION:2.0\",\n          \"PRODID:-//Test//EN\",\n          \"BEGIN:VEVENT\",\n          \"UID:uid1\",\n          \"DTSTAMP:20990101T000000Z\",\n          \"DTSTART:20990101T130000Z\",\n          \"DTEND:20990101T140000Z\",\n          \"SUMMARY:Test Event\",\n          \"LOCATION:Office\",\n          \"URL:https://example.com\",\n          \"END:VEVENT\",\n          \"END:VCALENDAR\",\n          \"\",\n        ].join(\"\\n\"),\n      },\n      error: undefined,\n    });\n\n    const setEvents = vi.fn();\n    render(\n      <Integration\n        config={{ name: \"Work\", type: \"ical\", color: \"blue\", params: { showName: true } }}\n        params={{ start: \"2099-01-01T00:00:00.000Z\", end: \"2099-01-02T00:00:00.000Z\" }}\n        setEvents={setEvents}\n        hideErrors\n        timezone=\"utc\"\n      />,\n    );\n\n    await waitFor(() => expect(setEvents).toHaveBeenCalled());\n\n    const updater = setEvents.mock.calls[0][0];\n    const next = updater({});\n    const entries = Object.values(next);\n    expect(entries).toHaveLength(1);\n\n    const [event] = entries;\n    expect(event.title).toBe(\"Work: Test Event\");\n    expect(event.color).toBe(\"blue\");\n    expect(event.type).toBe(\"ical\");\n    expect(event.additional).toBe(\"Office\");\n    expect(event.url).toBe(\"https://example.com\");\n    expect(event.isCompleted).toBe(false);\n  });\n});\n"
  },
  {
    "path": "src/widgets/calendar/integrations/lidarr.jsx",
    "content": "import { DateTime } from \"luxon\";\nimport { useEffect } from \"react\";\n\nimport Error from \"../../../components/services/widget/error\";\nimport useWidgetAPI from \"../../../utils/proxy/use-widget-api\";\n\nexport default function Integration({ config, params, setEvents, hideErrors = false }) {\n  const { data: lidarrData, error: lidarrError } = useWidgetAPI(config, \"calendar\", {\n    ...params,\n    includeArtist: \"false\",\n    ...(config?.params ?? {}),\n  });\n\n  useEffect(() => {\n    if (!lidarrData || lidarrError) {\n      return;\n    }\n\n    const eventsToAdd = {};\n\n    lidarrData?.forEach((event) => {\n      const title = `${event.artist.artistName} - ${event.title}`;\n\n      eventsToAdd[title] = {\n        title,\n        date: DateTime.fromISO(event.releaseDate),\n        color: config?.color ?? \"green\",\n        isCompleted: event.grabbed,\n        additional: \"\",\n      };\n    });\n\n    setEvents((prevEvents) => ({ ...prevEvents, ...eventsToAdd }));\n  }, [lidarrData, lidarrError, config, setEvents]);\n\n  const error = lidarrError ?? lidarrData?.error;\n  return error && !hideErrors && <Error error={{ message: `${config.type}: ${error.message ?? error}` }} />;\n}\n"
  },
  {
    "path": "src/widgets/calendar/integrations/lidarr.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, waitFor } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Integration from \"./lidarr\";\n\ndescribe(\"widgets/calendar/integrations/lidarr\", () => {\n  it(\"adds release events\", async () => {\n    useWidgetAPI.mockReturnValue({\n      data: [\n        { artist: { artistName: \"Artist\" }, title: \"Album\", releaseDate: \"2099-01-01T00:00:00.000Z\", grabbed: true },\n      ],\n      error: undefined,\n    });\n\n    const setEvents = vi.fn();\n    render(\n      <Integration\n        config={{ type: \"lidarr\", color: \"green\" }}\n        params={{ start: \"2099-01-01T00:00:00.000Z\", end: \"2099-01-02T00:00:00.000Z\" }}\n        setEvents={setEvents}\n        hideErrors\n      />,\n    );\n\n    await waitFor(() => expect(setEvents).toHaveBeenCalled());\n\n    const next = setEvents.mock.calls[0][0]({});\n    expect(Object.keys(next)).toEqual([\"Artist - Album\"]);\n    expect(next[\"Artist - Album\"].isCompleted).toBe(true);\n  });\n});\n"
  },
  {
    "path": "src/widgets/calendar/integrations/radarr.jsx",
    "content": "import { DateTime } from \"luxon\";\nimport { useTranslation } from \"next-i18next\";\nimport { useEffect } from \"react\";\n\nimport Error from \"../../../components/services/widget/error\";\nimport useWidgetAPI from \"../../../utils/proxy/use-widget-api\";\n\nexport default function Integration({ config, params, setEvents, hideErrors = false }) {\n  const { t } = useTranslation();\n  const { data: radarrData, error: radarrError } = useWidgetAPI(config, \"calendar\", {\n    ...params,\n    ...(config?.params ?? {}),\n  });\n  useEffect(() => {\n    if (!radarrData || radarrError) {\n      return;\n    }\n\n    const eventsToAdd = {};\n\n    radarrData?.forEach((event) => {\n      const cinemaTitle = `${event.title} - ${t(\"calendar.inCinemas\")}`;\n      const physicalTitle = `${event.title} - ${t(\"calendar.physicalRelease\")}`;\n      const digitalTitle = `${event.title} - ${t(\"calendar.digitalRelease\")}`;\n      const url = config?.baseUrl && event.titleSlug && `${config.baseUrl}/movie/${event.titleSlug}`;\n\n      if (event.inCinemas) {\n        eventsToAdd[cinemaTitle] = {\n          title: cinemaTitle,\n          date: DateTime.fromISO(event.inCinemas),\n          color: config?.color ?? \"amber\",\n          isCompleted: event.hasFile,\n          additional: \"\",\n          url,\n        };\n      }\n\n      if (event.physicalRelease) {\n        eventsToAdd[physicalTitle] = {\n          title: physicalTitle,\n          date: DateTime.fromISO(event.physicalRelease),\n          color: config?.color ?? \"cyan\",\n          isCompleted: event.hasFile,\n          additional: \"\",\n          url,\n        };\n      }\n\n      if (event.digitalRelease) {\n        eventsToAdd[digitalTitle] = {\n          title: digitalTitle,\n          date: DateTime.fromISO(event.digitalRelease),\n          color: config?.color ?? \"emerald\",\n          isCompleted: event.hasFile,\n          additional: \"\",\n          url,\n        };\n      }\n    });\n\n    setEvents((prevEvents) => ({ ...prevEvents, ...eventsToAdd }));\n  }, [radarrData, radarrError, config, setEvents, t]);\n\n  const error = radarrError ?? radarrData?.error;\n  return error && !hideErrors && <Error error={{ message: `${config.type}: ${error.message ?? error}` }} />;\n}\n"
  },
  {
    "path": "src/widgets/calendar/integrations/radarr.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, waitFor } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Integration from \"./radarr\";\n\ndescribe(\"widgets/calendar/integrations/radarr\", () => {\n  it(\"adds cinema/physical/digital events\", async () => {\n    useWidgetAPI.mockReturnValue({\n      data: [\n        {\n          title: \"Movie\",\n          titleSlug: \"movie\",\n          hasFile: false,\n          inCinemas: \"2099-01-01T00:00:00.000Z\",\n          physicalRelease: \"2099-01-02T00:00:00.000Z\",\n          digitalRelease: \"2099-01-03T00:00:00.000Z\",\n        },\n      ],\n      error: undefined,\n    });\n\n    const setEvents = vi.fn();\n    render(\n      <Integration\n        config={{ type: \"radarr\", baseUrl: \"https://radarr.example\", color: \"amber\" }}\n        params={{ start: \"2099-01-01T00:00:00.000Z\", end: \"2099-01-10T00:00:00.000Z\" }}\n        setEvents={setEvents}\n        hideErrors\n      />,\n    );\n\n    await waitFor(() => expect(setEvents).toHaveBeenCalled());\n\n    const next = setEvents.mock.calls[0][0]({});\n    const keys = Object.keys(next);\n    expect(keys.some((k) => k.includes(\"calendar.inCinemas\"))).toBe(true);\n    expect(keys.some((k) => k.includes(\"calendar.physicalRelease\"))).toBe(true);\n    expect(keys.some((k) => k.includes(\"calendar.digitalRelease\"))).toBe(true);\n    expect(Object.values(next)[0].url).toBe(\"https://radarr.example/movie/movie\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/calendar/integrations/readarr.jsx",
    "content": "import { DateTime } from \"luxon\";\nimport { useEffect } from \"react\";\n\nimport Error from \"../../../components/services/widget/error\";\nimport useWidgetAPI from \"../../../utils/proxy/use-widget-api\";\n\nexport default function Integration({ config, params, setEvents, hideErrors = false }) {\n  const { data: readarrData, error: readarrError } = useWidgetAPI(config, \"calendar\", {\n    ...params,\n    includeAuthor: \"true\",\n    ...(config?.params ?? {}),\n  });\n\n  useEffect(() => {\n    if (!readarrData || readarrError) {\n      return;\n    }\n\n    const eventsToAdd = {};\n\n    readarrData?.forEach((event) => {\n      const authorName = event.author?.authorName ?? event.authorTitle.replace(event.title, \"\");\n      const title = `${authorName} - ${event.title} ${event?.seriesTitle ? `(${event.seriesTitle})` : \"\"} `;\n\n      eventsToAdd[title] = {\n        title,\n        date: DateTime.fromISO(event.releaseDate),\n        color: config?.color ?? \"rose\",\n        isCompleted: event.grabbed,\n        additional: \"\",\n      };\n    });\n\n    setEvents((prevEvents) => ({ ...prevEvents, ...eventsToAdd }));\n  }, [readarrData, readarrError, config, setEvents]);\n\n  const error = readarrError ?? readarrData?.error;\n  return error && !hideErrors && <Error error={{ message: `${config.type}: ${error.message ?? error}` }} />;\n}\n"
  },
  {
    "path": "src/widgets/calendar/integrations/readarr.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, waitFor } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Integration from \"./readarr\";\n\ndescribe(\"widgets/calendar/integrations/readarr\", () => {\n  it(\"adds release events with author name\", async () => {\n    useWidgetAPI.mockReturnValue({\n      data: [\n        {\n          title: \"Book\",\n          seriesTitle: \"Series\",\n          releaseDate: \"2099-01-01T00:00:00.000Z\",\n          grabbed: false,\n          author: { authorName: \"Author\" },\n          authorTitle: \"Author Book\",\n        },\n      ],\n      error: undefined,\n    });\n\n    const setEvents = vi.fn();\n    render(\n      <Integration\n        config={{ type: \"readarr\", color: \"rose\" }}\n        params={{ start: \"2099-01-01T00:00:00.000Z\", end: \"2099-01-02T00:00:00.000Z\" }}\n        setEvents={setEvents}\n        hideErrors\n      />,\n    );\n\n    await waitFor(() => expect(setEvents).toHaveBeenCalled());\n\n    const next = setEvents.mock.calls[0][0]({});\n    const [key] = Object.keys(next);\n    expect(key).toContain(\"Author\");\n    expect(key).toContain(\"Book\");\n    expect(key).toContain(\"(Series)\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/calendar/integrations/sonarr.jsx",
    "content": "import { DateTime } from \"luxon\";\nimport { useEffect } from \"react\";\n\nimport Error from \"../../../components/services/widget/error\";\nimport useWidgetAPI from \"../../../utils/proxy/use-widget-api\";\n\nexport default function Integration({ config, params, setEvents, hideErrors = false }) {\n  const { data: sonarrData, error: sonarrError } = useWidgetAPI(config, \"calendar\", {\n    ...params,\n    includeSeries: \"true\",\n    includeEpisodeFile: \"false\",\n    includeEpisodeImages: \"false\",\n    ...(config?.params ?? {}),\n  });\n\n  useEffect(() => {\n    if (!sonarrData || sonarrError) {\n      return;\n    }\n\n    const eventsToAdd = {};\n\n    sonarrData?.forEach((event) => {\n      const title = `${event.series.title ?? event.title} - S${event.seasonNumber}E${event.episodeNumber}`;\n\n      eventsToAdd[title] = {\n        title: `${event.series.title ?? event.title}`,\n        date: DateTime.fromISO(event.airDateUtc),\n        color: config?.color ?? \"teal\",\n        isCompleted: event.hasFile,\n        additional: `S${event.seasonNumber} E${event.episodeNumber}`,\n        url: config?.baseUrl && event.series.titleSlug && `${config.baseUrl}/series/${event.series.titleSlug}`,\n      };\n    });\n\n    setEvents((prevEvents) => ({ ...prevEvents, ...eventsToAdd }));\n  }, [sonarrData, sonarrError, config, setEvents]);\n\n  const error = sonarrError ?? sonarrData?.error;\n  return error && !hideErrors && <Error error={{ message: `${config.type}: ${error.message ?? error}` }} />;\n}\n"
  },
  {
    "path": "src/widgets/calendar/integrations/sonarr.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, waitFor } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Integration from \"./sonarr\";\n\ndescribe(\"widgets/calendar/integrations/sonarr\", () => {\n  it(\"adds episode events\", async () => {\n    useWidgetAPI.mockReturnValue({\n      data: [\n        {\n          series: { title: \"Show\", titleSlug: \"show\" },\n          seasonNumber: 1,\n          episodeNumber: 2,\n          airDateUtc: \"2099-01-01T00:00:00.000Z\",\n          hasFile: true,\n        },\n      ],\n      error: undefined,\n    });\n\n    const setEvents = vi.fn();\n    render(\n      <Integration\n        config={{ type: \"sonarr\", baseUrl: \"https://sonarr.example\", color: \"teal\" }}\n        params={{ start: \"2099-01-01T00:00:00.000Z\", end: \"2099-01-02T00:00:00.000Z\" }}\n        setEvents={setEvents}\n        hideErrors\n      />,\n    );\n\n    await waitFor(() => expect(setEvents).toHaveBeenCalled());\n\n    const next = setEvents.mock.calls[0][0]({});\n    const [entry] = Object.values(next);\n    expect(entry.title).toBe(\"Show\");\n    expect(entry.additional).toBe(\"S1 E2\");\n    expect(entry.url).toBe(\"https://sonarr.example/series/show\");\n    expect(entry.isCompleted).toBe(true);\n  });\n});\n"
  },
  {
    "path": "src/widgets/calendar/monthly.jsx",
    "content": "import classNames from \"classnames\";\nimport { DateTime, Info } from \"luxon\";\nimport { useTranslation } from \"next-i18next\";\nimport { useMemo } from \"react\";\n\nimport Event, { compareDateTimezone } from \"./event\";\n\nconst cellStyle = \"relative w-10 flex items-center justify-center flex-col\";\nconst monthButton = \"pl-6 pr-6 ml-2 mr-2 hover:bg-theme-100/20 dark:hover:bg-white/5 rounded-md cursor-pointer\";\n\nexport function Day({ weekNumber, weekday, events, colorVariants, showDate, setShowDate, currentDate }) {\n  const cellDate = showDate.set({ weekday, weekNumber, weekYear: showDate.year }).startOf(\"day\");\n  const filteredEvents = events?.filter((event) => compareDateTimezone(cellDate, event));\n\n  const dayStyles = (displayDate) => {\n    let style = \"h-9 \";\n\n    if ([6, 7].includes(displayDate.weekday)) {\n      // weekend style\n      style += \"text-red-500 \";\n      // different month style\n      style += displayDate.month !== showDate.month ? \"text-red-500/40 \" : \"\";\n    } else if (displayDate.month !== showDate.month) {\n      // different month style\n      style += \"text-gray-500 \";\n    }\n\n    // selected same day style\n    style +=\n      displayDate.startOf(\"day\").ts === showDate.startOf(\"day\").ts\n        ? \"text-black-500 bg-theme-100/20 dark:bg-white/10 rounded-md \"\n        : \"\";\n\n    if (displayDate.startOf(\"day\").ts === currentDate.startOf(\"day\").ts) {\n      // today style\n      style += \"text-black-500 bg-theme-100/20 dark:bg-black/20 rounded-md \";\n    } else {\n      style += \"hover:bg-theme-100/20 dark:hover:bg-white/5 rounded-md cursor-pointer\";\n    }\n\n    return style;\n  };\n\n  return (\n    <button\n      key={`day${weekday}${weekNumber}}`}\n      type=\"button\"\n      className={classNames(dayStyles(cellDate), cellStyle)}\n      style={{ width: \"14%\" }}\n      onClick={() => setShowDate(cellDate)}\n    >\n      {cellDate.day}\n      <span className=\"flex justify-center items-center absolute w-full -mb-6\">\n        {filteredEvents &&\n          filteredEvents\n            .slice(0, 4)\n            .map((event) => (\n              <span\n                key={`${event.date.ts}+${event.color}-${event.title}-${event.additional}`}\n                className={classNames(\"inline-flex h-1 w-1 m-0.5 rounded-sm\", colorVariants[event.color] ?? \"gray\")}\n              />\n            ))}\n      </span>\n    </button>\n  );\n}\n\nconst dayInWeekId = {\n  monday: 1,\n  tuesday: 2,\n  wednesday: 3,\n  thursday: 4,\n  friday: 5,\n  saturday: 6,\n  sunday: 7,\n};\n\nexport default function Monthly({ service, colorVariants, events, showDate, setShowDate, currentDate }) {\n  const { widget } = service;\n  const { i18n } = useTranslation();\n\n  const dayNames = Info.weekdays(\"short\", { locale: i18n.language });\n\n  const firstDayInWeekCalendar = widget?.firstDayInWeek ? widget?.firstDayInWeek?.toLowerCase() : \"monday\";\n  for (let i = 1; i < dayInWeekId[firstDayInWeekCalendar]; i += 1) {\n    dayNames.push(dayNames.shift());\n  }\n\n  const daysInWeek = useMemo(\n    () => [...Array(7).keys()].map((i) => i + dayInWeekId[firstDayInWeekCalendar]),\n    [firstDayInWeekCalendar],\n  );\n\n  if (!showDate) {\n    return <div className=\"w-full text-center\" />;\n  }\n\n  const firstWeek = DateTime.local(showDate.year, showDate.month, 1).setLocale(i18n.language);\n\n  const weekIncrementChange = dayInWeekId[firstDayInWeekCalendar] > firstWeek.weekday ? -1 : 0;\n  let weekNumbers = [...Array(Math.ceil(5) + 1).keys()].map((i) => firstWeek.weekNumber + weekIncrementChange + i);\n\n  if (weekNumbers.includes(55)) {\n    // if we went too far with the weeks, it's the beginning of the year\n    weekNumbers = weekNumbers.map((weekNum) => weekNum - 52);\n  }\n\n  const eventsArray = Object.keys(events).map((eventKey) => events[eventKey]);\n  eventsArray.sort((a, b) => a.date - b.date);\n\n  return (\n    <div className=\"w-full text-center\">\n      <div className=\"flex-col\">\n        <span>\n          <button\n            type=\"button\"\n            onClick={() => setShowDate(showDate.minus({ months: 1 }).startOf(\"day\"))}\n            className={classNames(monthButton)}\n          >\n            &lt;\n          </button>\n        </span>\n        <span>\n          <button type=\"button\" onClick={() => setShowDate(currentDate.startOf(\"day\"))}>\n            {showDate.setLocale(i18n.language).toFormat(\"MMMM y\")}\n          </button>\n        </span>\n        <span>\n          <button\n            type=\"button\"\n            onClick={() => setShowDate(showDate.plus({ months: 1 }).startOf(\"day\"))}\n            className={classNames(monthButton)}\n          >\n            &gt;\n          </button>\n        </span>\n      </div>\n\n      <div className=\"pl-1 pr-1 pb-1 w-full\">\n        <div className=\"flex justify-between flex-wrap\">\n          {dayNames.map((name) => (\n            <span key={name} className={classNames(cellStyle)} style={{ width: \"14%\" }}>\n              {name}\n            </span>\n          ))}\n        </div>\n\n        <div\n          className={classNames(\n            \"flex justify-between flex-wrap pb-1\",\n            !eventsArray.length && widget?.integrations?.length && \"animate-pulse\",\n          )}\n        >\n          {weekNumbers.map((weekNumber) =>\n            daysInWeek.map((dayInWeek) => (\n              <Day\n                key={`week${weekNumber}day${dayInWeek}}`}\n                weekNumber={weekNumber}\n                weekday={dayInWeek}\n                events={eventsArray}\n                colorVariants={colorVariants}\n                showDate={showDate}\n                setShowDate={setShowDate}\n                currentDate={currentDate}\n              />\n            )),\n          )}\n        </div>\n\n        <div className=\"flex flex-col\">\n          {eventsArray\n            ?.filter((event) => compareDateTimezone(showDate, event))\n            .slice(0, widget?.maxEvents ?? 10)\n            .map((event) => (\n              <Event\n                key={`event-monthly-${event.title}-${event.date}-${event.additional}`}\n                event={event}\n                colorVariants={colorVariants}\n                showDateColumn={widget?.showTime ?? false}\n                showTime={widget?.showTime && compareDateTimezone(showDate, event)}\n              />\n            ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/widgets/calendar/monthly.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { fireEvent, render, screen } from \"@testing-library/react\";\nimport { DateTime } from \"luxon\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nconst { EventStub, compareDateTimezoneStub } = vi.hoisted(() => ({\n  EventStub: vi.fn(({ event }) => <div data-testid=\"event\">{event.title}</div>),\n  compareDateTimezoneStub: vi.fn(\n    (date, event) => date.startOf(\"day\").toISODate() === event.date.startOf(\"day\").toISODate(),\n  ),\n}));\n\nvi.mock(\"./event\", () => ({\n  default: EventStub,\n  compareDateTimezone: compareDateTimezoneStub,\n}));\n\nimport Monthly from \"./monthly\";\n\ndescribe(\"widgets/calendar/monthly\", () => {\n  it(\"renders an empty placeholder when showDate is not set\", () => {\n    const { container } = render(\n      <Monthly\n        service={{ widget: {} }}\n        colorVariants={{}}\n        events={{}}\n        showDate={null}\n        setShowDate={() => {}}\n        currentDate={DateTime.now()}\n      />,\n    );\n    expect(container.textContent).toBe(\"\");\n  });\n\n  it(\"navigates months and renders day events\", () => {\n    const setShowDate = vi.fn();\n    const showDate = DateTime.local(2099, 2, 15).startOf(\"day\");\n    const currentDate = DateTime.local(2099, 2, 4).startOf(\"day\");\n    const service = { widget: { maxEvents: 10, showTime: false } };\n\n    const events = {\n      e1: { title: \"Today Event\", date: DateTime.local(2099, 2, 15, 10, 0), color: \"zinc\" },\n      e2: { title: \"Other Event\", date: DateTime.local(2099, 2, 16, 10, 0), color: \"zinc\" },\n    };\n\n    render(\n      <Monthly\n        service={service}\n        colorVariants={{}}\n        events={events}\n        showDate={showDate}\n        setShowDate={setShowDate}\n        currentDate={currentDate}\n      />,\n    );\n\n    expect(screen.getByText(\"Today Event\")).toBeInTheDocument();\n    expect(screen.queryByText(\"Other Event\")).toBeNull();\n\n    fireEvent.click(screen.getByRole(\"button\", { name: \">\" }));\n    expect(setShowDate).toHaveBeenCalled();\n    expect(setShowDate.mock.calls[0][0].toISODate()).toBe(showDate.plus({ months: 1 }).startOf(\"day\").toISODate());\n\n    fireEvent.click(screen.getByRole(\"button\", { name: \"<\" }));\n    expect(setShowDate.mock.calls[1][0].toISODate()).toBe(showDate.minus({ months: 1 }).startOf(\"day\").toISODate());\n\n    fireEvent.click(screen.getByRole(\"button\", { name: showDate.toFormat(\"MMMM y\") }));\n    expect(setShowDate.mock.calls[2][0].toISODate()).toBe(currentDate.startOf(\"day\").toISODate());\n  });\n});\n"
  },
  {
    "path": "src/widgets/calendar/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { httpProxy } from \"utils/proxy/http\";\n\nconst logger = createLogger(\"calendarProxyHandler\");\n\nexport default async function calendarProxyHandler(req, res) {\n  const { group, service, endpoint, index } = req.query;\n\n  if (group && service) {\n    const widget = await getServiceWidget(group, service, index);\n    const integration = widget.integrations?.find((i) => i.name === endpoint);\n\n    if (integration) {\n      if (!integration.url) {\n        return res.status(403).json({ error: \"No integration URL specified\" });\n      }\n\n      const options = {};\n      if (integration.url?.includes(\"outlook\")) {\n        // Outlook requires a user agent header\n        options.headers = {\n          \"User-Agent\": `gethomepage/${process.env.NEXT_PUBLIC_VERSION || \"dev\"}`,\n        };\n      }\n      const [status, contentType, data] = await httpProxy(integration.url, options);\n\n      if (contentType) res.setHeader(\"Content-Type\", contentType);\n\n      if (status !== 200) {\n        logger.debug(`HTTP ${status} retrieving data from integration URL ${integration.url} : ${data}`);\n        return res.status(status).send(data);\n      }\n\n      return res.status(status).json({ data: data.toString() });\n    }\n  }\n\n  return res.status(400).json({ error: \"Invalid integration\" });\n}\n"
  },
  {
    "path": "src/widgets/calendar/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  logger: {\n    debug: vi.fn(),\n  },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nimport calendarProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/calendar/proxy\", () => {\n  const envVersion = process.env.NEXT_PUBLIC_VERSION;\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    process.env.NEXT_PUBLIC_VERSION = envVersion;\n  });\n\n  it(\"returns 400 when integration is missing\", async () => {\n    getServiceWidget.mockResolvedValue({ integrations: [] });\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"foo\", index: \"0\" } };\n    const res = createMockRes();\n\n    await calendarProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Invalid integration\" });\n  });\n\n  it(\"returns 403 when integration has no URL\", async () => {\n    getServiceWidget.mockResolvedValue({ integrations: [{ name: \"foo\", url: \"\" }] });\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"foo\", index: \"0\" } };\n    const res = createMockRes();\n\n    await calendarProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(403);\n    expect(res.body).toEqual({ error: \"No integration URL specified\" });\n  });\n\n  it(\"adds a User-Agent for Outlook integrations and returns string data\", async () => {\n    process.env.NEXT_PUBLIC_VERSION = \"1.2.3\";\n    getServiceWidget.mockResolvedValue({\n      integrations: [{ name: \"outlook\", url: \"https://example.com/outlook.ics\" }],\n    });\n\n    httpProxy.mockResolvedValueOnce([200, \"text/calendar\", Buffer.from(\"CAL\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"outlook\", index: \"0\" } };\n    const res = createMockRes();\n\n    await calendarProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledWith(\"https://example.com/outlook.ics\", {\n      headers: { \"User-Agent\": \"gethomepage/1.2.3\" },\n    });\n    expect(res.setHeader).toHaveBeenCalledWith(\"Content-Type\", \"text/calendar\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ data: \"CAL\" });\n  });\n\n  it(\"passes through non-200 status codes from integrations\", async () => {\n    getServiceWidget.mockResolvedValue({\n      integrations: [{ name: \"foo\", url: \"https://example.com/foo.ics\" }],\n    });\n\n    httpProxy.mockResolvedValueOnce([503, \"text/plain\", Buffer.from(\"nope\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"foo\", index: \"0\" } };\n    const res = createMockRes();\n\n    await calendarProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(503);\n    expect(res.body).toEqual(Buffer.from(\"nope\"));\n  });\n});\n"
  },
  {
    "path": "src/widgets/calendar/widget.js",
    "content": "import calendarProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}\",\n  proxyHandler: calendarProxyHandler,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/calendar/widget.test.js",
    "content": "import { describe, expect, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"calendar widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n    expect(widget.api).toBe(\"{url}\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/calibreweb/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n  const { data, error } = useWidgetAPI(widget, \"stats\");\n\n  if (error) {\n    return <Container service={service} error={error} />;\n  }\n\n  if (!data) {\n    return (\n      <Container service={service}>\n        <Block label=\"calibreweb.books\" />\n        <Block label=\"calibreweb.authors\" />\n        <Block label=\"calibreweb.categories\" />\n        <Block label=\"calibreweb.series\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"calibreweb.books\" value={t(\"common.number\", { value: data.books })} />\n      <Block label=\"calibreweb.authors\" value={t(\"common.number\", { value: data.authors })} />\n      <Block label=\"calibreweb.categories\" value={t(\"common.number\", { value: data.categories })} />\n      <Block label=\"calibreweb.series\" value={t(\"common.number\", { value: data.series })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/calibreweb/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/calibreweb/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"calibreweb\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"calibreweb.books\")).toBeInTheDocument();\n    expect(screen.getByText(\"calibreweb.authors\")).toBeInTheDocument();\n    expect(screen.getByText(\"calibreweb.categories\")).toBeInTheDocument();\n    expect(screen.getByText(\"calibreweb.series\")).toBeInTheDocument();\n  });\n\n  it(\"renders values when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { books: 1, authors: 2, categories: 3, series: 4 },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"calibreweb\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"1\")).toBeInTheDocument();\n    expect(screen.getByText(\"2\")).toBeInTheDocument();\n    expect(screen.getByText(\"3\")).toBeInTheDocument();\n    expect(screen.getByText(\"4\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/calibreweb/widget.js",
    "content": "import genericProxyHandler from \"../../utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    stats: {\n      endpoint: \"opds/stats\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/calibreweb/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"calibreweb widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/changedetectionio/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data, error } = useWidgetAPI(widget, \"info\");\n\n  if (error) {\n    return <Container service={service} error={error} />;\n  }\n\n  if (!data) {\n    return (\n      <Container service={service}>\n        <Block label=\"changedetectionio.diffsDetected\" />\n        <Block label=\"changedetectionio.totalObserved\" />\n      </Container>\n    );\n  }\n\n  const totalObserved = Object.keys(data).length;\n  let diffsDetected = 0;\n\n  Object.keys(data).forEach((key) => {\n    if (data[key].last_changed > 0 && !data[key].viewed) {\n      diffsDetected += 1;\n    }\n  });\n\n  return (\n    <Container service={service}>\n      <Block label=\"changedetectionio.diffsDetected\" value={t(\"common.number\", { value: diffsDetected })} />\n      <Block label=\"changedetectionio.totalObserved\" value={t(\"common.number\", { value: totalObserved })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/changedetectionio/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/changedetectionio/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"changedetectionio\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"changedetectionio.diffsDetected\")).toBeInTheDocument();\n    expect(screen.getByText(\"changedetectionio.totalObserved\")).toBeInTheDocument();\n  });\n\n  it(\"computes diffs detected (last_changed > 0 and not viewed)\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        a: { last_changed: 1, viewed: false },\n        b: { last_changed: 0, viewed: false },\n        c: { last_changed: 2, viewed: true },\n        d: { last_changed: 3, viewed: false },\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"changedetectionio\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"changedetectionio.totalObserved\", 4);\n    expectBlockValue(container, \"changedetectionio.diffsDetected\", 2);\n  });\n});\n"
  },
  {
    "path": "src/widgets/changedetectionio/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/v1/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    info: {\n      method: \"GET\",\n      endpoint: \"watch\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/changedetectionio/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"changedetectionio widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/channelsdvrserver/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { data: channelsData, error: channelsError } = useWidgetAPI(widget, \"status\");\n\n  if (channelsError) {\n    return <Container service={service} error={channelsError} />;\n  }\n\n  if (!channelsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"channelsdvrserver.shows\" />\n        <Block label=\"channelsdvrserver.recordings\" />\n        <Block label=\"channelsdvrserver.scheduled\" />\n        <Block label=\"channelsdvrserver.passes\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"channelsdvrserver.shows\" value={t(\"common.number\", { value: channelsData.stats.groups })} />\n      <Block label=\"channelsdvrserver.recordings\" value={t(\"common.number\", { value: channelsData.stats.files })} />\n      <Block label=\"channelsdvrserver.scheduled\" value={t(\"common.number\", { value: channelsData.stats.jobs })} />\n      <Block label=\"channelsdvrserver.passes\" value={t(\"common.number\", { value: channelsData.stats.rules })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/channelsdvrserver/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/channelsdvrserver/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"channelsdvrserver\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"channelsdvrserver.shows\")).toBeInTheDocument();\n    expect(screen.getByText(\"channelsdvrserver.recordings\")).toBeInTheDocument();\n    expect(screen.getByText(\"channelsdvrserver.scheduled\")).toBeInTheDocument();\n    expect(screen.getByText(\"channelsdvrserver.passes\")).toBeInTheDocument();\n  });\n\n  it(\"renders values when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { stats: { groups: 1, files: 2, jobs: 3, rules: 4 } },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"channelsdvrserver\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"channelsdvrserver.shows\", 1);\n    expectBlockValue(container, \"channelsdvrserver.recordings\", 2);\n    expectBlockValue(container, \"channelsdvrserver.scheduled\", 3);\n    expectBlockValue(container, \"channelsdvrserver.passes\", 4);\n  });\n});\n"
  },
  {
    "path": "src/widgets/channelsdvrserver/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    status: {\n      endpoint: \"dvr\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/channelsdvrserver/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"channelsdvrserver widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/checkmk/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: servicesData, error: servicesError } = useWidgetAPI(widget, \"services_info\", {\n    columns: \"state\",\n    query: '{\"op\": \"!=\", \"left\": \"state\", \"right\": \"0\"}',\n  });\n  const { data: hostsData, error: hostsError } = useWidgetAPI(widget, \"hosts_info\", {\n    columns: \"state\",\n    query: '{\"op\": \"!=\", \"left\": \"state\", \"right\": \"0\"}',\n  });\n\n  if (servicesError || hostsError) {\n    return <Container service={service} error={servicesError ?? hostsError} />;\n  }\n\n  if (!servicesData || !hostsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"checkmk.serviceErrors\" />\n        <Block label=\"checkmk.hostErrors\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"checkmk.serviceErrors\" value={t(\"common.number\", { value: servicesData.value.length })} />\n      <Block label=\"checkmk.hostErrors\" value={t(\"common.number\", { value: hostsData.value.length })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/checkmk/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/checkmk/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"calls both endpoints with the expected query params and renders placeholders while loading\", () => {\n    useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"checkmk\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(useWidgetAPI).toHaveBeenNthCalledWith(\n      1,\n      expect.any(Object),\n      \"services_info\",\n      expect.objectContaining({\n        columns: \"state\",\n        query: '{\"op\": \"!=\", \"left\": \"state\", \"right\": \"0\"}',\n      }),\n    );\n    expect(useWidgetAPI).toHaveBeenNthCalledWith(\n      2,\n      expect.any(Object),\n      \"hosts_info\",\n      expect.objectContaining({\n        columns: \"state\",\n        query: '{\"op\": \"!=\", \"left\": \"state\", \"right\": \"0\"}',\n      }),\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"checkmk.serviceErrors\")).toBeInTheDocument();\n    expect(screen.getByText(\"checkmk.hostErrors\")).toBeInTheDocument();\n  });\n\n  it(\"renders counts when loaded\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: { value: [{}, {}] }, error: undefined })\n      .mockReturnValueOnce({ data: { value: [{}] }, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"checkmk\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"checkmk.serviceErrors\", 2);\n    expectBlockValue(container, \"checkmk.hostErrors\", 1);\n  });\n});\n"
  },
  {
    "path": "src/widgets/checkmk/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/{site}/check_mk/api/1.0/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    services_info: {\n      endpoint: \"domain-types/service/collections/all\",\n      params: [\"columns\", \"query\"],\n    },\n    hosts_info: {\n      endpoint: \"domain-types/host/collections/all\",\n      params: [\"columns\", \"query\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/checkmk/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"checkmk widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/cloudflared/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  const { data: statsData, error: statsError } = useWidgetAPI(widget, \"cfd_tunnel\");\n\n  if (statsError) {\n    return <Container service={service} error={statsError} />;\n  }\n\n  if (!statsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"cloudflared.status\" />\n        <Block label=\"cloudflared.origin_ip\" />\n      </Container>\n    );\n  }\n\n  const originIP = statsData.result.connections?.origin_ip ?? statsData.result.connections[0]?.origin_ip;\n\n  return (\n    <Container service={service}>\n      <Block\n        label=\"cloudflared.status\"\n        value={statsData.result.status.charAt(0).toUpperCase() + statsData.result.status.slice(1)}\n      />\n      <Block label=\"cloudflared.origin_ip\" value={originIP} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/cloudflared/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/cloudflared/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"cloudflared\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"cloudflared.status\")).toBeInTheDocument();\n    expect(screen.getByText(\"cloudflared.origin_ip\")).toBeInTheDocument();\n  });\n\n  it(\"renders status capitalization and origin_ip from nested connections\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        result: { status: \"healthy\", connections: { origin_ip: \"1.2.3.4\" } },\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"cloudflared\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"cloudflared.status\", \"Healthy\");\n    expectBlockValue(container, \"cloudflared.origin_ip\", \"1.2.3.4\");\n  });\n\n  it(\"falls back to origin_ip from first connection entry\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        result: { status: \"down\", connections: [{ origin_ip: \"5.6.7.8\" }] },\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"cloudflared\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"cloudflared.origin_ip\", \"5.6.7.8\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/cloudflared/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"https://api.cloudflare.com/client/v4/accounts/{accountid}/{endpoint}/{tunnelid}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    cfd_tunnel: {\n      endpoint: \"cfd_tunnel\",\n      validate: [\"success\", \"result\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/cloudflared/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"cloudflared widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/coinmarketcap/component.jsx",
    "content": "import classNames from \"classnames\";\nimport Dropdown from \"components/services/dropdown\";\nimport Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\nimport { useState } from \"react\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const dateRangeOptions = [\n    { label: t(\"coinmarketcap.1hour\"), value: \"1h\" },\n    { label: t(\"coinmarketcap.1day\"), value: \"24h\" },\n    { label: t(\"coinmarketcap.7days\"), value: \"7d\" },\n    { label: t(\"coinmarketcap.30days\"), value: \"30d\" },\n  ];\n\n  const { widget } = service;\n  const { symbols } = widget;\n  const { slugs } = widget;\n  const currencyCode = widget.currency ?? \"USD\";\n  const interval = widget.defaultinterval ?? dateRangeOptions[0].value;\n\n  const [dateRange, setDateRange] = useState(interval);\n\n  const params = {\n    convert: `${currencyCode}`,\n  };\n\n  // slugs >> symbols, not both\n  if (slugs?.length) {\n    params.slug = slugs.join(\",\");\n  } else if (symbols?.length) {\n    params.symbol = symbols.join(\",\");\n  }\n\n  const { data: statsData, error: statsError } = useWidgetAPI(widget, \"v1/cryptocurrency/quotes/latest\", params);\n\n  if ((!symbols && !slugs) || (symbols?.length === 0 && slugs?.length === 0)) {\n    return (\n      <Container service={service}>\n        <Block value={t(\"coinmarketcap.configure\")} />\n      </Container>\n    );\n  }\n\n  if (statsError) {\n    return <Container service={service} error={statsError} />;\n  }\n\n  if (!statsData || !dateRange) {\n    return (\n      <Container service={service}>\n        <Block value={t(\"coinmarketcap.configure\")} />\n      </Container>\n    );\n  }\n\n  const { data } = statsData;\n  const validCryptos = Object.values(data).filter(\n    (crypto) => crypto.quote[currencyCode][`percent_change_${dateRange}`] !== null,\n  );\n\n  return (\n    <Container service={service}>\n      <div className={classNames(service.description ? \"-top-10\" : \"-top-8\", \"absolute right-1 z-20\")}>\n        <Dropdown options={dateRangeOptions} value={dateRange} setValue={setDateRange} />\n      </div>\n\n      <div className=\"flex flex-col w-full\">\n        {validCryptos.map((crypto) => (\n          <div\n            key={crypto.id}\n            className=\"bg-theme-200/50 dark:bg-theme-900/20 rounded-sm m-1 flex-1 flex flex-row items-center justify-between p-1 text-xs\"\n          >\n            <div className=\"font-thin pl-2\">{crypto.name}</div>\n            <div className=\"flex flex-row text-right\">\n              <div className=\"font-bold mr-2\">\n                {t(\"common.number\", {\n                  value: crypto.quote[currencyCode].price,\n                  style: \"currency\",\n                  currency: currencyCode,\n                })}\n              </div>\n              <div\n                className={`font-bold w-10 mr-2 ${\n                  crypto.quote[currencyCode][`percent_change_${dateRange}`] > 0 ? \"text-emerald-300\" : \"text-rose-300\"\n                }`}\n              >\n                {crypto.quote[currencyCode][`percent_change_${dateRange}`].toFixed(2)}%\n              </div>\n            </div>\n          </div>\n        ))}\n      </div>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/coinmarketcap/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { fireEvent, screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\n// HeadlessUI dropdown is hard to test reliably; stub to a simple button.\nvi.mock(\"components/services/dropdown\", () => ({\n  default: ({ value, setValue }) => (\n    <button type=\"button\" data-testid=\"cmc-dropdown\" onClick={() => setValue(\"24h\")}>\n      {value}\n    </button>\n  ),\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/coinmarketcap/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders a configure message when no symbols/slugs are provided\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"coinmarketcap\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"coinmarketcap.configure\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"coinmarketcap\", symbols: [\"BTC\"] } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    // Error component normalizes the error into a message line we can assert on.\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders valid cryptos and updates percent change when date range changes\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: {\n          BTC: {\n            id: 1,\n            name: \"Bitcoin\",\n            quote: { USD: { price: 30000, percent_change_1h: 1.234, percent_change_24h: -2.5 } },\n          },\n          ETH: {\n            id: 2,\n            name: \"Ethereum\",\n            quote: { USD: { price: 2000, percent_change_1h: null, percent_change_24h: null } },\n          },\n        },\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(\n      <Component\n        service={{ widget: { type: \"coinmarketcap\", symbols: [\"BTC\", \"ETH\"], currency: \"USD\", defaultinterval: \"1h\" } }}\n      />,\n      { settings: { hideErrors: false } },\n    );\n\n    // Only BTC is valid for 1h, ETH is filtered out due to null percent change.\n    expect(screen.getByTestId(\"cmc-dropdown\")).toHaveTextContent(\"1h\");\n    expect(screen.getByText(\"Bitcoin\")).toBeInTheDocument();\n    expect(screen.queryByText(\"Ethereum\")).toBeNull();\n    expect(screen.getByText(\"30000\")).toBeInTheDocument();\n    expect(screen.getByText(\"1.23%\")).toBeInTheDocument();\n\n    fireEvent.click(screen.getByTestId(\"cmc-dropdown\"));\n    expect(screen.getByTestId(\"cmc-dropdown\")).toHaveTextContent(\"24h\");\n    expect(screen.getByText(\"-2.50%\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/coinmarketcap/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"https://pro-api.coinmarketcap.com/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    \"v1/cryptocurrency/quotes/latest\": {\n      endpoint: \"v1/cryptocurrency/quotes/latest\",\n      params: [\"convert\"],\n      optionalParams: [\"symbol\", \"slug\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/coinmarketcap/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"coinmarketcap widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/components.js",
    "content": "import dynamic from \"next/dynamic\";\n\nconst components = {\n  adguard: dynamic(() => import(\"./adguard/component\")),\n  apcups: dynamic(() => import(\"./apcups/component\")),\n  arcane: dynamic(() => import(\"./arcane/component\")),\n  argocd: dynamic(() => import(\"./argocd/component\")),\n  atsumeru: dynamic(() => import(\"./atsumeru/component\")),\n  audiobookshelf: dynamic(() => import(\"./audiobookshelf/component\")),\n  authentik: dynamic(() => import(\"./authentik/component\")),\n  autobrr: dynamic(() => import(\"./autobrr/component\")),\n  azuredevops: dynamic(() => import(\"./azuredevops/component\")),\n  backrest: dynamic(() => import(\"./backrest/component\")),\n  bazarr: dynamic(() => import(\"./bazarr/component\")),\n  beszel: dynamic(() => import(\"./beszel/component\")),\n  booklore: dynamic(() => import(\"./booklore/component\")),\n  caddy: dynamic(() => import(\"./caddy/component\")),\n  calendar: dynamic(() => import(\"./calendar/component\")),\n  calibreweb: dynamic(() => import(\"./calibreweb/component\")),\n  changedetectionio: dynamic(() => import(\"./changedetectionio/component\")),\n  channelsdvrserver: dynamic(() => import(\"./channelsdvrserver/component\")),\n  checkmk: dynamic(() => import(\"./checkmk/component\")),\n  cloudflared: dynamic(() => import(\"./cloudflared/component\")),\n  coinmarketcap: dynamic(() => import(\"./coinmarketcap/component\")),\n  crowdsec: dynamic(() => import(\"./crowdsec/component\")),\n  iframe: dynamic(() => import(\"./iframe/component\")),\n  customapi: dynamic(() => import(\"./customapi/component\")),\n  deluge: dynamic(() => import(\"./deluge/component\")),\n  develancacheui: dynamic(() => import(\"./develancacheui/component\")),\n  diskstation: dynamic(() => import(\"./diskstation/component\")),\n  dispatcharr: dynamic(() => import(\"./dispatcharr/component\")),\n  downloadstation: dynamic(() => import(\"./downloadstation/component\")),\n  docker: dynamic(() => import(\"./docker/component\")),\n  dockhand: dynamic(() => import(\"./dockhand/component\")),\n  kubernetes: dynamic(() => import(\"./kubernetes/component\")),\n  emby: dynamic(() => import(\"./emby/component\")),\n  esphome: dynamic(() => import(\"./esphome/component\")),\n  evcc: dynamic(() => import(\"./evcc/component\")),\n  filebrowser: dynamic(() => import(\"./filebrowser/component\")),\n  fileflows: dynamic(() => import(\"./fileflows/component\")),\n  firefly: dynamic(() => import(\"./firefly/component\")),\n  flood: dynamic(() => import(\"./flood/component\")),\n  freshrss: dynamic(() => import(\"./freshrss/component\")),\n  frigate: dynamic(() => import(\"./frigate/component\")),\n  fritzbox: dynamic(() => import(\"./fritzbox/component\")),\n  gamedig: dynamic(() => import(\"./gamedig/component\")),\n  gatus: dynamic(() => import(\"./gatus/component\")),\n  ghostfolio: dynamic(() => import(\"./ghostfolio/component\")),\n  gitea: dynamic(() => import(\"./gitea/component\")),\n  gitlab: dynamic(() => import(\"./gitlab/component\")),\n  glances: dynamic(() => import(\"./glances/component\")),\n  gluetun: dynamic(() => import(\"./gluetun/component\")),\n  gotify: dynamic(() => import(\"./gotify/component\")),\n  grafana: dynamic(() => import(\"./grafana/component\")),\n  hdhomerun: dynamic(() => import(\"./hdhomerun/component\")),\n  headscale: dynamic(() => import(\"./headscale/component\")),\n  hoarder: dynamic(() => import(\"./karakeep/component\")),\n  karakeep: dynamic(() => import(\"./karakeep/component\")),\n  peanut: dynamic(() => import(\"./peanut/component\")),\n  homeassistant: dynamic(() => import(\"./homeassistant/component\")),\n  homebox: dynamic(() => import(\"./homebox/component\")),\n  homebridge: dynamic(() => import(\"./homebridge/component\")),\n  healthchecks: dynamic(() => import(\"./healthchecks/component\")),\n  immich: dynamic(() => import(\"./immich/component\")),\n  jackett: dynamic(() => import(\"./jackett/component\")),\n  jdownloader: dynamic(() => import(\"./jdownloader/component\")),\n  jellyfin: dynamic(() => import(\"./jellyfin/component\")),\n  jellyseerr: dynamic(() => import(\"./seerr/component\")),\n  jellystat: dynamic(() => import(\"./jellystat/component\")),\n  kavita: dynamic(() => import(\"./kavita/component\")),\n  komga: dynamic(() => import(\"./komga/component\")),\n  komodo: dynamic(() => import(\"./komodo/component\")),\n  kopia: dynamic(() => import(\"./kopia/component\")),\n  lidarr: dynamic(() => import(\"./lidarr/component\")),\n  linkwarden: dynamic(() => import(\"./linkwarden/component\")),\n  lubelogger: dynamic(() => import(\"./lubelogger/component\")),\n  mailcow: dynamic(() => import(\"./mailcow/component\")),\n  mastodon: dynamic(() => import(\"./mastodon/component\")),\n  mealie: dynamic(() => import(\"./mealie/component\")),\n  medusa: dynamic(() => import(\"./medusa/component\")),\n  minecraft: dynamic(() => import(\"./minecraft/component\")),\n  miniflux: dynamic(() => import(\"./miniflux/component\")),\n  mikrotik: dynamic(() => import(\"./mikrotik/component\")),\n  mjpeg: dynamic(() => import(\"./mjpeg/component\")),\n  moonraker: dynamic(() => import(\"./moonraker/component\")),\n  mylar: dynamic(() => import(\"./mylar/component\")),\n  myspeed: dynamic(() => import(\"./myspeed/component\")),\n  navidrome: dynamic(() => import(\"./navidrome/component\")),\n  netalertx: dynamic(() => import(\"./netalertx/component\")),\n  netdata: dynamic(() => import(\"./netdata/component\")),\n  nextcloud: dynamic(() => import(\"./nextcloud/component\")),\n  nextdns: dynamic(() => import(\"./nextdns/component\")),\n  npm: dynamic(() => import(\"./npm/component\")),\n  nzbget: dynamic(() => import(\"./nzbget/component\")),\n  octoprint: dynamic(() => import(\"./octoprint/component\")),\n  omada: dynamic(() => import(\"./omada/component\")),\n  ombi: dynamic(() => import(\"./ombi/component\")),\n  opendtu: dynamic(() => import(\"./opendtu/component\")),\n  opnsense: dynamic(() => import(\"./opnsense/component\")),\n  overseerr: dynamic(() => import(\"./seerr/component\")),\n  openmediavault: dynamic(() => import(\"./openmediavault/component\")),\n  openwrt: dynamic(() => import(\"./openwrt/component\")),\n  paperlessngx: dynamic(() => import(\"./paperlessngx/component\")),\n  pangolin: dynamic(() => import(\"./pangolin/component\")),\n  pfsense: dynamic(() => import(\"./pfsense/component\")),\n  photoprism: dynamic(() => import(\"./photoprism/component\")),\n  proxmoxbackupserver: dynamic(() => import(\"./proxmoxbackupserver/component\")),\n  pialert: dynamic(() => import(\"./netalertx/component\")),\n  pihole: dynamic(() => import(\"./pihole/component\")),\n  plantit: dynamic(() => import(\"./plantit/component\")),\n  plex: dynamic(() => import(\"./plex/component\")),\n  portainer: dynamic(() => import(\"./portainer/component\")),\n  prometheus: dynamic(() => import(\"./prometheus/component\")),\n  prometheusmetric: dynamic(() => import(\"./prometheusmetric/component\")),\n  prowlarr: dynamic(() => import(\"./prowlarr/component\")),\n  proxmox: dynamic(() => import(\"./proxmox/component\")),\n  pterodactyl: dynamic(() => import(\"./pterodactyl/component\")),\n  pyload: dynamic(() => import(\"./pyload/component\")),\n  qbittorrent: dynamic(() => import(\"./qbittorrent/component\")),\n  qnap: dynamic(() => import(\"./qnap/component\")),\n  radarr: dynamic(() => import(\"./radarr/component\")),\n  readarr: dynamic(() => import(\"./readarr/component\")),\n  romm: dynamic(() => import(\"./romm/component\")),\n  rutorrent: dynamic(() => import(\"./rutorrent/component\")),\n  sabnzbd: dynamic(() => import(\"./sabnzbd/component\")),\n  scrutiny: dynamic(() => import(\"./scrutiny/component\")),\n  seerr: dynamic(() => import(\"./seerr/component\")),\n  slskd: dynamic(() => import(\"./slskd/component\")),\n  sonarr: dynamic(() => import(\"./sonarr/component\")),\n  sparkyfitness: dynamic(() => import(\"./sparkyfitness/component\")),\n  speedtest: dynamic(() => import(\"./speedtest/component\")),\n  spoolman: dynamic(() => import(\"./spoolman/component\")),\n  stash: dynamic(() => import(\"./stash/component\")),\n  stocks: dynamic(() => import(\"./stocks/component\")),\n  strelaysrv: dynamic(() => import(\"./strelaysrv/component\")),\n  swagdashboard: dynamic(() => import(\"./swagdashboard/component\")),\n  suwayomi: dynamic(() => import(\"./suwayomi/component\")),\n  tailscale: dynamic(() => import(\"./tailscale/component\")),\n  tandoor: dynamic(() => import(\"./tandoor/component\")),\n  tautulli: dynamic(() => import(\"./tautulli/component\")),\n  technitium: dynamic(() => import(\"./technitium/component\")),\n  tdarr: dynamic(() => import(\"./tdarr/component\")),\n  tracearr: dynamic(() => import(\"./tracearr/component\")),\n  traefik: dynamic(() => import(\"./traefik/component\")),\n  transmission: dynamic(() => import(\"./transmission/component\")),\n  trilium: dynamic(() => import(\"./trilium/component\")),\n  tubearchivist: dynamic(() => import(\"./tubearchivist/component\")),\n  truenas: dynamic(() => import(\"./truenas/component\")),\n  unifi: dynamic(() => import(\"./unifi/component\")),\n  unmanic: dynamic(() => import(\"./unmanic/component\")),\n  unraid: dynamic(() => import(\"./unraid/component\")),\n  uptimekuma: dynamic(() => import(\"./uptimekuma/component\")),\n  uptimerobot: dynamic(() => import(\"./uptimerobot/component\")),\n  urbackup: dynamic(() => import(\"./urbackup/component\")),\n  vikunja: dynamic(() => import(\"./vikunja/component\")),\n  wallos: dynamic(() => import(\"./wallos/component\")),\n  watchtower: dynamic(() => import(\"./watchtower/component\")),\n  wgeasy: dynamic(() => import(\"./wgeasy/component\")),\n  whatsupdocker: dynamic(() => import(\"./whatsupdocker/component\")),\n  xteve: dynamic(() => import(\"./xteve/component\")),\n  yourspotify: dynamic(() => import(\"./yourspotify/component\")),\n  zabbix: dynamic(() => import(\"./zabbix/component\")),\n};\n\nexport default components;\n"
  },
  {
    "path": "src/widgets/crowdsec/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: alerts, error: alertsError } = useWidgetAPI(widget, !!widget.limit24h ? \"alerts24h\" : \"alerts\");\n  const { data: bans, error: bansError } = useWidgetAPI(widget, \"bans\");\n\n  if (alertsError || bansError) {\n    return <Container service={service} error={alertsError ?? bansError} />;\n  }\n\n  if (!alerts && !bans) {\n    return (\n      <Container service={service}>\n        <Block label=\"crowdsec.alerts\" />\n        <Block label=\"crowdsec.bans\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"crowdsec.alerts\" value={t(\"common.number\", { value: alerts?.length ?? 0 })} />\n      <Block label=\"crowdsec.bans\" value={t(\"common.number\", { value: bans?.length ?? 0 })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/crowdsec/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/crowdsec/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"selects alerts24h endpoint when limit24h is enabled\", () => {\n    useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));\n\n    renderWithProviders(<Component service={{ widget: { type: \"crowdsec\", limit24h: true } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(useWidgetAPI).toHaveBeenNthCalledWith(1, expect.any(Object), \"alerts24h\");\n    expect(useWidgetAPI).toHaveBeenNthCalledWith(2, expect.any(Object), \"bans\");\n  });\n\n  it(\"renders placeholders when both alerts and bans are missing\", () => {\n    useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"crowdsec\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"crowdsec.alerts\")).toBeInTheDocument();\n    expect(screen.getByText(\"crowdsec.bans\")).toBeInTheDocument();\n  });\n\n  it(\"renders 0-length arrays as 0 counts\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: [], error: undefined })\n      .mockReturnValueOnce({ data: [], error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"crowdsec\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"crowdsec.alerts\", 0);\n    expectBlockValue(container, \"crowdsec.bans\", 0);\n  });\n});\n"
  },
  {
    "path": "src/widgets/crowdsec/proxy.js",
    "content": "import cache from \"memory-cache\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst proxyName = \"crowdsecProxyHandler\";\nconst logger = createLogger(proxyName);\nconst sessionTokenCacheKey = `${proxyName}__sessionToken`;\n\nasync function login(widget, service) {\n  const url = formatApiCall(widgets[widget.type].loginURL, widget);\n  const [status, , data] = await httpProxy(url, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n      \"User-Agent\": \"Mozilla/5.0\", // Crowdsec requires a user-agent\n    },\n    body: JSON.stringify({\n      machine_id: widget.username,\n      password: widget.password,\n      scenarios: [],\n    }),\n  });\n\n  let dataParsed;\n  try {\n    dataParsed = JSON.parse(data);\n  } catch {\n    logger.error(\"Failed to parse Crowdsec login response, status: %d\", status);\n    cache.del(`${sessionTokenCacheKey}.${service}`);\n    return null;\n  }\n\n  if (status !== 200 || !dataParsed.token) {\n    logger.error(\"Failed to login to Crowdsec API, status: %d\", status);\n    cache.del(`${sessionTokenCacheKey}.${service}`);\n    return null;\n  }\n\n  const ttl = Math.max(new Date(dataParsed.expire) - new Date(), 1);\n  cache.put(`${sessionTokenCacheKey}.${service}`, dataParsed.token, ttl);\n\n  return dataParsed.token;\n}\n\nexport default async function crowdsecProxyHandler(req, res) {\n  const { group, service, endpoint, index } = req.query;\n\n  if (!group || !service) {\n    logger.error(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n  if (!widget || !widgets[widget.type].api) {\n    logger.error(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid widget configuration\" });\n  }\n\n  let token = cache.get(`${sessionTokenCacheKey}.${service}`);\n  if (!token) {\n    token = await login(widget, service);\n  }\n  if (!token) {\n    return res.status(500).json({ error: \"Failed to authenticate with Crowdsec\" });\n  }\n\n  const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));\n\n  try {\n    const params = {\n      method: \"GET\",\n      headers: {\n        \"User-Agent\": \"Mozilla/5.0\", // Crowdsec requires a user-agent\n        \"Content-Type\": \"application/json\",\n        Authorization: `Bearer ${token}`,\n      },\n    };\n\n    logger.debug(\"Calling Crowdsec API endpoint: %s\", endpoint);\n\n    let [status, , data] = await httpProxy(url, params);\n\n    if (status === 401) {\n      logger.debug(\"Crowdsec API returned 401, refreshing token and retrying request\");\n      cache.del(`${sessionTokenCacheKey}.${service}`);\n      const refreshedToken = await login(widget, service);\n\n      if (!refreshedToken) {\n        return res.status(500).json({ error: \"Failed to authenticate with Crowdsec\" });\n      }\n\n      params.headers.Authorization = `Bearer ${refreshedToken}`;\n      [status, , data] = await httpProxy(url, params);\n    }\n\n    if (status !== 200) {\n      logger.error(\"Error calling Crowdsec API: %d. Data: %s\", status, data);\n      return res.status(status).json({ error: \"Crowdsec API Error\", data });\n    }\n\n    return res.status(status).send(data);\n  } catch (error) {\n    logger.error(\"Exception calling Crowdsec API: %s\", error.message);\n    return res.status(500).json({ error: \"Crowdsec API Error\", message: error.message });\n  }\n}\n"
  },
  {
    "path": "src/widgets/crowdsec/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {\n  const store = new Map();\n  return {\n    httpProxy: vi.fn(),\n    getServiceWidget: vi.fn(),\n    cache: {\n      get: vi.fn((k) => store.get(k)),\n      put: vi.fn((k, v) => store.set(k, v)),\n      del: vi.fn((k) => store.delete(k)),\n      _reset: () => store.clear(),\n    },\n    logger: {\n      debug: vi.fn(),\n      error: vi.fn(),\n    },\n  };\n});\n\nvi.mock(\"memory-cache\", () => ({\n  default: cache,\n  ...cache,\n}));\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    crowdsec: {\n      api: \"{url}/{endpoint}\",\n      loginURL: \"{url}/login\",\n    },\n  },\n}));\n\nimport crowdsecProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/crowdsec/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    cache._reset();\n  });\n\n  it(\"logs in, caches a token, and uses it for requests\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"crowdsec\",\n      url: \"http://cs\",\n      username: \"machine\",\n      password: \"pw\",\n    });\n\n    httpProxy\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        JSON.stringify({ token: \"tok\", expire: new Date(Date.now() + 60_000).toISOString() }),\n      ])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"data\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"alerts\", index: \"0\" } };\n    const res = createMockRes();\n\n    await crowdsecProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(2);\n    expect(httpProxy.mock.calls[1][1].headers.Authorization).toBe(\"Bearer tok\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual(Buffer.from(\"data\"));\n  });\n\n  it(\"returns 500 if token cannot be obtained\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"crowdsec\", url: \"http://cs\", username: \"machine\", password: \"pw\" });\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", JSON.stringify({ expire: \"2099-01-01T00:00:00Z\" })]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"alerts\", index: \"0\" } };\n    const res = createMockRes();\n\n    await crowdsecProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: \"Failed to authenticate with Crowdsec\" });\n  });\n\n  it(\"re-authenticates and retries once when API returns 401\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"crowdsec\",\n      url: \"http://cs\",\n      username: \"machine\",\n      password: \"pw\",\n    });\n\n    httpProxy\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        JSON.stringify({ token: \"tok-old\", expire: new Date(Date.now() + 60_000).toISOString() }),\n      ])\n      .mockResolvedValueOnce([401, \"application/json\", Buffer.from(\"bad token\")])\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        JSON.stringify({ token: \"tok-new\", expire: new Date(Date.now() + 60_000).toISOString() }),\n      ])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"data\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"alerts\", index: \"0\" } };\n    const res = createMockRes();\n\n    await crowdsecProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(4);\n    expect(httpProxy.mock.calls[3][1].headers.Authorization).toBe(\"Bearer tok-new\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual(Buffer.from(\"data\"));\n  });\n\n  it(\"returns 500 when 401 refresh fails to get a new token\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"crowdsec\",\n      url: \"http://cs\",\n      username: \"machine\",\n      password: \"pw\",\n    });\n\n    httpProxy\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        JSON.stringify({ token: \"tok-old\", expire: new Date(Date.now() + 60_000).toISOString() }),\n      ])\n      .mockResolvedValueOnce([401, \"application/json\", Buffer.from(\"bad token\")])\n      .mockResolvedValueOnce([500, \"application/json\", JSON.stringify({ error: \"no token\" })]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"alerts\", index: \"0\" } };\n    const res = createMockRes();\n\n    await crowdsecProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: \"Failed to authenticate with Crowdsec\" });\n  });\n\n  it(\"returns 500 when login response is not JSON\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"crowdsec\", url: \"http://cs\", username: \"machine\", password: \"pw\" });\n    httpProxy.mockResolvedValueOnce([200, \"text/plain\", \"not-json\"]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"alerts\", index: \"0\" } };\n    const res = createMockRes();\n\n    await crowdsecProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: \"Failed to authenticate with Crowdsec\" });\n  });\n});\n"
  },
  {
    "path": "src/widgets/crowdsec/widget.js",
    "content": "import crowdsecProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/v1/{endpoint}\",\n  loginURL: \"{url}/v1/watchers/login\",\n  proxyHandler: crowdsecProxyHandler,\n\n  mappings: {\n    alerts: {\n      endpoint: \"alerts\",\n    },\n    alerts24h: {\n      endpoint: \"alerts?limit=0&since=24h\",\n    },\n    bans: {\n      endpoint: \"alerts?decision_type=ban&origin=crowdsec&has_active_decision=1\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/crowdsec/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"crowdsec widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/customapi/component.jsx",
    "content": "import classNames from \"classnames\";\nimport Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport * as shvl from \"utils/config/shvl\";\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nfunction getValue(field, data) {\n  let value = data;\n  let lastField = field;\n  let key = \"\";\n\n  // Support APIs that return arrays or scalars directly.\n  if (typeof field === \"undefined\") {\n    return value;\n  }\n\n  // shvl is easier, everything else is kept for backwards compatibility.\n  if (typeof field === \"string\") {\n    return shvl.get(data, field, null) ?? data[field] ?? null;\n  }\n\n  while (typeof lastField === \"object\") {\n    key = Object.keys(lastField)[0] ?? null;\n\n    if (key === null) {\n      break;\n    }\n\n    value = value[key];\n    lastField = lastField[key];\n  }\n\n  if (typeof value === \"undefined\") {\n    return null;\n  }\n\n  return value[lastField] ?? null;\n}\n\nfunction getSize(data) {\n  if (Array.isArray(data) || typeof data === \"string\") {\n    return data.length;\n  } else if (typeof data === \"object\" && data !== null) {\n    return Object.keys(data).length;\n  }\n\n  return NaN;\n}\n\nfunction formatValue(t, mapping, rawValue) {\n  let value = rawValue;\n\n  // Remap the value.\n  const remaps = mapping?.remap ?? [];\n  for (let i = 0; i < remaps.length; i += 1) {\n    const remap = remaps[i];\n    if (remap?.any || remap?.value === value) {\n      value = remap.to;\n      break;\n    }\n  }\n\n  // Scale the value. Accepts either a number to multiply by or a string\n  // like \"12/345\".\n  const scale = mapping?.scale;\n  if (typeof scale === \"number\") {\n    value *= scale;\n  } else if (typeof scale === \"string\") {\n    const parts = scale.split(\"/\");\n    const numerator = parts[0] ? parseFloat(parts[0]) : 1;\n    const denominator = parts[1] ? parseFloat(parts[1]) : 1;\n    value = (value * numerator) / denominator;\n  }\n\n  // Format the value using a known type.\n  switch (mapping?.format) {\n    case \"number\":\n      value = t(\"common.number\", { value: parseInt(value, 10) });\n      break;\n    case \"float\":\n      value = t(\"common.number\", { value });\n      break;\n    case \"percent\":\n      value = t(\"common.percent\", { value });\n      break;\n    case \"duration\":\n      value = t(\"common.duration\", { value });\n      break;\n    case \"bytes\":\n      value = t(\"common.bytes\", { value });\n      break;\n    case \"bitrate\":\n      value = t(\"common.bitrate\", { value });\n      break;\n    case \"date\":\n      value = t(\"common.date\", {\n        value,\n        lng: mapping?.locale,\n        dateStyle: mapping?.dateStyle ?? \"long\",\n        timeStyle: mapping?.timeStyle,\n      });\n      break;\n    case \"relativeDate\":\n      value = t(\"common.relativeDate\", {\n        value,\n        lng: mapping?.locale,\n        style: mapping?.style,\n        numeric: mapping?.numeric,\n      });\n      break;\n    case \"size\":\n      value = t(\"common.number\", { value: getSize(value) });\n      break;\n    case \"text\":\n    default:\n    // nothing\n  }\n\n  // Apply fixed prefix.\n  const prefix = mapping?.prefix;\n  if (prefix) {\n    value = `${prefix} ${value}`;\n  }\n\n  // Apply fixed suffix.\n  const suffix = mapping?.suffix;\n  if (suffix) {\n    value = `${value} ${suffix}`;\n  }\n\n  return value;\n}\n\nfunction getColor(mapping, customData) {\n  const value = getValue(mapping.additionalField.field, customData);\n  const { color } = mapping.additionalField;\n\n  switch (color) {\n    case \"adaptive\":\n      try {\n        const number = parseFloat(value);\n        return number > 0 ? \"text-emerald-300\" : \"text-rose-300\";\n      } catch (e) {\n        return \"\";\n      }\n    case \"black\":\n      return `text-black`;\n    case \"white\":\n      return `text-white`;\n    case \"theme\":\n      return `text-theme-500`;\n    default:\n      return \"\";\n  }\n}\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { mappings = [], refreshInterval = 10000, display = \"block\" } = widget;\n  const { data: customData, error: customError } = useWidgetAPI(widget, null, {\n    refreshInterval: Math.max(1000, refreshInterval),\n  });\n\n  // if mappings includes an error field and the data contains an error field then show data even if there is an error\n  const mappingsIncludesError = Array.isArray(mappings) && mappings.find((mapping) => mapping.field === \"error\");\n  const errorIsData = customData && typeof customData === \"object\" && \"error\" in customData;\n\n  if (customError && !(mappingsIncludesError && errorIsData)) {\n    return <Container service={service} error={customError} />;\n  }\n\n  if (!customData) {\n    switch (display) {\n      case \"dynamic-list\":\n        return (\n          <Container service={service}>\n            <div className=\"flex flex-col w-full\">\n              <div className=\"bg-theme-200/50 dark:bg-theme-900/20 rounded-sm m-1 flex-1 flex flex-row items-center justify-between p-1 text-xs animate-pulse\">\n                <div className=\"font-thin pl-2\">Loading...</div>\n              </div>\n            </div>\n          </Container>\n        );\n      case \"list\":\n        return (\n          <Container service={service}>\n            <div className=\"flex flex-col w-full\">\n              {mappings.map((mapping) => (\n                <div\n                  key={mapping.label}\n                  className=\"bg-theme-200/50 dark:bg-theme-900/20 rounded-sm m-1 flex-1 flex flex-row items-center justify-between p-1 text-xs animate-pulse\"\n                >\n                  <div className=\"font-thin pl-2\">{mapping.label}</div>\n                  <div className=\"flex flex-row text-right\">\n                    <div className=\"font-bold mr-2\">-</div>\n                  </div>\n                </div>\n              ))}\n            </div>\n          </Container>\n        );\n\n      default:\n        return (\n          <Container service={service}>\n            {mappings.slice(0, 4).map((item) => (\n              <Block label={item.label} key={item.label} />\n            ))}\n          </Container>\n        );\n    }\n  }\n\n  switch (display) {\n    case \"dynamic-list\":\n      let listItems = customData;\n      if (mappings.items) listItems = shvl.get(customData, mappings.items, null);\n      let error;\n      if (!listItems || !Array.isArray(listItems)) {\n        error = { message: \"Unable to find items\" };\n      }\n      const name = mappings.name;\n      const label = mappings.label;\n      if (!name || !label) {\n        error = { message: \"Name and label properties are required\" };\n      }\n      if (error) {\n        return <Container service={service} error={error}></Container>;\n      }\n\n      const target = mappings.target;\n      if (mappings.limit && parseInt(mappings.limit, 10) > 0) {\n        listItems.splice(mappings.limit);\n      }\n\n      return (\n        <Container service={service}>\n          <div className=\"flex flex-col w-full\">\n            {listItems.length === 0 ? (\n              <div className=\"bg-theme-200/50 dark:bg-theme-900/20 rounded-sm m-1 flex-1 flex flex-row items-center justify-between p-1 text-xs\">\n                <div className=\"font-thin pl-2\">No items found</div>\n              </div>\n            ) : (\n              listItems.map((item, index) => {\n                const itemName = shvl.get(item, name, item[name]) ?? \"\";\n                const itemLabel = shvl.get(item, label, item[label]) ?? \"\";\n\n                const itemUrl = target\n                  ? [...target.matchAll(/\\{(.*?)\\}/g)]\n                      .map((match) => match[1])\n                      .reduce((url, targetTemplate) => {\n                        const value = shvl.get(item, targetTemplate, item[targetTemplate]) ?? \"\";\n                        return url.replaceAll(`{${targetTemplate}}`, value);\n                      }, target)\n                  : null;\n                const className =\n                  \"bg-theme-200/50 dark:bg-theme-900/20 rounded-sm m-1 flex-1 flex flex-row items-center justify-between p-1 text-xs\";\n\n                return itemUrl ? (\n                  <a\n                    key={`${itemName}-${index}`}\n                    className={classNames(className, \"hover:bg-theme-300/50 dark:hover:bg-theme-800/20\")}\n                    href={itemUrl}\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                  >\n                    <div className=\"font-thin pl-2\">{itemName}</div>\n                    <div className=\"flex flex-row text-right\">\n                      <div className=\"font-bold mr-2\">{formatValue(t, mappings, itemLabel)}</div>\n                    </div>\n                  </a>\n                ) : (\n                  <div key={`${itemName}-${index}`} className={className}>\n                    <div className=\"font-thin pl-2\">{itemName}</div>\n                    <div className=\"flex flex-row text-right\">\n                      <div className=\"font-bold mr-2\">{formatValue(t, mappings, itemLabel)}</div>\n                    </div>\n                  </div>\n                );\n              })\n            )}\n          </div>\n        </Container>\n      );\n    case \"list\":\n      return (\n        <Container service={service}>\n          <div className=\"flex flex-col w-full\">\n            {mappings.map((mapping) => (\n              <div\n                key={mapping.label}\n                className=\"bg-theme-200/50 dark:bg-theme-900/20 rounded-sm m-1 flex-1 flex flex-row items-center justify-between p-1 text-xs\"\n              >\n                <div className=\"font-thin pl-2\">{mapping.label}</div>\n                <div className=\"flex flex-row text-right\">\n                  <div className=\"font-bold mr-2\">{formatValue(t, mapping, getValue(mapping.field, customData))}</div>\n                  {mapping.additionalField && (\n                    <div className={`font-bold mr-2 ${getColor(mapping, customData)}`}>\n                      {formatValue(t, mapping.additionalField, getValue(mapping.additionalField.field, customData))}\n                    </div>\n                  )}\n                </div>\n              </div>\n            ))}\n          </div>\n        </Container>\n      );\n\n    default:\n      return (\n        <Container service={service}>\n          {mappings.slice(0, 4).map((mapping) => (\n            <Block\n              label={mapping.label}\n              key={mapping.label}\n              value={formatValue(t, mapping, getValue(mapping.field, customData))}\n            />\n          ))}\n        </Container>\n      );\n  }\n}\n"
  },
  {
    "path": "src/widgets/customapi/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/customapi/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholder blocks for the first 4 mappings while loading (block display)\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = {\n      widget: {\n        type: \"customapi\",\n        mappings: [{ label: \"a\" }, { label: \"b\" }, { label: \"c\" }, { label: \"d\" }, { label: \"e\" }],\n      },\n    };\n\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"a\")).toBeInTheDocument();\n    expect(screen.getByText(\"b\")).toBeInTheDocument();\n    expect(screen.getByText(\"c\")).toBeInTheDocument();\n    expect(screen.getByText(\"d\")).toBeInTheDocument();\n    expect(screen.queryByText(\"e\")).toBeNull();\n  });\n\n  it(\"renders list display, including additionalField and adaptive color\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { foo: { bar: 10 }, delta: -1 },\n      error: undefined,\n    });\n\n    const service = {\n      widget: {\n        type: \"customapi\",\n        display: \"list\",\n        mappings: [\n          {\n            label: \"Value\",\n            field: \"foo.bar\",\n            format: \"number\",\n            prefix: \"$\",\n            additionalField: { field: \"delta\", color: \"adaptive\" },\n          },\n        ],\n      },\n    };\n\n    renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"Value\")).toBeInTheDocument();\n    expect(screen.getByText(\"$ 10\")).toBeInTheDocument();\n\n    const delta = screen.getByText(\"-1\");\n    expect(delta.className).toContain(\"text-rose-300\");\n  });\n\n  it(\"shows error UI when widget API errors and mappings do not treat error as data\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(\n      <Component service={{ widget: { type: \"customapi\", mappings: [{ label: \"x\", field: \"x\" }] } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"treats error payloads as data when a mapping targets the error field\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { error: \"rate limited\" },\n      error: { message: \"ignored\" },\n    });\n\n    renderWithProviders(\n      <Component\n        service={{\n          widget: {\n            type: \"customapi\",\n            display: \"list\",\n            mappings: [{ label: \"Error\", field: \"error\", format: \"text\" }],\n          },\n        }}\n      />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(screen.queryByText(\"ignored\")).toBeNull();\n    expect(screen.getByText(\"rate limited\")).toBeInTheDocument();\n  });\n\n  it(\"renders dynamic-list placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(\n      <Component service={{ widget: { type: \"customapi\", display: \"dynamic-list\", mappings: {} } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(screen.getByText(\"Loading...\")).toBeInTheDocument();\n  });\n\n  it(\"renders dynamic-list errors when required mapping properties are missing\", () => {\n    useWidgetAPI.mockReturnValue({ data: { items: [] }, error: undefined });\n\n    renderWithProviders(\n      <Component\n        service={{\n          widget: {\n            type: \"customapi\",\n            display: \"dynamic-list\",\n            mappings: { items: \"items\" },\n          },\n        }}\n      />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(screen.getByText(\"Name and label properties are required\")).toBeInTheDocument();\n  });\n\n  it(\"renders dynamic-list items with a target link and enforces the limit\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        items: [\n          { id: \"1\", name: \"First\", value: 2 },\n          { id: \"2\", name: \"Second\", value: 3 },\n        ],\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(\n      <Component\n        service={{\n          widget: {\n            type: \"customapi\",\n            display: \"dynamic-list\",\n            mappings: {\n              items: \"items\",\n              name: \"name\",\n              label: \"value\",\n              target: \"https://example.com/items/{id}\",\n              limit: \"1\",\n              prefix: \"#\",\n              scale: \"2/1\",\n              format: \"number\",\n            },\n          },\n        }}\n      />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(screen.getByText(\"First\")).toBeInTheDocument();\n    expect(screen.getByText(\"# 4\")).toBeInTheDocument();\n    expect(screen.queryByText(\"Second\")).toBeNull();\n\n    const link = screen.getByRole(\"link\", { name: /First/i });\n    expect(link).toHaveAttribute(\"href\", \"https://example.com/items/1\");\n  });\n\n  it(\"supports legacy object field definitions and size formatting\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { a: { b: { c: [\"x\", \"y\", \"z\"] } } },\n      error: undefined,\n    });\n\n    renderWithProviders(\n      <Component\n        service={{\n          widget: {\n            type: \"customapi\",\n            mappings: [\n              {\n                label: \"Count\",\n                field: { a: { b: \"c\" } },\n                format: \"size\",\n                suffix: \"items\",\n              },\n            ],\n          },\n        }}\n      />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(screen.getByText(\"Count\")).toBeInTheDocument();\n    expect(screen.getByText(\"3 items\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/customapi/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}\",\n  proxyHandler: genericProxyHandler,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/customapi/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"customapi widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/deluge/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport QueueEntry from \"../../components/widgets/queue/queueEntry\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: torrentData, error: torrentError } = useWidgetAPI(widget);\n\n  if (torrentError) {\n    return <Container service={service} error={torrentError} />;\n  }\n\n  if (!torrentData) {\n    return (\n      <Container service={service}>\n        <Block label=\"deluge.leech\" />\n        <Block label=\"deluge.download\" />\n        <Block label=\"deluge.seed\" />\n        <Block label=\"deluge.upload\" />\n      </Container>\n    );\n  }\n\n  const { torrents } = torrentData;\n  const keys = torrents ? Object.keys(torrents) : [];\n\n  let rateDl = 0;\n  let rateUl = 0;\n  let completed = 0;\n  const leechTorrents = [];\n\n  for (let i = 0; i < keys.length; i += 1) {\n    const torrent = torrents[keys[i]];\n    rateDl += torrent.download_payload_rate;\n    rateUl += torrent.upload_payload_rate;\n    completed += torrent.total_remaining === 0 ? 1 : 0;\n    if (torrent.state === \"Downloading\") {\n      leechTorrents.push(torrent);\n    }\n  }\n\n  const leech = keys.length - completed || 0;\n\n  return (\n    <>\n      <Container service={service}>\n        <Block label=\"deluge.leech\" value={t(\"common.number\", { value: leech })} />\n        <Block label=\"deluge.download\" value={t(\"common.byterate\", { value: rateDl })} highlightValue={rateDl} />\n        <Block label=\"deluge.seed\" value={t(\"common.number\", { value: completed })} />\n        <Block label=\"deluge.upload\" value={t(\"common.byterate\", { value: rateUl })} highlightValue={rateUl} />\n      </Container>\n      {widget?.enableLeechProgress &&\n        leechTorrents.map((queueEntry) => (\n          <QueueEntry\n            progress={queueEntry.progress}\n            timeLeft={t(\"common.duration\", { value: queueEntry.eta })}\n            title={queueEntry.name}\n            activity={queueEntry.state}\n            key={`${queueEntry.name}-${queueEntry.total_remaining}`}\n          />\n        ))}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/widgets/deluge/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nvi.mock(\"../../components/widgets/queue/queueEntry\", () => ({\n  default: ({ title }) => <div data-testid=\"queue-entry\">{title}</div>,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/deluge/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"deluge\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"deluge.leech\")).toBeInTheDocument();\n    expect(screen.getByText(\"deluge.download\")).toBeInTheDocument();\n    expect(screen.getByText(\"deluge.seed\")).toBeInTheDocument();\n    expect(screen.getByText(\"deluge.upload\")).toBeInTheDocument();\n  });\n\n  it(\"computes leech/seed counts and upload/download rates, and renders leech progress entries\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        torrents: {\n          a: { download_payload_rate: 10, upload_payload_rate: 1, total_remaining: 0, state: \"Seeding\", progress: 100 },\n          b: {\n            download_payload_rate: 5,\n            upload_payload_rate: 2,\n            total_remaining: 5,\n            state: \"Downloading\",\n            progress: 50,\n            eta: 60,\n            name: \"B\",\n          },\n          c: {\n            download_payload_rate: 0,\n            upload_payload_rate: 3,\n            total_remaining: 10,\n            state: \"Downloading\",\n            progress: 10,\n            eta: 120,\n            name: \"C\",\n          },\n        },\n      },\n      error: undefined,\n    });\n\n    const service = { widget: { type: \"deluge\", enableLeechProgress: true } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    // keys=3, completed=1 => leech=2\n    expectBlockValue(container, \"deluge.leech\", 2);\n    expectBlockValue(container, \"deluge.seed\", 1);\n    expectBlockValue(container, \"deluge.download\", 15);\n    expectBlockValue(container, \"deluge.upload\", 6);\n\n    // Only downloading torrents get QueueEntry.\n    expect(screen.getAllByTestId(\"queue-entry\").map((el) => el.textContent)).toEqual([\"B\", \"C\"]);\n  });\n});\n"
  },
  {
    "path": "src/widgets/deluge/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { sendJsonRpcRequest } from \"utils/proxy/handlers/jsonrpc\";\nimport widgets from \"widgets/widgets\";\n\nconst logger = createLogger(\"delugeProxyHandler\");\n\nconst dataMethod = \"web.update_ui\";\nconst dataParams = [\n  [\n    \"queue\",\n    \"name\",\n    \"total_wanted\",\n    \"state\",\n    \"progress\",\n    \"download_payload_rate\",\n    \"upload_payload_rate\",\n    \"total_remaining\",\n    \"eta\",\n  ],\n  {},\n];\nconst loginMethod = \"auth.login\";\n\nasync function sendRpc(url, method, params) {\n  const [status, contentType, data] = await sendJsonRpcRequest(url, method, params);\n  const json = JSON.parse(data.toString());\n  if (json?.error) {\n    if (json.error.code === 1) {\n      return [403, contentType, data];\n    }\n    return [500, contentType, data];\n  }\n\n  return [status, contentType, data];\n}\n\nfunction login(url, password) {\n  return sendRpc(url, loginMethod, [password]);\n}\n\nexport default async function delugeProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const api = widgets?.[widget.type]?.api;\n  const url = new URL(formatApiCall(api, { ...widget }));\n\n  let [status, contentType, data] = await sendRpc(url, dataMethod, dataParams);\n  if (status === 403) {\n    [status, contentType, data] = await login(url, widget.password);\n    if (status !== 200) {\n      return res.status(status).end(data);\n    }\n\n    [status, contentType, data] = await sendRpc(url, dataMethod, dataParams);\n  }\n\n  return res.status(status).end(data);\n}\n"
  },
  {
    "path": "src/widgets/deluge/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { sendJsonRpcRequest, getServiceWidget, logger } = vi.hoisted(() => ({\n  sendJsonRpcRequest: vi.fn(),\n  getServiceWidget: vi.fn(),\n  logger: {\n    debug: vi.fn(),\n  },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"utils/proxy/handlers/jsonrpc\", () => ({\n  sendJsonRpcRequest,\n}));\n\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    deluge: {\n      api: \"{url}\",\n    },\n  },\n}));\n\nimport delugeProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/deluge/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"logs in and retries the update call after an auth error\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"deluge\", url: \"http://deluge\", password: \"pw\" });\n\n    sendJsonRpcRequest\n      // update_ui -> error code 1 => 403\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ error: { code: 1 } }))])\n      // auth.login -> ok\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ result: true }))])\n      // update_ui retry -> ok\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ result: { torrents: {} } }))]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await delugeProxyHandler(req, res);\n\n    expect(sendJsonRpcRequest).toHaveBeenCalledTimes(3);\n    expect(sendJsonRpcRequest.mock.calls[0][1]).toBe(\"web.update_ui\");\n    expect(sendJsonRpcRequest.mock.calls[1][1]).toBe(\"auth.login\");\n    expect(sendJsonRpcRequest.mock.calls[2][1]).toBe(\"web.update_ui\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual(Buffer.from(JSON.stringify({ result: { torrents: {} } })));\n  });\n});\n"
  },
  {
    "path": "src/widgets/deluge/widget.js",
    "content": "import delugeProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/json\",\n  proxyHandler: delugeProxyHandler,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/deluge/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"deluge widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/develancacheui/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: downloadStatsData, error: downloadStatsError } = useWidgetAPI(widget, \"stats\");\n\n  if (downloadStatsError) {\n    return <Container service={service} error={downloadStatsError} />;\n  }\n\n  if (!downloadStatsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"develancacheui.cachehitbytes\" />\n        <Block label=\"develancacheui.cachemissbytes\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block\n        label=\"develancacheui.cachehitbytes\"\n        value={t(\"common.bytes\", { value: downloadStatsData.totalCacheHitBytes })}\n      />\n      <Block\n        label=\"develancacheui.cachemissbytes\"\n        value={t(\"common.bytes\", { value: downloadStatsData.totalCacheMissBytes })}\n      />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/develancacheui/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/develancacheui/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"develancacheui\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"develancacheui.cachehitbytes\")).toBeInTheDocument();\n    expect(screen.getByText(\"develancacheui.cachemissbytes\")).toBeInTheDocument();\n  });\n\n  it(\"renders byte totals when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { totalCacheHitBytes: 100, totalCacheMissBytes: 200 },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"develancacheui\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"100\")).toBeInTheDocument();\n    expect(screen.getByText(\"200\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/develancacheui/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    stats: {\n      endpoint: \"DownloadStats/GetTotalDownloadStats\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/develancacheui/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"develancacheui widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/diskstation/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const { data: infoData, error: infoError } = useWidgetAPI(widget, \"system_info\");\n  const { data: storageData, error: storageError } = useWidgetAPI(widget, \"system_storage\");\n  const { data: utilizationData, error: utilizationError } = useWidgetAPI(widget, \"utilization\");\n\n  if (storageError || infoError || utilizationError) {\n    return <Container service={service} error={storageError ?? infoError ?? utilizationError} />;\n  }\n\n  if (!storageData || !infoData || !utilizationData) {\n    return (\n      <Container service={service}>\n        <Block label=\"diskstation.uptime\" />\n        <Block label=\"diskstation.volumeAvailable\" />\n        <Block label=\"resources.cpu\" />\n        <Block label=\"resources.mem\" />\n      </Container>\n    );\n  }\n\n  // uptime info\n  // eslint-disable-next-line no-unused-vars\n  const [hour, minutes, seconds] = infoData.data.up_time.split(\":\");\n  const days = Math.floor(hour / 24);\n  const uptime = `${t(\"common.number\", { value: days })} ${t(\"diskstation.days\")}`;\n\n  // storage info\n  const volume = widget.volume\n    ? storageData.data.vol_info?.find((vol) => vol.name === widget.volume)\n    : storageData.data.vol_info?.[0];\n  const usedBytes = parseFloat(volume?.used_size);\n  const totalBytes = parseFloat(volume?.total_size);\n  const freeBytes = totalBytes - usedBytes;\n\n  // utilization info\n  const { cpu, memory } = utilizationData.data;\n  const cpuLoad = parseFloat(cpu.user_load) + parseFloat(cpu.system_load);\n  const memoryUsage = memory.real_usage\n    ? parseFloat(memory.real_usage)\n    : 100 - (100 * (parseFloat(memory.avail_real) + parseFloat(memory.cached))) / parseFloat(memory.total_real);\n\n  return (\n    <Container service={service}>\n      <Block label=\"diskstation.uptime\" value={uptime} />\n      <Block\n        label=\"diskstation.volumeAvailable\"\n        value={t(\"common.bbytes\", { value: freeBytes, maximumFractionDigits: 1 })}\n      />\n      <Block label=\"resources.cpu\" value={t(\"common.percent\", { value: cpuLoad })} />\n      <Block label=\"resources.mem\" value={t(\"common.percent\", { value: memoryUsage })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/diskstation/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/diskstation/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"diskstation\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"diskstation.uptime\")).toBeInTheDocument();\n    expect(screen.getByText(\"diskstation.volumeAvailable\")).toBeInTheDocument();\n    expect(screen.getByText(\"resources.cpu\")).toBeInTheDocument();\n    expect(screen.getByText(\"resources.mem\")).toBeInTheDocument();\n  });\n\n  it(\"computes uptime days, volume free bytes, and CPU/memory usage\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: { data: { up_time: \"48:00:00\" } }, error: undefined })\n      .mockReturnValueOnce({\n        data: { data: { vol_info: [{ name: \"vol1\", used_size: \"20\", total_size: \"100\" }] } },\n        error: undefined,\n      })\n      .mockReturnValueOnce({\n        data: { data: { cpu: { user_load: \"10\", system_load: \"5\" }, memory: { real_usage: \"25\" } } },\n        error: undefined,\n      });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"diskstation\", volume: \"vol1\" } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expectBlockValue(container, \"diskstation.uptime\", \"2 diskstation.days\");\n    expectBlockValue(container, \"diskstation.volumeAvailable\", 80);\n    expectBlockValue(container, \"resources.cpu\", 15);\n    expectBlockValue(container, \"resources.mem\", 25);\n  });\n});\n"
  },
  {
    "path": "src/widgets/diskstation/widget.js",
    "content": "import synologyProxyHandler from \"../../utils/proxy/handlers/synology\";\n\nconst widget = {\n  // cgiPath and maxVersion are discovered at runtime, don't supply\n  api: \"{url}/webapi/{cgiPath}?api={apiName}&version={maxVersion}&method={apiMethod}\",\n  proxyHandler: synologyProxyHandler,\n\n  mappings: {\n    system_storage: {\n      apiName: \"SYNO.Core.System\",\n      apiMethod: 'info&type=\"storage\"',\n      endpoint: \"system_storage\",\n    },\n    system_info: {\n      apiName: \"SYNO.Core.System\",\n      apiMethod: \"info\",\n      endpoint: \"system_info\",\n    },\n    utilization: {\n      apiName: \"SYNO.Core.System.Utilization\",\n      apiMethod: \"get\",\n      endpoint: \"utilization\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/diskstation/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"diskstation widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/dispatcharr/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nfunction StreamEntry({ title, clients, bitrate }) {\n  return (\n    <div className=\"text-theme-700 dark:text-theme-200 relative h-5 rounded-md bg-theme-200/50 dark:bg-theme-900/20 m-1 px-1 flex\">\n      <div className=\"text-xs z-10 self-center ml-2 relative h-4 grow mr-2\">\n        <div className=\"absolute w-full whitespace-nowrap text-ellipsis overflow-hidden text-left\">\n          {title} - Clients: {clients}\n        </div>\n      </div>\n      <div className=\"self-center text-xs flex justify-end mr-1.5 pl-1 z-10 text-ellipsis overflow-hidden whitespace-nowrap\">\n        {bitrate}\n      </div>\n    </div>\n  );\n}\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: channels, error: channelsError } = useWidgetAPI(widget, \"channels\");\n  const { data: streams, error: streamsError } = useWidgetAPI(widget, \"streams\");\n\n  if (channelsError || streamsError) {\n    return <Container service={service} error={channelsError ?? streamsError} />;\n  }\n\n  if (!channels || !streams) {\n    return (\n      <Container service={service}>\n        <Block label=\"dispatcharr.channels\" />\n        <Block label=\"dispatcharr.streams\" />\n      </Container>\n    );\n  }\n\n  return (\n    <>\n      <Container service={service}>\n        <Block label=\"dispatcharr.channels\" value={t(\"common.number\", { value: channels?.length ?? 0 })} />\n        <Block label=\"dispatcharr.streams\" value={t(\"common.number\", { value: streams?.count ?? 0 })} />\n      </Container>\n      {widget?.enableActiveStreams &&\n        streams?.channels &&\n        streams.channels.map((activeStream) => (\n          <StreamEntry\n            title={activeStream.stream_name}\n            clients={activeStream.clients.length}\n            bitrate={activeStream.avg_bitrate}\n            key={activeStream.stream_name}\n          />\n        ))}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/widgets/dispatcharr/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/dispatcharr/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"dispatcharr\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"dispatcharr.channels\")).toBeInTheDocument();\n    expect(screen.getByText(\"dispatcharr.streams\")).toBeInTheDocument();\n  });\n\n  it(\"renders counts and stream entries when enabled\", () => {\n    useWidgetAPI.mockReturnValueOnce({ data: [{}, {}, {}], error: undefined }).mockReturnValueOnce({\n      data: {\n        count: 1,\n        channels: [{ stream_name: \"Stream1\", clients: [{}, {}], avg_bitrate: \"1000kbps\" }],\n      },\n      error: undefined,\n    });\n\n    const service = { widget: { type: \"dispatcharr\", enableActiveStreams: true } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expectBlockValue(container, \"dispatcharr.channels\", 3);\n    expectBlockValue(container, \"dispatcharr.streams\", 1);\n    expect(screen.getByText(/Stream1 - Clients: 2/)).toBeInTheDocument();\n    expect(screen.getByText(\"1000kbps\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/dispatcharr/proxy.js",
    "content": "import cache from \"memory-cache\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst proxyName = \"dispatcharrProxyHandler\";\nconst tokenCacheKey = `${proxyName}__token`;\nconst logger = createLogger(proxyName);\n\nasync function login(loginUrl, username, password, service) {\n  const authResponse = await httpProxy(loginUrl, {\n    method: \"POST\",\n    body: JSON.stringify({ username, password }),\n    headers: {\n      \"Content-Type\": \"application/json\",\n    },\n  });\n\n  const status = authResponse[0];\n  let data = authResponse[2];\n  try {\n    data = JSON.parse(Buffer.from(authResponse[2]).toString());\n\n    if (status === 200) {\n      cache.put(`${tokenCacheKey}.${service}`, data.access);\n    } else {\n      throw new Error(`HTTP ${status} logging into dispatcharr`);\n    }\n  } catch (e) {\n    logger.error(`Error ${status} logging into dispatcharr`, JSON.stringify(data));\n    return [status, null];\n  }\n  return [status, data.access];\n}\n\nexport default async function dispatcharrProxyHandler(req, res) {\n  const { group, service, endpoint, index } = req.query;\n\n  if (group && service) {\n    const widget = await getServiceWidget(group, service, index);\n\n    if (!widgets?.[widget.type]?.api) {\n      return res.status(403).json({ error: \"Service does not support API calls\" });\n    }\n\n    if (widget) {\n      const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));\n      const loginUrl = formatApiCall(widgets[widget.type].api, {\n        endpoint: widgets[widget.type].mappings[\"token\"].endpoint,\n        ...widget,\n      });\n\n      let status;\n      let data;\n\n      let token = cache.get(`${tokenCacheKey}.${service}`);\n      if (!token) {\n        [status, token] = await login(loginUrl, widget.username, widget.password, service);\n        if (!token) {\n          logger.debug(`HTTP ${status} logging into Dispatcharr}`);\n          return res.status(status).send({ error: \"Failed to authenticate with Dispatcharr\" });\n        }\n      }\n\n      [status, , data] = await httpProxy(url, {\n        method: \"GET\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n          Authorization: `Bearer ${token}`,\n        },\n      });\n\n      const badRequest = [400, 401, 403].includes(status);\n      let isEmpty = false;\n\n      try {\n        const json = JSON.parse(data.toString(\"utf-8\"));\n        isEmpty = Array.isArray(json.items) && json.items.length === 0;\n      } catch (err) {\n        logger.error(\"Failed to parse Dispatcharr response JSON:\", err);\n      }\n\n      if (badRequest || isEmpty) {\n        if (badRequest) {\n          logger.debug(`HTTP ${status} retrieving data from Dispatcharr, logging in and trying again.`);\n        } else {\n          logger.debug(`Received empty list from Dispatcharr, logging in and trying again.`);\n        }\n        cache.del(`${tokenCacheKey}.${service}`);\n        [status, token] = await login(loginUrl, widget.username, widget.password, service);\n\n        if (status !== 200) {\n          logger.debug(`HTTP ${status} logging into Dispatcharr: ${JSON.stringify(data)}`);\n          return res.status(status).send(data);\n        }\n\n        [status, , data] = await httpProxy(url, {\n          method: \"GET\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n            Authorization: `Bearer ${token}`,\n          },\n        });\n      }\n\n      if (status !== 200) {\n        return res.status(status).send(data);\n      }\n\n      return res.send(data);\n    }\n  }\n\n  return res.status(400).json({ error: \"Invalid proxy service type\" });\n}\n"
  },
  {
    "path": "src/widgets/dispatcharr/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {\n  const store = new Map();\n\n  return {\n    httpProxy: vi.fn(),\n    getServiceWidget: vi.fn(),\n    cache: {\n      get: vi.fn((k) => store.get(k)),\n      put: vi.fn((k, v) => store.set(k, v)),\n      del: vi.fn((k) => store.delete(k)),\n      _reset: () => store.clear(),\n    },\n    logger: {\n      debug: vi.fn(),\n      error: vi.fn(),\n    },\n  };\n});\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nvi.mock(\"memory-cache\", () => ({\n  default: cache,\n  ...cache,\n}));\n\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    dispatcharr: {\n      api: \"{url}/{endpoint}\",\n      mappings: {\n        token: { endpoint: \"auth/token\" },\n      },\n    },\n  },\n}));\n\nimport dispatcharrProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/dispatcharr/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    cache._reset();\n  });\n\n  it(\"logs in when token is missing and uses Bearer token for requests\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"dispatcharr\",\n      url: \"http://dispatcharr\",\n      username: \"u\",\n      password: \"p\",\n    });\n\n    httpProxy\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ access: \"t1\" }))])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"data\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"items\", index: \"0\" } };\n    const res = createMockRes();\n\n    await dispatcharrProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(2);\n    expect(httpProxy.mock.calls[0][0].toString()).toBe(\"http://dispatcharr/auth/token\");\n    expect(httpProxy.mock.calls[1][1].headers.Authorization).toBe(\"Bearer t1\");\n    expect(res.body).toEqual(Buffer.from(\"data\"));\n  });\n\n  it(\"retries after a bad response by clearing cache and logging in again\", async () => {\n    cache.put(\"dispatcharrProxyHandler__token.svc\", \"old\");\n\n    getServiceWidget.mockResolvedValue({\n      type: \"dispatcharr\",\n      url: \"http://dispatcharr\",\n      username: \"u\",\n      password: \"p\",\n    });\n\n    httpProxy\n      .mockResolvedValueOnce([400, \"application/json\", Buffer.from(JSON.stringify({ items: [] }))])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ access: \"new\" }))])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"ok\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"items\", index: \"0\" } };\n    const res = createMockRes();\n\n    await dispatcharrProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(3);\n    expect(httpProxy.mock.calls[1][0].toString()).toBe(\"http://dispatcharr/auth/token\");\n    expect(httpProxy.mock.calls[2][1].headers.Authorization).toBe(\"Bearer new\");\n    expect(res.body).toEqual(Buffer.from(\"ok\"));\n  });\n});\n"
  },
  {
    "path": "src/widgets/dispatcharr/widget.js",
    "content": "import dispatcharrProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/{endpoint}\",\n  proxyHandler: dispatcharrProxyHandler,\n\n  mappings: {\n    token: {\n      endpoint: \"api/accounts/token/\",\n    },\n    channels: {\n      endpoint: \"api/channels/channels/\",\n    },\n    streams: {\n      endpoint: \"proxy/ts/status\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/dispatcharr/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"dispatcharr widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/docker/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\nimport useSWR from \"swr\";\n\nimport { calculateCPUPercent, calculateThroughput, calculateUsedMemory } from \"./stats-helpers\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: statusData, error: statusError } = useSWR(\n    `/api/docker/status/${widget.container}/${widget.server || \"\"}`,\n  );\n\n  const { data: statsData, error: statsError } = useSWR(`/api/docker/stats/${widget.container}/${widget.server || \"\"}`);\n\n  if (statsError || statsData?.error || statusError || statusData?.error) {\n    const finalError = statsError ?? statsData?.error ?? statusError ?? statusData?.error;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (statusData && !(statusData.status.includes(\"running\") || statusData.status.includes(\"partial\"))) {\n    return (\n      <Container>\n        <Block label={t(\"widget.status\")} value={t(\"docker.offline\")} />\n      </Container>\n    );\n  }\n\n  if (!statsData || !statusData) {\n    return (\n      <Container service={service}>\n        <Block label=\"docker.cpu\" />\n        <Block label=\"docker.mem\" />\n        <Block label=\"docker.rx\" />\n        <Block label=\"docker.tx\" />\n      </Container>\n    );\n  }\n\n  const { rxBytes, txBytes } = calculateThroughput(statsData.stats);\n  const cpuPercent = calculateCPUPercent(statsData.stats);\n  const usedMemory = calculateUsedMemory(statsData.stats);\n\n  return (\n    <Container service={service}>\n      <Block label=\"docker.cpu\" value={t(\"common.percent\", { value: cpuPercent })} highlightValue={cpuPercent} />\n      {statsData.stats.memory_stats.usage && (\n        <Block label=\"docker.mem\" value={t(\"common.bytes\", { value: usedMemory })} highlightValue={usedMemory} />\n      )}\n      {statsData.stats.networks && (\n        <>\n          <Block label=\"docker.rx\" value={t(\"common.bytes\", { value: rxBytes })} highlightValue={rxBytes} />\n          <Block label=\"docker.tx\" value={t(\"common.bytes\", { value: txBytes })} highlightValue={txBytes} />\n        </>\n      )}\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/docker/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));\n\nvi.mock(\"swr\", () => ({\n  default: useSWR,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/docker/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders offline status when container is not running\", () => {\n    useSWR\n      .mockReturnValueOnce({ data: { status: \"exited\" }, error: undefined }) // status\n      .mockReturnValueOnce({ data: undefined, error: undefined }); // stats\n\n    renderWithProviders(<Component service={{ widget: { type: \"docker\", container: \"c\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"widget.status\")).toBeInTheDocument();\n    expect(screen.getByText(\"docker.offline\")).toBeInTheDocument();\n  });\n\n  it(\"renders cpu/mem/rx/tx values when stats are available\", () => {\n    useSWR\n      .mockReturnValueOnce({ data: { status: \"running\" }, error: undefined }) // status\n      .mockReturnValueOnce({\n        data: {\n          stats: {\n            cpu_stats: { cpu_usage: { total_usage: 200 }, system_cpu_usage: 2000, online_cpus: 2 },\n            precpu_stats: { cpu_usage: { total_usage: 100 }, system_cpu_usage: 1000 },\n            memory_stats: { usage: 1000, total_inactive_file: 100 },\n            networks: { eth0: { rx_bytes: 1, tx_bytes: 2 }, eth1: { rx_bytes: 3, tx_bytes: 4 } },\n          },\n        },\n        error: undefined,\n      });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"docker\", container: \"c\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    // cpu: (100/1000)*2*100=20\n    expect(container.textContent).toContain(\"20\");\n    // mem used: 1000-100=900\n    expect(container.textContent).toContain(\"900\");\n    // rx=4, tx=6\n    expect(container.textContent).toContain(\"4\");\n    expect(container.textContent).toContain(\"6\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/docker/stats-helpers.js",
    "content": "export function calculateCPUPercent(stats) {\n  let cpuPercent = 0.0;\n  const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;\n  const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;\n\n  if (systemDelta > 0.0 && cpuDelta > 0.0) {\n    cpuPercent = (cpuDelta / systemDelta) * stats.cpu_stats.online_cpus * 100.0;\n  }\n\n  return Math.round(cpuPercent * 10) / 10;\n}\n\nexport function calculateUsedMemory(stats) {\n  // see https://github.com/docker/cli/blob/dcc161076861177b5eef6cb321722520db3184e7/cli/command/container/stats_helpers.go#L239\n  return (\n    stats.memory_stats.usage - (stats.memory_stats.total_inactive_file ?? stats.memory_stats.stats?.inactive_file ?? 0)\n  );\n}\n\nexport function calculateThroughput(stats) {\n  let rxBytes = 0;\n  let txBytes = 0;\n  if (stats.networks?.network) {\n    rxBytes = stats.networks?.network.rx_bytes;\n    txBytes = stats.networks?.network.tx_bytes;\n  } else if (stats.networks && Array.isArray(Object.values(stats.networks))) {\n    Object.values(stats.networks).forEach((containerInterface) => {\n      rxBytes += containerInterface.rx_bytes;\n      txBytes += containerInterface.tx_bytes;\n    });\n  }\n  return { rxBytes, txBytes };\n}\n"
  },
  {
    "path": "src/widgets/docker/stats-helpers.test.js",
    "content": "import { describe, expect, it } from \"vitest\";\n\nimport { calculateCPUPercent, calculateThroughput, calculateUsedMemory } from \"./stats-helpers\";\n\ndescribe(\"widgets/docker/stats-helpers\", () => {\n  it(\"calculateCPUPercent returns 0 when deltas are not positive\", () => {\n    expect(\n      calculateCPUPercent({\n        cpu_stats: { cpu_usage: { total_usage: 100 }, system_cpu_usage: 1000, online_cpus: 2 },\n        precpu_stats: { cpu_usage: { total_usage: 100 }, system_cpu_usage: 1000 },\n      }),\n    ).toBe(0);\n  });\n\n  it(\"calculateCPUPercent computes percent and rounds to 1 decimal\", () => {\n    // cpuDelta=100, systemDelta=1000, cpus=2 => (100/1000)*2*100 = 20.0\n    expect(\n      calculateCPUPercent({\n        cpu_stats: { cpu_usage: { total_usage: 200 }, system_cpu_usage: 2000, online_cpus: 2 },\n        precpu_stats: { cpu_usage: { total_usage: 100 }, system_cpu_usage: 1000 },\n      }),\n    ).toBe(20);\n  });\n\n  it(\"calculateUsedMemory subtracts inactive file (prefers total_inactive_file)\", () => {\n    const stats = {\n      memory_stats: {\n        usage: 1000,\n        total_inactive_file: 100,\n        stats: { inactive_file: 200 },\n      },\n    };\n    expect(calculateUsedMemory(stats)).toBe(900);\n  });\n\n  it(\"calculateUsedMemory falls back to stats.inactive_file when total_inactive_file missing\", () => {\n    const stats = {\n      memory_stats: {\n        usage: 1000,\n        stats: { inactive_file: 200 },\n      },\n    };\n    expect(calculateUsedMemory(stats)).toBe(800);\n  });\n\n  it(\"calculateThroughput uses the special networks.network key when present\", () => {\n    const stats = { networks: { network: { rx_bytes: 5, tx_bytes: 6 }, eth0: { rx_bytes: 1, tx_bytes: 2 } } };\n    expect(calculateThroughput(stats)).toEqual({ rxBytes: 5, txBytes: 6 });\n  });\n\n  it(\"calculateThroughput sums all interfaces otherwise\", () => {\n    const stats = { networks: { eth0: { rx_bytes: 1, tx_bytes: 2 }, eth1: { rx_bytes: 3, tx_bytes: 4 } } };\n    expect(calculateThroughput(stats)).toEqual({ rxBytes: 4, txBytes: 6 });\n  });\n});\n"
  },
  {
    "path": "src/widgets/dockhand/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst MAX_FIELDS = 4;\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  if (!widget.fields) {\n    widget.fields = [\"running\", \"total\", \"cpu\", \"memory\"];\n  } else if (widget.fields.length > MAX_FIELDS) {\n    widget.fields = widget.fields.slice(0, MAX_FIELDS);\n  }\n\n  const { data: stats, error: statsError } = useWidgetAPI(widget, \"dashboard/stats\");\n\n  if (statsError) {\n    return <Container service={service} error={statsError} />;\n  }\n\n  if (!stats) {\n    return (\n      <Container service={service}>\n        <Block label=\"dockhand.running\" />\n        <Block label=\"dockhand.total\" />\n        <Block label=\"dockhand.stopped\" />\n        <Block label=\"dockhand.paused\" />\n        <Block label=\"dockhand.pending_updates\" />\n        <Block label=\"dockhand.cpu\" />\n        <Block label=\"dockhand.memory\" />\n        <Block label=\"dockhand.images\" />\n        <Block label=\"dockhand.volumes\" />\n        <Block label=\"dockhand.stacks\" />\n        <Block label=\"dockhand.events_today\" />\n      </Container>\n    );\n  }\n\n  let running;\n  let stopped;\n  let paused;\n  let totalContainers;\n  let pendingUpdates;\n  let cpuPercent;\n  let memoryPercent;\n  let imagesTotal;\n  let volumesTotal;\n  let stacksRunning;\n  let stacksTotal;\n  let eventsToday;\n\n  if (widget?.environment) {\n    const environment = stats.find(\n      (env) =>\n        env?.name?.toString().toLowerCase() === widget?.environment.toString().toLowerCase() ||\n        env?.id?.toString() === widget?.environment.toString(),\n    );\n    if (environment) {\n      running = environment?.containers?.running;\n      stopped = environment?.containers?.stopped ?? (environment?.containers?.total ?? 0) - (running ?? 0);\n      paused = environment?.containers?.paused;\n      pendingUpdates = environment?.containers?.pendingUpdates;\n      totalContainers = environment?.containers?.total;\n      cpuPercent = environment?.metrics?.cpuPercent;\n      memoryPercent = environment?.metrics?.memoryPercent;\n      imagesTotal = environment?.images?.total;\n      volumesTotal = environment?.volumes?.total;\n      stacksRunning = environment?.stacks?.running;\n      stacksTotal = environment?.stacks?.total;\n      eventsToday = environment?.events?.today;\n    } else {\n      return (\n        <Container service={service} error={t(\"dockhand.environment_not_found\", { environment: widget.environment })} />\n      );\n    }\n  }\n\n  if (running === undefined) {\n    // Aggregate across all environments\n    running = stats.reduce((sum, env) => sum + (env?.containers?.running ?? 0), 0);\n    totalContainers = stats.reduce((sum, env) => sum + (env?.containers?.total ?? 0), 0);\n    stopped = totalContainers - running;\n    paused = stats.reduce((sum, env) => sum + (env?.containers?.paused ?? 0), 0);\n    pendingUpdates = stats.reduce((sum, env) => sum + (env?.containers?.pendingUpdates ?? 0), 0);\n    const totalCpu = stats.reduce((sum, env) => sum + (env?.metrics?.cpuPercent ?? 0), 0);\n    const totalMemory = stats.reduce((sum, env) => sum + (env?.metrics?.memoryPercent ?? 0), 0);\n    const envCount = stats.length;\n    cpuPercent = envCount > 0 ? totalCpu / envCount : 0;\n    memoryPercent = envCount > 0 ? totalMemory / envCount : 0;\n    imagesTotal = stats.reduce((sum, env) => sum + (env?.images?.total ?? 0), 0);\n    volumesTotal = stats.reduce((sum, env) => sum + (env?.volumes?.total ?? 0), 0);\n    stacksRunning = stats.reduce((sum, env) => sum + (env?.stacks?.running ?? 0), 0);\n    stacksTotal = stats.reduce((sum, env) => sum + (env?.stacks?.total ?? 0), 0);\n    eventsToday = stats.reduce((sum, env) => sum + (env?.events?.today ?? 0), 0);\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"dockhand.running\" value={t(\"common.number\", { value: running })} />\n      <Block label=\"dockhand.stopped\" value={t(\"common.number\", { value: stopped })} />\n      <Block label=\"dockhand.paused\" value={t(\"common.number\", { value: paused ?? 0 })} />\n      <Block label=\"dockhand.pending_updates\" value={t(\"common.number\", { value: pendingUpdates ?? 0 })} />\n      <Block label=\"dockhand.total\" value={t(\"common.number\", { value: totalContainers })} />\n      <Block\n        label=\"dockhand.cpu\"\n        value={t(\"common.percent\", { value: cpuPercent, maximumFractionDigits: 1 })}\n        highlightValue={cpuPercent}\n      />\n      <Block\n        label=\"dockhand.memory\"\n        value={t(\"common.percent\", { value: memoryPercent, maximumFractionDigits: 1 })}\n        highlightValue={memoryPercent}\n      />\n      <Block label=\"dockhand.images\" value={t(\"common.number\", { value: imagesTotal ?? 0 })} />\n      <Block label=\"dockhand.volumes\" value={t(\"common.number\", { value: volumesTotal ?? 0 })} />\n      <Block\n        label=\"dockhand.stacks\"\n        value={\n          stacksRunning != null && stacksTotal != null\n            ? `${stacksRunning} / ${stacksTotal}`\n            : t(\"common.number\", { value: stacksRunning ?? stacksTotal ?? 0 })\n        }\n      />\n      <Block label=\"dockhand.events_today\" value={t(\"common.number\", { value: eventsToday ?? 0 })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/dockhand/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/dockhand/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"defaults fields and filters to 4 blocks while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"dockhand\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"running\", \"total\", \"cpu\", \"memory\"]);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"dockhand.running\")).toBeInTheDocument();\n    expect(screen.getByText(\"dockhand.total\")).toBeInTheDocument();\n    expect(screen.getByText(\"dockhand.cpu\")).toBeInTheDocument();\n    expect(screen.getByText(\"dockhand.memory\")).toBeInTheDocument();\n  });\n\n  it(\"renders environment-specific values when widget.environment matches\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: [\n        {\n          id: \"1\",\n          name: \"Prod\",\n          containers: { running: 2, total: 5, paused: 1, pendingUpdates: 3 },\n          metrics: { cpuPercent: 10, memoryPercent: 20 },\n        },\n      ],\n      error: undefined,\n    });\n\n    const service = { widget: { type: \"dockhand\", environment: \"prod\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expectBlockValue(container, \"dockhand.running\", 2);\n    expectBlockValue(container, \"dockhand.total\", 5);\n    expectBlockValue(container, \"dockhand.cpu\", 10);\n    expectBlockValue(container, \"dockhand.memory\", 20);\n  });\n});\n"
  },
  {
    "path": "src/widgets/dockhand/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall, sanitizeErrorURL } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst logger = createLogger(\"dockhandProxyHandler\");\n\nasync function login(widget) {\n  if (!widget.username || !widget.password) return false;\n\n  const baseUrl = widget.url?.replace(/\\/+$/, \"\");\n  const [status] = await httpProxy(`${baseUrl}/api/auth/login`, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n    },\n    body: JSON.stringify({ username: widget.username, password: widget.password }),\n  });\n\n  return status === 200;\n}\n\nexport default async function dockhandProxyHandler(req, res) {\n  const { group, service, endpoint, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));\n\n  let [status, contentType, data] = await httpProxy(url, {\n    method: req.method,\n  });\n\n  // Attempt login and retrying once\n  if (status === 401) {\n    const loggedIn = await login(widget);\n    if (loggedIn) {\n      [status, contentType, data] = await httpProxy(url, {\n        method: req.method,\n      });\n    }\n  }\n\n  let resultData = data;\n\n  if (status >= 400) {\n    logger.error(\"HTTP Error %d calling %s\", status, url.toString());\n    return res.status(status).json({\n      error: {\n        message: \"HTTP Error\",\n        url: sanitizeErrorURL(url),\n        data: Buffer.isBuffer(resultData) ? Buffer.from(resultData).toString() : resultData,\n      },\n    });\n  }\n\n  if (contentType) res.setHeader(\"Content-Type\", contentType);\n  return res.status(status).send(resultData);\n}\n"
  },
  {
    "path": "src/widgets/dockhand/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  logger: {\n    debug: vi.fn(),\n    error: vi.fn(),\n  },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    dockhand: {\n      api: \"{url}/{endpoint}\",\n    },\n  },\n}));\n\nimport dockhandProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/dockhand/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"retries after a 401 by logging in once\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"dockhand\",\n      url: \"http://dockhand/\",\n      username: \"u\",\n      password: \"p\",\n    });\n\n    httpProxy\n      .mockResolvedValueOnce([401, \"application/json\", Buffer.from(\"nope\")])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"ok\")]) // login\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"data\")]); // retry\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"svc\", endpoint: \"api/v1/status\", index: \"0\" } };\n    const res = createMockRes();\n\n    await dockhandProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(3);\n    expect(httpProxy.mock.calls[1][0]).toBe(\"http://dockhand/api/auth/login\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual(Buffer.from(\"data\"));\n  });\n\n  it(\"returns a sanitized error response for HTTP errors\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"dockhand\",\n      url: \"http://dockhand\",\n    });\n\n    httpProxy.mockResolvedValueOnce([500, \"application/json\", Buffer.from(\"boom\")]);\n\n    const req = {\n      method: \"GET\",\n      query: { group: \"g\", service: \"svc\", endpoint: \"api/v1/status?token=abc\", index: \"0\" },\n    };\n    const res = createMockRes();\n\n    await dockhandProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body.error.message).toBe(\"HTTP Error\");\n    expect(res.body.error.url).toContain(\"token=***\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/dockhand/widget.js",
    "content": "import dockhandProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: dockhandProxyHandler,\n\n  mappings: {\n    \"dashboard/stats\": {\n      endpoint: \"dashboard/stats\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/dockhand/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"dockhand widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/downloadstation/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const { data: listData, error: listError } = useWidgetAPI(widget, \"list\");\n\n  if (listError) {\n    return <Container service={service} error={listError} />;\n  }\n\n  const tasks = listData?.data?.tasks;\n  if (!tasks) {\n    return (\n      <Container service={service}>\n        <Block label=\"downloadstation.leech\" />\n        <Block label=\"downloadstation.download\" />\n        <Block label=\"downloadstation.seed\" />\n        <Block label=\"downloadstation.upload\" />\n      </Container>\n    );\n  }\n\n  const rateDl = tasks.reduce((acc, task) => acc + (task?.additional?.transfer?.speed_download ?? 0), 0);\n  const rateUl = tasks.reduce((acc, task) => acc + (task?.additional?.transfer?.speed_upload ?? 0), 0);\n  const completed = tasks.filter((task) => task?.additional?.transfer?.size_downloaded === task?.size)?.length || 0;\n  const leech = tasks.length - completed || 0;\n\n  return (\n    <Container service={service}>\n      <Block label=\"downloadstation.leech\" value={t(\"common.number\", { value: leech })} />\n      <Block label=\"downloadstation.download\" value={t(\"common.byterate\", { value: rateDl })} highlightValue={rateDl} />\n      <Block label=\"downloadstation.seed\" value={t(\"common.number\", { value: completed })} />\n      <Block label=\"downloadstation.upload\" value={t(\"common.byterate\", { value: rateUl })} highlightValue={rateUl} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/downloadstation/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/downloadstation/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while tasks are missing\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"downloadstation\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"downloadstation.leech\")).toBeInTheDocument();\n    expect(screen.getByText(\"downloadstation.download\")).toBeInTheDocument();\n    expect(screen.getByText(\"downloadstation.seed\")).toBeInTheDocument();\n    expect(screen.getByText(\"downloadstation.upload\")).toBeInTheDocument();\n  });\n\n  it(\"computes leech/seed counts and total upload/download rates\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: {\n          tasks: [\n            { size: 10, additional: { transfer: { size_downloaded: 10, speed_download: 5, speed_upload: 1 } } },\n            { size: 20, additional: { transfer: { size_downloaded: 5, speed_download: 6, speed_upload: 2 } } },\n          ],\n        },\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"downloadstation\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    // completed = 1, leech = 1\n    expectBlockValue(container, \"downloadstation.seed\", 1);\n    expectBlockValue(container, \"downloadstation.leech\", 1);\n    expectBlockValue(container, \"downloadstation.download\", 11);\n    expectBlockValue(container, \"downloadstation.upload\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/downloadstation/widget.js",
    "content": "import synologyProxyHandler from \"../../utils/proxy/handlers/synology\";\n\nconst widget = {\n  // cgiPath and maxVersion are discovered at runtime, don't supply\n  api: \"{url}/webapi/{cgiPath}?api={apiName}&version={maxVersion}&method={apiMethod}\",\n  proxyHandler: synologyProxyHandler,\n\n  mappings: {\n    list: {\n      apiName: \"SYNO.DownloadStation.Task\",\n      apiMethod: \"list&additional=transfer\",\n      endpoint: \"list\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/downloadstation/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"downloadstation widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/emby/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\nimport { BsCpu, BsFillCpuFill, BsFillPlayFill, BsPauseFill, BsVolumeMuteFill } from \"react-icons/bs\";\nimport { MdOutlineSmartDisplay } from \"react-icons/md\";\n\nimport { getURLSearchParams } from \"utils/proxy/api-helpers\";\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nfunction ticksToTime(ticks) {\n  const milliseconds = ticks / 10000;\n  const seconds = Math.floor((milliseconds / 1000) % 60);\n  const minutes = Math.floor((milliseconds / (1000 * 60)) % 60);\n  const hours = Math.floor((milliseconds / (1000 * 60 * 60)) % 24);\n  return { hours, minutes, seconds };\n}\n\nfunction ticksToString(ticks) {\n  const { hours, minutes, seconds } = ticksToTime(ticks);\n  const parts = [];\n  if (hours > 0) {\n    parts.push(hours);\n  }\n  parts.push(minutes);\n  parts.push(seconds);\n\n  return parts.map((part) => part.toString().padStart(2, \"0\")).join(\":\");\n}\n\nfunction generateStreamTitle(session, enableUser, showEpisodeNumber) {\n  const {\n    NowPlayingItem: { Name, SeriesName, Type, ParentIndexNumber, IndexNumber, AlbumArtist, Album },\n    UserName,\n  } = session;\n  let streamTitle = \"\";\n\n  if (Type === \"Episode\" && showEpisodeNumber) {\n    const seasonStr = ParentIndexNumber ? `S${ParentIndexNumber.toString().padStart(2, \"0\")}` : \"\";\n    const episodeStr = IndexNumber ? `E${IndexNumber.toString().padStart(2, \"0\")}` : \"\";\n    streamTitle = `${SeriesName}: ${seasonStr} · ${episodeStr} - ${Name}`;\n  } else if (Type === \"Audio\") {\n    streamTitle = `${AlbumArtist} - ${Album} - ${Name}`;\n  } else {\n    streamTitle = `${Name}${SeriesName ? ` - ${SeriesName}` : \"\"}`;\n  }\n\n  return enableUser ? `${streamTitle} (${UserName})` : streamTitle;\n}\n\nfunction SingleSessionEntry({ playCommand, session, enableUser, showEpisodeNumber, enableMediaControl }) {\n  const {\n    PlayState: { PositionTicks, IsPaused, IsMuted },\n  } = session;\n\n  const RunTimeTicks =\n    session.NowPlayingItem?.RunTimeTicks ?? session.NowPlayingItem?.CurrentProgram?.RunTimeTicks ?? 0;\n\n  const { IsVideoDirect, VideoDecoderIsHardware, VideoEncoderIsHardware } = session?.TranscodingInfo || {\n    IsVideoDirect: true,\n  }; // if no transcodinginfo its videodirect\n\n  const percent = Math.min(1, PositionTicks / RunTimeTicks) * 100;\n\n  const streamTitle = generateStreamTitle(session, enableUser, showEpisodeNumber);\n  return (\n    <>\n      <div className=\"text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex\">\n        <div className=\"grow text-xs z-10 self-center ml-2 relative w-full h-4 mr-2\">\n          <div className=\"absolute w-full whitespace-nowrap text-ellipsis overflow-hidden\" title={streamTitle}>\n            {streamTitle}\n          </div>\n        </div>\n        <div className=\"self-center text-xs flex justify-end mr-1.5 pl-1\">\n          {IsVideoDirect && <MdOutlineSmartDisplay className=\"opacity-50\" />}\n          {!IsVideoDirect && (!VideoDecoderIsHardware || !VideoEncoderIsHardware) && <BsCpu className=\"opacity-50\" />}\n          {!IsVideoDirect && VideoDecoderIsHardware && VideoEncoderIsHardware && (\n            <BsFillCpuFill className=\"opacity-50\" />\n          )}\n        </div>\n      </div>\n\n      <div className=\"text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex\">\n        <div\n          className=\"absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0\"\n          style={{\n            width: `${percent}%`,\n          }}\n        />\n        <div className=\"text-xs z-10 self-center ml-1\">\n          {enableMediaControl && IsPaused && (\n            <BsFillPlayFill\n              onClick={() => {\n                playCommand(session, \"Unpause\");\n              }}\n              className=\"inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80\"\n            />\n          )}\n          {enableMediaControl && !IsPaused && (\n            <BsPauseFill\n              onClick={() => {\n                playCommand(session, \"Pause\");\n              }}\n              className=\"inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80\"\n            />\n          )}\n        </div>\n        <div className=\"grow \" />\n        <div className=\"self-center text-xs flex justify-end mr-1 z-10\">{IsMuted && <BsVolumeMuteFill />}</div>\n        <div className=\"self-center text-xs flex justify-end mr-2 z-10\">\n          {ticksToString(PositionTicks)}\n          <span className=\"mx-0.5 text-[8px]\">/</span>\n          {ticksToString(RunTimeTicks)}\n        </div>\n      </div>\n    </>\n  );\n}\n\nfunction SessionEntry({ playCommand, session, enableUser, showEpisodeNumber, enableMediaControl }) {\n  const {\n    PlayState: { PositionTicks, IsPaused, IsMuted },\n  } = session;\n\n  const RunTimeTicks =\n    session.NowPlayingItem?.RunTimeTicks ?? session.NowPlayingItem?.CurrentProgram?.RunTimeTicks ?? 0;\n\n  const { IsVideoDirect, VideoDecoderIsHardware, VideoEncoderIsHardware } = session?.TranscodingInfo || {\n    IsVideoDirect: true,\n  }; // if no transcodinginfo its videodirect\n\n  const streamTitle = generateStreamTitle(session, enableUser, showEpisodeNumber);\n\n  const percent = Math.min(1, PositionTicks / RunTimeTicks) * 100;\n\n  return (\n    <div className=\"text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex\">\n      <div\n        className=\"absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0\"\n        style={{\n          width: `${percent}%`,\n        }}\n      />\n      <div className=\"text-xs z-10 self-center ml-1\">\n        {enableMediaControl && IsPaused && (\n          <BsFillPlayFill\n            onClick={() => {\n              playCommand(session, \"Unpause\");\n            }}\n            className=\"inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80\"\n          />\n        )}\n        {enableMediaControl && !IsPaused && (\n          <BsPauseFill\n            onClick={() => {\n              playCommand(session, \"Pause\");\n            }}\n            className=\"inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80\"\n          />\n        )}\n      </div>\n      <div className=\"grow text-xs z-10 self-center relative w-full h-4\">\n        <div className=\"absolute w-full whitespace-nowrap text-ellipsis overflow-hidden\" title={streamTitle}>\n          {streamTitle}\n        </div>\n      </div>\n      <div className=\"self-center text-xs flex justify-end mr-1 z-10\">{IsMuted && <BsVolumeMuteFill />}</div>\n      <div className=\"self-center text-xs flex justify-end mr-1 z-10\">{ticksToString(PositionTicks)}</div>\n      <div className=\"self-center items-center text-xs flex justify-end mr-1.5 pl-1 z-10\">\n        {IsVideoDirect && <MdOutlineSmartDisplay className=\"opacity-50\" />}\n        {!IsVideoDirect && (!VideoDecoderIsHardware || !VideoEncoderIsHardware) && <BsCpu className=\"opacity-50\" />}\n        {!IsVideoDirect && VideoDecoderIsHardware && VideoEncoderIsHardware && <BsFillCpuFill className=\"opacity-50\" />}\n      </div>\n    </div>\n  );\n}\n\nfunction CountBlocks({ service, countData }) {\n  const { t } = useTranslation();\n\n  if (!countData) {\n    return (\n      <Container service={service}>\n        <Block label=\"emby.movies\" />\n        <Block label=\"emby.series\" />\n        <Block label=\"emby.episodes\" />\n        <Block label=\"emby.songs\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"emby.movies\" value={t(\"common.number\", { value: countData.MovieCount })} />\n      <Block label=\"emby.series\" value={t(\"common.number\", { value: countData.SeriesCount })} />\n      <Block label=\"emby.episodes\" value={t(\"common.number\", { value: countData.EpisodeCount })} />\n      <Block label=\"emby.songs\" value={t(\"common.number\", { value: countData.SongCount })} />\n    </Container>\n  );\n}\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n  const enableNowPlaying = service.widget?.enableNowPlaying ?? true;\n\n  const {\n    data: sessionsData,\n    error: sessionsError,\n    mutate: sessionMutate,\n  } = useWidgetAPI(widget, enableNowPlaying ? \"Sessions\" : \"\", {\n    refreshInterval: enableNowPlaying ? 5000 : undefined,\n  });\n\n  const { data: countData, error: countError } = useWidgetAPI(widget, \"Count\", {\n    refreshInterval: 60000,\n  });\n\n  async function handlePlayCommand(session, command) {\n    const params = getURLSearchParams(widget, command);\n    params.append(\n      \"segments\",\n      JSON.stringify({\n        sessionId: session.Id,\n      }),\n    );\n    const url = `/api/services/proxy?${params.toString()}`;\n    await fetch(url, {\n      method: \"POST\",\n    }).then(() => {\n      sessionMutate();\n    });\n  }\n\n  if (sessionsError || countError) {\n    return <Container service={service} error={sessionsError ?? countError} />;\n  }\n\n  const enableBlocks = service.widget?.enableBlocks;\n  const enableMediaControl = service.widget?.enableMediaControl !== false; // default is true\n  const enableUser = !!service.widget?.enableUser; // default is false\n  const expandOneStreamToTwoRows = service.widget?.expandOneStreamToTwoRows !== false; // default is true\n  const showEpisodeNumber = !!service.widget?.showEpisodeNumber; // default is false\n\n  if ((enableNowPlaying && !sessionsData) || !countData) {\n    return (\n      <>\n        {enableBlocks && <CountBlocks service={service} countData={null} />}\n        {enableNowPlaying && (\n          <div className=\"flex flex-col pb-1\">\n            <div className=\"text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1\">\n              <span className=\"absolute left-2 text-xs mt-[2px]\">-</span>\n            </div>\n            {expandOneStreamToTwoRows && (\n              <div className=\"text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1\">\n                <span className=\"absolute left-2 text-xs mt-[2px]\">-</span>\n              </div>\n            )}\n          </div>\n        )}\n      </>\n    );\n  }\n\n  if (enableNowPlaying) {\n    const playing = sessionsData\n      .filter((session) => session?.NowPlayingItem)\n      .sort((a, b) => {\n        if (a.PlayState.PositionTicks > b.PlayState.PositionTicks) {\n          return 1;\n        }\n        if (a.PlayState.PositionTicks < b.PlayState.PositionTicks) {\n          return -1;\n        }\n        return 0;\n      });\n\n    if (playing.length === 0) {\n      return (\n        <>\n          {enableBlocks && <CountBlocks service={service} countData={countData} />}\n          <div className=\"flex flex-col pb-1 mx-1\">\n            <div className=\"text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1\">\n              <span className=\"absolute left-2 text-xs mt-[2px]\">{t(\"emby.no_active\")}</span>\n            </div>\n            {expandOneStreamToTwoRows && (\n              <div className=\"text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1\">\n                <span className=\"absolute left-2 text-xs mt-[2px]\">-</span>\n              </div>\n            )}\n          </div>\n        </>\n      );\n    }\n\n    if (expandOneStreamToTwoRows && playing.length === 1) {\n      const session = playing[0];\n      return (\n        <>\n          {enableBlocks && <CountBlocks service={service} countData={countData} />}\n          <div className=\"flex flex-col pb-1 mx-1\">\n            <SingleSessionEntry\n              playCommand={(currentSession, command) => handlePlayCommand(currentSession, command)}\n              session={session}\n              enableUser={enableUser}\n              showEpisodeNumber={showEpisodeNumber}\n              enableMediaControl={enableMediaControl}\n            />\n          </div>\n        </>\n      );\n    }\n\n    return (\n      <>\n        {enableBlocks && <CountBlocks service={service} countData={countData} />}\n        <div className=\"flex flex-col pb-1 mx-1\">\n          {playing.map((session) => (\n            <SessionEntry\n              key={session.Id}\n              playCommand={(currentSession, command) => handlePlayCommand(currentSession, command)}\n              session={session}\n              enableUser={enableUser}\n              showEpisodeNumber={showEpisodeNumber}\n              enableMediaControl={enableMediaControl}\n            />\n          ))}\n        </div>\n      </>\n    );\n  }\n\n  if (enableBlocks) {\n    return <CountBlocks service={service} countData={countData} />;\n  }\n}\n"
  },
  {
    "path": "src/widgets/emby/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\nfunction baseSession(overrides = {}) {\n  return {\n    Id: \"s1\",\n    UserName: \"Alice\",\n    NowPlayingItem: {\n      Type: \"Episode\",\n      Name: \"Pilot\",\n      SeriesName: \"Show\",\n      ParentIndexNumber: 1,\n      IndexNumber: 2,\n      RunTimeTicks: 100000000,\n    },\n    PlayState: { PositionTicks: 50000000, IsPaused: true, IsMuted: true },\n    TranscodingInfo: { IsVideoDirect: true, VideoDecoderIsHardware: true, VideoEncoderIsHardware: true },\n    ...overrides,\n  };\n}\n\ndescribe(\"widgets/emby/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders loading skeleton when sessions/count are missing\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined, mutate: vi.fn() });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"emby\", enableBlocks: true, enableNowPlaying: true } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    // CountBlocks placeholders should be present.\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"emby.movies\")).toBeInTheDocument();\n    expect(screen.getByText(\"emby.series\")).toBeInTheDocument();\n    expect(screen.getByText(\"emby.episodes\")).toBeInTheDocument();\n    expect(screen.getByText(\"emby.songs\")).toBeInTheDocument();\n  });\n\n  it(\"renders single-session view with expanded two rows and stream title with user + episode number\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: [baseSession()], error: undefined, mutate: vi.fn() }) // Sessions\n      .mockReturnValueOnce({\n        data: { MovieCount: 1, SeriesCount: 2, EpisodeCount: 3, SongCount: 4 },\n        error: undefined,\n      }); // Count\n\n    renderWithProviders(\n      <Component\n        service={{\n          widget: {\n            type: \"emby\",\n            enableBlocks: true,\n            enableNowPlaying: true,\n            enableUser: true,\n            showEpisodeNumber: true,\n            expandOneStreamToTwoRows: true,\n          },\n        }}\n      />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(screen.getByText(\"Show: S01 · E02 - Pilot (Alice)\")).toBeInTheDocument();\n    expect(screen.getByText(/00:05/)).toBeInTheDocument();\n    expect(screen.getByText(/00:10/)).toBeInTheDocument();\n  });\n\n  it(\"renders no_active when there are no sessions playing\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({\n        data: [{ Id: \"s2\", PlayState: { PositionTicks: 0 }, UserName: \"Bob\" }],\n        error: undefined,\n        mutate: vi.fn(),\n      })\n      .mockReturnValueOnce({\n        data: { MovieCount: 0, SeriesCount: 0, EpisodeCount: 0, SongCount: 0 },\n        error: undefined,\n      });\n\n    renderWithProviders(\n      <Component service={{ widget: { type: \"emby\", enableNowPlaying: true, enableBlocks: true } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(screen.getByText(\"emby.no_active\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/emby/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/emby/{endpoint}?api_key={key}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    Sessions: {\n      endpoint: \"Sessions\",\n    },\n    Count: {\n      endpoint: \"Items/Counts\",\n    },\n    Unpause: {\n      method: \"POST\",\n      endpoint: \"Sessions/{sessionId}/Playing/Unpause\",\n      segments: [\"sessionId\"],\n    },\n    Pause: {\n      method: \"POST\",\n      endpoint: \"Sessions/{sessionId}/Playing/Pause\",\n      segments: [\"sessionId\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/emby/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"emby widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/esphome/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n  const { data: resultData, error: resultError } = useWidgetAPI(widget);\n\n  if (resultError) {\n    return <Container service={service} error={resultError} />;\n  }\n\n  if (!widget.fields || widget.fields.length === 0) {\n    widget.fields = [\"online\", \"offline\", \"offline_alt\", \"total\"];\n  } else if (widget.fields.length > 4) {\n    widget.fields = widget.fields.slice(0, 4);\n  }\n\n  if (!resultData) {\n    return (\n      <Container service={service}>\n        <Block label=\"esphome.online\" />\n        <Block label=\"esphome.offline\" />\n        <Block label=\"esphome.offline_alt\" />\n        <Block label=\"esphome.unknown\" />\n        <Block label=\"esphome.total\" />\n      </Container>\n    );\n  }\n\n  const total = Object.keys(resultData).length;\n  const online = Object.entries(resultData).filter(([, v]) => v === true).length;\n  const notOnline = Object.entries(resultData).filter(([, v]) => v !== true).length;\n  const offline = Object.entries(resultData).filter(([, v]) => v === false).length;\n  const unknown = Object.entries(resultData).filter(([, v]) => v === null).length;\n\n  return (\n    <Container service={service}>\n      <Block label=\"esphome.online\" value={t(\"common.number\", { value: online })} />\n      <Block label=\"esphome.offline\" value={t(\"common.number\", { value: offline })} />\n      <Block label=\"esphome.offline_alt\" value={t(\"common.number\", { value: notOnline })} />\n      <Block label=\"esphome.unknown\" value={t(\"common.number\", { value: unknown })} />\n      <Block label=\"esphome.total\" value={t(\"common.number\", { value: total })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/esphome/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/esphome/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"defaults fields and filters placeholders to 4 blocks while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"esphome\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"online\", \"offline\", \"offline_alt\", \"total\"]);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"esphome.online\")).toBeInTheDocument();\n    expect(screen.getByText(\"esphome.offline\")).toBeInTheDocument();\n    expect(screen.getByText(\"esphome.offline_alt\")).toBeInTheDocument();\n    expect(screen.getByText(\"esphome.total\")).toBeInTheDocument();\n    expect(screen.queryByText(\"esphome.unknown\")).toBeNull();\n  });\n\n  it(\"computes online/offline/unknown and filters to default fields\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { a: true, b: false, c: null },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"esphome\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expectBlockValue(container, \"esphome.online\", 1);\n    expectBlockValue(container, \"esphome.offline\", 1);\n    // offline_alt is count of not-true, i.e. false+null = 2\n    expectBlockValue(container, \"esphome.offline_alt\", 2);\n    expectBlockValue(container, \"esphome.total\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/esphome/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/ping\",\n  proxyHandler: credentialedProxyHandler,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/esphome/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"esphome widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/evcc/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nfunction toKilowatts(t, value) {\n  return value > 0 ? t(\"common.number\", { value: value / 1000, maximumFractionDigits: 1 }) : 0;\n}\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n  const { data: stateData, error: stateError } = useWidgetAPI(widget, \"state\");\n\n  if (stateError) {\n    return <Container service={service} error={stateError} />;\n  }\n\n  if (!stateData) {\n    return (\n      <Container service={service}>\n        <Block label=\"evcc.pv_power\" />\n        <Block label=\"evcc.grid_power\" />\n        <Block label=\"evcc.home_power\" />\n        <Block label=\"evcc.charge_power\" />\n      </Container>\n    );\n  }\n\n  // evcc v0.207 changed the API structure so its no longer under 'result'\n  const data = stateData.result ?? stateData;\n\n  // broken by evcc v0.133.0 https://github.com/evcc-io/evcc/commit/9dcb1fa0a7c08dd926b79309aa1f676a5fc6c8aa\n  const gridPower = data.gridPower ?? data.grid?.power ?? 0;\n\n  // Sum chargePower of all loadpoints\n  const totalChargePower = Array.isArray(data.loadpoints)\n    ? data.loadpoints.reduce((sum, lp) => sum + (lp.chargePower ?? 0), 0)\n    : 0;\n\n  return (\n    <Container service={service}>\n      <Block label=\"evcc.pv_power\" value={`${toKilowatts(t, data.pvPower)} ${t(\"evcc.kilowatt\")}`} />\n      <Block label=\"evcc.grid_power\" value={`${toKilowatts(t, gridPower)} ${t(\"evcc.kilowatt\")}`} />\n      <Block label=\"evcc.home_power\" value={`${toKilowatts(t, data.homePower)} ${t(\"evcc.kilowatt\")}`} />\n      <Block label=\"evcc.charge_power\" value={`${toKilowatts(t, totalChargePower)} ${t(\"evcc.kilowatt\")}`} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/evcc/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/evcc/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"evcc\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"evcc.pv_power\")).toBeInTheDocument();\n    expect(screen.getByText(\"evcc.grid_power\")).toBeInTheDocument();\n    expect(screen.getByText(\"evcc.home_power\")).toBeInTheDocument();\n    expect(screen.getByText(\"evcc.charge_power\")).toBeInTheDocument();\n    expect(screen.getAllByText(\"-\")).toHaveLength(4);\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"evcc\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n  });\n\n  it(\"renders computed kilowatt values (including result wrapper, grid fallback, and loadpoint sum)\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        result: {\n          pvPower: 1000,\n          grid: { power: 2000 },\n          homePower: 3000,\n          loadpoints: [{ chargePower: 500 }, { chargePower: 1500 }],\n        },\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"evcc\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"evcc.pv_power\", \"1 evcc.kilowatt\");\n    expectBlockValue(container, \"evcc.grid_power\", \"2 evcc.kilowatt\");\n    expectBlockValue(container, \"evcc.home_power\", \"3 evcc.kilowatt\");\n    expectBlockValue(container, \"evcc.charge_power\", \"2 evcc.kilowatt\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/evcc/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    state: {\n      endpoint: \"state\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/evcc/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"evcc widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/filebrowser/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: usage, error: usageError } = useWidgetAPI(widget, \"usage\");\n\n  if (usageError) {\n    return <Container service={service} error={usageError} />;\n  }\n\n  if (!usage) {\n    return (\n      <Container service={service}>\n        <Block label=\"filebrowser.available\" />\n        <Block label=\"filebrowser.used\" />\n        <Block label=\"filebrowser.total\" />\n      </Container>\n    );\n  }\n\n  const available = (usage?.total ?? 0) - (usage?.used ?? 0);\n\n  return (\n    <Container service={service}>\n      <Block label=\"filebrowser.available\" value={t(\"common.bytes\", { value: available })} highlightValue={available} />\n      <Block\n        label=\"filebrowser.used\"\n        value={t(\"common.bytes\", { value: usage?.used ?? 0 })}\n        highlightValue={usage?.used ?? 0}\n      />\n      <Block\n        label=\"filebrowser.total\"\n        value={t(\"common.bytes\", { value: usage?.total ?? 0 })}\n        highlightValue={usage?.total ?? 0}\n      />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/filebrowser/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/filebrowser/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"filebrowser\", url: \"http://x\" } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"filebrowser.available\")).toBeInTheDocument();\n    expect(screen.getByText(\"filebrowser.used\")).toBeInTheDocument();\n    expect(screen.getByText(\"filebrowser.total\")).toBeInTheDocument();\n    expect(screen.getAllByText(\"-\")).toHaveLength(3);\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"filebrowser\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n  });\n\n  it(\"renders computed available/used/total bytes\", () => {\n    useWidgetAPI.mockReturnValue({ data: { total: 100, used: 40 }, error: undefined });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"filebrowser\", url: \"http://x\" } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expectBlockValue(container, \"filebrowser.available\", 60);\n    expectBlockValue(container, \"filebrowser.used\", 40);\n    expectBlockValue(container, \"filebrowser.total\", 100);\n  });\n});\n"
  },
  {
    "path": "src/widgets/filebrowser/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst proxyName = \"filebrowserProxyHandler\";\nconst logger = createLogger(proxyName);\n\nasync function login(widget, service) {\n  const url = formatApiCall(widgets[widget.type].api, { ...widget, endpoint: \"login\" });\n  const headers = {};\n  if (widget.authHeader) {\n    headers[widget.authHeader] = widget.username;\n  }\n  const [status, , data] = await httpProxy(url, {\n    method: \"POST\",\n    headers,\n    body: JSON.stringify({\n      username: widget.username,\n      password: widget.password,\n    }),\n  });\n\n  switch (status) {\n    case 200:\n      return data;\n    case 401:\n      logger.error(\"Unauthorized access to Filebrowser API for service '%s'. Check credentials.\", service);\n      break;\n    default:\n      logger.error(\"Unexpected status code %d when logging in to Filebrowser API for service '%s'\", status, service);\n      break;\n  }\n}\n\nexport default async function filebrowserProxyHandler(req, res) {\n  const { group, service, endpoint, index } = req.query;\n\n  if (!group || !service) {\n    logger.error(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n  if (!widget || !widgets[widget.type].api) {\n    logger.error(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid widget configuration\" });\n  }\n\n  const token = await login(widget, service);\n  if (!token) {\n    return res.status(500).json({ error: \"Failed to authenticate with Filebrowser\" });\n  }\n\n  const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));\n\n  try {\n    const params = {\n      method: \"GET\",\n      headers: {\n        \"X-AUTH\": token,\n      },\n    };\n\n    logger.debug(\"Calling Filebrowser API endpoint: %s\", endpoint);\n\n    const [status, , data] = await httpProxy(url, params);\n\n    if (status !== 200) {\n      logger.error(\"Error calling Filebrowser API: %d. Data: %s\", status, data);\n      return res.status(status).json({ error: \"Filebrowser API Error\", data });\n    }\n\n    return res.status(status).send(data);\n  } catch (error) {\n    logger.error(\"Exception calling Filebrowser API: %s\", error.message);\n    return res.status(500).json({ error: \"Filebrowser API Error\", message: error.message });\n  }\n}\n"
  },
  {
    "path": "src/widgets/filebrowser/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  logger: {\n    debug: vi.fn(),\n    error: vi.fn(),\n  },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    filebrowser: {\n      api: \"{url}/{endpoint}\",\n    },\n  },\n}));\n\nimport filebrowserProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/filebrowser/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"logs in and uses X-AUTH token for subsequent requests\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"filebrowser\",\n      url: \"http://fb\",\n      username: \"u\",\n      password: \"p\",\n      authHeader: \"X-User\",\n    });\n\n    httpProxy\n      .mockResolvedValueOnce([200, \"text/plain\", \"token123\"])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"data\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"api/raw\", index: \"0\" } };\n    const res = createMockRes();\n\n    await filebrowserProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(2);\n    expect(httpProxy.mock.calls[0][0]).toBe(\"http://fb/login\");\n    expect(httpProxy.mock.calls[0][1].headers).toEqual({ \"X-User\": \"u\" });\n    expect(httpProxy.mock.calls[1][1].headers[\"X-AUTH\"]).toBe(\"token123\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual(Buffer.from(\"data\"));\n  });\n\n  it(\"returns 500 when login fails\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"filebrowser\", url: \"http://fb\", username: \"u\", password: \"p\" });\n    httpProxy.mockResolvedValueOnce([401, \"text/plain\", \"nope\"]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"api/raw\", index: \"0\" } };\n    const res = createMockRes();\n\n    await filebrowserProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: \"Failed to authenticate with Filebrowser\" });\n  });\n});\n"
  },
  {
    "path": "src/widgets/filebrowser/widget.js",
    "content": "import filebrowserProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: filebrowserProxyHandler,\n\n  mappings: {\n    usage: {\n      endpoint: \"usage\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/filebrowser/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"filebrowser widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/fileflows/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: fileflowsData, error: fileflowsError } = useWidgetAPI(widget, \"status\");\n\n  if (fileflowsError) {\n    return <Container service={service} error={fileflowsError} />;\n  }\n\n  if (!fileflowsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"fileflows.queue\" />\n        <Block label=\"fileflows.processing\" />\n        <Block label=\"fileflows.processed\" />\n        <Block label=\"fileflows.time\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"fileflows.queue\" value={t(\"common.number\", { value: fileflowsData.queue })} />\n      <Block label=\"fileflows.processing\" value={t(\"common.number\", { value: fileflowsData.processing })} />\n      <Block label=\"fileflows.processed\" value={t(\"common.number\", { value: fileflowsData.processed })} />\n      <Block label=\"fileflows.time\" value={fileflowsData.time?.length ? fileflowsData.time : \"0:00\"} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/fileflows/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/fileflows/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"fileflows\", url: \"http://x\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"fileflows.queue\")).toBeInTheDocument();\n    expect(screen.getByText(\"fileflows.processing\")).toBeInTheDocument();\n    expect(screen.getByText(\"fileflows.processed\")).toBeInTheDocument();\n    expect(screen.getByText(\"fileflows.time\")).toBeInTheDocument();\n    expect(screen.getAllByText(\"-\")).toHaveLength(4);\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"fileflows\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n  });\n\n  it(\"renders values and falls back time to 0:00\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { queue: 1, processing: 2, processed: 3, time: \"\" },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"fileflows\", url: \"http://x\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n\n    expectBlockValue(container, \"fileflows.queue\", 1);\n    expectBlockValue(container, \"fileflows.processing\", 2);\n    expectBlockValue(container, \"fileflows.processed\", 3);\n    expectBlockValue(container, \"fileflows.time\", \"0:00\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/fileflows/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    status: {\n      endpoint: \"status\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/fileflows/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"fileflows widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/firefly/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const startOfMonth = new Date();\n  startOfMonth.setDate(1);\n  startOfMonth.setHours(0, 0, 0, 0);\n  const startOfMonthFormatted = startOfMonth.toISOString().split(\"T\")[0];\n\n  const endOfMonth = new Date(startOfMonth);\n  endOfMonth.setMonth(endOfMonth.getMonth() + 1);\n  endOfMonth.setDate(0);\n  endOfMonth.setHours(23, 59, 59, 999);\n  const endOfMonthFormatted = endOfMonth.toISOString().split(\"T\")[0];\n\n  const { data: summaryData, error: summaryError } = useWidgetAPI(widget, \"summary\", {\n    start: startOfMonthFormatted,\n    end: endOfMonthFormatted,\n  });\n\n  const { data: budgetData, error: budgetError } = useWidgetAPI(widget, \"budgets\", {\n    start: startOfMonthFormatted,\n    end: endOfMonthFormatted,\n  });\n\n  if (summaryError || budgetError) {\n    return <Container service={service} error=\"Failed to load Firefly account summary and budgets\" />;\n  }\n\n  if (!summaryData || !budgetData) {\n    return (\n      <Container service={service}>\n        <Block label=\"firefly.networth\" />\n        <Block label=\"firefly.budget\" />\n      </Container>\n    );\n  }\n\n  const netWorth = Object.keys(summaryData)\n    .filter((key) => key.includes(\"net-worth-in\"))\n    .map((key) => summaryData[key]);\n\n  let budgetValue = null;\n\n  if (budgetData.data?.length && budgetData.data[0].type === \"available_budgets\") {\n    const budgetAmount = parseFloat(budgetData.data[0].attributes.amount);\n    const budgetSpent = -parseFloat(budgetData.data[0].attributes.spent_in_budgets[0]?.sum ?? \"0\");\n    const budgetCurrency = budgetData.data[0].attributes.currency_symbol;\n\n    budgetValue = `${budgetCurrency} ${t(\"common.number\", {\n      value: budgetSpent,\n      minimumFractionDigits: 2,\n    })} / ${budgetCurrency} ${t(\"common.number\", {\n      value: budgetAmount,\n      minimumFractionDigits: 2,\n    })}`;\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"firefly.networth\" value={netWorth[0].value_parsed} />\n      <Block label=\"firefly.budget\" value={budgetValue} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/firefly/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/firefly/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"firefly\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"firefly.networth\")).toBeInTheDocument();\n    expect(screen.getByText(\"firefly.budget\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when either request errors\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: { message: \"nope\" } }) // summary\n      .mockReturnValueOnce({ data: undefined, error: undefined }); // budgets\n\n    renderWithProviders(<Component service={{ widget: { type: \"firefly\" } }} />, { settings: { hideErrors: false } });\n\n    // The widget uses a string error, which Error normalizes to { message }.\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"Failed to load Firefly account summary and budgets\")).toBeInTheDocument();\n  });\n\n  it(\"renders net worth and budget summary\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({\n        data: { \"net-worth-in-EUR\": { value_parsed: \"100\" } },\n        error: undefined,\n      })\n      .mockReturnValueOnce({\n        data: {\n          data: [\n            {\n              type: \"available_budgets\",\n              attributes: {\n                amount: \"100\",\n                currency_symbol: \"$\",\n                spent_in_budgets: [{ sum: \"-10\" }],\n              },\n            },\n          ],\n        },\n        error: undefined,\n      });\n\n    renderWithProviders(<Component service={{ widget: { type: \"firefly\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"100\")).toBeInTheDocument();\n    expect(screen.getByText(\"$ 10 / $ 100\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/firefly/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    summary: {\n      endpoint: \"v1/summary/basic\",\n      params: [\"start\", \"end\"],\n    },\n    budgets: {\n      endpoint: \"v1/available-budgets\",\n      params: [\"start\", \"end\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/firefly/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"firefly widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/flood/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: torrentData, error: torrentError } = useWidgetAPI(widget, \"torrents\");\n\n  if (torrentError || !torrentData?.torrents) {\n    return <Container service={service} error={torrentError ?? { message: \"No torrent data returned\" }} />;\n  }\n\n  if (!torrentData || !torrentData.torrents) {\n    return (\n      <Container service={service}>\n        <Block label=\"flood.leech\" />\n        <Block label=\"flood.download\" />\n        <Block label=\"flood.seed\" />\n        <Block label=\"flood.upload\" />\n      </Container>\n    );\n  }\n\n  let rateDl = 0;\n  let rateUl = 0;\n  let completed = 0;\n  let leech = 0;\n\n  Object.values(torrentData.torrents).forEach((torrent) => {\n    rateDl += torrent.downRate;\n    rateUl += torrent.upRate;\n    if (torrent.status.includes(\"complete\")) {\n      completed += 1;\n    }\n    if (torrent.status.includes(\"downloading\")) {\n      leech += 1;\n    }\n  });\n\n  return (\n    <Container service={service}>\n      <Block label=\"flood.leech\" value={t(\"common.number\", { value: leech })} />\n      <Block label=\"flood.download\" value={t(\"common.byterate\", { value: rateDl })} highlightValue={rateDl} />\n      <Block label=\"flood.seed\" value={t(\"common.number\", { value: completed })} />\n      <Block label=\"flood.upload\" value={t(\"common.byterate\", { value: rateUl })} highlightValue={rateUl} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/flood/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/flood/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"flood\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders a helpful error when the API returns no torrent data\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"flood\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"No torrent data returned\")).toBeInTheDocument();\n  });\n\n  it(\"renders computed leech/seed counts and up/down rates\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        torrents: {\n          a: { downRate: 10, upRate: 20, status: [\"downloading\"] },\n          b: { downRate: 5, upRate: 0, status: [\"complete\"] },\n          c: { downRate: 0, upRate: 1, status: [\"complete\", \"downloading\"] },\n        },\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"flood\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"flood.leech\", 2);\n    expectBlockValue(container, \"flood.download\", 15);\n    expectBlockValue(container, \"flood.seed\", 2);\n    expectBlockValue(container, \"flood.upload\", 21);\n  });\n});\n"
  },
  {
    "path": "src/widgets/flood/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\n\nconst logger = createLogger(\"floodProxyHandler\");\n\nasync function login(widget) {\n  logger.debug(\"flood is rejecting the request, logging in.\");\n  const loginUrl = new URL(`${widget.url}/api/auth/authenticate`).toString();\n\n  const loginParams = {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: null,\n  };\n\n  if (widget.username && widget.password) {\n    loginParams.body = JSON.stringify({\n      username: widget.username,\n      password: widget.password,\n    });\n  }\n\n  const [status, contentType, data] = await httpProxy(loginUrl, loginParams);\n  return [status, data];\n}\n\nexport default async function floodProxyHandler(req, res) {\n  const { group, service, endpoint, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const url = new URL(formatApiCall(\"{url}/api/{endpoint}\", { endpoint, ...widget }));\n  const params = { method: \"GET\", headers: {} };\n\n  let [status, contentType, data] = await httpProxy(url, params);\n  if (status === 401) {\n    [status, data] = await login(widget);\n\n    if (status !== 200) {\n      logger.error(\"HTTP %d logging in to flood.  Data: %s\", status, data);\n      return res.status(status).end(data);\n    }\n\n    [status, contentType, data] = await httpProxy(url, params);\n  }\n\n  if (status !== 200) {\n    logger.error(\"HTTP %d getting data from flood.  Data: %s\", status, data);\n  }\n\n  if (contentType) res.setHeader(\"Content-Type\", contentType);\n  return res.status(status).send(data);\n}\n"
  },
  {
    "path": "src/widgets/flood/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  logger: {\n    debug: vi.fn(),\n    error: vi.fn(),\n  },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nimport floodProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/flood/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"logs in and retries after a 401 response\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://flood\" });\n    httpProxy\n      .mockResolvedValueOnce([401, \"application/json\", Buffer.from(\"nope\")])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"ok\")])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"data\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"stats\", index: \"0\" } };\n    const res = createMockRes();\n\n    await floodProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(3);\n    expect(httpProxy.mock.calls[0][0].toString()).toBe(\"http://flood/api/stats\");\n    expect(httpProxy.mock.calls[1][0]).toBe(\"http://flood/api/auth/authenticate\");\n    expect(httpProxy.mock.calls[1][1].body).toBeNull();\n    expect(httpProxy.mock.calls[2][0].toString()).toBe(\"http://flood/api/stats\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual(Buffer.from(\"data\"));\n  });\n\n  it(\"returns the login error status when authentication fails\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://flood\", username: \"u\", password: \"p\" });\n    httpProxy\n      .mockResolvedValueOnce([401, \"application/json\", Buffer.from(\"nope\")])\n      .mockResolvedValueOnce([500, \"application/json\", Buffer.from(\"bad\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"stats\", index: \"0\" } };\n    const res = createMockRes();\n\n    await floodProxyHandler(req, res);\n\n    expect(httpProxy.mock.calls[1][1].body).toBe(JSON.stringify({ username: \"u\", password: \"p\" }));\n    expect(res.statusCode).toBe(500);\n    expect(res.end).toHaveBeenCalledWith(Buffer.from(\"bad\"));\n  });\n});\n"
  },
  {
    "path": "src/widgets/flood/widget.js",
    "content": "import floodProxyHandler from \"./proxy\";\n\nconst widget = {\n  proxyHandler: floodProxyHandler,\n\n  mappings: {\n    torrents: {\n      endpoint: \"torrents\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/flood/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"flood widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/freshrss/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: freshrssData, error: freshrssError } = useWidgetAPI(widget, \"info\");\n\n  if (freshrssError) {\n    return <Container service={service} error={freshrssError} />;\n  }\n\n  if (!freshrssData) {\n    return (\n      <Container service={service}>\n        <Block label=\"freshrss.unread\" />\n        <Block label=\"freshrss.subscriptions\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"freshrss.unread\" value={t(\"common.number\", { value: freshrssData.unread })} />\n      <Block label=\"freshrss.subscriptions\" value={t(\"common.number\", { value: freshrssData.subscriptions })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/freshrss/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/freshrss/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"freshrss\", url: \"http://x\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"freshrss.unread\")).toBeInTheDocument();\n    expect(screen.getByText(\"freshrss.subscriptions\")).toBeInTheDocument();\n    expect(screen.getAllByText(\"-\")).toHaveLength(2);\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"freshrss\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n  });\n\n  it(\"renders unread and subscription counts\", () => {\n    useWidgetAPI.mockReturnValue({ data: { unread: 7, subscriptions: 3 }, error: undefined });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"freshrss\", url: \"http://x\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n\n    expectBlockValue(container, \"freshrss.unread\", 7);\n    expectBlockValue(container, \"freshrss.subscriptions\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/freshrss/proxy.js",
    "content": "import cache from \"memory-cache\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst proxyName = \"freshrssProxyHandler\";\nconst sessionTokenCacheKey = `${proxyName}__sessionToken`;\nconst logger = createLogger(proxyName);\n\nasync function login(widget, service) {\n  const endpoint = \"accounts/ClientLogin\";\n  const api = widgets?.[widget.type]?.api;\n  const loginUrl = new URL(formatApiCall(api, { endpoint, ...widget }));\n  const headers = { \"Content-Type\": \"application/x-www-form-urlencoded\" };\n\n  const [, , data] = await httpProxy(loginUrl, {\n    method: \"POST\",\n    body: new URLSearchParams({\n      Email: widget.username,\n      Passwd: widget.password,\n    }).toString(),\n    headers,\n  });\n\n  try {\n    const [, token] = data\n      .toString()\n      .split(\"\\n\")\n      .find((line) => line.startsWith(\"Auth=\"))\n      .split(\"=\");\n    cache.put(`${sessionTokenCacheKey}.${service}`, token);\n    return { token };\n  } catch (e) {\n    logger.error(\"Unable to login to FreshRSS API: %s\", e);\n  }\n\n  return { token: false };\n}\n\nasync function apiCall(widget, endpoint, service) {\n  const key = `${sessionTokenCacheKey}.${service}`;\n  const headers = {\n    Authorization: `GoogleLogin auth=${cache.get(key)}`,\n  };\n  const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));\n  const method = \"GET\";\n\n  let [status, contentType, data, responseHeaders] = await httpProxy(url, {\n    method,\n    headers,\n  });\n\n  if (status === 401) {\n    logger.debug(\"FreshRSS API rejected the request, attempting to obtain new session token\");\n    const { token } = await login(widget, service);\n    headers.Authorization = `GoogleLogin auth=${token}`;\n\n    // retry the request, now with the new session token\n    [status, contentType, data, responseHeaders] = await httpProxy(url, {\n      method,\n      headers,\n    });\n  }\n\n  if (status !== 200) {\n    logger.error(\"Error getting data from FreshRSS: %s status %d. Data: %s\", url, status, data);\n    return { status, contentType, data: null, responseHeaders };\n  }\n\n  return { status, contentType, data: JSON.parse(data.toString()), responseHeaders };\n}\n\nexport default async function freshrssProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  if (!cache.get(`${sessionTokenCacheKey}.${service}`)) {\n    await login(widget, service);\n  }\n\n  const { data: subscriptionData } = await apiCall(widget, \"reader/api/0/subscription/list\", service);\n  const { data: unreadCountData } = await apiCall(widget, \"reader/api/0/unread-count\", service);\n\n  return res.status(200).send({\n    subscriptions: subscriptionData?.subscriptions.length,\n    unread: unreadCountData?.max,\n  });\n}\n"
  },
  {
    "path": "src/widgets/freshrss/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {\n  const store = new Map();\n\n  return {\n    httpProxy: vi.fn(),\n    getServiceWidget: vi.fn(),\n    cache: {\n      get: vi.fn((k) => store.get(k)),\n      put: vi.fn((k, v) => store.set(k, v)),\n      del: vi.fn((k) => store.delete(k)),\n      _reset: () => store.clear(),\n    },\n    logger: {\n      debug: vi.fn(),\n      error: vi.fn(),\n    },\n  };\n});\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nvi.mock(\"memory-cache\", () => ({\n  default: cache,\n  ...cache,\n}));\n\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    freshrss: {\n      api: \"{url}/{endpoint}\",\n    },\n  },\n}));\n\nimport freshrssProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/freshrss/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    cache._reset();\n  });\n\n  it(\"logs in, caches token, and returns subscription + unread counts\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"freshrss\",\n      url: \"http://fresh\",\n      username: \"u\",\n      password: \"p\",\n    });\n\n    httpProxy\n      .mockResolvedValueOnce([200, \"text/plain\", Buffer.from(\"SID=1\\nAuth=token123\\n\")])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ subscriptions: [1, 2, 3] }))])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ max: 7 }))]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await freshrssProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(3);\n    expect(httpProxy.mock.calls[0][0].toString()).toBe(\"http://fresh/accounts/ClientLogin\");\n    expect(httpProxy.mock.calls[1][1].headers.Authorization).toBe(\"GoogleLogin auth=token123\");\n    expect(httpProxy.mock.calls[2][1].headers.Authorization).toBe(\"GoogleLogin auth=token123\");\n\n    expect(res.status).toHaveBeenCalledWith(200);\n    expect(res.send).toHaveBeenCalledWith({ subscriptions: 3, unread: 7 });\n  });\n\n  it(\"retries API calls after a 401 by obtaining a new session token\", async () => {\n    cache.put(\"freshrssProxyHandler__sessionToken.svc\", \"old\");\n\n    getServiceWidget.mockResolvedValue({\n      type: \"freshrss\",\n      url: \"http://fresh\",\n      username: \"u\",\n      password: \"p\",\n    });\n\n    httpProxy\n      .mockResolvedValueOnce([401, \"application/json\", Buffer.from(\"{}\")])\n      .mockResolvedValueOnce([200, \"text/plain\", Buffer.from(\"SID=1\\nAuth=newtok\\n\")])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ subscriptions: [1] }))])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ max: 2 }))]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await freshrssProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(4);\n    const loginCalls = httpProxy.mock.calls.filter(([url]) => url.toString().includes(\"accounts/ClientLogin\"));\n    expect(loginCalls).toHaveLength(1);\n    const listCalls = httpProxy.mock.calls.filter(([url]) => url.toString().includes(\"subscription/list\"));\n    expect(listCalls).toHaveLength(2);\n    expect(res.body).toEqual({ subscriptions: 1, unread: 2 });\n  });\n});\n"
  },
  {
    "path": "src/widgets/freshrss/widget.js",
    "content": "import freshrssProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/api/greader.php/{endpoint}?output=json\",\n  proxyHandler: freshrssProxyHandler,\n  mappings: {\n    info: {\n      endpoint: \"/\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/freshrss/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"freshrss widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/frigate/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { data, error } = useWidgetAPI(widget, \"stats\");\n  const { data: eventsData, error: eventsError } = useWidgetAPI(widget, \"events\");\n\n  if (error) {\n    return <Container service={service} error={error} />;\n  }\n\n  if (eventsError) {\n    return <Container service={service} error={eventsError} />;\n  }\n\n  if (!data || !eventsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"frigate.cameras\" />\n        <Block label=\"frigate.uptime\" />\n        <Block label=\"frigate.version\" />\n      </Container>\n    );\n  }\n\n  return (\n    <>\n      <Container service={service}>\n        <Block\n          label=\"frigate.cameras\"\n          value={t(\"common.number\", {\n            value: data.num_cameras,\n          })}\n        />\n        <Block\n          label=\"frigate.uptime\"\n          value={t(\"common.duration\", {\n            value: data.uptime,\n          })}\n        />\n        <Block label=\"frigate.version\" value={data.version} />\n      </Container>\n      {widget.enableRecentEvents &&\n        eventsData?.map((event) => (\n          <div\n            key={event.id}\n            className=\"text-theme-700 dark:text-theme-200 _relative h-5 rounded-md bg-theme-200/50 dark:bg-theme-900/20 m-1 px-1 flex\"\n          >\n            <div className=\"text-xs z-10 self-center ml-2 relative h-4 grow mr-2\">\n              <div className=\"absolute w-full h-4 whitespace-nowrap text-ellipsis overflow-hidden text-left\">\n                {event.camera} ({event.label} {t(\"common.percent\", { value: event.score * 100 })})\n              </div>\n            </div>\n            <div className=\"self-center text-xs flex justify-end mr-1.5 pl-1 z-10 text-ellipsis overflow-hidden whitespace-nowrap\">\n              {t(\"common.date\", {\n                value: event.start_time,\n                formatParams: { value: { timeStyle: \"short\", dateStyle: \"medium\" } },\n              })}\n            </div>\n          </div>\n        ))}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/widgets/frigate/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/frigate/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // stats\n      .mockReturnValueOnce({ data: undefined, error: undefined }); // events\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"frigate\", url: \"http://x\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"frigate.cameras\")).toBeInTheDocument();\n    expect(screen.getByText(\"frigate.uptime\")).toBeInTheDocument();\n    expect(screen.getByText(\"frigate.version\")).toBeInTheDocument();\n    expect(screen.getAllByText(\"-\")).toHaveLength(3);\n  });\n\n  it(\"renders error UI when either endpoint errors\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: { message: \"nope\" } })\n      .mockReturnValueOnce({ data: undefined, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"frigate\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders stats and recent events when enabled\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({\n        data: { num_cameras: 2, uptime: 3600, version: \"1.0.0\" },\n        error: undefined,\n      })\n      .mockReturnValueOnce({\n        data: [{ id: \"e1\", camera: \"Cam1\", label: \"Person\", score: 0.5, start_time: 123 }],\n        error: undefined,\n      });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"frigate\", url: \"http://x\", enableRecentEvents: true } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expectBlockValue(container, \"frigate.cameras\", 2);\n    expectBlockValue(container, \"frigate.uptime\", 3600);\n    expectBlockValue(container, \"frigate.version\", \"1.0.0\");\n\n    // The event text is composed of multiple text nodes; match on the element's full textContent.\n    expect(\n      screen.getByText((_, el) => el?.classList?.contains(\"absolute\") && el.textContent?.includes(\"Cam1 (Person 50)\")),\n    ).toBeInTheDocument();\n    expect(screen.getByText(\"123\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/frigate/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { asJson, formatApiCall, sanitizeErrorURL } from \"utils/proxy/api-helpers\";\nimport { addCookieToJar } from \"utils/proxy/cookie-jar\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst proxyName = \"frigateProxyHandler\";\nconst logger = createLogger(proxyName);\n\nexport default async function frigateProxyHandler(req, res, map) {\n  const { group, service, endpoint, index } = req.query;\n\n  if (group && service) {\n    const widget = await getServiceWidget(group, service, index);\n\n    if (!widget) {\n      logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n      return res.status(400).json({ error: \"Invalid proxy service type\" });\n    }\n\n    if (!widgets?.[widget.type]?.api) {\n      return res.status(403).json({ error: \"Service does not support API calls\" });\n    }\n\n    if (widget) {\n      const url = formatApiCall(widgets[widget.type].api, { endpoint, ...widget });\n\n      const params = {\n        method: \"GET\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      };\n\n      let [status, , data] = await httpProxy(url, params);\n\n      if (status === 401 && widget.username && widget.password) {\n        const loginUrl = `${widget.url}/api/login`;\n        logger.debug(\"Attempting login to Frigate at %s\", loginUrl);\n        const [loginStatus, , , loginResponseHeaders] = await httpProxy(loginUrl, {\n          method: \"POST\",\n          body: JSON.stringify({ user: widget.username, password: widget.password }),\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n        });\n\n        if (loginStatus !== 200) {\n          logger.error(\"HTTP Error %d calling %s\", loginStatus, sanitizeErrorURL(loginUrl));\n          return res.status(status).json({\n            error: {\n              message: `HTTP Error ${status} while trying to login to Frigate`,\n              url: sanitizeErrorURL(url),\n            },\n          });\n        }\n\n        addCookieToJar(url, loginResponseHeaders);\n        // Retry original request with cookie set\n        [status, , data] = await httpProxy(url, params);\n      }\n\n      if (status >= 400) {\n        logger.error(\"HTTP Error %d calling %s\", status, sanitizeErrorURL(url));\n        return res.status(status).json({\n          error: {\n            message: `HTTP Error ${status} from Frigate`,\n            url: sanitizeErrorURL(url),\n          },\n        });\n      }\n\n      data = asJson(data);\n\n      if (endpoint == \"stats\") {\n        return res.status(status).send({\n          num_cameras: data?.cameras !== undefined ? Object.keys(data?.cameras).length : 0,\n          uptime: data?.service?.uptime,\n          version: data?.service.version,\n        });\n      } else if (endpoint == \"events\") {\n        return res.status(status).send(\n          data.slice(0, 5).map((event) => ({\n            id: event.id,\n            camera: event.camera,\n            label: event.label,\n            start_time: new Date(event.start_time * 1000),\n            thumbnail: event.thumbnail,\n            score: event.data.score,\n            type: event.data.type,\n          })),\n        );\n      }\n    }\n  }\n\n  logger.debug(\"Invalid or missing proxy service type '%s' in group '%s'\", service, group);\n  return res.status(400).json({ error: \"Invalid proxy service type\" });\n}\n"
  },
  {
    "path": "src/widgets/frigate/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, cookieJar, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  cookieJar: {\n    addCookieToJar: vi.fn(),\n  },\n  logger: {\n    debug: vi.fn(),\n    error: vi.fn(),\n  },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\nvi.mock(\"utils/proxy/cookie-jar\", () => cookieJar);\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    frigate: {\n      api: \"{url}/api/{endpoint}\",\n    },\n  },\n}));\n\nimport frigateProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/frigate/proxy\", () => {\n  beforeEach(() => {\n    httpProxy.mockReset();\n    getServiceWidget.mockReset();\n    vi.clearAllMocks();\n  });\n\n  it(\"returns 400 when group/service are missing\", async () => {\n    const req = { query: { service: \"svc\", endpoint: \"stats\", index: \"0\" } };\n    const res = createMockRes();\n\n    await frigateProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Invalid proxy service type\" });\n    expect(getServiceWidget).not.toHaveBeenCalled();\n  });\n\n  it(\"returns 400 when the widget cannot be resolved\", async () => {\n    getServiceWidget.mockResolvedValue(null);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"stats\", index: \"0\" } };\n    const res = createMockRes();\n\n    await frigateProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Invalid proxy service type\" });\n  });\n\n  it(\"returns 403 when the service does not support API calls\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"not-frigate\",\n      url: \"http://frigate\",\n    });\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"stats\", index: \"0\" } };\n    const res = createMockRes();\n\n    await frigateProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(403);\n    expect(res.body).toEqual({ error: \"Service does not support API calls\" });\n    expect(httpProxy).not.toHaveBeenCalled();\n  });\n\n  it(\"returns an HTTP error when the request fails without login credentials\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"frigate\",\n      url: \"http://frigate\",\n    });\n\n    httpProxy.mockResolvedValueOnce([401, \"application/json\", Buffer.from(\"nope\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"stats\", index: \"0\" } };\n    const res = createMockRes();\n\n    await frigateProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(401);\n    expect(res.body).toEqual(\n      expect.objectContaining({\n        error: expect.objectContaining({\n          message: \"HTTP Error 401 from Frigate\",\n          url: \"http://frigate/api/stats\",\n        }),\n      }),\n    );\n  });\n\n  it(\"logs in after a 401 and returns derived stats\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"frigate\",\n      url: \"http://frigate\",\n      username: \"u\",\n      password: \"p\",\n    });\n\n    httpProxy\n      // initial request\n      .mockResolvedValueOnce([401, \"application/json\", Buffer.from(\"nope\")])\n      // login\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"{}\"), { \"set-cookie\": [\"sid=1\"] }])\n      // retry stats\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ cameras: { a: {}, b: {} }, service: { uptime: 123, version: \"1.0\" } })),\n      ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"stats\", index: \"0\" } };\n    const res = createMockRes();\n\n    await frigateProxyHandler(req, res);\n\n    expect(cookieJar.addCookieToJar).toHaveBeenCalled();\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ num_cameras: 2, uptime: 123, version: \"1.0\" });\n  });\n\n  it(\"returns an error when login fails\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"frigate\",\n      url: \"http://frigate\",\n      username: \"u\",\n      password: \"p\",\n    });\n\n    httpProxy\n      // initial request unauthorized\n      .mockResolvedValueOnce([401, \"application/json\", Buffer.from(\"nope\")])\n      // login fails\n      .mockResolvedValueOnce([500, \"application/json\", Buffer.from(\"nope\"), {}]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"stats\", index: \"0\" } };\n    const res = createMockRes();\n\n    await frigateProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(401);\n    expect(res.body).toEqual(\n      expect.objectContaining({\n        error: expect.objectContaining({\n          message: \"HTTP Error 401 while trying to login to Frigate\",\n          url: \"http://frigate/api/stats\",\n        }),\n      }),\n    );\n    expect(cookieJar.addCookieToJar).not.toHaveBeenCalled();\n    expect(httpProxy).toHaveBeenCalledTimes(2);\n  });\n\n  it(\"maps events to a simplified payload with dates\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"frigate\",\n      url: \"http://frigate\",\n    });\n\n    httpProxy.mockResolvedValueOnce([\n      200,\n      \"application/json\",\n      Buffer.from(\n        JSON.stringify([\n          {\n            id: \"1\",\n            camera: \"front\",\n            label: \"person\",\n            start_time: 1700000000,\n            thumbnail: \"t1\",\n            data: { score: 0.5, type: \"object\" },\n          },\n          {\n            id: \"2\",\n            camera: \"back\",\n            label: \"car\",\n            start_time: 1700000100,\n            thumbnail: \"t2\",\n            data: { score: 0.8, type: \"object\" },\n          },\n          {\n            id: \"3\",\n            camera: \"side\",\n            label: \"dog\",\n            start_time: 1700000200,\n            thumbnail: \"t3\",\n            data: { score: 0.9, type: \"object\" },\n          },\n          {\n            id: \"4\",\n            camera: \"garage\",\n            label: \"cat\",\n            start_time: 1700000300,\n            thumbnail: \"t4\",\n            data: { score: 0.1, type: \"object\" },\n          },\n          {\n            id: \"5\",\n            camera: \"drive\",\n            label: \"person\",\n            start_time: 1700000400,\n            thumbnail: \"t5\",\n            data: { score: 0.2, type: \"object\" },\n          },\n          {\n            id: \"6\",\n            camera: \"extra\",\n            label: \"person\",\n            start_time: 1700000500,\n            thumbnail: \"t6\",\n            data: { score: 0.3, type: \"object\" },\n          },\n        ]),\n      ),\n    ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"events\", index: \"0\" } };\n    const res = createMockRes();\n\n    await frigateProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toHaveLength(5);\n    expect(res.body[0]).toEqual(\n      expect.objectContaining({\n        id: \"1\",\n        camera: \"front\",\n        label: \"person\",\n        thumbnail: \"t1\",\n        score: 0.5,\n        type: \"object\",\n        start_time: expect.any(Date),\n      }),\n    );\n  });\n});\n"
  },
  {
    "path": "src/widgets/frigate/widget.js",
    "content": "import frigateProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: frigateProxyHandler,\n\n  mappings: {\n    stats: { endpoint: \"stats\" },\n    events: { endpoint: \"events\" },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/frigate/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"frigate widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/fritzbox/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport const fritzboxDefaultFields = [\"connectionStatus\", \"uptime\", \"maxDown\", \"maxUp\"];\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const { data: fritzboxData, error: fritzboxError } = useWidgetAPI(widget, \"status\");\n\n  if (fritzboxError) {\n    return <Container service={service} error={fritzboxError} />;\n  }\n\n  // Default fields\n  if (!widget.fields?.length > 0) {\n    widget.fields = fritzboxDefaultFields;\n  }\n  const MAX_ALLOWED_FIELDS = 4;\n  // Limits max number of displayed fields\n  if (widget.fields?.length > MAX_ALLOWED_FIELDS) {\n    widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);\n  }\n\n  if (!fritzboxData) {\n    return (\n      <Container service={service}>\n        <Block label=\"fritzbox.connectionStatus\" />\n        <Block label=\"fritzbox.uptime\" />\n        <Block label=\"fritzbox.maxDown\" />\n        <Block label=\"fritzbox.maxUp\" />\n        <Block label=\"fritzbox.down\" />\n        <Block label=\"fritzbox.up\" />\n        <Block label=\"fritzbox.received\" />\n        <Block label=\"fritzbox.sent\" />\n        <Block label=\"fritzbox.externalIPAddress\" />\n        <Block label=\"fritzbox.externalIPv6Address\" />\n        <Block label=\"fritzbox.externalIPv6Prefix\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"fritzbox.connectionStatus\" value={t(`fritzbox.connectionStatus${fritzboxData.connectionStatus}`)} />\n      <Block label=\"fritzbox.uptime\" value={t(\"common.duration\", { value: fritzboxData.uptime })} />\n      <Block\n        label=\"fritzbox.maxDown\"\n        value={t(\"common.byterate\", { value: fritzboxData.maxDown / 8, decimals: 1 })}\n        highlightValue={fritzboxData.maxDown / 8}\n      />\n      <Block\n        label=\"fritzbox.maxUp\"\n        value={t(\"common.byterate\", { value: fritzboxData.maxUp / 8, decimals: 1 })}\n        highlightValue={fritzboxData.maxUp / 8}\n      />\n      <Block\n        label=\"fritzbox.down\"\n        value={t(\"common.byterate\", { value: fritzboxData.down, decimals: 1 })}\n        highlightValue={fritzboxData.down}\n      />\n      <Block\n        label=\"fritzbox.up\"\n        value={t(\"common.byterate\", { value: fritzboxData.up, decimals: 1 })}\n        highlightValue={fritzboxData.up}\n      />\n      <Block\n        label=\"fritzbox.received\"\n        value={t(\"common.bytes\", { value: fritzboxData.received })}\n        highlightValue={fritzboxData.received}\n      />\n      <Block\n        label=\"fritzbox.sent\"\n        value={t(\"common.bytes\", { value: fritzboxData.sent })}\n        highlightValue={fritzboxData.sent}\n      />\n      <Block label=\"fritzbox.externalIPAddress\" value={fritzboxData.externalIPAddress} />\n      <Block label=\"fritzbox.externalIPv6Address\" value={fritzboxData.externalIPv6Address} />\n      <Block label=\"fritzbox.externalIPv6Prefix\" value={fritzboxData.externalIPv6Prefix} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/fritzbox/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component, { fritzboxDefaultFields } from \"./component\";\n\ndescribe(\"widgets/fritzbox/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"defaults fields and filters to 4 blocks while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"fritzbox\", url: \"http://x\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual(fritzboxDefaultFields);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"fritzbox.connectionStatus\")).toBeInTheDocument();\n    expect(screen.getByText(\"fritzbox.uptime\")).toBeInTheDocument();\n    expect(screen.getByText(\"fritzbox.maxDown\")).toBeInTheDocument();\n    expect(screen.getByText(\"fritzbox.maxUp\")).toBeInTheDocument();\n    expect(screen.queryByText(\"fritzbox.externalIPAddress\")).toBeNull();\n  });\n\n  it(\"caps widget.fields at 4 entries\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = {\n      widget: {\n        type: \"fritzbox\",\n        fields: [\"down\", \"up\", \"received\", \"sent\", \"externalIPAddress\", \"externalIPv6Prefix\"],\n      },\n    };\n\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"down\", \"up\", \"received\", \"sent\"]);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"fritzbox.down\")).toBeInTheDocument();\n    expect(screen.getByText(\"fritzbox.up\")).toBeInTheDocument();\n    expect(screen.getByText(\"fritzbox.received\")).toBeInTheDocument();\n    expect(screen.getByText(\"fritzbox.sent\")).toBeInTheDocument();\n    expect(screen.queryByText(\"fritzbox.externalIPAddress\")).toBeNull();\n  });\n\n  it(\"renders computed values when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        connectionStatus: 1,\n        uptime: 100,\n        maxDown: 8000,\n        maxUp: 16000,\n        down: 80,\n        up: 40,\n        received: 1024,\n        sent: 2048,\n        externalIPAddress: \"1.2.3.4\",\n        externalIPv6Address: \"::1\",\n        externalIPv6Prefix: \"abcd::/64\",\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"fritzbox\", url: \"http://x\" } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expectBlockValue(container, \"fritzbox.connectionStatus\", \"fritzbox.connectionStatus1\");\n    expectBlockValue(container, \"fritzbox.uptime\", 100);\n    expectBlockValue(container, \"fritzbox.maxDown\", 1000); // 8000/8\n    expectBlockValue(container, \"fritzbox.maxUp\", 2000); // 16000/8\n  });\n});\n"
  },
  {
    "path": "src/widgets/fritzbox/proxy.js",
    "content": "import { xml2json } from \"xml-js\";\n\nimport { fritzboxDefaultFields } from \"./component\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { httpProxy } from \"utils/proxy/http\";\n\nconst logger = createLogger(\"fritzboxProxyHandler\");\n\nasync function requestEndpoint(apiBaseUrl, service, action) {\n  const servicePath = service === \"WANIPConnection\" ? \"WANIPConn1\" : \"WANCommonIFC1\";\n  const params = {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"text/xml; charset='utf-8'\",\n      SoapAction: `urn:schemas-upnp-org:service:${service}:1#${action}`,\n    },\n    body:\n      \"<?xml version='1.0' encoding='utf-8'?>\" +\n      \"<s:Envelope s:encodingStyle='http://schemas.xmlsoap.org/soap/encoding/' xmlns:s='http://schemas.xmlsoap.org/soap/envelope/'>\" +\n      \"<s:Body>\" +\n      `<u:${action} xmlns:u='urn:schemas-upnp-org:service:${service}:1' />` +\n      \"</s:Body>\" +\n      \"</s:Envelope>\",\n  };\n  const apiUrl = `${apiBaseUrl}/igdupnp/control/${servicePath}`;\n  const [status, , data] = await httpProxy(apiUrl, params);\n  if (status !== 200) {\n    logger.debug(`HTTP ${status} performing SoapRequest for ${service}->${action}`, data);\n    throw new Error(`Failed fetching '${action}'`);\n  }\n  const response = {};\n  try {\n    const jsonData = JSON.parse(xml2json(data));\n    const responseElements = jsonData?.elements?.[0]?.elements?.[0]?.elements?.[0]?.elements || [];\n    responseElements.forEach((element) => {\n      response[element.name] = element.elements?.[0].text || \"\";\n    });\n  } catch (e) {\n    logger.debug(`Failed parsing ${service}->${action} response:`, data);\n    throw new Error(`Failed parsing '${action}' response`);\n  }\n\n  return response;\n}\n\nexport default async function fritzboxProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n  const serviceWidget = await getServiceWidget(group, service, index);\n\n  if (!serviceWidget) {\n    res.status(500).json({ error: { message: \"Service widget not found\" } });\n    return;\n  }\n\n  if (!serviceWidget.url) {\n    res.status(500).json({ error: { message: \"Service widget url not configured\" } });\n    return;\n  }\n\n  const serviceWidgetUrl = new URL(serviceWidget.url);\n  const port = serviceWidgetUrl.protocol === \"https:\" ? 49443 : 49000;\n  const apiBaseUrl = `${serviceWidgetUrl.protocol}//${serviceWidgetUrl.hostname}:${port}`;\n\n  if (!serviceWidget.fields?.length > 0) {\n    serviceWidget.fields = fritzboxDefaultFields;\n  }\n  const requestStatusInfo = [\"connectionStatus\", \"uptime\"].some((field) => serviceWidget.fields.includes(field));\n  const requestLinkProperties = [\"maxDown\", \"maxUp\"].some((field) => serviceWidget.fields.includes(field));\n  const requestAddonInfos = [\"down\", \"up\", \"received\", \"sent\"].some((field) => serviceWidget.fields.includes(field));\n  const requestExternalIPAddress = [\"externalIPAddress\"].some((field) => serviceWidget.fields.includes(field));\n  const requestExternalIPv6Address = [\"externalIPv6Address\"].some((field) => serviceWidget.fields.includes(field));\n  const requestExternalIPv6Prefix = [\"externalIPv6Prefix\"].some((field) => serviceWidget.fields.includes(field));\n\n  await Promise.all([\n    // as per http://fritz.box:49000/igddesc.xml specifications (fritz.box is a hostname of your router)\n    requestStatusInfo ? requestEndpoint(apiBaseUrl, \"WANIPConnection\", \"GetStatusInfo\") : null,\n    requestLinkProperties ? requestEndpoint(apiBaseUrl, \"WANCommonInterfaceConfig\", \"GetCommonLinkProperties\") : null,\n    requestAddonInfos ? requestEndpoint(apiBaseUrl, \"WANCommonInterfaceConfig\", \"GetAddonInfos\") : null,\n    requestExternalIPAddress ? requestEndpoint(apiBaseUrl, \"WANIPConnection\", \"GetExternalIPAddress\") : null,\n    requestExternalIPv6Address\n      ? requestEndpoint(apiBaseUrl, \"WANIPConnection\", \"X_AVM_DE_GetExternalIPv6Address\")\n      : null,\n    requestExternalIPv6Prefix ? requestEndpoint(apiBaseUrl, \"WANIPConnection\", \"X_AVM_DE_GetIPv6Prefix\") : null,\n  ])\n    .then(([statusInfo, linkProperties, addonInfos, externalIPAddress, externalIPv6Address, externalIPv6Prefix]) => {\n      const ipv6Prefix = externalIPv6Prefix?.NewIPv6Prefix;\n      const ipv6Len = externalIPv6Prefix?.NewPrefixLength;\n\n      res.status(200).json({\n        connectionStatus: statusInfo?.NewConnectionStatus || \"Unconfigured\",\n        uptime: statusInfo?.NewUptime || 0,\n        maxDown: linkProperties?.NewLayer1DownstreamMaxBitRate || 0,\n        maxUp: linkProperties?.NewLayer1UpstreamMaxBitRate || 0,\n        down: addonInfos?.NewByteReceiveRate || 0,\n        up: addonInfos?.NewByteSendRate || 0,\n        received: addonInfos?.NewX_AVM_DE_TotalBytesReceived64 || 0,\n        sent: addonInfos?.NewX_AVM_DE_TotalBytesSent64 || 0,\n        externalIPAddress: externalIPAddress?.NewExternalIPAddress || null,\n        externalIPv6Address: externalIPv6Address?.NewExternalIPv6Address || null,\n        externalIPv6Prefix: ipv6Prefix && ipv6Len != null ? `${ipv6Prefix}/${ipv6Len}` : (ipv6Prefix ?? null),\n      });\n    })\n    .catch((error) => {\n      res.status(500).json({ error: { message: error.message } });\n    });\n}\n"
  },
  {
    "path": "src/widgets/fritzbox/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, xml2json, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  xml2json: vi.fn((xml) => {\n    const xmlString = Buffer.isBuffer(xml) ? xml.toString() : xml;\n    if (xmlString === \"GetStatusInfo\") {\n      return JSON.stringify({\n        elements: [\n          {\n            elements: [\n              {\n                elements: [\n                  {\n                    elements: [\n                      { name: \"NewConnectionStatus\", elements: [{ text: \"Connected\" }] },\n                      { name: \"NewUptime\", elements: [{ text: \"42\" }] },\n                    ],\n                  },\n                ],\n              },\n            ],\n          },\n        ],\n      });\n    }\n    return JSON.stringify({ elements: [] });\n  }),\n  logger: { debug: vi.fn() },\n}));\n\nvi.mock(\"xml-js\", () => ({\n  xml2json,\n}));\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nimport fritzboxProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/fritzbox/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"queries the configured fields and returns derived data\", async () => {\n    getServiceWidget.mockResolvedValue({\n      url: \"http://fritz.box\",\n      fields: [\"connectionStatus\", \"uptime\"],\n    });\n\n    httpProxy.mockResolvedValueOnce([200, \"text/xml\", Buffer.from(\"GetStatusInfo\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await fritzboxProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual(\n      expect.objectContaining({\n        connectionStatus: \"Connected\",\n        uptime: \"42\",\n      }),\n    );\n  });\n});\n"
  },
  {
    "path": "src/widgets/fritzbox/widget.js",
    "content": "import fritzboxProxyHandler from \"./proxy\";\n\nconst widget = {\n  proxyHandler: fritzboxProxyHandler,\n  allowedEndpoints: /status/,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/fritzbox/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"fritzbox widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/gamedig/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n  const { data: serverData, error: serverError } = useWidgetAPI(widget, \"status\");\n  const { t } = useTranslation();\n\n  if (serverError) {\n    return <Container service={service} error={serverError} />;\n  }\n\n  // Default fields\n  if (widget.fields == null || widget.fields.length === 0) {\n    widget.fields = [\"map\", \"currentPlayers\", \"ping\"];\n  }\n  const MAX_ALLOWED_FIELDS = 4;\n  // Limits max number of displayed fields\n  if (widget.fields != null && widget.fields.length > MAX_ALLOWED_FIELDS) {\n    widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);\n  }\n\n  if (!serverData) {\n    return (\n      <Container service={service}>\n        <Block label=\"gamedig.status\" />\n        <Block label=\"gamedig.name\" />\n        <Block label=\"gamedig.map\" />\n        <Block label=\"gamedig.currentPlayers\" />\n        <Block label=\"gamedig.players\" />\n        <Block label=\"gamedig.maxPlayers\" />\n        <Block label=\"gamedig.bots\" />\n        <Block label=\"gamedig.ping\" />\n      </Container>\n    );\n  }\n\n  const status = serverData.online ? t(\"gamedig.online\") : t(\"gamedig.offline\");\n  const name = serverData.online ? serverData.name : \"-\";\n  const map = serverData.online ? serverData.map : \"-\";\n  const currentPlayers = serverData.online ? `${serverData.players} / ${serverData.maxplayers}` : \"-\";\n  const players = serverData.online ? `${serverData.players}` : \"-\";\n  const maxPlayers = serverData.online ? `${serverData.maxplayers}` : \"-\";\n  const bots = serverData.online ? `${serverData.bots}` : \"-\";\n  const ping = serverData.online\n    ? `${t(\"common.ms\", { value: serverData.ping, style: \"unit\", unit: \"millisecond\" })}`\n    : \"-\";\n\n  return (\n    <Container service={service}>\n      <Block label=\"gamedig.status\" value={status} />\n      <Block label=\"gamedig.name\" value={name} />\n      <Block label=\"gamedig.map\" value={map} />\n      <Block label=\"gamedig.currentPlayers\" value={currentPlayers} />\n      <Block label=\"gamedig.players\" value={players} />\n      <Block label=\"gamedig.maxPlayers\" value={maxPlayers} />\n      <Block label=\"gamedig.bots\" value={bots} />\n      <Block label=\"gamedig.ping\" value={ping} highlightValue={serverData.online ? serverData.ping : undefined} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/gamedig/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/gamedig/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"defaults fields and filters blocks while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"gamedig\", url: \"http://x\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"map\", \"currentPlayers\", \"ping\"]);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"gamedig.map\")).toBeInTheDocument();\n    expect(screen.getByText(\"gamedig.currentPlayers\")).toBeInTheDocument();\n    expect(screen.getByText(\"gamedig.ping\")).toBeInTheDocument();\n    expect(screen.queryByText(\"gamedig.status\")).toBeNull();\n  });\n\n  it(\"caps fields at 4 and renders online values\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        online: true,\n        name: \"Server1\",\n        map: \"MapA\",\n        players: 5,\n        maxplayers: 10,\n        bots: 1,\n        ping: 42,\n      },\n      error: undefined,\n    });\n\n    const service = {\n      widget: {\n        type: \"gamedig\",\n        url: \"http://x\",\n        fields: [\"status\", \"name\", \"map\", \"currentPlayers\", \"ping\"],\n      },\n    };\n\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"status\", \"name\", \"map\", \"currentPlayers\"]);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n\n    expectBlockValue(container, \"gamedig.status\", \"gamedig.online\");\n    expectBlockValue(container, \"gamedig.name\", \"Server1\");\n    expectBlockValue(container, \"gamedig.map\", \"MapA\");\n    expectBlockValue(container, \"gamedig.currentPlayers\", \"5 / 10\");\n    expect(screen.queryByText(\"gamedig.ping\")).toBeNull();\n  });\n});\n"
  },
  {
    "path": "src/widgets/gamedig/proxy.js",
    "content": "import { GameDig } from \"gamedig\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\n\nconst proxyName = \"gamedigProxyHandler\";\nconst logger = createLogger(proxyName);\n\nexport default async function gamedigProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n  const serviceWidget = await getServiceWidget(group, service, index);\n  const url = new URL(serviceWidget.url);\n\n  try {\n    const gamedigOptions = {\n      type: serviceWidget.serverType,\n      host: url.hostname,\n      port: url.port,\n      givenPortOnly: true,\n      checkOldIDs: true,\n    };\n\n    if (serviceWidget.gameToken) {\n      gamedigOptions.token = serviceWidget.gameToken;\n    }\n\n    const serverData = await GameDig.query(gamedigOptions);\n\n    res.status(200).send({\n      online: true,\n      name: serverData.name,\n      map: serverData.map,\n      players: serverData.numplayers ?? serverData.players?.length,\n      maxplayers: serverData.maxplayers,\n      bots: serverData.bots.length,\n      ping: serverData.ping,\n    });\n  } catch (e) {\n    if (e) logger.error(e);\n\n    res.status(200).send({\n      online: false,\n    });\n  }\n}\n"
  },
  {
    "path": "src/widgets/gamedig/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { GameDig, getServiceWidget, logger } = vi.hoisted(() => ({\n  GameDig: { query: vi.fn() },\n  getServiceWidget: vi.fn(),\n  logger: { error: vi.fn() },\n}));\n\nvi.mock(\"gamedig\", () => ({\n  GameDig,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nimport gamedigProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/gamedig/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"returns online=true with server details when query succeeds\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://example.com:1234\", serverType: \"csgo\" });\n    GameDig.query.mockResolvedValue({\n      name: \"Server\",\n      map: \"de_dust2\",\n      numplayers: 3,\n      maxplayers: 10,\n      bots: [],\n      ping: 42,\n    });\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await gamedigProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual(\n      expect.objectContaining({\n        online: true,\n        name: \"Server\",\n        players: 3,\n        maxplayers: 10,\n      }),\n    );\n  });\n\n  it(\"returns online=false when query fails\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://example.com:1234\", serverType: \"csgo\" });\n    GameDig.query.mockRejectedValue(new Error(\"nope\"));\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await gamedigProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ online: false });\n  });\n});\n"
  },
  {
    "path": "src/widgets/gamedig/widget.js",
    "content": "import gamedigProxyHandler from \"./proxy\";\n\nconst widget = {\n  proxyHandler: gamedigProxyHandler,\n  allowedEndpoints: /status/,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/gamedig/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"gamedig widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/gatus/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: statusData, error: statusError } = useWidgetAPI(widget, \"status\");\n\n  if (statusError) {\n    return <Container service={service} error={statusError} />;\n  }\n\n  if (!statusData) {\n    return (\n      <Container service={service}>\n        <Block label=\"gatus.up\" />\n        <Block label=\"gatus.down\" />\n        <Block label=\"gatus.uptime\" />\n      </Container>\n    );\n  }\n\n  let sitesUp = 0;\n  let sitesDown = 0;\n  Object.values(statusData).forEach((site) => {\n    const lastResult = site.results[site.results.length - 1];\n    if (lastResult?.success === true) {\n      sitesUp += 1;\n    } else {\n      sitesDown += 1;\n    }\n  });\n\n  // Adapted from https://github.com/bastienwirtz/homer/blob/b7cd8f9482e6836a96b354b11595b03b9c3d67cd/src/components/services/UptimeKuma.vue#L105\n  const resultsList = Object.values(statusData).reduce((a, b) => a.concat(b.results), []);\n  const percent = resultsList.reduce((a, b) => a + (b?.success === true ? 1 : 0), 0) / resultsList.length;\n  const uptime = (percent * 100).toFixed(1);\n\n  return (\n    <Container service={service}>\n      <Block label=\"gatus.up\" value={t(\"common.number\", { value: sitesUp })} />\n      <Block label=\"gatus.down\" value={t(\"common.number\", { value: sitesDown })} />\n      <Block label=\"gatus.uptime\" value={t(\"common.percent\", { value: uptime })} highlightValue={Number(uptime)} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/gatus/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/gatus/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"gatus\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"gatus.up\")).toBeInTheDocument();\n    expect(screen.getByText(\"gatus.down\")).toBeInTheDocument();\n    expect(screen.getByText(\"gatus.uptime\")).toBeInTheDocument();\n    expect(screen.getAllByText(\"-\")).toHaveLength(3);\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"gatus\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n  });\n\n  it(\"renders computed up/down site counts and uptime percent\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        site1: { results: [{ success: true }, { success: false }] }, // last: down\n        site2: { results: [{ success: true }] }, // last: up\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"gatus\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"gatus.up\", 1);\n    expectBlockValue(container, \"gatus.down\", 1);\n    expectBlockValue(container, \"gatus.uptime\", \"66.7\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/gatus/widget.js",
    "content": "// import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\nimport genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    status: {\n      endpoint: \"api/v1/endpoints/statuses\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/gatus/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"gatus widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/ghostfolio/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nfunction getPerformancePercent(t, performanceRange) {\n  // ghostfolio v2.79.0 changed to grossPerformancePercentage\n  // ghostfolio v2.106.0 changed to netPerformancePercentageWithCurrencyEffect\n  const percent =\n    performanceRange.performance.netPerformancePercentageWithCurrencyEffect ??\n    performanceRange.performance.grossPerformancePercentage ??\n    performanceRange.performance.currentGrossPerformancePercent;\n  return `${percent > 0 ? \"+\" : \"\"}${t(\"common.percent\", {\n    value: percent * 100,\n    maximumFractionDigits: 2,\n  })}`;\n}\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const includeNetWorth = widget.fields?.includes(\"net_worth\");\n\n  const { data: performanceToday, error: ghostfolioErrorToday } = useWidgetAPI(widget, \"today\");\n  const { data: performanceYear, error: ghostfolioErrorYear } = useWidgetAPI(widget, \"year\");\n  const { data: performanceMax, error: ghostfolioErrorMax } = useWidgetAPI(widget, \"max\");\n  const { data: userInfo, error: ghostfolioErrorUserInfo } = useWidgetAPI(widget, includeNetWorth ? \"userInfo\" : \"\");\n\n  if (ghostfolioErrorToday || ghostfolioErrorYear || ghostfolioErrorMax || ghostfolioErrorUserInfo) {\n    const finalError = ghostfolioErrorToday ?? ghostfolioErrorYear ?? ghostfolioErrorMax ?? ghostfolioErrorUserInfo;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (performanceToday?.statusCode === 401) {\n    return <Container service={service} error={performanceToday} />;\n  }\n\n  if (!performanceToday || !performanceYear || !performanceMax || (includeNetWorth && !userInfo)) {\n    return (\n      <Container service={service}>\n        <Block label=\"ghostfolio.gross_percent_today\" />\n        <Block label=\"ghostfolio.gross_percent_1y\" />\n        <Block label=\"ghostfolio.gross_percent_max\" />\n        {includeNetWorth && <Block label=\"ghostfolio.net_worth\" />}\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"ghostfolio.gross_percent_today\" value={getPerformancePercent(t, performanceToday)} />\n      <Block label=\"ghostfolio.gross_percent_1y\" value={getPerformancePercent(t, performanceYear)} />\n      <Block label=\"ghostfolio.gross_percent_max\" value={getPerformancePercent(t, performanceMax)} />\n      {includeNetWorth && (\n        <Block\n          label=\"ghostfolio.net_worth\"\n          value={`${performanceToday.performance.currentNetWorth.toFixed(2)} ${userInfo?.settings?.currency ?? \"\"}`}\n        />\n      )}\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/ghostfolio/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/ghostfolio/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading (no net worth by default)\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // today\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // year\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // max\n      .mockReturnValueOnce({ data: undefined, error: undefined }); // userInfo (disabled)\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"ghostfolio\", url: \"http://x\" } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"ghostfolio.gross_percent_today\")).toBeInTheDocument();\n    expect(screen.getByText(\"ghostfolio.gross_percent_1y\")).toBeInTheDocument();\n    expect(screen.getByText(\"ghostfolio.gross_percent_max\")).toBeInTheDocument();\n    expect(screen.queryByText(\"ghostfolio.net_worth\")).toBeNull();\n  });\n\n  it(\"renders error UI when the today endpoint returns 401\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: { statusCode: 401, message: \"Unauthorized\" }, error: undefined })\n      .mockReturnValueOnce({ data: undefined, error: undefined })\n      .mockReturnValueOnce({ data: undefined, error: undefined })\n      .mockReturnValueOnce({ data: undefined, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"ghostfolio\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"Unauthorized\")).toBeInTheDocument();\n  });\n\n  it(\"renders performance percent ranges and net worth when enabled\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({\n        data: { performance: { netPerformancePercentageWithCurrencyEffect: 0.1, currentNetWorth: 123.456 } },\n        error: undefined,\n      })\n      .mockReturnValueOnce({\n        data: { performance: { grossPerformancePercentage: -0.05 } },\n        error: undefined,\n      })\n      .mockReturnValueOnce({\n        data: { performance: { currentGrossPerformancePercent: 0 } },\n        error: undefined,\n      })\n      .mockReturnValueOnce({ data: { settings: { currency: \"USD\" } }, error: undefined });\n\n    const { container } = renderWithProviders(\n      <Component\n        service={{\n          widget: {\n            type: \"ghostfolio\",\n            url: \"http://x\",\n            fields: [\"gross_percent_today\", \"gross_percent_1y\", \"gross_percent_max\", \"net_worth\"],\n          },\n        }}\n      />,\n      { settings: { hideErrors: false } },\n    );\n\n    expectBlockValue(container, \"ghostfolio.gross_percent_today\", \"+10\");\n    expectBlockValue(container, \"ghostfolio.gross_percent_1y\", \"-5\");\n    expectBlockValue(container, \"ghostfolio.gross_percent_max\", \"0\");\n    expectBlockValue(container, \"ghostfolio.net_worth\", \"123.46 USD\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/ghostfolio/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    today: {\n      endpoint: \"v2/portfolio/performance?range=1d\",\n    },\n    year: {\n      endpoint: \"v2/portfolio/performance?range=1y\",\n    },\n    max: {\n      endpoint: \"v2/portfolio/performance?range=max\",\n    },\n    userInfo: {\n      endpoint: \"v1/user\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/ghostfolio/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"ghostfolio widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/gitea/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  const { data: giteaNotifications, error: giteaNotificationsError } = useWidgetAPI(widget, \"notifications\");\n  const { data: giteaIssues, error: giteaIssuesError } = useWidgetAPI(widget, \"issues\");\n  const { data: giteaRepositories, error: giteaRepositoriesError } = useWidgetAPI(widget, \"repositories\");\n\n  if (giteaNotificationsError || giteaIssuesError || giteaRepositoriesError) {\n    return (\n      <Container service={service} error={giteaNotificationsError ?? giteaIssuesError ?? giteaRepositoriesError} />\n    );\n  }\n\n  if (!giteaNotifications || !giteaIssues || !giteaRepositories) {\n    return (\n      <Container service={service}>\n        <Block label=\"gitea.notifications\" />\n        <Block label=\"gitea.issues\" />\n        <Block label=\"gitea.pulls\" />\n        <Block label=\"gitea.repositories\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"gitea.notifications\" value={giteaNotifications.length} />\n      <Block label=\"gitea.issues\" value={giteaIssues.issues.length} />\n      <Block label=\"gitea.pulls\" value={giteaIssues.pulls.length} />\n      <Block label=\"gitea.repositories\" value={giteaRepositories.data.length} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/gitea/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/gitea/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // notifications\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // issues\n      .mockReturnValueOnce({ data: undefined, error: undefined }); // repositories\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"gitea\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"gitea.notifications\")).toBeInTheDocument();\n    expect(screen.getByText(\"gitea.issues\")).toBeInTheDocument();\n    expect(screen.getByText(\"gitea.pulls\")).toBeInTheDocument();\n    expect(screen.getByText(\"gitea.repositories\")).toBeInTheDocument();\n    expect(screen.getAllByText(\"-\")).toHaveLength(4);\n  });\n\n  it(\"renders error UI when any endpoint errors\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: undefined })\n      .mockReturnValueOnce({ data: undefined, error: { message: \"nope\" } })\n      .mockReturnValueOnce({ data: undefined, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"gitea\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders computed counts when loaded\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: [{ id: 1 }, { id: 2 }], error: undefined })\n      .mockReturnValueOnce({\n        data: { issues: [{ id: 1 }], pulls: [{ id: 1 }, { id: 2 }, { id: 3 }] },\n        error: undefined,\n      })\n      .mockReturnValueOnce({ data: { data: [{ id: 1 }] }, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"gitea\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"gitea.notifications\", 2);\n    expectBlockValue(container, \"gitea.issues\", 1);\n    expectBlockValue(container, \"gitea.pulls\", 3);\n    expectBlockValue(container, \"gitea.repositories\", 1);\n  });\n});\n"
  },
  {
    "path": "src/widgets/gitea/widget.js",
    "content": "import { asJson } from \"utils/proxy/api-helpers\";\nimport genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/v1/{endpoint}?access_token={key}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    notifications: {\n      endpoint: \"notifications\",\n    },\n    issues: {\n      endpoint: \"repos/issues/search\",\n      map: (data) => ({\n        pulls: asJson(data).filter((issue) => issue.pull_request),\n        issues: asJson(data).filter((issue) => !issue.pull_request),\n      }),\n    },\n    repositories: {\n      endpoint: \"repos/search\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/gitea/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"gitea widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/gitlab/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { data: gitlabCounts, error: gitlabCountsError } = useWidgetAPI(widget, \"counts\");\n\n  if (gitlabCountsError) {\n    return <Container service={service} error={gitlabCountsError} />;\n  }\n\n  if (!gitlabCounts) {\n    return (\n      <Container service={service}>\n        <Block label=\"gitlab.groups\" />\n        <Block label=\"gitlab.issues\" />\n        <Block label=\"gitlab.merges\" />\n        <Block label=\"gitlab.projects\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"gitlab.groups\" value={t(\"common.number\", { value: gitlabCounts.groups_count })} />\n      <Block label=\"gitlab.issues\" value={t(\"common.number\", { value: gitlabCounts.issues_count })} />\n      <Block label=\"gitlab.merges\" value={t(\"common.number\", { value: gitlabCounts.merge_requests_count })} />\n      <Block label=\"gitlab.projects\" value={t(\"common.number\", { value: gitlabCounts.projects_count })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/gitlab/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/gitlab/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"gitlab\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"gitlab.groups\")).toBeInTheDocument();\n    expect(screen.getByText(\"gitlab.issues\")).toBeInTheDocument();\n    expect(screen.getByText(\"gitlab.merges\")).toBeInTheDocument();\n    expect(screen.getByText(\"gitlab.projects\")).toBeInTheDocument();\n    expect(screen.getAllByText(\"-\")).toHaveLength(4);\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"gitlab\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders counts when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { groups_count: 1, issues_count: 2, merge_requests_count: 3, projects_count: 4 },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"gitlab\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"gitlab.groups\", 1);\n    expectBlockValue(container, \"gitlab.issues\", 2);\n    expectBlockValue(container, \"gitlab.merges\", 3);\n    expectBlockValue(container, \"gitlab.projects\", 4);\n  });\n});\n"
  },
  {
    "path": "src/widgets/gitlab/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/v4/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n  mappings: {\n    counts: {\n      endpoint: \"users/{user_id}/associations_count\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/gitlab/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"gitlab widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/glances/component.jsx",
    "content": "import Containers from \"./metrics/containers\";\nimport Cpu from \"./metrics/cpu\";\nimport Disk from \"./metrics/disk\";\nimport Fs from \"./metrics/fs\";\nimport GPU from \"./metrics/gpu\";\nimport Info from \"./metrics/info\";\nimport Memory from \"./metrics/memory\";\nimport Net from \"./metrics/net\";\nimport Process from \"./metrics/process\";\nimport Sensor from \"./metrics/sensor\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  if (widget.metric === \"info\") {\n    return <Info service={service} />;\n  }\n\n  if (widget.metric === \"memory\") {\n    return <Memory service={service} />;\n  }\n\n  if (widget.metric === \"process\") {\n    return <Process service={service} />;\n  }\n\n  if (widget.metric === \"containers\") {\n    return <Containers service={service} />;\n  }\n\n  if (widget.metric === \"cpu\") {\n    return <Cpu service={service} />;\n  }\n\n  if (widget.metric.match(/^network:/)) {\n    return <Net service={service} />;\n  }\n\n  if (widget.metric.match(/^sensor:/)) {\n    return <Sensor service={service} />;\n  }\n\n  if (widget.metric.match(/^disk:/)) {\n    return <Disk service={service} />;\n  }\n\n  if (widget.metric.match(/^gpu:/)) {\n    return <GPU service={service} />;\n  }\n\n  if (widget.metric.match(/^fs:/)) {\n    return <Fs service={service} />;\n  }\n}\n"
  },
  {
    "path": "src/widgets/glances/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nvi.mock(\"./metrics/info\", () => ({ default: () => <div>glances-info</div> }));\nvi.mock(\"./metrics/memory\", () => ({ default: () => <div>glances-memory</div> }));\nvi.mock(\"./metrics/process\", () => ({ default: () => <div>glances-process</div> }));\nvi.mock(\"./metrics/containers\", () => ({ default: () => <div>glances-containers</div> }));\nvi.mock(\"./metrics/cpu\", () => ({ default: () => <div>glances-cpu</div> }));\nvi.mock(\"./metrics/net\", () => ({ default: () => <div>glances-net</div> }));\nvi.mock(\"./metrics/sensor\", () => ({ default: () => <div>glances-sensor</div> }));\nvi.mock(\"./metrics/disk\", () => ({ default: () => <div>glances-disk</div> }));\nvi.mock(\"./metrics/gpu\", () => ({ default: () => <div>glances-gpu</div> }));\nvi.mock(\"./metrics/fs\", () => ({ default: () => <div>glances-fs</div> }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/glances/component\", () => {\n  it(\"routes metric=info to Info\", () => {\n    renderWithProviders(<Component service={{ widget: { type: \"glances\", metric: \"info\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"glances-info\")).toBeInTheDocument();\n  });\n\n  it(\"routes metric=cpu to Cpu\", () => {\n    renderWithProviders(<Component service={{ widget: { type: \"glances\", metric: \"cpu\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"glances-cpu\")).toBeInTheDocument();\n  });\n\n  it(\"routes metric patterns (network:, sensor:, disk:, gpu:, fs:) to their modules\", () => {\n    const { rerender } = renderWithProviders(\n      <Component service={{ widget: { type: \"glances\", metric: \"network:eth0\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n    expect(screen.getByText(\"glances-net\")).toBeInTheDocument();\n\n    rerender(<Component service={{ widget: { type: \"glances\", metric: \"sensor:temp\" } }} />);\n    expect(screen.getByText(\"glances-sensor\")).toBeInTheDocument();\n\n    rerender(<Component service={{ widget: { type: \"glances\", metric: \"disk:sda\" } }} />);\n    expect(screen.getByText(\"glances-disk\")).toBeInTheDocument();\n\n    rerender(<Component service={{ widget: { type: \"glances\", metric: \"gpu:nvidia\" } }} />);\n    expect(screen.getByText(\"glances-gpu\")).toBeInTheDocument();\n\n    rerender(<Component service={{ widget: { type: \"glances\", metric: \"fs:/\" } }} />);\n    expect(screen.getByText(\"glances-fs\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/glances/components/block.jsx",
    "content": "export default function Block({ position, children }) {\n  return <div className={`absolute ${position} z-20 text-sm pointer-events-none`}>{children}</div>;\n}\n"
  },
  {
    "path": "src/widgets/glances/components/block.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport Block from \"./block\";\n\ndescribe(\"widgets/glances/components/block\", () => {\n  it(\"renders children with the given absolute position classes\", () => {\n    render(\n      <Block position=\"top-1 left-2\">\n        <div>hi</div>\n      </Block>,\n    );\n\n    const el = screen.getByText(\"hi\").parentElement;\n    expect(el).toHaveClass(\"absolute\");\n    expect(el).toHaveClass(\"top-1\");\n    expect(el).toHaveClass(\"left-2\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/glances/components/chart.jsx",
    "content": "import { PureComponent } from \"react\";\nimport { Area, AreaChart, ResponsiveContainer, Tooltip } from \"recharts\";\n\nimport CustomTooltip from \"./custom_tooltip\";\n\nclass Chart extends PureComponent {\n  render() {\n    const { dataPoints, formatter, label } = this.props;\n\n    return (\n      <div className=\"absolute -top-10 -left-2 h-[calc(100%+3em)] w-[calc(100%+1em)] z-0\">\n        <div className=\"overflow-clip z-10 w-full h-full\">\n          <ResponsiveContainer width=\"100%\" height=\"100%\">\n            <AreaChart data={dataPoints}>\n              <defs>\n                <linearGradient id=\"color\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n                  <stop offset=\"5%\" stopColor=\"rgb(var(--color-500))\" stopOpacity={0.4} />\n                  <stop offset=\"95%\" stopColor=\"rgb(var(--color-500))\" stopOpacity={0.1} />\n                </linearGradient>\n              </defs>\n              <Area\n                name={label[0]}\n                isAnimationActive={false}\n                type=\"monotoneX\"\n                dataKey=\"value\"\n                stroke=\"rgb(var(--color-500))\"\n                fillOpacity={1}\n                fill=\"url(#color)\"\n                baseLine={0}\n              />\n              <Tooltip\n                allowEscapeViewBox={{ x: false, y: false }}\n                formatter={formatter}\n                content={<CustomTooltip formatter={formatter} />}\n                classNames=\"rounded-md text-xs p-0.5\"\n                contentStyle={{\n                  backgroundColor: \"rgb(var(--color-800))\",\n                  color: \"rgb(var(--color-100))\",\n                }}\n              />\n            </AreaChart>\n          </ResponsiveContainer>\n        </div>\n      </div>\n    );\n  }\n}\n\nexport default Chart;\n"
  },
  {
    "path": "src/widgets/glances/components/chart.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport React from \"react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nvi.mock(\"recharts\", () => ({\n  ResponsiveContainer: ({ children }) => <div data-testid=\"ResponsiveContainer\">{children}</div>,\n  AreaChart: ({ children }) => {\n    // Filter out raw SVG elements (defs/linearGradient/stop) so jsdom doesn't warn.\n    const kept = React.Children.toArray(children).filter((child) => typeof child?.type === \"function\");\n    return <div data-testid=\"AreaChart\">{kept}</div>;\n  },\n  Area: ({ name, dataKey }) => <div data-testid=\"Area\" data-name={name} data-key={dataKey} />,\n  Tooltip: ({ content }) => <div data-testid=\"Tooltip\">{content}</div>,\n}));\n\nimport Chart from \"./chart\";\n\ndescribe(\"widgets/glances/components/chart\", () => {\n  it(\"renders a single-series chart scaffold\", () => {\n    render(<Chart dataPoints={[{ value: 1 }]} formatter={(v) => String(v)} label={[\"Series\"]} />);\n\n    expect(screen.getByTestId(\"ResponsiveContainer\")).toBeInTheDocument();\n    expect(screen.getByTestId(\"AreaChart\")).toBeInTheDocument();\n    const area = screen.getByTestId(\"Area\");\n    expect(area).toHaveAttribute(\"data-name\", \"Series\");\n    expect(area).toHaveAttribute(\"data-key\", \"value\");\n    expect(screen.getByTestId(\"Tooltip\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/glances/components/chart_dual.jsx",
    "content": "import { PureComponent } from \"react\";\nimport { Area, AreaChart, ResponsiveContainer, Tooltip } from \"recharts\";\n\nimport CustomTooltip from \"./custom_tooltip\";\n\nclass ChartDual extends PureComponent {\n  render() {\n    const { dataPoints, formatter, stack, label, stackOffset } = this.props;\n\n    return (\n      <div className=\"absolute -top-10 -left-2 h-[calc(100%+3em)] w-[calc(100%+1em)] z-0\">\n        <div className=\"overflow-clip z-10 w-full h-full\">\n          <ResponsiveContainer width=\"100%\" height=\"100%\">\n            <AreaChart data={dataPoints} stackOffset={stackOffset ?? \"none\"}>\n              <defs>\n                <linearGradient id=\"colorA\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n                  <stop offset=\"5%\" stopColor=\"rgb(var(--color-800))\" stopOpacity={0.8} />\n                  <stop offset=\"95%\" stopColor=\"rgb(var(--color-800))\" stopOpacity={0.5} />\n                </linearGradient>\n                <linearGradient id=\"colorB\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n                  <stop offset=\"5%\" stopColor=\"rgb(var(--color-500))\" stopOpacity={0.4} />\n                  <stop offset=\"95%\" stopColor=\"rgb(var(--color-500))\" stopOpacity={0.1} />\n                </linearGradient>\n              </defs>\n\n              <Area\n                name={label[0]}\n                stackId={(stack && stack[0]) ?? \"1\"}\n                isAnimationActive={false}\n                type=\"monotoneX\"\n                dataKey=\"a\"\n                stroke=\"rgb(var(--color-700))\"\n                fillOpacity={1}\n                fill=\"url(#colorA)\"\n              />\n              <Area\n                name={label[1]}\n                stackId={(stack && stack[1]) ?? \"1\"}\n                isAnimationActive={false}\n                type=\"monotoneX\"\n                dataKey=\"b\"\n                stroke=\"rgb(var(--color-500))\"\n                fillOpacity={1}\n                fill=\"url(#colorB)\"\n              />\n              <Tooltip\n                allowEscapeViewBox={{ x: false, y: false }}\n                formatter={formatter}\n                content={<CustomTooltip formatter={formatter} />}\n                classNames=\"rounded-md text-xs p-0.5\"\n                contentStyle={{\n                  backgroundColor: \"rgb(var(--color-800))\",\n                  color: \"rgb(var(--color-100))\",\n                }}\n              />\n            </AreaChart>\n          </ResponsiveContainer>\n        </div>\n      </div>\n    );\n  }\n}\n\nexport default ChartDual;\n"
  },
  {
    "path": "src/widgets/glances/components/chart_dual.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport React from \"react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nvi.mock(\"recharts\", () => ({\n  ResponsiveContainer: ({ children }) => <div data-testid=\"ResponsiveContainer\">{children}</div>,\n  AreaChart: ({ children, stackOffset }) => (\n    <div data-testid=\"AreaChart\" data-stackoffset={stackOffset ?? \"\"}>\n      {\n        // Filter out raw SVG elements (defs/linearGradient/stop) so jsdom doesn't warn.\n        React.Children.toArray(children).filter((child) => typeof child?.type === \"function\")\n      }\n    </div>\n  ),\n  Area: ({ name, dataKey }) => <div data-testid=\"Area\" data-name={name} data-key={dataKey} />,\n  Tooltip: ({ content }) => <div data-testid=\"Tooltip\">{content}</div>,\n}));\n\nimport ChartDual from \"./chart_dual\";\n\ndescribe(\"widgets/glances/components/chart_dual\", () => {\n  it(\"renders a dual-series chart scaffold\", () => {\n    render(\n      <ChartDual dataPoints={[{ a: 1, b: 2 }]} formatter={(v) => String(v)} label={[\"A\", \"B\"]} stackOffset=\"expand\" />,\n    );\n\n    expect(screen.getByTestId(\"ResponsiveContainer\")).toBeInTheDocument();\n    const chart = screen.getByTestId(\"AreaChart\");\n    expect(chart).toHaveAttribute(\"data-stackoffset\", \"expand\");\n\n    const areas = screen.getAllByTestId(\"Area\");\n    expect(areas).toHaveLength(2);\n    expect(areas[0]).toHaveAttribute(\"data-key\", \"a\");\n    expect(areas[1]).toHaveAttribute(\"data-key\", \"b\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/glances/components/container.jsx",
    "content": "import classNames from \"classnames\";\nimport { useContext } from \"react\";\nimport { SettingsContext } from \"utils/contexts/settings\";\n\nimport Error from \"./error\";\n\nexport default function Container({ children, widget, error = null, chart = true, className = \"\" }) {\n  const { settings } = useContext(SettingsContext);\n  const hideErrors = settings.hideErrors || widget?.hideErrors;\n\n  if (error) {\n    if (hideErrors) {\n      return null;\n    }\n\n    return <Error />;\n  }\n\n  return (\n    <div className={classNames(\"service-container\", chart ? \"chart relative h-[68px]\" : \"\")}>\n      {children}\n      <div className={`absolute -top-10 right-0 bottom-0 left-0 overflow-clip pointer-events-none ${className}`} />\n      {chart && <div className=\"h-[68px] overflow-clip\" />}\n      {!chart && <div className=\"h-[16px] overflow-clip\" />}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/widgets/glances/components/container.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nimport Container from \"./container\";\n\ndescribe(\"widgets/glances/components/container\", () => {\n  it(\"renders children and chart spacing when not in error state\", () => {\n    renderWithProviders(\n      <Container chart>\n        <div>child</div>\n      </Container>,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(screen.getByText(\"child\")).toBeInTheDocument();\n    expect(document.querySelector(\".service-container\")).toBeTruthy();\n    expect(document.querySelector(\".h-\\\\[68px\\\\]\")).toBeTruthy();\n  });\n\n  it(\"renders nothing when error is present and errors are hidden\", () => {\n    const { container } = renderWithProviders(<Container error={{ message: \"nope\" }} widget={{ hideErrors: true }} />, {\n      settings: { hideErrors: true },\n    });\n    expect(container.firstChild).toBeNull();\n  });\n\n  it(\"renders the error message when error is present and errors are not hidden\", () => {\n    renderWithProviders(<Container error={{ message: \"nope\" }} widget={{ hideErrors: false }} />, {\n      settings: { hideErrors: false },\n    });\n    expect(screen.getByText(\"widget.api_error\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/glances/components/custom_tooltip.jsx",
    "content": "export default function Tooltip({ active, payload, formatter }) {\n  if (active && payload && payload.length) {\n    return (\n      <div className=\"bg-theme-800/80 rounded-md text-theme-200 px-2 py-0\">\n        {payload.map((pld, id) => (\n          <div key={Math.random()} className=\"first-of-type:pt-0 pt-0.5\">\n            <div>\n              {formatter(pld.value)} {payload[id].name}\n            </div>\n          </div>\n        ))}\n      </div>\n    );\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "src/widgets/glances/components/custom_tooltip.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport CustomTooltip from \"./custom_tooltip\";\n\ndescribe(\"widgets/glances/components/custom_tooltip\", () => {\n  it(\"returns null when inactive\", () => {\n    const { container } = render(<CustomTooltip active={false} payload={[]} formatter={(v) => String(v)} />);\n    expect(container.firstChild).toBeNull();\n  });\n\n  it(\"renders formatted values and series names when active\", () => {\n    render(\n      <CustomTooltip\n        active\n        formatter={(v) => `v=${v}`}\n        payload={[\n          { value: 1, name: \"A\" },\n          { value: 2, name: \"B\" },\n        ]}\n      />,\n    );\n\n    expect(screen.getByText(\"v=1 A\")).toBeInTheDocument();\n    expect(screen.getByText(\"v=2 B\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/glances/components/error.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\n\nexport default function Error() {\n  const { t } = useTranslation();\n\n  return <div className=\"absolute bottom-2 left-2 z-20 text-red-400 text-xs opacity-75\">{t(\"widget.api_error\")}</div>;\n}\n"
  },
  {
    "path": "src/widgets/glances/components/error.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport Error from \"./error\";\n\ndescribe(\"widgets/glances/components/error\", () => {\n  it(\"renders the standard widget api error message\", () => {\n    render(<Error />);\n    expect(screen.getByText(\"widget.api_error\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/glances/metrics/containers.jsx",
    "content": "import ResolvedIcon from \"components/resolvedicon\";\nimport { useTranslation } from \"next-i18next\";\n\nimport Block from \"../components/block\";\nimport Container from \"../components/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst statusMap = {\n  running: <ResolvedIcon icon=\"mdi-circle\" width={32} height={32} />,\n  healthy: <ResolvedIcon icon=\"mdi-circle\" width={32} height={32} />,\n  paused: <ResolvedIcon icon=\"mdi-circle-outline\" width={32} height={32} />,\n  stopped: <ResolvedIcon icon=\"mdi-circle-double\" width={32} height={32} />,\n};\n\nconst defaultInterval = 1000;\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const { chart, refreshInterval = defaultInterval, version = 3 } = widget;\n\n  const idKey = version === 3 ? \"Id\" : \"id\";\n  const statusKey = version === 3 ? \"Status\" : \"status\";\n\n  const { data, error } = useWidgetAPI(service.widget, `${version}/containers`, {\n    refreshInterval: Math.max(defaultInterval, refreshInterval),\n  });\n\n  if (error) {\n    return <Container service={service} widget={widget} />;\n  }\n\n  if (!data) {\n    return (\n      <Container chart={chart}>\n        <Block position=\"bottom-3 left-3\">-</Block>\n      </Container>\n    );\n  }\n\n  data.splice(chart ? 5 : 1);\n  let headerYPosition = \"top-4\";\n  let listYPosition = \"bottom-4\";\n  if (chart) {\n    headerYPosition = \"-top-6\";\n    listYPosition = \"-top-3\";\n  }\n\n  return (\n    <Container chart={chart}>\n      <Block position={`${headerYPosition} right-3 left-3`}>\n        <div className=\"flex items-center text-xs\">\n          <div className=\"grow\" />\n          <div className=\"w-14 text-right italic\">{t(\"resources.cpu\")}</div>\n          <div className=\"w-14 text-right\">{t(\"resources.mem\")}</div>\n        </div>\n      </Block>\n\n      <Block position={`${listYPosition} right-3 left-3`}>\n        <div className=\"pointer-events-none text-theme-900 dark:text-theme-200\">\n          {data.map((item) => (\n            <div key={item[idKey]} className=\"text-[0.75rem] h-[0.8rem]\">\n              <div className=\"flex items-center\">\n                <div className=\"w-3 h-3 mr-1.5 opacity-50\">{statusMap[item[statusKey]]}</div>\n                <div className=\"opacity-75 grow truncate\">{item.name}</div>\n                <div className=\"opacity-25 w-14 text-right\">{item.cpu_percent.toFixed(1)}%</div>\n                <div className=\"opacity-25 w-14 text-right\">\n                  {t(\"common.bytes\", {\n                    value: item.memory.usage - item.memory.inactive_file,\n                    maximumFractionDigits: 0,\n                  })}\n                </div>\n              </div>\n            </div>\n          ))}\n        </div>\n      </Block>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/glances/metrics/containers.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\n// Avoid pulling Next/Image + ThemeContext requirements into these unit tests.\nvi.mock(\"components/resolvedicon\", () => ({ default: () => <span data-testid=\"resolvedicon\" /> }));\n\nvi.mock(\"next-i18next\", () => ({\n  useTranslation: () => ({\n    t: (key, opts) => (key === \"common.bytes\" ? `${key}:${opts?.value}` : key),\n  }),\n}));\n\n// Avoid pulling Next/Image + ThemeContext requirements into these unit tests.\nvi.mock(\"components/resolvedicon\", () => ({ default: () => <span data-testid=\"resolvedicon\" /> }));\n\nimport Component from \"./containers\";\n\ndescribe(\"widgets/glances/metrics/containers\", () => {\n  it(\"renders a placeholder while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n    renderWithProviders(<Component service={{ widget: { chart: false, version: 3 } }} />, {\n      settings: { hideErrors: false },\n    });\n    expect(screen.getByText(\"-\")).toBeInTheDocument();\n  });\n\n  it(\"renders a placeholder while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n    renderWithProviders(<Component service={{ widget: { chart: false, version: 3 } }} />, {\n      settings: { hideErrors: false },\n    });\n    expect(screen.getByText(\"-\")).toBeInTheDocument();\n  });\n\n  it(\"renders nothing when there is an error\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: new Error(\"fail\") });\n    renderWithProviders(<Component service={{ widget: { chart: false, version: 3 } }} />, {\n      settings: { hideErrors: false },\n    });\n    expect(screen.queryByText(\"resources.cpu\")).not.toBeInTheDocument();\n    expect(screen.queryByText(\"-\")).not.toBeInTheDocument();\n  });\n\n  it(\"renders container rows using v3 keys and formats values\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: [\n        {\n          Id: \"one\",\n          Status: \"running\",\n          name: \"alpha\",\n          cpu_percent: 12.34,\n          memory: { usage: 1000, inactive_file: 400 },\n        },\n        {\n          Id: \"two\",\n          Status: \"paused\",\n          name: \"beta\",\n          cpu_percent: 99.99,\n          memory: { usage: 2000, inactive_file: 1000 },\n        },\n      ],\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { chart: false, version: 3 } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    // data.splice(1) keeps only one item when chart is false\n    expect(screen.getByText(\"resources.cpu\")).toBeInTheDocument();\n    expect(screen.getByText(\"resources.mem\")).toBeInTheDocument();\n\n    expect(screen.getByText(\"alpha\")).toBeInTheDocument();\n    expect(screen.queryByText(\"beta\")).not.toBeInTheDocument();\n\n    expect(screen.getByText(\"12.3%\")).toBeInTheDocument();\n    expect(screen.getByText(\"common.bytes:600\")).toBeInTheDocument();\n    expect(screen.getAllByTestId(\"resolvedicon\")).toHaveLength(1);\n  });\n\n  it(\"limits rows to 5 when chart is enabled\", () => {\n    const data = Array.from({ length: 6 }).map((_, index) => ({\n      Id: `id-${index}`,\n      Status: \"healthy\",\n      name: `item-${index}`,\n      cpu_percent: index + 0.1,\n      memory: { usage: 100 * (index + 1), inactive_file: 0 },\n    }));\n\n    useWidgetAPI.mockReturnValue({ data, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { chart: true, version: 3 } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"item-0\")).toBeInTheDocument();\n    expect(screen.getByText(\"item-4\")).toBeInTheDocument();\n    expect(screen.queryByText(\"item-5\")).not.toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/glances/metrics/cpu.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport dynamic from \"next/dynamic\";\nimport { useEffect, useState } from \"react\";\n\nimport Block from \"../components/block\";\nimport Container from \"../components/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst Chart = dynamic(() => import(\"../components/chart\"), { ssr: false });\n\nconst defaultPointsLimit = 15;\nconst defaultInterval = 1000;\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;\n\n  const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));\n\n  const { data, error } = useWidgetAPI(service.widget, `${version}/cpu`, {\n    refreshInterval: Math.max(defaultInterval, refreshInterval),\n  });\n\n  const { data: quicklookData, error: quicklookError } = useWidgetAPI(service.widget, `${version}/quicklook`);\n\n  useEffect(() => {\n    if (data) {\n      setDataPoints((prevDataPoints) => {\n        const newDataPoints = [...prevDataPoints, { value: data.total }];\n        if (newDataPoints.length > pointsLimit) {\n          newDataPoints.shift();\n        }\n        return newDataPoints;\n      });\n    }\n  }, [data, pointsLimit]);\n\n  if (error) {\n    return <Container error={error} widget={widget} />;\n  }\n\n  if (!data) {\n    return (\n      <Container chart={chart}>\n        <Block position=\"bottom-3 left-3\">-</Block>\n      </Container>\n    );\n  }\n\n  return (\n    <Container chart={chart}>\n      {chart && (\n        <Chart\n          dataPoints={dataPoints}\n          label={[t(\"resources.used\")]}\n          formatter={(value) =>\n            t(\"common.number\", {\n              value,\n              style: \"unit\",\n              unit: \"percent\",\n              maximumFractionDigits: 0,\n            })\n          }\n        />\n      )}\n\n      {!chart && quicklookData && !quicklookError && (\n        <Block position=\"top-3 right-3\">\n          <div className=\"text-[0.6rem] opacity-50\">{quicklookData.cpu_name && quicklookData.cpu_name}</div>\n        </Block>\n      )}\n\n      {quicklookData && !quicklookError && (\n        <Block position=\"bottom-3 left-3\">\n          {quicklookData.cpu_name && chart && <div className=\"text-xs opacity-50\">{quicklookData.cpu_name}</div>}\n        </Block>\n      )}\n\n      <Block position=\"bottom-3 right-3\">\n        <div className=\"text-xs font-bold opacity-75\">\n          {t(\"common.number\", {\n            value: data.total,\n            style: \"unit\",\n            unit: \"percent\",\n            maximumFractionDigits: 0,\n          })}{\" \"}\n          {t(\"resources.used\")}\n        </div>\n      </Block>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/glances/metrics/cpu.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\nvi.mock(\"next/dynamic\", () => ({ default: () => () => null }));\n\nimport Component from \"./cpu\";\n\ndescribe(\"widgets/glances/metrics/cpu\", () => {\n  it(\"renders a placeholder while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n    renderWithProviders(<Component service={{ widget: { chart: false, version: 3, pointsLimit: 3 } }} />, {\n      settings: { hideErrors: false },\n    });\n    expect(screen.getByText(\"-\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/glances/metrics/disk.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport dynamic from \"next/dynamic\";\nimport { useEffect, useState } from \"react\";\n\nimport Block from \"../components/block\";\nimport Container from \"../components/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst ChartDual = dynamic(() => import(\"../components/chart_dual\"), { ssr: false });\n\nconst defaultPointsLimit = 15;\nconst defaultInterval = 1000;\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;\n  const [, diskName] = widget.metric.split(\":\");\n\n  const [dataPoints, setDataPoints] = useState(\n    new Array(pointsLimit).fill({ read_bytes: 0, write_bytes: 0, time_since_update: 0 }, 0, pointsLimit),\n  );\n  const [ratePoints, setRatePoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit));\n\n  const { data, error } = useWidgetAPI(service.widget, `${version}/diskio`, {\n    refreshInterval: Math.max(defaultInterval, refreshInterval),\n  });\n\n  const calculateRates = (d) =>\n    d.map((item) => ({\n      a: item.read_bytes / item.time_since_update,\n      b: item.write_bytes / item.time_since_update,\n    }));\n\n  useEffect(() => {\n    if (data && !data.error) {\n      const diskData = data.find((item) => item.disk_name === diskName);\n\n      setDataPoints((prevDataPoints) => {\n        const newDataPoints = [...prevDataPoints, diskData];\n        if (newDataPoints.length > pointsLimit) {\n          newDataPoints.shift();\n        }\n        return newDataPoints;\n      });\n    }\n  }, [data, diskName, pointsLimit]);\n\n  useEffect(() => {\n    setRatePoints(calculateRates(dataPoints));\n  }, [dataPoints]);\n\n  if (error || (data && data.error)) {\n    const finalError = error || data.error;\n    return <Container error={finalError} widget={widget} />;\n  }\n\n  if (!data) {\n    return (\n      <Container chart={chart}>\n        <Block position=\"bottom-3 left-3\">-</Block>\n      </Container>\n    );\n  }\n\n  const diskData = data.find((item) => item.disk_name === diskName);\n\n  if (!diskData) {\n    return (\n      <Container chart={chart}>\n        <Block position=\"bottom-3 left-3\">-</Block>\n      </Container>\n    );\n  }\n\n  const diskRates = calculateRates(dataPoints);\n  const currentRate = diskRates[diskRates.length - 1];\n\n  return (\n    <Container chart={chart}>\n      {chart && (\n        <ChartDual\n          dataPoints={ratePoints}\n          label={[t(\"glances.read\"), t(\"glances.write\")]}\n          max={diskData.critical}\n          formatter={(value) =>\n            t(\"common.bitrate\", {\n              value,\n            })\n          }\n        />\n      )}\n\n      {currentRate && !error && (\n        <Block position={chart ? \"bottom-3 left-3\" : \"bottom-3 right-3\"}>\n          <div className=\"text-xs opacity-50 text-right\">\n            {t(\"common.bitrate\", {\n              value: currentRate.a,\n            })}{\" \"}\n            {t(\"glances.read\")}\n          </div>\n          <div className=\"text-xs opacity-50 text-right\">\n            {t(\"common.bitrate\", {\n              value: currentRate.b,\n            })}{\" \"}\n            {t(\"glances.write\")}\n          </div>\n        </Block>\n      )}\n\n      <Block position={chart ? \"bottom-3 right-3\" : \"bottom-3 left-3\"}>\n        <div className=\"text-xs opacity-75\">\n          {t(\"common.bitrate\", {\n            value: currentRate.a + currentRate.b,\n          })}\n        </div>\n      </Block>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/glances/metrics/disk.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\nvi.mock(\"next/dynamic\", () => ({ default: () => () => null }));\n\nimport Component from \"./disk\";\n\ndescribe(\"widgets/glances/metrics/disk\", () => {\n  it(\"renders a placeholder while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n    renderWithProviders(\n      <Component service={{ widget: { chart: false, version: 3, pointsLimit: 3, metric: \"disk:sda\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n    expect(screen.getByText(\"-\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/glances/metrics/fs.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\n\nimport Block from \"../components/block\";\nimport Container from \"../components/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst defaultInterval = 1000;\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const { chart, refreshInterval = defaultInterval, version = 3 } = widget;\n  const [, fsName] = widget.metric.split(\"fs:\");\n  const diskUnits = widget.diskUnits === \"bbytes\" ? \"common.bbytes\" : \"common.bytes\";\n\n  const { data, error } = useWidgetAPI(widget, `${version}/fs`, {\n    refreshInterval: Math.max(defaultInterval, refreshInterval),\n  });\n\n  if (error) {\n    return <Container error={error} widget={widget} />;\n  }\n\n  if (!data) {\n    return (\n      <Container chart={chart}>\n        <Block position=\"bottom-3 left-3\">-</Block>\n      </Container>\n    );\n  }\n\n  const fsData = data.find((item) => item[item.key] === fsName);\n\n  if (!fsData) {\n    return (\n      <Container chart={chart}>\n        <Block position=\"bottom-3 left-3\">-</Block>\n      </Container>\n    );\n  }\n\n  return (\n    <Container chart={chart}>\n      {chart && (\n        <div className=\"absolute -top-2 -left-2 -right-2 -bottom-2\">\n          <div\n            style={{\n              height: `${Math.max(20, (140 * (fsData.size - fsData.free)) / fsData.size)}px`,\n            }}\n            className=\"absolute bottom-0 border-t border-t-theme-500 bg-linear-to-b from-theme-500/40 to-theme-500/10 w-full\"\n          />\n        </div>\n      )}\n\n      <Block position=\"bottom-3 left-3\">\n        {fsData.used && chart && (\n          <div className=\"text-xs opacity-50\">\n            {t(diskUnits, {\n              value: fsData.used,\n              maximumFractionDigits: 1,\n            })}{\" \"}\n            {t(\"resources.used\")}\n          </div>\n        )}\n\n        <div className=\"text-xs opacity-75\">\n          {t(diskUnits, {\n            value: fsData.free,\n            maximumFractionDigits: 1,\n          })}{\" \"}\n          {t(\"resources.free\")}\n        </div>\n      </Block>\n\n      {!chart && (\n        <Block position=\"top-3 right-3\">\n          {fsData.used && (\n            <div className=\"text-xs opacity-50\">\n              {t(diskUnits, {\n                value: fsData.used,\n                maximumFractionDigits: 1,\n              })}{\" \"}\n              {t(\"resources.used\")}\n            </div>\n          )}\n        </Block>\n      )}\n\n      <Block position=\"bottom-3 right-3\">\n        <div className=\"text-xs opacity-75\">\n          {t(diskUnits, {\n            value: fsData.size,\n            maximumFractionDigits: 1,\n          })}{\" \"}\n          {t(\"resources.total\")}\n        </div>\n      </Block>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/glances/metrics/fs.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\nvi.mock(\"next/dynamic\", () => ({ default: () => () => null }));\n\nimport Component from \"./fs\";\n\ndescribe(\"widgets/glances/metrics/fs\", () => {\n  it(\"renders a placeholder while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n    renderWithProviders(\n      <Component service={{ widget: { chart: false, version: 3, pointsLimit: 3, metric: \"fs:/mnt\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n    expect(screen.getByText(\"-\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/glances/metrics/gpu.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport dynamic from \"next/dynamic\";\nimport { useEffect, useState } from \"react\";\n\nimport Block from \"../components/block\";\nimport Container from \"../components/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst ChartDual = dynamic(() => import(\"../components/chart_dual\"), { ssr: false });\n\nconst defaultPointsLimit = 15;\nconst defaultInterval = 1000;\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;\n  const [, gpuName] = widget.metric.split(\":\");\n\n  const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit));\n\n  const { data, error } = useWidgetAPI(widget, `${version}/gpu`, {\n    refreshInterval: Math.max(defaultInterval, refreshInterval),\n  });\n\n  useEffect(() => {\n    if (data && !data.error) {\n      // eslint-disable-next-line eqeqeq\n      const gpuData = data.find((item) => item[item.key] == gpuName);\n\n      if (gpuData) {\n        setDataPoints((prevDataPoints) => {\n          const newDataPoints = [...prevDataPoints, { a: gpuData.mem, b: gpuData.proc }];\n          if (newDataPoints.length > pointsLimit) {\n            newDataPoints.shift();\n          }\n          return newDataPoints;\n        });\n      }\n    }\n  }, [data, gpuName, pointsLimit]);\n\n  if (error || (data && data.error)) {\n    const finalError = error || data.error;\n    return <Container error={finalError} widget={widget} />;\n  }\n\n  if (!data) {\n    return (\n      <Container chart={chart}>\n        <Block position=\"bottom-3 left-3\">-</Block>\n      </Container>\n    );\n  }\n\n  // eslint-disable-next-line eqeqeq\n  const gpuData = data.find((item) => item[item.key] == gpuName);\n\n  if (!gpuData) {\n    return (\n      <Container chart={chart}>\n        <Block position=\"bottom-3 left-3\">-</Block>\n      </Container>\n    );\n  }\n\n  return (\n    <Container chart={chart}>\n      {chart && (\n        <ChartDual\n          dataPoints={dataPoints}\n          label={[t(\"glances.mem\"), t(\"glances.gpu\")]}\n          stack={[\"mem\", \"proc\"]}\n          formatter={(value) =>\n            t(\"common.percent\", {\n              value,\n              maximumFractionDigits: 1,\n            })\n          }\n        />\n      )}\n\n      {chart && (\n        <Block position=\"bottom-3 left-3\">\n          {gpuData && gpuData.name && <div className=\"text-xs opacity-50\">{gpuData.name}</div>}\n\n          <div className=\"text-xs opacity-50\">\n            {t(\"common.number\", {\n              value: gpuData.mem,\n              maximumFractionDigits: 1,\n            })}\n            % {t(\"resources.mem\")}\n          </div>\n        </Block>\n      )}\n\n      {!chart && (\n        <Block position=\"bottom-3 left-3\">\n          <div className=\"text-xs opacity-50\">\n            {t(\"common.number\", {\n              value: gpuData.temperature,\n              maximumFractionDigits: 1,\n            })}\n            &deg; C\n          </div>\n        </Block>\n      )}\n\n      <Block position=\"bottom-3 right-3\">\n        <div className=\"text-xs opacity-75\">\n          {!chart && (\n            <div className=\"inline-block mr-1\">\n              {t(\"common.number\", {\n                value: gpuData.proc,\n                maximumFractionDigits: 1,\n              })}\n              % {t(\"glances.gpu\")}\n            </div>\n          )}\n          {!chart && <>&bull;</>}\n          <div className=\"inline-block ml-1\">\n            {t(\"common.number\", {\n              value: gpuData.proc,\n              maximumFractionDigits: 1,\n            })}\n            % {t(\"glances.gpu\")}\n          </div>\n        </div>\n      </Block>\n\n      <Block position=\"top-3 right-3\">\n        {chart && (\n          <div className=\"text-xs opacity-50\">\n            {t(\"common.number\", {\n              value: gpuData.temperature,\n              maximumFractionDigits: 1,\n            })}\n            &deg; C\n          </div>\n        )}\n\n        {gpuData && gpuData.name && !chart && <div className=\"text-xs opacity-50\">{gpuData.name}</div>}\n      </Block>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/glances/metrics/gpu.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\nvi.mock(\"next/dynamic\", () => ({ default: () => () => null }));\n\nimport Component from \"./gpu\";\n\ndescribe(\"widgets/glances/metrics/gpu\", () => {\n  it(\"renders a placeholder while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n    renderWithProviders(\n      <Component service={{ widget: { chart: false, version: 3, pointsLimit: 3, metric: \"gpu:gpu0\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n    expect(screen.getByText(\"-\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/glances/metrics/info.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\n\nimport Block from \"../components/block\";\nimport Container from \"../components/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nfunction Swap({ quicklookData, className = \"\" }) {\n  const { t } = useTranslation();\n\n  return (\n    quicklookData &&\n    quicklookData.swap !== 0 && (\n      <div className=\"text-xs flex place-content-between\">\n        <div className={className}>{t(\"glances.swap\")}</div>\n        <div className={className}>\n          {t(\"common.number\", {\n            value: quicklookData.swap,\n            style: \"unit\",\n            unit: \"percent\",\n            maximumFractionDigits: 0,\n          })}\n        </div>\n      </div>\n    )\n  );\n}\n\nfunction CPU({ quicklookData, className = \"\" }) {\n  const { t } = useTranslation();\n\n  return (\n    quicklookData &&\n    quicklookData.cpu !== undefined &&\n    quicklookData.cpu !== null && (\n      <div className=\"text-xs flex place-content-between\">\n        <div className={className}>{t(\"glances.cpu\")}</div>\n        <div className={className}>\n          {t(\"common.number\", {\n            value: quicklookData.cpu,\n            style: \"unit\",\n            unit: \"percent\",\n            maximumFractionDigits: 0,\n          })}\n        </div>\n      </div>\n    )\n  );\n}\n\nfunction Mem({ quicklookData, className = \"\" }) {\n  const { t } = useTranslation();\n\n  return (\n    quicklookData &&\n    quicklookData.mem && (\n      <div className=\"text-xs flex place-content-between\">\n        <div className={className}>{t(\"glances.mem\")}</div>\n        <div className={className}>\n          {t(\"common.number\", {\n            value: quicklookData.mem,\n            style: \"unit\",\n            unit: \"percent\",\n            maximumFractionDigits: 0,\n          })}\n        </div>\n      </div>\n    )\n  );\n}\n\nconst defaultInterval = 1000;\nconst defaultSystemInterval = 30000; // This data (OS, hostname, distribution) is usually super stable.\n\nexport default function Component({ service }) {\n  const { widget } = service;\n  const { chart, refreshInterval = defaultInterval, version = 3 } = widget;\n\n  const { data: quicklookData, errorL: quicklookError } = useWidgetAPI(service.widget, `${version}/quicklook`, {\n    refreshInterval,\n  });\n\n  const { data: systemData, errorL: systemError } = useWidgetAPI(service.widget, `${version}/system`, {\n    refreshInterval: defaultSystemInterval,\n  });\n\n  if (quicklookError || (quicklookData && quicklookData.error)) {\n    const qlError = quicklookError || quicklookData.error;\n    return <Container error={qlError} widget={widget} />;\n  }\n\n  if (systemError) {\n    return <Container error={systemError} service={service} />;\n  }\n\n  const dataCharts = [];\n\n  if (quicklookData) {\n    quicklookData.percpu.forEach((cpu, index) => {\n      dataCharts.push({\n        name: `CPU ${index}`,\n        cpu: cpu.total,\n        mem: quicklookData.mem,\n        swap: quicklookData.swap,\n        proc: quicklookData.cpu,\n      });\n    });\n  }\n\n  return (\n    <Container chart={chart}>\n      {chart && (\n        <div className=\"bg-linear-to-br from-theme-500/30 via-theme-600/20 to-theme-700/10 absolute -top-20 -left-2 -right-2 -bottom-2\" />\n      )}\n\n      <Block position={chart ? \"-top-6 right-2\" : \"top-3 right-2\"}>\n        {quicklookData && quicklookData.cpu_name && chart && (\n          <div className=\"text-[0.6rem] opacity-50\">{quicklookData.cpu_name}</div>\n        )}\n\n        {!chart && quicklookData?.swap === 0 && (\n          <div className=\"text-[0.6rem] opacity-50\">\n            {systemData && systemData.linux_distro && `${systemData.linux_distro} - `}\n            {systemData && systemData.os_version}\n          </div>\n        )}\n\n        <div>{!chart && <Swap quicklookData={quicklookData} className=\"opacity-25 ml-2\" />}</div>\n      </Block>\n\n      {chart && (\n        <Block position=\"bottom-3 left-2\">\n          {systemData && systemData.linux_distro && <div className=\"text-xs opacity-50\">{systemData.linux_distro}</div>}\n          {systemData && systemData.os_version && <div className=\"text-xs opacity-50\">{systemData.os_version}</div>}\n          {systemData && systemData.hostname && <div className=\"text-xs opacity-75\">{systemData.hostname}</div>}\n        </Block>\n      )}\n\n      {!chart && (\n        <Block position=\"bottom-3 left-3\">\n          <CPU quicklookData={quicklookData} className=\"opacity-75 mr-2\" />\n        </Block>\n      )}\n\n      <Block position=\"bottom-3 right-2\">\n        {chart && <CPU quicklookData={quicklookData} className=\"opacity-50 ml-2\" />}\n\n        {chart && <Mem quicklookData={quicklookData} className=\"opacity-50 ml-2\" />}\n        {!chart && <Mem quicklookData={quicklookData} className=\"opacity-75 ml-2\" />}\n\n        {chart && <Swap quicklookData={quicklookData} className=\"opacity-50 ml-2\" />}\n      </Block>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/glances/metrics/info.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./info\";\n\ndescribe(\"widgets/glances/metrics/info\", () => {\n  it(\"renders a placeholder while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n    renderWithProviders(<Component service={{ widget: { chart: false, version: 3 } }} />, {\n      settings: { hideErrors: false },\n    });\n    expect(document.querySelector(\".service-container\")).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "src/widgets/glances/metrics/memory.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport dynamic from \"next/dynamic\";\nimport { useEffect, useState } from \"react\";\n\nimport Block from \"../components/block\";\nimport Container from \"../components/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst ChartDual = dynamic(() => import(\"../components/chart_dual\"), { ssr: false });\n\nconst defaultPointsLimit = 15;\nconst defaultInterval = (isChart) => (isChart ? 1000 : 5000);\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const { chart } = widget;\n  const { refreshInterval = defaultInterval(chart), pointsLimit = defaultPointsLimit, version = 3 } = widget;\n\n  const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));\n\n  const { data, error } = useWidgetAPI(service.widget, `${version}/mem`, {\n    refreshInterval: Math.max(defaultInterval(chart), refreshInterval),\n  });\n\n  useEffect(() => {\n    if (data) {\n      setDataPoints((prevDataPoints) => {\n        const newDataPoints = [...prevDataPoints, { a: data.used, b: data.available }];\n        if (newDataPoints.length > pointsLimit) {\n          newDataPoints.shift();\n        }\n        return newDataPoints;\n      });\n    }\n  }, [data, pointsLimit]);\n\n  if (error) {\n    return <Container error={error} widget={widget} />;\n  }\n\n  if (!data) {\n    return (\n      <Container chart={chart}>\n        <Block position=\"bottom-3 left-3\">-</Block>\n      </Container>\n    );\n  }\n\n  return (\n    <Container chart={chart}>\n      {chart && (\n        <ChartDual\n          dataPoints={dataPoints}\n          max={data.total}\n          label={[t(\"resources.used\"), t(\"resources.free\")]}\n          formatter={(value) =>\n            t(\"common.bytes\", {\n              value,\n              maximumFractionDigits: 0,\n              binary: true,\n            })\n          }\n        />\n      )}\n\n      {data && !error && (\n        <Block position=\"bottom-3 left-3\">\n          {data.available && chart && (\n            <div className=\"text-xs opacity-50\">\n              {t(\"common.bytes\", {\n                value: data.available,\n                maximumFractionDigits: 1,\n                binary: true,\n              })}{\" \"}\n              {t(\"resources.free\")}\n            </div>\n          )}\n\n          {data.total && (\n            <div className=\"text-xs opacity-50\">\n              {t(\"common.bytes\", {\n                value: data.total,\n                maximumFractionDigits: 1,\n                binary: true,\n              })}{\" \"}\n              {t(\"resources.total\")}\n            </div>\n          )}\n        </Block>\n      )}\n\n      {!chart && (\n        <Block position=\"top-3 right-3\">\n          {data.available && (\n            <div className=\"text-xs opacity-50\">\n              {t(\"common.bytes\", {\n                value: data.available,\n                maximumFractionDigits: 1,\n                binary: true,\n              })}{\" \"}\n              {t(\"resources.free\")}\n            </div>\n          )}\n        </Block>\n      )}\n\n      <Block position=\"bottom-3 right-3\">\n        <div className=\"text-xs font-bold opacity-75\">\n          {t(\"common.bytes\", {\n            value: data.used,\n            maximumFractionDigits: 1,\n            binary: true,\n          })}{\" \"}\n          {t(\"resources.used\")}\n        </div>\n      </Block>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/glances/metrics/memory.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\nvi.mock(\"next/dynamic\", () => ({ default: () => () => null }));\n\nimport Component from \"./memory\";\n\ndescribe(\"widgets/glances/metrics/memory\", () => {\n  it(\"renders a placeholder while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n    renderWithProviders(<Component service={{ widget: { chart: false, version: 3, pointsLimit: 3 } }} />, {\n      settings: { hideErrors: false },\n    });\n    expect(screen.getByText(\"-\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/glances/metrics/net.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport dynamic from \"next/dynamic\";\nimport { useEffect, useState } from \"react\";\n\nimport Block from \"../components/block\";\nimport Container from \"../components/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst ChartDual = dynamic(() => import(\"../components/chart_dual\"), { ssr: false });\n\nconst defaultPointsLimit = 15;\nconst defaultInterval = (isChart) => (isChart ? 1000 : 5000);\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const { chart, metric } = widget;\n  const { refreshInterval = defaultInterval(chart), pointsLimit = defaultPointsLimit, version = 3 } = widget;\n\n  const rxKey = version === 3 ? \"rx\" : \"bytes_recv\";\n  const txKey = version === 3 ? \"tx\" : \"bytes_sent\";\n\n  const [, interfaceName] = metric.split(\":\");\n\n  const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));\n\n  const { data, error } = useWidgetAPI(widget, `${version}/network`, {\n    refreshInterval: Math.max(defaultInterval(chart), refreshInterval),\n  });\n\n  useEffect(() => {\n    if (data && !data.error) {\n      const interfaceData = data.find((item) => item[item.key] === interfaceName);\n\n      if (interfaceData) {\n        setDataPoints((prevDataPoints) => {\n          const newDataPoints = [\n            ...prevDataPoints,\n            {\n              a: (interfaceData[rxKey] * 8) / interfaceData.time_since_update,\n              b: (interfaceData[txKey] * 8) / interfaceData.time_since_update,\n            },\n          ];\n          if (newDataPoints.length > pointsLimit) {\n            newDataPoints.shift();\n          }\n          return newDataPoints;\n        });\n      }\n    }\n  }, [data, interfaceName, pointsLimit, rxKey, txKey]);\n\n  if (error || (data && data.error)) {\n    const finalError = error || data.error;\n    return <Container error={finalError} widget={widget} />;\n  }\n\n  if (!data) {\n    return (\n      <Container chart={chart}>\n        <Block position=\"bottom-3 left-3\">-</Block>\n      </Container>\n    );\n  }\n\n  const interfaceData = data.find((item) => item[item.key] === interfaceName);\n\n  if (!interfaceData) {\n    return (\n      <Container chart={chart}>\n        <Block position=\"bottom-3 left-3\">-</Block>\n      </Container>\n    );\n  }\n\n  return (\n    <Container chart={chart}>\n      {chart && (\n        <ChartDual\n          dataPoints={dataPoints}\n          label={[t(\"docker.rx\"), t(\"docker.tx\")]}\n          formatter={(value) =>\n            t(\"common.bitrate\", {\n              value,\n              maximumFractionDigits: 0,\n            })\n          }\n        />\n      )}\n\n      <Block position=\"bottom-3 left-3\">\n        {interfaceData && interfaceData.interface_name && chart && (\n          <div className=\"text-xs opacity-50\">{interfaceData.interface_name}</div>\n        )}\n\n        <div className=\"text-xs opacity-75\">\n          {t(\"common.bitrate\", {\n            value: (interfaceData[rxKey] * 8) / interfaceData.time_since_update,\n            maximumFractionDigits: 0,\n          })}{\" \"}\n          {t(\"docker.rx\")}\n        </div>\n      </Block>\n\n      {!chart && (\n        <Block position=\"top-3 right-3\">\n          {interfaceData && interfaceData.interface_name && (\n            <div className=\"text-xs opacity-50\">{interfaceData.interface_name}</div>\n          )}\n        </Block>\n      )}\n\n      <Block position=\"bottom-3 right-3\">\n        <div className=\"text-xs opacity-75\">\n          {t(\"common.bitrate\", {\n            value: (interfaceData[txKey] * 8) / interfaceData.time_since_update,\n            maximumFractionDigits: 0,\n          })}{\" \"}\n          {t(\"docker.tx\")}\n        </div>\n      </Block>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/glances/metrics/net.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\nvi.mock(\"next/dynamic\", () => ({ default: () => () => null }));\n\nimport Component from \"./net\";\n\ndescribe(\"widgets/glances/metrics/net\", () => {\n  it(\"renders a placeholder while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n    renderWithProviders(\n      <Component service={{ widget: { chart: false, version: 3, pointsLimit: 3, metric: \"net:eth0\" } }} />,\n      { settings: { hideErrors: false } },\n    );\n    expect(screen.getByText(\"-\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/glances/metrics/process.jsx",
    "content": "import ResolvedIcon from \"components/resolvedicon\";\nimport { useTranslation } from \"next-i18next\";\n\nimport Block from \"../components/block\";\nimport Container from \"../components/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst statusMap = {\n  R: <ResolvedIcon icon=\"mdi-circle\" width={32} height={32} />, // running\n  S: <ResolvedIcon icon=\"mdi-circle-outline\" width={32} height={32} />, // sleeping\n  D: <ResolvedIcon icon=\"mdi-circle-double\" width={32} height={32} />, // disk sleep\n  Z: <ResolvedIcon icon=\"mdi-circle-opacity\" width={32} height={32} />, // zombie\n  T: <ResolvedIcon icon=\"mdi-decagram-outline\" width={32} height={32} />, // traced\n  t: <ResolvedIcon icon=\"mdi-hexagon-outline\" width={32} height={32} />, // traced\n  X: <ResolvedIcon icon=\"mdi-rhombus-outline\" width={32} height={32} />, // dead\n};\n\nconst defaultInterval = 1000;\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const { chart, refreshInterval = defaultInterval, version = 3 } = widget;\n\n  const memoryInfoKey = version === 3 ? 0 : \"rss\";\n\n  const { data, error } = useWidgetAPI(service.widget, `${version}/processlist`, {\n    refreshInterval: Math.max(defaultInterval, refreshInterval),\n  });\n\n  if (error) {\n    return <Container service={service} widget={widget} />;\n  }\n\n  if (!data) {\n    return (\n      <Container chart={chart}>\n        <Block position=\"bottom-3 left-3\">-</Block>\n      </Container>\n    );\n  }\n\n  data.splice(chart ? 5 : 1);\n  let headerYPosition = \"top-4\";\n  let listYPosition = \"bottom-4\";\n  if (chart) {\n    headerYPosition = \"-top-6\";\n    listYPosition = \"-top-2\";\n  }\n\n  return (\n    <Container chart={chart}>\n      <Block position={`${headerYPosition} right-3 left-3`}>\n        <div className=\"flex items-center text-xs\">\n          <div className=\"grow\" />\n          <div className=\"w-14 text-right italic\">{t(\"resources.cpu\")}</div>\n          <div className=\"w-14 text-right\">{t(\"resources.mem\")}</div>\n        </div>\n      </Block>\n\n      <Block position={`${listYPosition} right-3 left-3`}>\n        <div className=\"pointer-events-none text-theme-900 dark:text-theme-200\">\n          {data.map((item) => (\n            <div key={item.pid} className=\"text-[0.75rem] h-[0.8rem]\">\n              <div className=\"flex items-center\">\n                <div className=\"w-3 h-3 mr-1.5 opacity-50\">{statusMap[item.status]}</div>\n                <div className=\"opacity-75 grow truncate\">{item.name}</div>\n                <div className=\"opacity-25 w-14 text-right\">{item.cpu_percent.toFixed(1)}%</div>\n                <div className=\"opacity-25 w-14 text-right\">\n                  {t(\"common.bytes\", {\n                    value: item.memory_info[memoryInfoKey] ?? item.memory_info.data ?? item.memory_info.wset,\n                    maximumFractionDigits: 0,\n                  })}\n                </div>\n              </div>\n            </div>\n          ))}\n        </div>\n      </Block>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/glances/metrics/process.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\nvi.mock(\"components/resolvedicon\", () => ({ default: () => <span data-testid=\"resolvedicon\" /> }));\n\nimport Component from \"./process\";\n\ndescribe(\"widgets/glances/metrics/process\", () => {\n  it(\"renders a placeholder while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n    renderWithProviders(<Component service={{ widget: { chart: false, version: 3 } }} />, {\n      settings: { hideErrors: false },\n    });\n    expect(screen.getByText(\"-\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/glances/metrics/sensor.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\nimport dynamic from \"next/dynamic\";\nimport { useEffect, useState } from \"react\";\n\nimport Block from \"../components/block\";\nimport Container from \"../components/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst Chart = dynamic(() => import(\"../components/chart\"), { ssr: false });\n\nconst defaultPointsLimit = 15;\nconst defaultInterval = 1000;\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;\n  const [, sensorName] = widget.metric.split(\":\");\n\n  const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));\n\n  const { data, error } = useWidgetAPI(service.widget, `${version}/sensors`, {\n    refreshInterval: Math.max(defaultInterval, refreshInterval),\n  });\n\n  useEffect(() => {\n    if (data && !data.error) {\n      const sensorData = data.find((item) => item.label === sensorName);\n      if (sensorData) {\n        setDataPoints((prevDataPoints) => {\n          const newDataPoints = [...prevDataPoints, { value: sensorData.value }];\n          if (newDataPoints.length > pointsLimit) {\n            newDataPoints.shift();\n          }\n          return newDataPoints;\n        });\n      } else {\n        data.error = true;\n      }\n    }\n  }, [data, sensorName, pointsLimit]);\n\n  if (error || (data && data.error)) {\n    const finalError = error || data.error;\n    return <Container error={finalError} widget={widget} />;\n  }\n\n  if (!data) {\n    return (\n      <Container chart={chart}>\n        <Block position=\"bottom-3 left-3\">-</Block>\n      </Container>\n    );\n  }\n\n  const sensorData = data.find((item) => item.label === sensorName);\n\n  if (!sensorData) {\n    return (\n      <Container chart={chart}>\n        <Block position=\"bottom-3 left-3\">-</Block>\n      </Container>\n    );\n  }\n\n  return (\n    <Container chart={chart}>\n      {chart && (\n        <Chart\n          dataPoints={dataPoints}\n          label={[sensorData.unit]}\n          max={sensorData.critical}\n          formatter={(value) =>\n            t(\"common.number\", {\n              value,\n            })\n          }\n        />\n      )}\n\n      {sensorData && !error && (\n        <Block position=\"bottom-3 left-3\">\n          {sensorData.warning && chart && (\n            <div className=\"text-xs opacity-50\">\n              {t(\"glances.warn\")} {sensorData.warning} {sensorData.unit}\n            </div>\n          )}\n          {sensorData.critical && (\n            <div className=\"text-xs opacity-50\">\n              {t(\"glances.crit\")} {sensorData.critical} {sensorData.unit}\n            </div>\n          )}\n        </Block>\n      )}\n\n      <Block position=\"bottom-3 right-3\">\n        <div className=\"text-xs opacity-50\">\n          {sensorData.warning && !chart && (\n            <>\n              {t(\"glances.warn\")} {sensorData.warning} {sensorData.unit}\n            </>\n          )}\n        </div>\n        <div className=\"text-xs opacity-75\">\n          {t(\"glances.temp\")}{\" \"}\n          {t(\"common.number\", {\n            value: sensorData.value,\n          })}{\" \"}\n          {sensorData.unit}\n        </div>\n      </Block>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/glances/metrics/sensor.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\nvi.mock(\"next/dynamic\", () => ({ default: () => () => null }));\n\nimport Component from \"./sensor\";\n\ndescribe(\"widgets/glances/metrics/sensor\", () => {\n  it(\"renders a placeholder while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n    renderWithProviders(\n      <Component service={{ widget: { chart: false, version: 3, pointsLimit: 3, metric: \"sensor:CPU\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n    expect(screen.getByText(\"-\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/glances/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n  allowedEndpoints: /\\d\\/quicklook|diskio|cpu|fs|gpu|system|mem|network|processlist|sensors|containers/,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/glances/widget.test.js",
    "content": "import { describe, expect, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"glances widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n    expect(widget.allowedEndpoints?.test(\"3/quicklook\")).toBe(true);\n    expect(widget.allowedEndpoints?.test(\"unknown\")).toBe(false);\n  });\n});\n"
  },
  {
    "path": "src/widgets/gluetun/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  if (!widget.fields) {\n    widget.fields = [\"public_ip\", \"region\", \"country\"];\n  }\n\n  const { data: gluetunData, error: gluetunError } = useWidgetAPI(widget, \"ip\");\n  const includePF = widget.fields.includes(\"port_forwarded\");\n  const pfEndpoint = widget.version > 1 ? \"port_forwarded_v2\" : \"port_forwarded\";\n  const { data: portForwardedData, error: portForwardedError } = useWidgetAPI(widget, includePF ? pfEndpoint : \"\");\n\n  if (gluetunError || (includePF && portForwardedError)) {\n    return <Container service={service} error={gluetunError || portForwardedError} />;\n  }\n\n  if (!gluetunData || (includePF && !portForwardedData)) {\n    return (\n      <Container service={service}>\n        <Block label=\"gluetun.public_ip\" />\n        <Block label=\"gluetun.region\" />\n        <Block label=\"gluetun.country\" />\n        <Block label=\"gluetun.port_forwarded\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"gluetun.public_ip\" value={gluetunData.public_ip} />\n      <Block label=\"gluetun.region\" value={gluetunData.region} />\n      <Block label=\"gluetun.country\" value={gluetunData.country} />\n      <Block label=\"gluetun.port_forwarded\" value={portForwardedData?.port} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/gluetun/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/gluetun/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"defaults fields and filters to 3 blocks while loading (no port_forwarded)\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"gluetun\", url: \"http://x\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"public_ip\", \"region\", \"country\"]);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"gluetun.public_ip\")).toBeInTheDocument();\n    expect(screen.getByText(\"gluetun.region\")).toBeInTheDocument();\n    expect(screen.getByText(\"gluetun.country\")).toBeInTheDocument();\n    expect(screen.queryByText(\"gluetun.port_forwarded\")).toBeNull();\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"gluetun\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"includes port_forwarded and uses the v2 endpoint when widget.version > 1\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: { public_ip: \"1.2.3.4\", region: \"CA\", country: \"US\" }, error: undefined })\n      .mockReturnValueOnce({ data: { port: 12345 }, error: undefined });\n\n    const service = {\n      widget: {\n        type: \"gluetun\",\n        url: \"http://x\",\n        version: 2,\n        fields: [\"public_ip\", \"region\", \"country\", \"port_forwarded\"],\n      },\n    };\n\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(useWidgetAPI.mock.calls[0][1]).toBe(\"ip\");\n    expect(useWidgetAPI.mock.calls[1][1]).toBe(\"port_forwarded_v2\");\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expectBlockValue(container, \"gluetun.public_ip\", \"1.2.3.4\");\n    expectBlockValue(container, \"gluetun.region\", \"CA\");\n    expectBlockValue(container, \"gluetun.country\", \"US\");\n    expectBlockValue(container, \"gluetun.port_forwarded\", 12345);\n  });\n});\n"
  },
  {
    "path": "src/widgets/gluetun/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/v1/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    ip: {\n      endpoint: \"publicip/ip\",\n      validate: [\"public_ip\", \"country\"],\n    },\n    port_forwarded: {\n      endpoint: \"openvpn/portforwarded\",\n      validate: [\"port\"],\n    },\n    port_forwarded_v2: {\n      endpoint: \"portforward\",\n      validate: [\"port\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/gluetun/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"gluetun widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/gotify/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  const { data: appsData, error: appsError } = useWidgetAPI(widget, \"application\");\n  const { data: messagesData, error: messagesError } = useWidgetAPI(widget, \"message\");\n  const { data: clientsData, error: clientsError } = useWidgetAPI(widget, \"client\");\n\n  if (appsError || messagesError || clientsError) {\n    const finalError = appsError ?? messagesError ?? clientsError;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!appsData || !messagesData || !clientsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"gotify.apps\" />\n        <Block label=\"gotify.clients\" />\n        <Block label=\"gotify.messages\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"gotify.apps\" value={appsData?.length} />\n      <Block label=\"gotify.clients\" value={clientsData?.length} />\n      <Block label=\"gotify.messages\" value={messagesData?.messages?.length} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/gotify/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/gotify/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // application\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // message\n      .mockReturnValueOnce({ data: undefined, error: undefined }); // client\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"gotify\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"gotify.apps\")).toBeInTheDocument();\n    expect(screen.getByText(\"gotify.clients\")).toBeInTheDocument();\n    expect(screen.getByText(\"gotify.messages\")).toBeInTheDocument();\n    expect(screen.getAllByText(\"-\")).toHaveLength(3);\n  });\n\n  it(\"renders error UI when any endpoint errors\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: undefined })\n      .mockReturnValueOnce({ data: undefined, error: { message: \"nope\" } })\n      .mockReturnValueOnce({ data: undefined, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"gotify\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders computed counts when loaded\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: [{ id: 1 }, { id: 2 }], error: undefined })\n      .mockReturnValueOnce({ data: { messages: [{ id: 1 }] }, error: undefined })\n      .mockReturnValueOnce({ data: [{ id: 1 }, { id: 2 }, { id: 3 }], error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"gotify\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"gotify.apps\", 2);\n    expectBlockValue(container, \"gotify.clients\", 3);\n    expectBlockValue(container, \"gotify.messages\", 1);\n  });\n});\n"
  },
  {
    "path": "src/widgets/gotify/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    application: {\n      endpoint: \"application\",\n    },\n    client: {\n      endpoint: \"client\",\n    },\n    message: {\n      endpoint: \"message\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/gotify/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"gotify widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/grafana/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { version = 1, alerts = \"grafana\" } = widget;\n\n  const { data: statsData, error: statsError } = useWidgetAPI(widget, \"stats\");\n\n  let primaryAlertsEndpoint = \"alerts\";\n  let secondaryAlertsEndpoint = \"grafana\";\n  if (version === 2) {\n    primaryAlertsEndpoint = alerts;\n    secondaryAlertsEndpoint = \"\";\n  }\n\n  const { data: primaryAlertsData, error: primaryAlertsError } = useWidgetAPI(widget, primaryAlertsEndpoint);\n  const { data: secondaryAlertsData, error: secondaryAlertsError } = useWidgetAPI(widget, secondaryAlertsEndpoint);\n\n  let alertsInt = 0;\n  let alertsError = null;\n  if (version === 1) {\n    if (primaryAlertsError || !primaryAlertsData || primaryAlertsData.length === 0) {\n      if (secondaryAlertsData) {\n        alertsInt = secondaryAlertsData.length;\n      }\n    } else {\n      alertsInt = primaryAlertsData.filter((a) => a.state === \"alerting\").length;\n    }\n\n    if (primaryAlertsError && secondaryAlertsError) {\n      alertsError = primaryAlertsError ?? secondaryAlertsError;\n    }\n  } else if (version === 2) {\n    if (primaryAlertsData) {\n      alertsInt = primaryAlertsData.length;\n    }\n\n    if (primaryAlertsError) {\n      alertsError = primaryAlertsError;\n    }\n  }\n\n  if (statsError || alertsError) {\n    return <Container service={service} error={statsError ?? alertsError} />;\n  }\n\n  if (!statsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"grafana.dashboards\" />\n        <Block label=\"grafana.datasources\" />\n        <Block label=\"grafana.totalalerts\" />\n        <Block label=\"grafana.alertstriggered\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"grafana.dashboards\" value={t(\"common.number\", { value: statsData.dashboards })} />\n      <Block label=\"grafana.datasources\" value={t(\"common.number\", { value: statsData.datasources })} />\n      <Block label=\"grafana.totalalerts\" value={t(\"common.number\", { value: statsData.alerts })} />\n      <Block label=\"grafana.alertstriggered\" value={t(\"common.number\", { value: alertsInt })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/grafana/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/grafana/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading (stats missing)\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // stats\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // alerts\n      .mockReturnValueOnce({ data: undefined, error: undefined }); // grafana\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"grafana\", url: \"http://x\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"grafana.dashboards\")).toBeInTheDocument();\n    expect(screen.getByText(\"grafana.datasources\")).toBeInTheDocument();\n    expect(screen.getByText(\"grafana.totalalerts\")).toBeInTheDocument();\n    expect(screen.getByText(\"grafana.alertstriggered\")).toBeInTheDocument();\n  });\n\n  it(\"computes triggered alerts for v1 from alert state=alerting\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: { dashboards: 1, datasources: 2, alerts: 3 }, error: undefined }) // stats\n      .mockReturnValueOnce(\n        {\n          data: [{ state: \"ok\" }, { state: \"alerting\" }, { state: \"alerting\" }],\n          error: undefined,\n        }, // alerts\n      )\n      .mockReturnValueOnce({ data: [{ id: 1 }], error: undefined }); // grafana (unused)\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"grafana\", url: \"http://x\", version: 1 } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expectBlockValue(container, \"grafana.dashboards\", 1);\n    expectBlockValue(container, \"grafana.datasources\", 2);\n    expectBlockValue(container, \"grafana.totalalerts\", 3);\n    expectBlockValue(container, \"grafana.alertstriggered\", 2);\n  });\n\n  it(\"falls back to the secondary endpoint for v1 when the primary alerts endpoint errors\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: { dashboards: 0, datasources: 0, alerts: 0 }, error: undefined }) // stats\n      .mockReturnValueOnce({ data: undefined, error: { message: \"primary down\" } }) // alerts\n      .mockReturnValueOnce({ data: [{ id: 1 }, { id: 2 }, { id: 3 }], error: undefined }); // grafana\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"grafana\", url: \"http://x\", version: 1 } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    // Should not error if only the primary endpoint failed.\n    expect(screen.queryAllByText(/widget\\.api_error/i)).toHaveLength(0);\n    expectBlockValue(container, \"grafana.alertstriggered\", 3);\n  });\n\n  it(\"uses the configured alerts endpoint for v2 and counts all returned alerts\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: { dashboards: 9, datasources: 8, alerts: 7 }, error: undefined }) // stats\n      .mockReturnValueOnce({ data: [{ id: 1 }, { id: 2 }], error: undefined }) // primary (custom)\n      .mockReturnValueOnce({ data: undefined, error: undefined }); // secondary (disabled)\n\n    const service = { widget: { type: \"grafana\", url: \"http://x\", version: 2, alerts: \"custom\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(useWidgetAPI.mock.calls[1][1]).toBe(\"custom\");\n    expectBlockValue(container, \"grafana.alertstriggered\", 2);\n  });\n});\n"
  },
  {
    "path": "src/widgets/grafana/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    alerts: {\n      endpoint: \"alerts\",\n    },\n    alertmanager: {\n      endpoint: \"alertmanager/alertmanager/api/v2/alerts\",\n    },\n    grafana: {\n      endpoint: \"alertmanager/grafana/api/v2/alerts\",\n    },\n    stats: {\n      endpoint: \"admin/stats\",\n      validate: [\"dashboards\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/grafana/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"grafana widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/hdhomerun/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n  const { tuner = 0 } = widget;\n\n  const { data: channelsData, error: channelsError } = useWidgetAPI(widget, \"lineup\");\n  const { data: statusData, error: statusError } = useWidgetAPI(widget, \"status\");\n\n  if (channelsError || statusError) {\n    const finalError = channelsError ?? statusError;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!channelsData || !statusData) {\n    return (\n      <Container service={service}>\n        <Block label=\"hdhomerun.channels\" />\n        <Block label=\"hdhomerun.hd\" />\n      </Container>\n    );\n  }\n\n  // Provide a default if not set in the config\n  if (!widget.fields) {\n    widget.fields = [\"channels\", \"hd\"];\n  }\n  // Limit to a maximum of 4 at a time\n  if (widget.fields.length > 4) {\n    widget.fields = widget.fields.slice(0, 4);\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"hdhomerun.channels\" value={channelsData?.length} />\n      <Block label=\"hdhomerun.hd\" value={channelsData?.filter((channel) => channel.HD === 1)?.length} />\n      <Block\n        label=\"hdhomerun.tunerCount\"\n        value={`${statusData?.filter((num) => num.VctNumber != null).length ?? 0} / ${statusData?.length ?? 0}`}\n      />\n      <Block label=\"hdhomerun.channelNumber\" value={statusData[tuner]?.VctNumber ?? null} />\n      <Block label=\"hdhomerun.channelNetwork\" value={statusData[tuner]?.VctName ?? null} />\n      <Block label=\"hdhomerun.signalStrength\" value={statusData[tuner]?.SignalStrengthPercent ?? null} />\n      <Block label=\"hdhomerun.signalQuality\" value={statusData[tuner]?.SignalQualityPercent ?? null} />\n      <Block label=\"hdhomerun.symbolQuality\" value={statusData[tuner]?.SymbolQualityPercent ?? null} />\n      <Block label=\"hdhomerun.clientIP\" value={statusData[tuner]?.TargetIP ?? null} />\n      <Block label=\"hdhomerun.networkRate\" value={statusData[tuner]?.NetworkRate ?? null} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/hdhomerun/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/hdhomerun/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // lineup\n      .mockReturnValueOnce({ data: undefined, error: undefined }); // status\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"hdhomerun\", url: \"http://x\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"hdhomerun.channels\")).toBeInTheDocument();\n    expect(screen.getByText(\"hdhomerun.hd\")).toBeInTheDocument();\n    expect(screen.getAllByText(\"-\")).toHaveLength(2);\n  });\n\n  it(\"caps widget.fields at 4 and filters blocks accordingly\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({\n        data: [{ HD: 1 }, { HD: 0 }, { HD: 1 }],\n        error: undefined,\n      })\n      .mockReturnValueOnce({\n        data: [\n          { VctNumber: \"5.1\", VctName: \"ABC\", SignalStrengthPercent: 90 },\n          { VctNumber: null, VctName: null, SignalStrengthPercent: null },\n        ],\n        error: undefined,\n      });\n\n    const service = {\n      widget: {\n        type: \"hdhomerun\",\n        url: \"http://x\",\n        fields: [\"channels\", \"hd\", \"tunerCount\", \"channelNumber\", \"signalStrength\"],\n      },\n    };\n\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"channels\", \"hd\", \"tunerCount\", \"channelNumber\"]);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"hdhomerun.channels\")).toBeInTheDocument();\n    expect(screen.getByText(\"hdhomerun.hd\")).toBeInTheDocument();\n    expect(screen.getByText(\"hdhomerun.tunerCount\")).toBeInTheDocument();\n    expect(screen.getByText(\"hdhomerun.channelNumber\")).toBeInTheDocument();\n    expect(screen.queryByText(\"hdhomerun.signalStrength\")).toBeNull();\n\n    expectBlockValue(container, \"hdhomerun.channels\", 3);\n    expectBlockValue(container, \"hdhomerun.hd\", 2);\n    expectBlockValue(container, \"hdhomerun.tunerCount\", \"1 / 2\");\n    expectBlockValue(container, \"hdhomerun.channelNumber\", \"5.1\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/hdhomerun/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    lineup: {\n      endpoint: \"lineup.json\",\n    },\n    status: {\n      endpoint: \"status.json\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/hdhomerun/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"hdhomerun widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/headscale/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { data: nodeData, error: nodeError } = useWidgetAPI(widget, \"node\");\n\n  if (nodeError) {\n    return <Container service={service} error={nodeError} />;\n  }\n\n  if (!nodeData) {\n    return (\n      <Container service={service}>\n        <Block label=\"headscale.name\" />\n        <Block label=\"headscale.address\" />\n        <Block label=\"headscale.last_seen\" />\n        <Block label=\"headscale.status\" />\n      </Container>\n    );\n  }\n\n  const {\n    givenName,\n    ipAddresses: [address],\n    lastSeen,\n    online,\n  } = nodeData.node;\n\n  return (\n    <Container service={service}>\n      <Block label=\"headscale.name\" value={givenName} />\n      <Block label=\"headscale.address\" value={address} />\n      <Block label=\"headscale.last_seen\" value={t(\"common.relativeDate\", { value: lastSeen })} />\n      <Block label=\"headscale.status\" value={t(online ? \"headscale.online\" : \"headscale.offline\")} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/headscale/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/headscale/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"headscale\", url: \"http://x\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"headscale.name\")).toBeInTheDocument();\n    expect(screen.getByText(\"headscale.address\")).toBeInTheDocument();\n    expect(screen.getByText(\"headscale.last_seen\")).toBeInTheDocument();\n    expect(screen.getByText(\"headscale.status\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"headscale\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders node details when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        node: {\n          givenName: \"node1\",\n          ipAddresses: [\"100.64.0.1\"],\n          lastSeen: 123,\n          online: true,\n        },\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"headscale\", url: \"http://x\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n\n    expectBlockValue(container, \"headscale.name\", \"node1\");\n    expectBlockValue(container, \"headscale.address\", \"100.64.0.1\");\n    expectBlockValue(container, \"headscale.last_seen\", 123);\n    expectBlockValue(container, \"headscale.status\", \"headscale.online\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/headscale/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/v1/{endpoint}/{nodeId}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    node: {\n      endpoint: \"node\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/headscale/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"headscale widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/healthchecks/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport { i18n } from \"../../../next-i18next.config\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nfunction formatDate(dateString) {\n  const date = new Date(dateString);\n  const now = new Date();\n  let dateOptions = {\n    month: \"numeric\",\n    day: \"numeric\",\n    hour: \"numeric\",\n    minute: \"numeric\",\n  };\n\n  if (\n    date.getFullYear() === now.getFullYear() &&\n    date.getMonth() === now.getMonth() &&\n    date.getDate() === now.getDate()\n  ) {\n    dateOptions = { timeStyle: \"short\" };\n  }\n\n  return new Intl.DateTimeFormat(i18n.language, dateOptions).format(date);\n}\n\nfunction countStatus(data) {\n  let upCount = 0;\n  let downCount = 0;\n\n  if (data.checks) {\n    data.checks.forEach((check) => {\n      if (check.status === \"up\") {\n        upCount += 1;\n      } else if (check.status === \"down\") {\n        downCount += 1;\n      }\n    });\n  }\n\n  return { upCount, downCount };\n}\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { data, error } = useWidgetAPI(widget, \"checks\");\n\n  if (error) {\n    return <Container service={service} error={error} />;\n  }\n\n  if (!data) {\n    return (\n      <Container service={service}>\n        <Block label=\"healthchecks.status\" />\n        <Block label=\"healthchecks.last_ping\" />\n      </Container>\n    );\n  }\n\n  const hasUuid = !!widget?.uuid;\n\n  const { upCount, downCount } = countStatus(data);\n\n  return hasUuid ? (\n    <Container service={service}>\n      <Block label=\"healthchecks.status\" value={t(`healthchecks.${data.status}`)} />\n      <Block\n        label=\"healthchecks.last_ping\"\n        value={data.last_ping ? formatDate(data.last_ping) : t(\"healthchecks.never\")}\n      />\n    </Container>\n  ) : (\n    <Container service={service}>\n      <Block label=\"healthchecks.up\" value={upCount} />\n      <Block label=\"healthchecks.down\" value={downCount} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/healthchecks/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/healthchecks/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"healthchecks\", url: \"http://x\" } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"healthchecks.status\")).toBeInTheDocument();\n    expect(screen.getByText(\"healthchecks.last_ping\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"healthchecks\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders up/down counts when widget.uuid is not set\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        checks: [{ status: \"up\" }, { status: \"down\" }, { status: \"up\" }, { status: \"paused\" }],\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"healthchecks\", url: \"http://x\" } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expectBlockValue(container, \"healthchecks.up\", 2);\n    expectBlockValue(container, \"healthchecks.down\", 1);\n  });\n\n  it(\"renders status and never when widget.uuid is set but last_ping is missing\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { status: \"up\", last_ping: null, checks: [] },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"healthchecks\", url: \"http://x\", uuid: \"abc\" } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expectBlockValue(container, \"healthchecks.status\", \"healthchecks.up\");\n    expectBlockValue(container, \"healthchecks.last_ping\", \"healthchecks.never\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/healthchecks/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/v3/{endpoint}/{uuid}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    checks: {\n      endpoint: \"checks\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/healthchecks/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"healthchecks widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/homeassistant/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  const { data, error } = useWidgetAPI(widget, null, { refreshInterval: 60000 });\n  if (error) {\n    return <Container service={service} error={error} />;\n  }\n\n  return (\n    <Container service={service}>\n      {data?.map((d) => (\n        <Block label={d.label} value={d.value} key={d.label} />\n      ))}\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/homeassistant/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/homeassistant/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"homeassistant\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders blocks returned from the API\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: [\n        { label: \"ha.temp\", value: \"72\" },\n        { label: \"ha.mode\", value: \"cool\" },\n      ],\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"homeassistant\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"ha.temp\")).toBeInTheDocument();\n    expect(screen.getByText(\"72\")).toBeInTheDocument();\n    expect(screen.getByText(\"ha.mode\")).toBeInTheDocument();\n    expect(screen.getByText(\"cool\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/homeassistant/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { httpProxy } from \"utils/proxy/http\";\n\nconst logger = createLogger(\"homeassistantProxyHandler\");\n\nconst defaultQueries = [\n  {\n    template: \"{{ states.person|selectattr('state','equalto','home')|list|length }} / {{ states.person|list|length }}\",\n    label: \"homeassistant.people_home\",\n  },\n  {\n    template: \"{{ states.light|selectattr('state','equalto','on')|list|length }} / {{ states.light|list|length }}\",\n    label: \"homeassistant.lights_on\",\n  },\n  {\n    template: \"{{ states.switch|selectattr('state','equalto','on')|list|length }} / {{ states.switch|list|length }}\",\n    label: \"homeassistant.switches_on\",\n  },\n];\n\nfunction formatOutput(output, data) {\n  return output.replace(\n    /\\{.*?\\}/g,\n    (match) =>\n      match\n        .replace(/\\{|\\}/g, \"\")\n        .split(\".\")\n        .reduce((o, p) => (o ? o[p] : \"\"), data) ?? \"\",\n  );\n}\n\nasync function getQuery(query, { url, key }) {\n  const headers = { Authorization: `Bearer ${key}` };\n  const { state, template, label, value } = query;\n  if (state) {\n    return {\n      result: await httpProxy(new URL(`${url}/api/states/${state}`), {\n        headers,\n        method: \"GET\",\n      }),\n      output: (data) => {\n        const jsonData = JSON.parse(data);\n        return {\n          label: formatOutput(label ?? \"{attributes.friendly_name}\", jsonData),\n          value: formatOutput(value ?? \"{state} {attributes.unit_of_measurement}\", jsonData),\n        };\n      },\n    };\n  }\n  if (template) {\n    return {\n      result: await httpProxy(new URL(`${url}/api/template`), {\n        headers,\n        method: \"POST\",\n        body: JSON.stringify({ template }),\n      }),\n      output: (data) => ({ label, value: data.toString() }),\n    };\n  }\n  return { result: [500, null, { error: { message: `invalid query ${JSON.stringify(query)}` } }] };\n}\n\nexport default async function homeassistantProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  let queries = defaultQueries;\n  if (!widget.fields && widget.custom) {\n    if (typeof widget.custom === \"string\") {\n      try {\n        widget.custom = JSON.parse(widget.custom);\n      } catch (error) {\n        logger.debug(\"Error parsing HASS widget custom label: %s\", JSON.stringify(error));\n        return res.status(400).json({ error: \"Error parsing widget custom label\" });\n      }\n    }\n    queries = widget.custom.slice(0, 4);\n  }\n\n  const results = await Promise.all(queries.map((q) => getQuery(q, widget)));\n\n  const err = results.find((r) => r.result[2]?.error);\n  if (err) {\n    const [status, , data] = err.result;\n    return res.status(status).send(data);\n  }\n\n  return res.status(200).send(\n    results.map((r) => {\n      const [status, , data] = r.result;\n      return status === 200 ? r.output(data) : { label: status, value: data.toString() };\n    }),\n  );\n}\n"
  },
  {
    "path": "src/widgets/homeassistant/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  logger: { debug: vi.fn() },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nimport homeassistantProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/homeassistant/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"returns 400 when custom JSON cannot be parsed\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://hass\", key: \"k\", custom: \"{not-json\" });\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await homeassistantProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Error parsing widget custom label\" });\n  });\n\n  it(\"runs default template queries and returns label/value pairs\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://hass\", key: \"k\" });\n    httpProxy\n      .mockResolvedValueOnce([200, \"text/plain\", Buffer.from(\"1 / 2\")])\n      .mockResolvedValueOnce([200, \"text/plain\", Buffer.from(\"3 / 4\")])\n      .mockResolvedValueOnce([200, \"text/plain\", Buffer.from(\"5 / 6\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await homeassistantProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(3);\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual([\n      { label: \"homeassistant.people_home\", value: \"1 / 2\" },\n      { label: \"homeassistant.lights_on\", value: \"3 / 4\" },\n      { label: \"homeassistant.switches_on\", value: \"5 / 6\" },\n    ]);\n  });\n});\n"
  },
  {
    "path": "src/widgets/homeassistant/widget.js",
    "content": "import homeassistantProxyHandler from \"./proxy\";\n\nconst widget = {\n  proxyHandler: homeassistantProxyHandler,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/homeassistant/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"homeassistant widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/homebox/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport const homeboxDefaultFields = [\"items\", \"locations\", \"totalValue\"];\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const { data: homeboxData, error: homeboxError } = useWidgetAPI(widget);\n\n  if (homeboxError) {\n    return <Container service={service} error={homeboxError} />;\n  }\n\n  // Default fields\n  if (!widget.fields?.length > 0) {\n    widget.fields = homeboxDefaultFields;\n  }\n  const MAX_ALLOWED_FIELDS = 4;\n  // Limits max number of displayed fields\n  if (widget.fields?.length > MAX_ALLOWED_FIELDS) {\n    widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);\n  }\n\n  if (!homeboxData) {\n    return (\n      <Container service={service}>\n        <Block label=\"homebox.items\" />\n        <Block label=\"homebox.totalWithWarranty\" />\n        <Block label=\"homebox.locations\" />\n        <Block label=\"homebox.labels\" />\n        <Block label=\"homebox.users\" />\n        <Block label=\"homebox.totalValue\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"homebox.items\" value={t(\"common.number\", { value: homeboxData.items })} />\n      <Block label=\"homebox.totalWithWarranty\" value={t(\"common.number\", { value: homeboxData.totalWithWarranty })} />\n      <Block label=\"homebox.locations\" value={t(\"common.number\", { value: homeboxData.locations })} />\n      <Block label=\"homebox.labels\" value={t(\"common.number\", { value: homeboxData.labels })} />\n      <Block label=\"homebox.users\" value={t(\"common.number\", { value: homeboxData.users })} />\n      <Block\n        label=\"homebox.totalValue\"\n        value={t(\"common.number\", {\n          value: homeboxData.totalValue,\n          style: \"currency\",\n          currency: `${homeboxData.currencyCode}`,\n        })}\n      />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/homebox/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component, { homeboxDefaultFields } from \"./component\";\n\ndescribe(\"widgets/homebox/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"defaults fields and filters to 3 blocks while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"homebox\", url: \"http://x\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual(homeboxDefaultFields);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"homebox.items\")).toBeInTheDocument();\n    expect(screen.getByText(\"homebox.locations\")).toBeInTheDocument();\n    expect(screen.getByText(\"homebox.totalValue\")).toBeInTheDocument();\n    expect(screen.queryByText(\"homebox.labels\")).toBeNull();\n    expect(screen.queryByText(\"homebox.users\")).toBeNull();\n    expect(screen.queryByText(\"homebox.totalWithWarranty\")).toBeNull();\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"homebox\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders values when loaded (currency formatting delegated to i18n)\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        items: 10,\n        totalWithWarranty: 2,\n        locations: 3,\n        labels: 4,\n        users: 5,\n        totalValue: 123.45,\n        currencyCode: \"USD\",\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"homebox\", url: \"http://x\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expectBlockValue(container, \"homebox.items\", 10);\n    expectBlockValue(container, \"homebox.locations\", 3);\n    expectBlockValue(container, \"homebox.totalValue\", 123.45);\n  });\n});\n"
  },
  {
    "path": "src/widgets/homebox/proxy.js",
    "content": "import cache from \"memory-cache\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\n\nconst proxyName = \"homeboxProxyHandler\";\nconst sessionTokenCacheKey = `${proxyName}__sessionToken`;\nconst logger = createLogger(proxyName);\n\nasync function login(widget, service) {\n  logger.debug(\"Homebox is rejecting the request, logging in.\");\n\n  const loginUrl = new URL(`${widget.url}/api/v1/users/login`).toString();\n  const loginBody = `username=${encodeURIComponent(widget.username)}&password=${encodeURIComponent(widget.password)}`;\n  const loginParams = {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n    body: loginBody,\n  };\n\n  const [, , data] = await httpProxy(loginUrl, loginParams);\n\n  try {\n    const { token, expiresAt } = JSON.parse(data.toString());\n    const expiresAtDate = new Date(expiresAt).getTime();\n    cache.put(`${sessionTokenCacheKey}.${service}`, token, expiresAtDate - Date.now());\n    return { token };\n  } catch (e) {\n    logger.error(\"Unable to login to Homebox API: %s\", e);\n  }\n\n  return { token: false };\n}\n\nasync function apiCall(widget, endpoint, service) {\n  const key = `${sessionTokenCacheKey}.${service}`;\n  const url = new URL(formatApiCall(\"{url}/api/v1/{endpoint}\", { endpoint, ...widget }));\n  const headers = {\n    \"Content-Type\": \"application/json\",\n    Authorization: `${cache.get(key)}`,\n  };\n  const params = { method: \"GET\", headers };\n\n  let [status, contentType, data, responseHeaders] = await httpProxy(url, params);\n\n  if (status === 401 || status === 403) {\n    logger.debug(\"Homebox API rejected the request, attempting to obtain new access token\");\n    const { token } = await login(widget, service);\n    headers.Authorization = `${token}`;\n\n    // retry request with new token\n    [status, contentType, data, responseHeaders] = await httpProxy(url, params);\n\n    if (status !== 200) {\n      logger.error(\"HTTP %d logging in to Homebox, data: %s\", status, data);\n      return { status, contentType, data: null, responseHeaders };\n    }\n  }\n\n  if (status !== 200) {\n    logger.error(\"HTTP %d getting data from Homebox, data: %s\", status, data);\n    return { status, contentType, data: null, responseHeaders };\n  }\n\n  return { status, contentType, data: JSON.parse(data.toString()), responseHeaders };\n}\n\nexport default async function homeboxProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  if (!cache.get(`${sessionTokenCacheKey}.${service}`)) {\n    await login(widget, service);\n  }\n\n  // Get stats for the main blocks\n  const { data: groupStats } = await apiCall(widget, \"groups/statistics\", service);\n\n  // Get group info for currency\n  const { data: groupData } = await apiCall(widget, \"groups\", service);\n\n  return res.status(200).send({\n    items: groupStats?.totalItems,\n    locations: groupStats?.totalLocations,\n    labels: groupStats?.totalLabels,\n    totalWithWarranty: groupStats?.totalWithWarranty,\n    totalValue: groupStats?.totalItemPrice,\n    users: groupStats?.totalUsers,\n    currencyCode: groupData?.currency,\n  });\n}\n"
  },
  {
    "path": "src/widgets/homebox/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {\n  const store = new Map();\n\n  return {\n    httpProxy: vi.fn(),\n    getServiceWidget: vi.fn(),\n    cache: {\n      get: vi.fn((k) => store.get(k)),\n      put: vi.fn((k, v) => store.set(k, v)),\n      del: vi.fn((k) => store.delete(k)),\n      _reset: () => store.clear(),\n    },\n    logger: {\n      debug: vi.fn(),\n      error: vi.fn(),\n    },\n  };\n});\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\nvi.mock(\"memory-cache\", () => ({\n  default: cache,\n  ...cache,\n}));\n\nimport homeboxProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/homebox/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    cache._reset();\n  });\n\n  it(\"logs in and returns group statistics + currency\", async () => {\n    getServiceWidget.mockResolvedValue({\n      url: \"http://homebox\",\n      username: \"u\",\n      password: \"p\",\n    });\n\n    httpProxy\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ token: \"tok\", expiresAt: new Date(Date.now() + 60_000).toISOString() })),\n      ])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ totalItems: 1, totalUsers: 2 }))])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ currency: \"USD\" }))]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await homeboxProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(3);\n    expect(httpProxy.mock.calls[0][0]).toBe(\"http://homebox/api/v1/users/login\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body.currencyCode).toBe(\"USD\");\n    expect(res.body.users).toBe(2);\n  });\n});\n"
  },
  {
    "path": "src/widgets/homebox/widget.js",
    "content": "import homeboxProxyHandler from \"./proxy\";\n\nconst widget = {\n  proxyHandler: homeboxProxyHandler,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/homebox/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"homebox widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/homebridge/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: homebridgeData, error: homebridgeError } = useWidgetAPI(widget, \"info\");\n\n  if (homebridgeError) {\n    return <Container service={service} error={homebridgeError} />;\n  }\n\n  if (!homebridgeData) {\n    return (\n      <Container service={service}>\n        <Block label=\"widget.status\" />\n        <Block label=\"homebridge.updates\" />\n        <Block label=\"homebridge.child_bridges\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"widget.status\" value={t(`homebridge.${homebridgeData.status}`)} />\n      <Block\n        label=\"homebridge.updates\"\n        value={\n          homebridgeData.updateAvailable || homebridgeData.plugins?.updatesAvailable\n            ? t(\"homebridge.update_available\")\n            : t(\"homebridge.up_to_date\")\n        }\n      />\n      {homebridgeData?.childBridges?.total > 0 && (\n        <Block\n          label=\"homebridge.child_bridges\"\n          value={t(\"homebridge.child_bridges_status\", {\n            total: homebridgeData.childBridges.total,\n            ok: homebridgeData.childBridges.running,\n          })}\n        />\n      )}\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/homebridge/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/homebridge/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"homebridge\", url: \"http://x\" } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"widget.status\")).toBeInTheDocument();\n    expect(screen.getByText(\"homebridge.updates\")).toBeInTheDocument();\n    expect(screen.getByText(\"homebridge.child_bridges\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"homebridge\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders update status and child bridge summary when child bridges exist\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        status: \"ok\",\n        updateAvailable: true,\n        plugins: { updatesAvailable: 0 },\n        childBridges: { total: 2, running: 1 },\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"homebridge\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"homebridge.ok\")).toBeInTheDocument();\n    expect(screen.getByText(\"homebridge.update_available\")).toBeInTheDocument();\n    // key is returned by the i18n mock; presence indicates the conditional block is rendered.\n    expect(screen.getByText(\"homebridge.child_bridges_status\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/homebridge/proxy.js",
    "content": "import cache from \"memory-cache\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst proxyName = \"homebridgeProxyHandler\";\nconst sessionTokenCacheKey = `${proxyName}__sessionToken`;\nconst logger = createLogger(proxyName);\n\nasync function login(widget, service) {\n  const endpoint = \"auth/login\";\n  const api = widgets?.[widget.type]?.api;\n  const loginUrl = new URL(formatApiCall(api, { endpoint, ...widget }));\n  const loginBody = { username: widget.username.toString(), password: widget.password.toString() };\n  const headers = { \"Content-Type\": \"application/json\" };\n\n  const [status, contentType, data, responseHeaders] = await httpProxy(loginUrl, {\n    method: \"POST\",\n    body: JSON.stringify(loginBody),\n    headers,\n  });\n\n  try {\n    const { access_token: accessToken, expires_in: expiresIn } = JSON.parse(data.toString());\n\n    cache.put(`${sessionTokenCacheKey}.${service}`, accessToken, expiresIn * 1000 - 5 * 60 * 1000); // expiresIn (s) - 5m\n    return { accessToken };\n  } catch (e) {\n    logger.error(\"Unable to login to Homebridge API: %s\", e);\n  }\n\n  return { accessToken: false };\n}\n\nasync function apiCall(widget, endpoint, service) {\n  const key = `${sessionTokenCacheKey}.${service}`;\n  const headers = {\n    \"content-type\": \"application/json\",\n    Authorization: `Bearer ${cache.get(key)}`,\n  };\n\n  const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));\n  const method = \"GET\";\n\n  let [status, contentType, data, responseHeaders] = await httpProxy(url, {\n    method,\n    headers,\n  });\n\n  if (status === 401 || status === 403) {\n    logger.debug(\"Homebridge API rejected the request, attempting to obtain new session token\");\n    const { accessToken } = await login(widget, service);\n    headers.Authorization = `Bearer ${accessToken}`;\n\n    // retry the request, now with the new session token\n    [status, contentType, data, responseHeaders] = await httpProxy(url, {\n      method,\n      headers,\n    });\n  }\n\n  if (status !== 200) {\n    logger.error(\"Error getting data from Homebridge: %s status %d. Data: %s\", url, status, JSON.stringify(data));\n    return { status, contentType, data: null, responseHeaders };\n  }\n\n  return { status, contentType, data: JSON.parse(data.toString()), responseHeaders };\n}\n\nexport default async function homebridgeProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  if (!cache.get(`${sessionTokenCacheKey}.${service}`)) {\n    await login(widget, service);\n  }\n\n  const { data: statusData } = await apiCall(widget, \"status/homebridge\", service);\n  const { data: versionData } = await apiCall(widget, \"status/homebridge-version\", service);\n  const { data: childBridgeData } = await apiCall(widget, \"status/homebridge/child-bridges\", service);\n  const { data: pluginsData } = await apiCall(widget, \"plugins\", service);\n\n  return res.status(200).send({\n    status: statusData?.status,\n    updateAvailable: versionData?.updateAvailable,\n    plugins: {\n      updatesAvailable: pluginsData?.filter((p) => p.updateAvailable).length,\n    },\n    childBridges: {\n      running: childBridgeData?.filter((cb) => cb.status === \"ok\").length,\n      total: childBridgeData?.length,\n    },\n  });\n}\n"
  },
  {
    "path": "src/widgets/homebridge/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {\n  const store = new Map();\n\n  return {\n    httpProxy: vi.fn(),\n    getServiceWidget: vi.fn(),\n    cache: {\n      get: vi.fn((k) => store.get(k)),\n      put: vi.fn((k, v) => store.set(k, v)),\n      del: vi.fn((k) => store.delete(k)),\n      _reset: () => store.clear(),\n    },\n    logger: {\n      debug: vi.fn(),\n      error: vi.fn(),\n    },\n  };\n});\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\nvi.mock(\"memory-cache\", () => ({\n  default: cache,\n  ...cache,\n}));\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    homebridge: {\n      api: \"{url}/{endpoint}\",\n    },\n  },\n}));\n\nimport homebridgeProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/homebridge/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    cache._reset();\n  });\n\n  it(\"logs in and aggregates status, versions, plugin updates, and child bridge counts\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"homebridge\", url: \"http://hb\", username: \"u\", password: \"p\" });\n\n    httpProxy\n      // login\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ access_token: \"tok\", expires_in: 3600 })),\n        {},\n      ])\n      // status\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ status: \"ok\" })), {}])\n      // version\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ updateAvailable: true })), {}])\n      // child bridges\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify([{ status: \"ok\" }, { status: \"down\" }])),\n        {},\n      ])\n      // plugins\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify([{ updateAvailable: true }, { updateAvailable: false }])),\n        {},\n      ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await homebridgeProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({\n      status: \"ok\",\n      updateAvailable: true,\n      plugins: { updatesAvailable: 1 },\n      childBridges: { running: 1, total: 2 },\n    });\n  });\n});\n"
  },
  {
    "path": "src/widgets/homebridge/widget.js",
    "content": "import homebridgeProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: homebridgeProxyHandler,\n\n  mappings: {\n    info: {\n      endpoint: \"/\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/homebridge/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"homebridge widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/iframe/component.jsx",
    "content": "import classNames from \"classnames\";\nimport Container from \"components/services/widget/container\";\nimport { useEffect, useState } from \"react\";\n\nexport default function Component({ service }) {\n  const [refreshTimer, setRefreshTimer] = useState(0);\n\n  const { widget } = service;\n\n  useEffect(() => {\n    if (widget?.refreshInterval) {\n      setInterval(\n        () => setRefreshTimer(refreshTimer + 1),\n        widget?.refreshInterval < 1000 ? 1000 : widget?.refreshInterval,\n      );\n    }\n  }, [refreshTimer, widget?.refreshInterval]);\n\n  const scrollingDisableStyle = widget?.allowScrolling === \"no\" ? { pointerEvents: \"none\", overflow: \"hidden\" } : {};\n\n  const classes = widget?.classes || \"h-60 sm:h-60 md:h-60 lg:h-60 xl:h-60 2xl:h-72\";\n\n  return (\n    <Container service={service}>\n      <div\n        className={classNames(\n          \"bg-theme-200/50 dark:bg-theme-900/20 rounded-sm m-1 flex-1 flex flex-col items-center justify-center text-center scheme-light\",\n          \"service-block\",\n        )}\n      >\n        <iframe\n          src={widget?.src}\n          key={`${widget?.name}-${refreshTimer}`}\n          name={widget?.name}\n          title={widget?.name}\n          allow={widget?.allowPolicy}\n          allowFullScreen={widget?.allowfullscreen}\n          referrerPolicy={widget?.referrerPolicy}\n          loading={widget?.loadingStrategy}\n          scrolling={widget?.allowScrolling}\n          style={{\n            scrollingDisableStyle,\n          }}\n          className={`rounded-sm w-full ${classes}`}\n        />\n      </div>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/iframe/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { describe, expect, it } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/iframe/component\", () => {\n  it(\"renders an iframe with the configured src/title and classes\", () => {\n    const service = {\n      widget: {\n        type: \"iframe\",\n        name: \"My Frame\",\n        src: \"http://example.test\",\n        classes: \"h-10 w-10\",\n        allowScrolling: \"no\",\n      },\n    };\n\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    const iframe = container.querySelector(\"iframe\");\n    expect(iframe).toBeTruthy();\n    expect(iframe.getAttribute(\"src\")).toBe(\"http://example.test\");\n    expect(iframe.getAttribute(\"title\")).toBe(\"My Frame\");\n    expect(iframe.getAttribute(\"name\")).toBe(\"My Frame\");\n    expect(iframe.getAttribute(\"scrolling\")).toBe(\"no\");\n    expect(iframe.className).toContain(\"h-10 w-10\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/iframe/widget.js",
    "content": "const widget = {};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/iframe/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"iframe widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/immich/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { version = 1 } = widget;\n\n  const versionEndpoint = version === 2 ? \"version_v2\" : \"version\";\n\n  const { data: versionData, error: versionError } = useWidgetAPI(widget, versionEndpoint);\n\n  let statsEndpoint = version === 2 ? \"statistics_v2\" : \"stats\";\n  if (version === 1) {\n    // see https://github.com/gethomepage/homepage/issues/2282\n    statsEndpoint =\n      versionData?.major > 1 || (versionData?.major === 1 && versionData?.minor > 84) ? \"statistics\" : \"stats\";\n  }\n  const { data: immichData, error: immichError } = useWidgetAPI(widget, statsEndpoint);\n\n  if (immichError || versionError || immichData?.statusCode === 401) {\n    return <Container service={service} error={immichData ?? immichError ?? versionError} />;\n  }\n\n  if (!immichData) {\n    return (\n      <Container service={service}>\n        <Block label=\"immich.users\" />\n        <Block label=\"immich.photos\" />\n        <Block label=\"immich.videos\" />\n        <Block label=\"immich.storage\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"immich.users\" value={t(\"common.number\", { value: immichData.usageByUser.length })} />\n      <Block label=\"immich.photos\" value={t(\"common.number\", { value: immichData.photos })} />\n      <Block label=\"immich.videos\" value={t(\"common.number\", { value: immichData.videos })} />\n      <Block\n        label=\"immich.storage\"\n        value={\n          // backwards-compatible e.g. '9 GiB'\n          immichData.usage.toString().toLowerCase().includes(\"b\")\n            ? immichData.usage\n            : t(\"common.bytes\", {\n                value: immichData.usage,\n                maximumFractionDigits: 1,\n                binary: true, // match immich\n              })\n        }\n      />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/immich/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/immich/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"uses v1 endpoints and renders placeholders while loading\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // version\n      .mockReturnValueOnce({ data: undefined, error: undefined }); // stats\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"immich\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(useWidgetAPI.mock.calls[0][1]).toBe(\"version\");\n    expect(useWidgetAPI.mock.calls[1][1]).toBe(\"stats\");\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"immich.users\")).toBeInTheDocument();\n    expect(screen.getByText(\"immich.photos\")).toBeInTheDocument();\n    expect(screen.getByText(\"immich.videos\")).toBeInTheDocument();\n    expect(screen.getByText(\"immich.storage\")).toBeInTheDocument();\n  });\n\n  it(\"selects the v1 statistics endpoint when version is > 1.84\", () => {\n    useWidgetAPI.mockReturnValueOnce({ data: { major: 1, minor: 85 }, error: undefined }).mockReturnValueOnce({\n      data: { usageByUser: [{ id: 1 }, { id: 2 }], photos: 3, videos: 4, usage: \"9 GiB\" },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"immich\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(useWidgetAPI.mock.calls[1][1]).toBe(\"statistics\");\n    expectBlockValue(container, \"immich.users\", 2);\n    expectBlockValue(container, \"immich.photos\", 3);\n    expectBlockValue(container, \"immich.videos\", 4);\n    expectBlockValue(container, \"immich.storage\", \"9 GiB\");\n  });\n\n  it(\"uses v2 endpoints when widget.version === 2\", () => {\n    useWidgetAPI.mockReturnValueOnce({ data: { major: 2, minor: 0 }, error: undefined }).mockReturnValueOnce({\n      data: { usageByUser: [], photos: 0, videos: 0, usage: 0 },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"immich\", url: \"http://x\", version: 2 } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(useWidgetAPI.mock.calls[0][1]).toBe(\"version_v2\");\n    expect(useWidgetAPI.mock.calls[1][1]).toBe(\"statistics_v2\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/immich/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    version: {\n      endpoint: \"server-info/version\",\n    },\n    statistics: {\n      endpoint: \"server-info/statistics\",\n    },\n    stats: {\n      endpoint: \"server-info/stats\",\n    },\n    version_v2: {\n      endpoint: \"server/version\",\n    },\n    statistics_v2: {\n      endpoint: \"server/statistics\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/immich/widget.test.js",
    "content": "import { describe, expect, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"immich widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n    expect(widget.api).toContain(\"/api/\");\n    expect(widget.mappings?.version?.endpoint).toBe(\"server-info/version\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/jackett/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: indexersData, error: indexersError } = useWidgetAPI(widget, \"indexers\");\n\n  if (indexersError) {\n    return <Container service={service} error={indexersError} />;\n  }\n\n  if (!indexersData) {\n    return (\n      <Container service={service}>\n        <Block label=\"jackett.configured\" />\n        <Block label=\"jackett.errored\" />\n      </Container>\n    );\n  }\n\n  const errored = indexersData.filter((indexer) => indexer.last_error);\n\n  return (\n    <Container service={service}>\n      <Block label=\"jackett.configured\" value={t(\"common.number\", { value: indexersData.length })} />\n      <Block label=\"jackett.errored\" value={t(\"common.number\", { value: errored.length })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/jackett/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/jackett/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"jackett\", url: \"http://x\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"jackett.configured\")).toBeInTheDocument();\n    expect(screen.getByText(\"jackett.errored\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"jackett\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders configured and errored counts when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: [\n        { id: 1, last_error: \"\" },\n        { id: 2, last_error: \"boom\" },\n        { id: 3, last_error: null },\n      ],\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"jackett\", url: \"http://x\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n\n    expectBlockValue(container, \"jackett.configured\", 3);\n    expectBlockValue(container, \"jackett.errored\", 1);\n  });\n});\n"
  },
  {
    "path": "src/widgets/jackett/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst logger = createLogger(\"jackettProxyHandler\");\n\nasync function fetchJackettCookie(widget, loginURL) {\n  const url = new URL(formatApiCall(loginURL, widget));\n  const loginData = `password=${encodeURIComponent(widget.password)}`;\n  const [status, , , , params] = await httpProxy(url, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/x-www-form-urlencoded\",\n    },\n    body: loginData,\n  });\n\n  if (!(status === 200) || !params?.headers?.Cookie) {\n    logger.error(\"Failed to fetch Jackett cookie, status: %d\", status);\n    return null;\n  }\n  return params.headers.Cookie;\n}\n\nexport default async function jackettProxyHandler(req, res) {\n  const { group, service, endpoint, index } = req.query;\n\n  if (!group || !service) {\n    logger.error(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n  if (!widget || !widgets[widget.type].api) {\n    logger.error(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid widget configuration\" });\n  }\n\n  if (widget.password) {\n    const jackettCookie = await fetchJackettCookie(widget, widgets[widget.type].loginURL);\n    if (!jackettCookie) {\n      return res.status(500).json({ error: \"Failed to authenticate with Jackett\" });\n    }\n    // Add the cookie to the widget for use in subsequent requests\n    widget.headers = { ...widget.headers, Cookie: jackettCookie };\n  }\n\n  const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));\n\n  try {\n    const [status, , data] = await httpProxy(url, {\n      method: \"GET\",\n      headers: widget.headers,\n    });\n\n    if (status !== 200) {\n      logger.error(\"Error calling Jackett API: %d. Data: %s\", status, data);\n      return res.status(status).json({ error: \"Failed to call Jackett API\", data });\n    }\n\n    return res.status(status).send(data);\n  } catch (error) {\n    logger.error(\"Exception calling Jackett API: %s\", error.message);\n    return res.status(500).json({ error: \"Server error\", message: error.message });\n  }\n}\n"
  },
  {
    "path": "src/widgets/jackett/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  logger: {\n    error: vi.fn(),\n  },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    jackett: {\n      api: \"{url}/{endpoint}\",\n      loginURL: \"{url}/UI/Dashboard\",\n    },\n  },\n}));\n\nimport jackettProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/jackett/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"fetches an auth cookie when password is set and passes it on requests\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"jackett\",\n      url: \"http://jackett\",\n      password: \"pw\",\n    });\n\n    httpProxy\n      // login cookie fetch\n      .mockResolvedValueOnce([200, \"text/plain\", null, null, { headers: { Cookie: \"c=1\" } }])\n      // api call\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"ok\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"api/v2.0/indexers/all/results\", index: \"0\" } };\n    const res = createMockRes();\n\n    await jackettProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(2);\n    expect(httpProxy.mock.calls[1][1].headers.Cookie).toBe(\"c=1\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual(Buffer.from(\"ok\"));\n  });\n\n  it(\"returns 500 when cookie authentication fails\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"jackett\",\n      url: \"http://jackett\",\n      password: \"pw\",\n    });\n\n    httpProxy.mockResolvedValueOnce([200, \"text/plain\", null, null, { headers: {} }]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"api\", index: \"0\" } };\n    const res = createMockRes();\n\n    await jackettProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: \"Failed to authenticate with Jackett\" });\n  });\n});\n"
  },
  {
    "path": "src/widgets/jackett/widget.js",
    "content": "import jackettProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/api/v2.0/{endpoint}?apikey={key}&configured=true\",\n  proxyHandler: jackettProxyHandler,\n  loginURL: \"{url}/UI/Dashboard\",\n\n  mappings: {\n    indexers: {\n      endpoint: \"indexers\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/jackett/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"jackett widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/jdownloader/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: jdownloaderData, error: jdownloaderAPIError } = useWidgetAPI(widget, \"unified\", {\n    refreshInterval: 30000,\n  });\n\n  if (jdownloaderAPIError) {\n    return <Container service={service} error={jdownloaderAPIError} />;\n  }\n\n  if (!jdownloaderData) {\n    return (\n      <Container service={service}>\n        <Block label=\"jdownloader.downloadCount\" />\n        <Block label=\"jdownloader.downloadTotalBytes\" />\n        <Block label=\"jdownloader.downloadBytesRemaining\" />\n        <Block label=\"jdownloader.downloadSpeed\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"jdownloader.downloadCount\" value={t(\"common.number\", { value: jdownloaderData.downloadCount })} />\n      <Block\n        label=\"jdownloader.downloadTotalBytes\"\n        value={t(\"common.bytes\", { value: jdownloaderData.totalBytes })}\n        highlightValue={jdownloaderData.totalBytes}\n      />\n      <Block\n        label=\"jdownloader.downloadBytesRemaining\"\n        value={t(\"common.bytes\", { value: jdownloaderData.bytesRemaining })}\n        highlightValue={jdownloaderData.bytesRemaining}\n      />\n      <Block\n        label=\"jdownloader.downloadSpeed\"\n        value={t(\"common.byterate\", { value: jdownloaderData.totalSpeed })}\n        highlightValue={jdownloaderData.totalSpeed}\n      />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/jdownloader/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/jdownloader/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"jdownloader\", url: \"http://x\" } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"jdownloader.downloadCount\")).toBeInTheDocument();\n    expect(screen.getByText(\"jdownloader.downloadTotalBytes\")).toBeInTheDocument();\n    expect(screen.getByText(\"jdownloader.downloadBytesRemaining\")).toBeInTheDocument();\n    expect(screen.getByText(\"jdownloader.downloadSpeed\")).toBeInTheDocument();\n  });\n\n  it(\"calls the unified endpoint with a 30s refresh interval\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"jdownloader\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(useWidgetAPI.mock.calls[0][1]).toBe(\"unified\");\n    expect(useWidgetAPI.mock.calls[0][2]?.refreshInterval).toBe(30000);\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"jdownloader\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders values when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        downloadCount: 1,\n        totalBytes: 100,\n        bytesRemaining: 40,\n        totalSpeed: 10,\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"jdownloader\", url: \"http://x\" } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expectBlockValue(container, \"jdownloader.downloadCount\", 1);\n    expectBlockValue(container, \"jdownloader.downloadTotalBytes\", 100);\n    expectBlockValue(container, \"jdownloader.downloadBytesRemaining\", 40);\n    expectBlockValue(container, \"jdownloader.downloadSpeed\", 10);\n  });\n});\n"
  },
  {
    "path": "src/widgets/jdownloader/proxy.js",
    "content": "import crypto from \"crypto\";\nimport querystring from \"querystring\";\n\nimport { createEncryptionToken, decrypt, encrypt, sha256, uniqueRid, validateRid } from \"./tools\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { httpProxy } from \"utils/proxy/http\";\n\nconst proxyName = \"jdownloaderProxyHandler\";\nconst logger = createLogger(proxyName);\n\nasync function getWidget(req) {\n  const { group, service, index } = req.query;\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return null;\n  }\n  const widget = await getServiceWidget(group, service, index);\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return null;\n  }\n\n  return widget;\n}\n\nasync function login(loginSecret, deviceSecret, params) {\n  const rid = uniqueRid();\n  const path = `/my/connect?${querystring.stringify({ ...params, rid })}`;\n\n  const signature = crypto.createHmac(\"sha256\", loginSecret).update(path).digest(\"hex\");\n  const url = `${new URL(`https://api.jdownloader.org${path}&signature=${signature}`)}`;\n\n  const [status, contentType, data] = await httpProxy(url, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n    },\n  });\n\n  if (status !== 200) {\n    logger.error(\"HTTP %d communicating with jdownloader. Data: %s\", status, data.toString());\n    return [status, data];\n  }\n\n  try {\n    const decryptedData = JSON.parse(decrypt(data.toString(), loginSecret));\n    const sessionToken = decryptedData.sessiontoken;\n    validateRid(decryptedData, rid);\n    const serverEncryptionToken = createEncryptionToken(loginSecret, sessionToken);\n    const deviceEncryptionToken = createEncryptionToken(deviceSecret, sessionToken);\n    return [status, decryptedData, contentType, serverEncryptionToken, deviceEncryptionToken, sessionToken];\n  } catch (e) {\n    logger.error(\"Error decoding jdownloader API data. Data: %s\", data.toString());\n    return [status, null];\n  }\n}\n\nasync function getDevice(serverEncryptionToken, deviceName, params) {\n  const rid = uniqueRid();\n  const path = `/my/listdevices?${querystring.stringify({ ...params, rid })}`;\n  const signature = crypto.createHmac(\"sha256\", serverEncryptionToken).update(path).digest(\"hex\");\n  const url = `${new URL(`https://api.jdownloader.org${path}&signature=${signature}`)}`;\n\n  const [status, , data] = await httpProxy(url, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n    },\n  });\n\n  if (status !== 200) {\n    logger.error(\"HTTP %d communicating with jdownloader. Data: %s\", status, data.toString());\n    return [status, data];\n  }\n\n  try {\n    const decryptedData = JSON.parse(decrypt(data.toString(), serverEncryptionToken));\n    const filteredDevice = decryptedData.list.filter((device) => device.name === deviceName);\n    return [status, filteredDevice[0].id];\n  } catch (e) {\n    logger.error(\"Error decoding jdownloader API data. Data: %s\", data.toString());\n    return [status, null];\n  }\n}\n\nfunction createBody(rid, query, params) {\n  const baseBody = {\n    apiVer: 1,\n    rid,\n    url: query,\n  };\n  return params ? { ...baseBody, params: [JSON.stringify(params)] } : baseBody;\n}\n\nasync function queryPackages(deviceEncryptionToken, deviceId, sessionToken, params) {\n  const rid = uniqueRid();\n  const body = encrypt(JSON.stringify(createBody(rid, \"/downloadsV2/queryPackages\", params)), deviceEncryptionToken);\n  const url = `${new URL(\n    `https://api.jdownloader.org/t_${encodeURI(sessionToken)}_${encodeURI(deviceId)}/downloadsV2/queryPackages`,\n  )}`;\n  const [status, , data] = await httpProxy(url, {\n    method: \"POST\",\n    body,\n  });\n\n  if (status !== 200) {\n    logger.error(\"HTTP %d communicating with jdownloader. Data: %s\", status, data.toString());\n    return [status, data];\n  }\n\n  try {\n    const decryptedData = JSON.parse(decrypt(data.toString(), deviceEncryptionToken));\n    return decryptedData.data;\n  } catch (e) {\n    logger.error(\"Error decoding JDRss jdownloader data. Data: %s\", data.toString());\n    return [status, null];\n  }\n}\n\nexport default async function jdownloaderProxyHandler(req, res) {\n  const widget = await getWidget(req);\n\n  if (!widget) {\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n  logger.debug(\"Getting data from JDRss API\");\n  const { username } = widget;\n  const { password } = widget;\n\n  const appKey = \"homepage\";\n  const loginSecret = sha256(`${username}${password}server`);\n  const deviceSecret = sha256(`${username}${password}device`);\n  const email = username;\n\n  const loginData = await login(loginSecret, deviceSecret, {\n    appKey,\n    email,\n  });\n\n  const deviceData = await getDevice(loginData[3], widget.client, {\n    sessiontoken: loginData[5],\n  });\n\n  const packageStatus = await queryPackages(loginData[4], deviceData[1], loginData[5], {\n    bytesLoaded: true,\n    bytesTotal: true,\n    comment: false,\n    enabled: true,\n    eta: false,\n    priority: false,\n    finished: true,\n    running: true,\n    speed: true,\n    status: true,\n    childCount: false,\n    hosts: false,\n    saveTo: false,\n    maxResults: -1,\n    startAt: 0,\n  });\n\n  let totalLoaded = 0;\n  let totalBytes = 0;\n  let totalSpeed = 0;\n  packageStatus.forEach((file) => {\n    totalBytes += file.bytesTotal;\n    totalLoaded += file.bytesLoaded;\n    if (file.finished !== true && file.speed) {\n      totalSpeed += file.speed;\n    }\n  });\n\n  const data = {\n    downloadCount: packageStatus.length,\n    bytesRemaining: totalBytes - totalLoaded,\n    totalBytes,\n    totalSpeed,\n  };\n\n  return res.send(data);\n}\n"
  },
  {
    "path": "src/widgets/jdownloader/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, tools, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  tools: {\n    uniqueRid: vi.fn(() => 123),\n    sha256: vi.fn(() => \"secret\"),\n    validateRid: vi.fn(() => true),\n    createEncryptionToken: vi.fn(() => \"enc-token\"),\n    decrypt: vi.fn((cipherText) => {\n      if (cipherText === \"connect\") {\n        return JSON.stringify({ rid: 123, sessiontoken: \"sess\" });\n      }\n      if (cipherText === \"devices\") {\n        return JSON.stringify({ list: [{ name: \"myclient\", id: \"dev1\" }] });\n      }\n      if (cipherText === \"packages\") {\n        return JSON.stringify({\n          data: [\n            { bytesLoaded: 40, bytesTotal: 100, finished: false, speed: 10 },\n            { bytesLoaded: 100, bytesTotal: 100, finished: true, speed: 0 },\n          ],\n        });\n      }\n      return JSON.stringify({});\n    }),\n    encrypt: vi.fn(() => \"encrypted-body\"),\n  },\n  logger: { debug: vi.fn(), error: vi.fn() },\n}));\n\nvi.mock(\"./tools\", () => tools);\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nimport jdownloaderProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/jdownloader/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"aggregates package stats from the JDownloader API\", async () => {\n    getServiceWidget.mockResolvedValue({\n      url: \"http://ignored\",\n      username: \"user@example.com\",\n      password: \"pw\",\n      client: \"myclient\",\n    });\n\n    httpProxy\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"connect\")])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"devices\")])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"packages\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await jdownloaderProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(3);\n    expect(res.body).toEqual({\n      downloadCount: 2,\n      bytesRemaining: 60,\n      totalBytes: 200,\n      totalSpeed: 10,\n    });\n  });\n});\n"
  },
  {
    "path": "src/widgets/jdownloader/tools.js",
    "content": "import crypto from \"crypto\";\n\nexport function sha256(data) {\n  return crypto.createHash(\"sha256\").update(data).digest();\n}\n\nexport function uniqueRid() {\n  return Math.floor(Math.random() * 10e12);\n}\n\nexport function validateRid(decryptedData, rid) {\n  if (decryptedData.rid !== rid) {\n    throw new Error(\"RequestID mismatch\");\n  }\n  return decryptedData;\n}\n\nexport function decrypt(data, ivKey) {\n  const iv = ivKey.slice(0, ivKey.length / 2);\n  const key = ivKey.slice(ivKey.length / 2, ivKey.length);\n  const cipher = crypto.createDecipheriv(\"aes-128-cbc\", key, iv);\n  return Buffer.concat([cipher.update(data, \"base64\"), cipher.final()]).toString();\n}\n\nexport function createEncryptionToken(oldTokenBuff, updateToken) {\n  const updateTokenBuff = Buffer.from(updateToken, \"hex\");\n  const mergedBuffer = Buffer.concat([oldTokenBuff, updateTokenBuff], oldTokenBuff.length + updateTokenBuff.length);\n  return sha256(mergedBuffer);\n}\n\nexport function encrypt(data, ivKey) {\n  if (typeof data !== \"string\") {\n    throw new Error(\"data no es un string\");\n  }\n  if (!(ivKey instanceof Buffer)) {\n    throw new Error(\"ivKey no es un buffer\");\n  }\n  if (ivKey.length !== 32) {\n    throw new Error(\"ivKey tiene que tener tamaño 32\");\n  }\n  const stringIVKey = ivKey.toString(\"hex\");\n  const stringIV = stringIVKey.substring(0, stringIVKey.length / 2);\n  const stringKey = stringIVKey.substring(stringIVKey.length / 2, stringIVKey.length);\n  const iv = Buffer.from(stringIV, \"hex\");\n  const key = Buffer.from(stringKey, \"hex\");\n  const cipher = crypto.createCipheriv(\"aes-128-cbc\", key, iv);\n  return cipher.update(data, \"utf8\", \"base64\") + cipher.final(\"base64\");\n}\n"
  },
  {
    "path": "src/widgets/jdownloader/tools.test.js",
    "content": "import crypto from \"crypto\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { createEncryptionToken, decrypt, encrypt, sha256, uniqueRid, validateRid } from \"./tools\";\n\ndescribe(\"widgets/jdownloader/tools\", () => {\n  it(\"sha256 returns a 32-byte buffer\", () => {\n    expect(sha256(\"hello\")).toBeInstanceOf(Buffer);\n    expect(sha256(\"hello\")).toHaveLength(32);\n  });\n\n  it(\"uniqueRid returns an integer\", () => {\n    vi.spyOn(Math, \"random\").mockReturnValueOnce(0.123);\n    expect(uniqueRid()).toBeTypeOf(\"number\");\n    expect(Number.isInteger(uniqueRid())).toBe(true);\n    Math.random.mockRestore();\n  });\n\n  it(\"validateRid throws when mismatched\", () => {\n    expect(() => validateRid({ rid: 1 }, 2)).toThrow(/RequestID mismatch/i);\n    expect(validateRid({ rid: 5 }, 5)).toEqual({ rid: 5 });\n  });\n\n  it(\"encrypt/decrypt roundtrip with a 32-byte ivKey\", () => {\n    const ivKey = crypto.randomBytes(32);\n    const plaintext = \"secret\";\n    const encrypted = encrypt(plaintext, ivKey);\n    const decrypted = decrypt(encrypted, ivKey);\n    expect(decrypted).toBe(plaintext);\n  });\n\n  it(\"createEncryptionToken merges buffers and hashes\", () => {\n    const oldToken = Buffer.from(\"aa\", \"hex\");\n    const updateToken = \"bb\";\n    const token = createEncryptionToken(oldToken, updateToken);\n    expect(token).toBeInstanceOf(Buffer);\n    expect(token).toHaveLength(32);\n  });\n});\n"
  },
  {
    "path": "src/widgets/jdownloader/widget.js",
    "content": "import jdownloaderProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"https://api.jdownloader.org/{endpoint}/&signature={signature}\",\n  proxyHandler: jdownloaderProxyHandler,\n\n  mappings: {\n    unified: {\n      endpoint: \"/\",\n      signature: \"\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/jdownloader/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"jdownloader widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/jellyfin/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\nimport { BsCpu, BsFillCpuFill, BsFillPlayFill, BsPauseFill, BsVolumeMuteFill } from \"react-icons/bs\";\nimport { MdOutlineSmartDisplay } from \"react-icons/md\";\n\nimport { getURLSearchParams } from \"utils/proxy/api-helpers\";\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nfunction ticksToTime(ticks) {\n  const milliseconds = ticks / 10000;\n  const seconds = Math.floor((milliseconds / 1000) % 60);\n  const minutes = Math.floor((milliseconds / (1000 * 60)) % 60);\n  const hours = Math.floor((milliseconds / (1000 * 60 * 60)) % 24);\n  return { hours, minutes, seconds };\n}\n\nfunction ticksToString(ticks) {\n  const { hours, minutes, seconds } = ticksToTime(ticks);\n  const parts = [];\n  if (hours > 0) {\n    parts.push(hours);\n  }\n  parts.push(minutes);\n  parts.push(seconds);\n\n  return parts.map((part) => part.toString().padStart(2, \"0\")).join(\":\");\n}\n\nfunction generateStreamTitle(session, enableUser, showEpisodeNumber) {\n  const {\n    NowPlayingItem: { Name, SeriesName, Type, ParentIndexNumber, IndexNumber, AlbumArtist, Album },\n    UserName,\n  } = session;\n  let streamTitle = \"\";\n\n  if (Type === \"Episode\" && showEpisodeNumber) {\n    const seasonStr = ParentIndexNumber ? `S${ParentIndexNumber.toString().padStart(2, \"0\")}` : \"\";\n    const episodeStr = IndexNumber ? `E${IndexNumber.toString().padStart(2, \"0\")}` : \"\";\n    streamTitle = `${SeriesName}: ${seasonStr} · ${episodeStr} - ${Name}`;\n  } else if (Type === \"Audio\") {\n    streamTitle = `${AlbumArtist} - ${Album} - ${Name}`;\n  } else {\n    streamTitle = `${Name}${SeriesName ? ` - ${SeriesName}` : \"\"}`;\n  }\n\n  return enableUser ? `${streamTitle} (${UserName})` : streamTitle;\n}\n\nfunction SingleSessionEntry({ playCommand, session, enableUser, showEpisodeNumber, enableMediaControl }) {\n  const {\n    PlayState: { PositionTicks, IsPaused, IsMuted },\n  } = session;\n\n  const RunTimeTicks =\n    session.NowPlayingItem?.RunTimeTicks ?? session.NowPlayingItem?.CurrentProgram?.RunTimeTicks ?? 0;\n\n  const { IsVideoDirect, VideoDecoderIsHardware, VideoEncoderIsHardware } = session?.TranscodingInfo || {\n    IsVideoDirect: true,\n  }; // if no transcodinginfo its videodirect\n\n  const percent = Math.min(1, PositionTicks / RunTimeTicks) * 100;\n\n  const streamTitle = generateStreamTitle(session, enableUser, showEpisodeNumber);\n  return (\n    <>\n      <div className=\"text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex\">\n        <div className=\"grow text-xs z-10 self-center ml-2 relative w-full h-4 mr-2\">\n          <div className=\"absolute w-full whitespace-nowrap text-ellipsis overflow-hidden\" title={streamTitle}>\n            {streamTitle}\n          </div>\n        </div>\n        <div className=\"self-center text-xs flex justify-end mr-1.5 pl-1\">\n          {IsVideoDirect && <MdOutlineSmartDisplay className=\"opacity-50\" />}\n          {!IsVideoDirect && (!VideoDecoderIsHardware || !VideoEncoderIsHardware) && <BsCpu className=\"opacity-50\" />}\n          {!IsVideoDirect && VideoDecoderIsHardware && VideoEncoderIsHardware && (\n            <BsFillCpuFill className=\"opacity-50\" />\n          )}\n        </div>\n      </div>\n\n      <div className=\"text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex\">\n        <div\n          className=\"absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0\"\n          style={{\n            width: `${percent}%`,\n          }}\n        />\n        <div className=\"text-xs z-10 self-center ml-1\">\n          {enableMediaControl && IsPaused && (\n            <BsFillPlayFill\n              onClick={() => {\n                playCommand(session, \"Unpause\");\n              }}\n              className=\"inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80\"\n            />\n          )}\n          {enableMediaControl && !IsPaused && (\n            <BsPauseFill\n              onClick={() => {\n                playCommand(session, \"Pause\");\n              }}\n              className=\"inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80\"\n            />\n          )}\n        </div>\n        <div className=\"grow \" />\n        <div className=\"self-center text-xs flex justify-end mr-1 z-10\">{IsMuted && <BsVolumeMuteFill />}</div>\n        <div className=\"self-center text-xs flex justify-end mr-2 z-10\">\n          {ticksToString(PositionTicks)}\n          <span className=\"mx-0.5 text-[8px]\">/</span>\n          {ticksToString(RunTimeTicks)}\n        </div>\n      </div>\n    </>\n  );\n}\n\nfunction SessionEntry({ playCommand, session, enableUser, showEpisodeNumber, enableMediaControl }) {\n  const {\n    PlayState: { PositionTicks, IsPaused, IsMuted },\n  } = session;\n\n  const RunTimeTicks =\n    session.NowPlayingItem?.RunTimeTicks ?? session.NowPlayingItem?.CurrentProgram?.RunTimeTicks ?? 0;\n\n  const { IsVideoDirect, VideoDecoderIsHardware, VideoEncoderIsHardware } = session?.TranscodingInfo || {\n    IsVideoDirect: true,\n  }; // if no transcodinginfo its videodirect\n\n  const streamTitle = generateStreamTitle(session, enableUser, showEpisodeNumber);\n\n  const percent = Math.min(1, PositionTicks / RunTimeTicks) * 100;\n\n  return (\n    <div className=\"text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex\">\n      <div\n        className=\"absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0\"\n        style={{\n          width: `${percent}%`,\n        }}\n      />\n      <div className=\"text-xs z-10 self-center ml-1\">\n        {enableMediaControl && IsPaused && (\n          <BsFillPlayFill\n            onClick={() => {\n              playCommand(session, \"Unpause\");\n            }}\n            className=\"inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80\"\n          />\n        )}\n        {enableMediaControl && !IsPaused && (\n          <BsPauseFill\n            onClick={() => {\n              playCommand(session, \"Pause\");\n            }}\n            className=\"inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80\"\n          />\n        )}\n      </div>\n      <div className=\"grow text-xs z-10 self-center relative w-full h-4\">\n        <div className=\"absolute w-full whitespace-nowrap text-ellipsis overflow-hidden\" title={streamTitle}>\n          {streamTitle}\n        </div>\n      </div>\n      <div className=\"self-center text-xs flex justify-end mr-1 z-10\">{IsMuted && <BsVolumeMuteFill />}</div>\n      <div className=\"self-center text-xs flex justify-end mr-1 z-10\">{ticksToString(PositionTicks)}</div>\n      <div className=\"self-center items-center text-xs flex justify-end mr-1.5 pl-1 z-10\">\n        {IsVideoDirect && <MdOutlineSmartDisplay className=\"opacity-50\" />}\n        {!IsVideoDirect && (!VideoDecoderIsHardware || !VideoEncoderIsHardware) && <BsCpu className=\"opacity-50\" />}\n        {!IsVideoDirect && VideoDecoderIsHardware && VideoEncoderIsHardware && <BsFillCpuFill className=\"opacity-50\" />}\n      </div>\n    </div>\n  );\n}\n\nfunction CountBlocks({ service, countData }) {\n  const { t } = useTranslation();\n\n  if (!countData) {\n    return (\n      <Container service={service}>\n        <Block label=\"jellyfin.movies\" />\n        <Block label=\"jellyfin.series\" />\n        <Block label=\"jellyfin.episodes\" />\n        <Block label=\"jellyfin.songs\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"jellyfin.movies\" value={t(\"common.number\", { value: countData.MovieCount })} />\n      <Block label=\"jellyfin.series\" value={t(\"common.number\", { value: countData.SeriesCount })} />\n      <Block label=\"jellyfin.episodes\" value={t(\"common.number\", { value: countData.EpisodeCount })} />\n      <Block label=\"jellyfin.songs\" value={t(\"common.number\", { value: countData.SongCount })} />\n    </Container>\n  );\n}\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n  const version = widget?.version ?? 1;\n  const useJellyfinV2 = version === 2;\n  const sessionsEndpoint = useJellyfinV2 ? \"SessionsV2\" : \"Sessions\";\n  const countEndpoint = useJellyfinV2 ? \"CountV2\" : \"Count\";\n  const commandMap = {\n    Pause: useJellyfinV2 ? \"PauseV2\" : \"Pause\",\n    Unpause: useJellyfinV2 ? \"UnpauseV2\" : \"Unpause\",\n  };\n  const enableNowPlaying = service.widget?.enableNowPlaying ?? true;\n\n  const {\n    data: sessionsData,\n    error: sessionsError,\n    mutate: sessionMutate,\n  } = useWidgetAPI(widget, enableNowPlaying ? sessionsEndpoint : \"\", {\n    refreshInterval: enableNowPlaying ? 5000 : undefined,\n  });\n\n  const { data: countData, error: countError } = useWidgetAPI(widget, countEndpoint, {\n    refreshInterval: 60000,\n  });\n\n  async function handlePlayCommand(session, command) {\n    const mappedCommand = commandMap[command] ?? command;\n    const params = getURLSearchParams(widget, mappedCommand);\n    params.append(\n      \"segments\",\n      JSON.stringify({\n        sessionId: session.Id,\n      }),\n    );\n    const url = `/api/services/proxy?${params.toString()}`;\n    await fetch(url, {\n      method: \"POST\",\n    }).then(() => {\n      sessionMutate();\n    });\n  }\n\n  if (sessionsError || countError) {\n    return <Container service={service} error={sessionsError ?? countError} />;\n  }\n\n  const enableBlocks = service.widget?.enableBlocks;\n  const enableMediaControl = service.widget?.enableMediaControl !== false; // default is true\n  const enableUser = !!service.widget?.enableUser; // default is false\n  const expandOneStreamToTwoRows = service.widget?.expandOneStreamToTwoRows !== false; // default is true\n  const showEpisodeNumber = !!service.widget?.showEpisodeNumber; // default is false\n\n  if ((enableNowPlaying && !sessionsData) || !countData) {\n    return (\n      <>\n        {enableBlocks && <CountBlocks service={service} countData={null} />}\n        {enableNowPlaying && (\n          <div className=\"flex flex-col pb-1\">\n            <div className=\"text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1\">\n              <span className=\"absolute left-2 text-xs mt-[2px]\">-</span>\n            </div>\n            {expandOneStreamToTwoRows && (\n              <div className=\"text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1\">\n                <span className=\"absolute left-2 text-xs mt-[2px]\">-</span>\n              </div>\n            )}\n          </div>\n        )}\n      </>\n    );\n  }\n\n  if (enableNowPlaying) {\n    const playing = sessionsData\n      .filter((session) => session?.NowPlayingItem)\n      .sort((a, b) => {\n        if (a.PlayState.PositionTicks > b.PlayState.PositionTicks) {\n          return 1;\n        }\n        if (a.PlayState.PositionTicks < b.PlayState.PositionTicks) {\n          return -1;\n        }\n        return 0;\n      });\n\n    if (playing.length === 0) {\n      return (\n        <>\n          {enableBlocks && <CountBlocks service={service} countData={countData} />}\n          <div className=\"flex flex-col pb-1 mx-1\">\n            <div className=\"text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1\">\n              <span className=\"absolute left-2 text-xs mt-[2px]\">{t(\"jellyfin.no_active\")}</span>\n            </div>\n            {expandOneStreamToTwoRows && (\n              <div className=\"text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1\">\n                <span className=\"absolute left-2 text-xs mt-[2px]\">-</span>\n              </div>\n            )}\n          </div>\n        </>\n      );\n    }\n\n    if (expandOneStreamToTwoRows && playing.length === 1) {\n      const session = playing[0];\n      return (\n        <>\n          {enableBlocks && <CountBlocks service={service} countData={countData} />}\n          <div className=\"flex flex-col pb-1 mx-1\">\n            <SingleSessionEntry\n              playCommand={(currentSession, command) => handlePlayCommand(currentSession, command)}\n              session={session}\n              enableUser={enableUser}\n              showEpisodeNumber={showEpisodeNumber}\n              enableMediaControl={enableMediaControl}\n            />\n          </div>\n        </>\n      );\n    }\n\n    return (\n      <>\n        {enableBlocks && <CountBlocks service={service} countData={countData} />}\n        <div className=\"flex flex-col pb-1 mx-1\">\n          {playing.map((session) => (\n            <SessionEntry\n              key={session.Id}\n              playCommand={(currentSession, command) => handlePlayCommand(currentSession, command)}\n              session={session}\n              enableUser={enableUser}\n              showEpisodeNumber={showEpisodeNumber}\n              enableMediaControl={enableMediaControl}\n            />\n          ))}\n        </div>\n      </>\n    );\n  }\n\n  if (enableBlocks) {\n    return <CountBlocks service={service} countData={countData} />;\n  }\n}\n"
  },
  {
    "path": "src/widgets/jellyfin/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/jellyfin/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders CountBlocks placeholders while loading when enableBlocks is true\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: undefined, mutate: vi.fn() }) // sessions\n      .mockReturnValueOnce({ data: undefined, error: undefined }); // count\n\n    renderWithProviders(\n      <Component\n        service={{\n          widget: { type: \"jellyfin\", url: \"http://x\", enableBlocks: true },\n        }}\n      />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(screen.getByText(\"jellyfin.movies\")).toBeInTheDocument();\n    expect(screen.getByText(\"jellyfin.series\")).toBeInTheDocument();\n    expect(screen.getByText(\"jellyfin.episodes\")).toBeInTheDocument();\n    expect(screen.getByText(\"jellyfin.songs\")).toBeInTheDocument();\n    expect(screen.getAllByText(\"-\").length).toBeGreaterThan(0);\n  });\n\n  it(\"renders the no-active message when there are no playing sessions\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: [], error: undefined, mutate: vi.fn() }) // sessions\n      .mockReturnValueOnce({\n        data: { MovieCount: 1, SeriesCount: 2, EpisodeCount: 3, SongCount: 4 },\n        error: undefined,\n      }); // count\n\n    renderWithProviders(\n      <Component\n        service={{\n          widget: { type: \"jellyfin\", url: \"http://x\", enableBlocks: true },\n        }}\n      />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(screen.getByText(\"jellyfin.no_active\")).toBeInTheDocument();\n    expect(screen.getByText(\"1\")).toBeInTheDocument();\n    expect(screen.getByText(\"2\")).toBeInTheDocument();\n    expect(screen.getByText(\"3\")).toBeInTheDocument();\n    expect(screen.getByText(\"4\")).toBeInTheDocument();\n  });\n\n  it(\"renders a single now-playing entry (expanded to two rows by default)\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({\n        data: [\n          {\n            Id: \"s1\",\n            UserName: \"u1\",\n            NowPlayingItem: { Name: \"Movie1\", Type: \"Movie\", RunTimeTicks: 600000000 },\n            PlayState: { PositionTicks: 0, IsPaused: false, IsMuted: false },\n            TranscodingInfo: { IsVideoDirect: true },\n          },\n        ],\n        error: undefined,\n        mutate: vi.fn(),\n      })\n      .mockReturnValueOnce({\n        data: { MovieCount: 0, SeriesCount: 0, EpisodeCount: 0, SongCount: 0 },\n        error: undefined,\n      });\n\n    renderWithProviders(<Component service={{ widget: { type: \"jellyfin\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"Movie1\")).toBeInTheDocument();\n    // Time strings are rendered in a combined node (e.g. \"00:00/01:00\").\n    expect(screen.getByText(/00:00/)).toBeInTheDocument();\n    expect(screen.getByText(/01:00/)).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/jellyfin/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall, sanitizeErrorURL } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport validateWidgetData from \"utils/proxy/validate-widget-data\";\nimport widgets from \"widgets/widgets\";\n\nconst logger = createLogger(\"jellyfinProxyHandler\");\n\nexport default async function jellyfinProxyHandler(req, res, map) {\n  const { group, service, endpoint, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing proxy service type '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n\n  if (!widget || !widgets?.[widget.type]?.api) {\n    logger.debug(\"Invalid or missing proxy service type '%s' in group '%s'\", service, group);\n    return res.status(403).json({ error: \"Service does not support API calls\" });\n  }\n\n  const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));\n\n  const deviceIdRaw = widget.deviceId ?? `${widget.service_group || \"group\"}-${widget.service_name || \"service\"}`;\n  const deviceId = encodeURIComponent(deviceIdRaw);\n  const authHeader = `MediaBrowser Token=\"${encodeURIComponent(\n    widget.key,\n  )}\", Client=\"Homepage\", Device=\"Homepage\", DeviceId=\"${deviceId}\", Version=\"1.0.0\"`;\n\n  const headers = {\n    Authorization: authHeader,\n  };\n\n  const params = {\n    method: req.method,\n    withCredentials: true,\n    credentials: \"include\",\n    headers,\n  };\n\n  const [status, contentType, data] = await httpProxy(url, params);\n\n  let resultData = data;\n\n  if (resultData.error?.url) {\n    resultData.error.url = sanitizeErrorURL(url);\n  }\n\n  if (status === 204 || status === 304) {\n    return res.status(status).end();\n  }\n\n  if (status >= 400) {\n    logger.error(\"HTTP Error %d calling %s\", status, url.toString());\n  }\n\n  if (status === 200) {\n    if (!validateWidgetData(widget, endpoint, resultData)) {\n      return res.status(500).json({ error: { message: \"Invalid data\", url: sanitizeErrorURL(url), data: resultData } });\n    }\n    if (map) resultData = map(resultData);\n  }\n\n  if (contentType) res.setHeader(\"Content-Type\", contentType);\n  return res.status(status).send(resultData);\n}\n"
  },
  {
    "path": "src/widgets/jellyfin/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, validateWidgetData, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  validateWidgetData: vi.fn(() => true),\n  logger: {\n    debug: vi.fn(),\n    error: vi.fn(),\n  },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nvi.mock(\"utils/proxy/validate-widget-data\", () => ({\n  default: validateWidgetData,\n}));\n\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    jellyfin: {\n      api: \"{url}/{endpoint}\",\n    },\n  },\n}));\n\nimport jellyfinProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/jellyfin/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    validateWidgetData.mockReturnValue(true);\n  });\n\n  it(\"adds MediaBrowser auth header and applies an optional mapping function\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"jellyfin\",\n      url: \"http://jf\",\n      key: \"abc\",\n      service_group: \"mygroup\",\n      service_name: \"myservice\",\n    });\n\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", { items: [1] }]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"svc\", endpoint: \"Users\", index: \"0\" } };\n    const res = createMockRes();\n\n    await jellyfinProxyHandler(req, res, () => ({ mapped: true }));\n\n    expect(httpProxy).toHaveBeenCalledTimes(1);\n    expect(httpProxy.mock.calls[0][0].toString()).toBe(\"http://jf/Users\");\n    expect(httpProxy.mock.calls[0][1].headers.Authorization).toBe(\n      'MediaBrowser Token=\"abc\", Client=\"Homepage\", Device=\"Homepage\", DeviceId=\"mygroup-myservice\", Version=\"1.0.0\"',\n    );\n    expect(validateWidgetData).toHaveBeenCalledWith(expect.objectContaining({ type: \"jellyfin\" }), \"Users\", {\n      items: [1],\n    });\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ mapped: true });\n    expect(res.setHeader).toHaveBeenCalledWith(\"Content-Type\", \"application/json\");\n  });\n\n  it(\"returns 500 when data validation fails\", async () => {\n    validateWidgetData.mockReturnValue(false);\n    getServiceWidget.mockResolvedValue({ type: \"jellyfin\", url: \"http://jf\", key: \"abc\" });\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", { nope: true }]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"svc\", endpoint: \"Users\", index: \"0\" } };\n    const res = createMockRes();\n\n    await jellyfinProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body.error.message).toBe(\"Invalid data\");\n  });\n\n  it(\"ends the response for 204 responses\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"jellyfin\", url: \"http://jf\", key: \"abc\" });\n    httpProxy.mockResolvedValueOnce([204, \"application/json\", {}]);\n\n    const req = { method: \"GET\", query: { group: \"g\", service: \"svc\", endpoint: \"Users\", index: \"0\" } };\n    const res = createMockRes();\n\n    await jellyfinProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(204);\n    expect(res.end).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/widgets/jellyfin/widget.js",
    "content": "import jellyfinProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/{endpoint}\",\n  proxyHandler: jellyfinProxyHandler,\n  mappings: {\n    Sessions: {\n      endpoint: \"emby/Sessions?api_key={key}\",\n    },\n    Count: {\n      endpoint: \"emby/Items/Counts?api_key={key}\",\n    },\n    Unpause: {\n      method: \"POST\",\n      endpoint: \"emby/Sessions/{sessionId}/Playing/Unpause?api_key={key}\",\n      segments: [\"sessionId\"],\n    },\n    Pause: {\n      method: \"POST\",\n      endpoint: \"emby/Sessions/{sessionId}/Playing/Pause?api_key={key}\",\n      segments: [\"sessionId\"],\n    },\n    // V2 Endpoints\n    SessionsV2: {\n      endpoint: \"Sessions\",\n    },\n    CountV2: {\n      endpoint: \"Items/Counts\",\n    },\n    UnpauseV2: {\n      method: \"POST\",\n      endpoint: \"Sessions/{sessionId}/Playing/Unpause\",\n      segments: [\"sessionId\"],\n    },\n    PauseV2: {\n      method: \"POST\",\n      endpoint: \"Sessions/{sessionId}/Playing/Pause\",\n      segments: [\"sessionId\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/jellyfin/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"jellyfin widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/jellystat/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  // Days validation\n  if (!(Number.isInteger(widget.days) && 0 < widget.days)) widget.days = 30;\n\n  const { data: viewsData, error: viewsError } = useWidgetAPI(widget, \"getViewsByLibraryType\", { days: widget.days });\n\n  const error = viewsError || viewsData?.message;\n  if (error) {\n    return <Container service={service} error={error} />;\n  }\n\n  if (!viewsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"jellystat.songs\" />\n        <Block label=\"jellystat.movies\" />\n        <Block label=\"jellystat.episodes\" />\n        <Block label=\"jellystat.other\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"jellystat.songs\" value={viewsData.Audio} />\n      <Block label=\"jellystat.movies\" value={viewsData.Movie} />\n      <Block label=\"jellystat.episodes\" value={viewsData.Series} />\n      <Block label=\"jellystat.other\" value={viewsData.Other} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/jellystat/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/jellystat/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"defaults invalid days to 30 and renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"jellystat\", days: -1 } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.days).toBe(30);\n    expect(useWidgetAPI).toHaveBeenCalledWith(service.widget, \"getViewsByLibraryType\", { days: 30 });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"jellystat.songs\")).toBeInTheDocument();\n    expect(screen.getByText(\"jellystat.movies\")).toBeInTheDocument();\n    expect(screen.getByText(\"jellystat.episodes\")).toBeInTheDocument();\n    expect(screen.getByText(\"jellystat.other\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"jellystat\", days: 7 } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n  });\n\n  it(\"renders values when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { Audio: 1, Movie: 2, Series: 3, Other: 4 },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"jellystat\", days: 7 } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"1\")).toBeInTheDocument();\n    expect(screen.getByText(\"2\")).toBeInTheDocument();\n    expect(screen.getByText(\"3\")).toBeInTheDocument();\n    expect(screen.getByText(\"4\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/jellystat/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    getViewsByLibraryType: {\n      endpoint: \"stats/getViewsByLibraryType\",\n      params: [\"days\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/jellystat/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"jellystat widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/karakeep/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport const karakeepDefaultFields = [\"bookmarks\", \"favorites\", \"archived\", \"highlights\"];\nconst MAX_ALLOWED_FIELDS = 4;\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { data: statsData, error: statsError } = useWidgetAPI(widget, \"stats\");\n\n  if (statsError) {\n    return <Container service={service} error={statsError} />;\n  }\n\n  if (!widget.fields || widget.fields.length === 0) {\n    widget.fields = karakeepDefaultFields;\n  } else if (widget.fields?.length > MAX_ALLOWED_FIELDS) {\n    widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);\n  }\n\n  if (!statsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"karakeep.bookmarks\" />\n        <Block label=\"karakeep.favorites\" />\n        <Block label=\"karakeep.archived\" />\n        <Block label=\"karakeep.highlights\" />\n        <Block label=\"karakeep.lists\" />\n        <Block label=\"karakeep.tags\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"karakeep.bookmarks\" value={t(\"common.number\", { value: statsData.numBookmarks })} />\n      <Block label=\"karakeep.favorites\" value={t(\"common.number\", { value: statsData.numFavorites })} />\n      <Block label=\"karakeep.archived\" value={t(\"common.number\", { value: statsData.numArchived })} />\n      <Block label=\"karakeep.highlights\" value={t(\"common.number\", { value: statsData.numHighlights })} />\n      <Block label=\"karakeep.lists\" value={t(\"common.number\", { value: statsData.numLists })} />\n      <Block label=\"karakeep.tags\" value={t(\"common.number\", { value: statsData.numTags })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/karakeep/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component, { karakeepDefaultFields } from \"./component\";\n\ndescribe(\"widgets/karakeep/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"defaults fields and filters to 4 blocks while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"karakeep\", url: \"http://x\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual(karakeepDefaultFields);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"karakeep.bookmarks\")).toBeInTheDocument();\n    expect(screen.getByText(\"karakeep.favorites\")).toBeInTheDocument();\n    expect(screen.getByText(\"karakeep.archived\")).toBeInTheDocument();\n    expect(screen.getByText(\"karakeep.highlights\")).toBeInTheDocument();\n    expect(screen.queryByText(\"karakeep.lists\")).toBeNull();\n    expect(screen.queryByText(\"karakeep.tags\")).toBeNull();\n  });\n\n  it(\"caps widget.fields at 4 entries\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"karakeep\", fields: [\"tags\", \"lists\", \"bookmarks\", \"favorites\", \"archived\"] } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"tags\", \"lists\", \"bookmarks\", \"favorites\"]);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"karakeep.tags\")).toBeInTheDocument();\n    expect(screen.getByText(\"karakeep.lists\")).toBeInTheDocument();\n  });\n\n  it(\"renders values when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        numBookmarks: 1,\n        numFavorites: 2,\n        numArchived: 3,\n        numHighlights: 4,\n        numLists: 5,\n        numTags: 6,\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"karakeep\", url: \"http://x\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expectBlockValue(container, \"karakeep.bookmarks\", 1);\n    expectBlockValue(container, \"karakeep.favorites\", 2);\n    expectBlockValue(container, \"karakeep.archived\", 3);\n    expectBlockValue(container, \"karakeep.highlights\", 4);\n  });\n});\n"
  },
  {
    "path": "src/widgets/karakeep/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: `{url}/api/v1/{endpoint}`,\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    stats: {\n      endpoint: \"users/me/stats\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/karakeep/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"karakeep widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/kavita/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: kavitaData, error: kavitaError } = useWidgetAPI(widget, \"info\");\n\n  if (kavitaError) {\n    return <Container service={service} error={kavitaError} />;\n  }\n\n  if (!kavitaData) {\n    return (\n      <Container service={service}>\n        <Block label=\"kavita.seriesCount\" />\n        <Block label=\"kavita.totalFiles\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"kavita.seriesCount\" value={t(\"common.number\", { value: kavitaData.seriesCount })} />\n      <Block label=\"kavita.totalFiles\" value={t(\"common.number\", { value: kavitaData.totalFiles })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/kavita/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/kavita/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"kavita\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"kavita.seriesCount\")).toBeInTheDocument();\n    expect(screen.getByText(\"kavita.totalFiles\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"kavita\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders counts when loaded\", () => {\n    useWidgetAPI.mockReturnValue({ data: { seriesCount: 12, totalFiles: 34 }, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"kavita\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"12\")).toBeInTheDocument();\n    expect(screen.getByText(\"34\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/kavita/proxy.js",
    "content": "import cache from \"memory-cache\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst proxyName = \"kavitaProxyHandler\";\nconst sessionTokenCacheKey = `${proxyName}__sessionToken`;\nconst logger = createLogger(proxyName);\n\nasync function login(widget, service) {\n  const endpoint = \"Account/login\";\n  const api = widgets?.[widget.type]?.api;\n  const loginUrl = new URL(formatApiCall(api, { endpoint, ...widget }));\n  const loginBody = {\n    username: \"\",\n    password: \"\",\n    apiKey: \"\",\n  };\n  if (widget.username && widget.password) {\n    loginBody.username = widget.username;\n    loginBody.password = widget.password;\n  } else if (widget.key) {\n    loginBody.apiKey = widget.key;\n  }\n  const headers = { \"Content-Type\": \"application/json\", accept: \"text/plain\" };\n\n  const [, , data] = await httpProxy(loginUrl, {\n    method: \"POST\",\n    body: JSON.stringify(loginBody),\n    headers,\n  });\n\n  try {\n    const { token: accessToken } = JSON.parse(data.toString());\n    cache.put(`${sessionTokenCacheKey}.${service}`, accessToken);\n    return { accessToken };\n  } catch (e) {\n    logger.error(\"Unable to login to Kavita API: %s\", e);\n  }\n\n  return { token: false };\n}\n\nasync function apiCall(widget, endpoint, service) {\n  const key = `${sessionTokenCacheKey}.${service}`;\n  const headers = {\n    \"content-type\": \"application/json\",\n    Authorization: `Bearer ${cache.get(key)}`,\n  };\n\n  const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));\n  const method = \"GET\";\n\n  let [status, contentType, data, responseHeaders] = await httpProxy(url, {\n    method,\n    headers,\n  });\n\n  if (status === 401 || status === 403) {\n    logger.debug(\"Kavita API rejected the request, attempting to obtain new session token\");\n    const { accessToken } = await login(widget, service);\n    headers.Authorization = `Bearer ${accessToken}`;\n\n    // retry the request, now with the new session token\n    [status, contentType, data, responseHeaders] = await httpProxy(url, {\n      method,\n      headers,\n    });\n  }\n\n  if (status !== 200) {\n    logger.error(\"Error getting data from Kavita: %s status %d. Data: %s\", url, status, data);\n    return { status, contentType, data: null, responseHeaders };\n  }\n\n  return { status, contentType, data: JSON.parse(data.toString()), responseHeaders };\n}\n\nexport default async function KavitaProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  if (!cache.get(`${sessionTokenCacheKey}.${service}`)) {\n    await login(widget, service);\n  }\n\n  const { data: statsData } = await apiCall(widget, \"Stats/server/stats\", service);\n\n  return res.status(200).send({\n    seriesCount: statsData?.seriesCount,\n    totalFiles: statsData?.totalFiles,\n  });\n}\n"
  },
  {
    "path": "src/widgets/kavita/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {\n  const store = new Map();\n\n  return {\n    httpProxy: vi.fn(),\n    getServiceWidget: vi.fn(),\n    cache: {\n      get: vi.fn((k) => store.get(k)),\n      put: vi.fn((k, v) => store.set(k, v)),\n      del: vi.fn((k) => store.delete(k)),\n      _reset: () => store.clear(),\n    },\n    logger: {\n      debug: vi.fn(),\n      error: vi.fn(),\n    },\n  };\n});\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nvi.mock(\"memory-cache\", () => ({\n  default: cache,\n  ...cache,\n}));\n\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    kavita: {\n      api: \"{url}/{endpoint}\",\n    },\n  },\n}));\n\nimport kavitaProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/kavita/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    cache._reset();\n  });\n\n  it(\"logs in and returns server stats\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"kavita\", url: \"http://kv\", username: \"u\", password: \"p\" });\n\n    httpProxy\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ token: \"tok\" }))])\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ seriesCount: 5, totalFiles: 100 })),\n      ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await kavitaProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(2);\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ seriesCount: 5, totalFiles: 100 });\n  });\n\n  it(\"retries after a 401 by obtaining a new session token\", async () => {\n    cache.put(\"kavitaProxyHandler__sessionToken.svc\", \"old\");\n\n    getServiceWidget.mockResolvedValue({ type: \"kavita\", url: \"http://kv\", username: \"u\", password: \"p\" });\n\n    httpProxy\n      .mockResolvedValueOnce([401, \"application/json\", Buffer.from(\"{}\")])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ token: \"newtok\" }))])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ seriesCount: 1, totalFiles: 2 }))]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await kavitaProxyHandler(req, res);\n\n    const loginCalls = httpProxy.mock.calls.filter(([url]) => url.toString().includes(\"Account/login\"));\n    expect(loginCalls).toHaveLength(1);\n    expect(res.body).toEqual({ seriesCount: 1, totalFiles: 2 });\n  });\n});\n"
  },
  {
    "path": "src/widgets/kavita/widget.js",
    "content": "import kavitaProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: kavitaProxyHandler,\n  mappings: {\n    info: {\n      endpoint: \"/\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/kavita/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"kavita widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/komga/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { data: komgaData, error: komgaError } = useWidgetAPI(widget);\n\n  if (komgaError) {\n    return <Container service={service} error={komgaError} />;\n  }\n\n  if (!komgaData) {\n    return (\n      <Container service={service}>\n        <Block label=\"komga.libraries\" />\n        <Block label=\"komga.series\" />\n        <Block label=\"komga.books\" />\n      </Container>\n    );\n  }\n\n  const { libraries: libraryData, series: seriesData, books: bookData } = komgaData;\n\n  return (\n    <Container service={service}>\n      <Block label=\"komga.libraries\" value={t(\"common.number\", { value: libraryData.length })} />\n      <Block label=\"komga.series\" value={t(\"common.number\", { value: seriesData.totalElements })} />\n      <Block label=\"komga.books\" value={t(\"common.number\", { value: bookData.totalElements })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/komga/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/komga/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"komga\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"komga.libraries\")).toBeInTheDocument();\n    expect(screen.getByText(\"komga.series\")).toBeInTheDocument();\n    expect(screen.getByText(\"komga.books\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"komga\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders library/series/book totals when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        libraries: [{ id: 1 }, { id: 2 }],\n        series: { totalElements: 10 },\n        books: { totalElements: 20 },\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"komga\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"komga.libraries\", 2);\n    expectBlockValue(container, \"komga.series\", 10);\n    expectBlockValue(container, \"komga.books\", 20);\n  });\n});\n"
  },
  {
    "path": "src/widgets/komga/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst proxyName = \"komgaProxyHandler\";\nconst logger = createLogger(proxyName);\n\nexport default async function komgaProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n\n  if (group && service) {\n    const widget = await getServiceWidget(group, service, index);\n\n    if (!widgets?.[widget.type]?.api) {\n      return res.status(403).json({ error: \"Service does not support API calls\" });\n    }\n\n    if (widget) {\n      try {\n        const data = {};\n        const headers = {\n          accept: \"application/json\",\n          \"Content-Type\": \"application/json\",\n        };\n        if (widget.username && widget.password) {\n          headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString(\"base64\")}`;\n        } else if (widget.key) {\n          headers[\"X-API-Key\"] = widget.key;\n        }\n        const librariesURL = formatApiCall(widgets?.[widget.type].api, { ...widget, endpoint: \"libraries\" });\n        const [librariesStatus, , librariesData] = await httpProxy(librariesURL, {\n          method: \"GET\",\n          headers,\n          cookieHeader: \"X-Auth-Token\",\n        });\n\n        if (librariesStatus !== 200) {\n          return res.status(librariesStatus).send(data);\n        }\n\n        data.libraries = JSON.parse(Buffer.from(librariesData).toString()).filter((library) => !library.unavailable);\n\n        const seriesEndpointName = widget.version === 2 ? \"seriesv2\" : \"series\";\n        const seriesEndpoint = widgets[widget.type].mappings[seriesEndpointName].endpoint;\n        const seriesURL = formatApiCall(widgets?.[widget.type].api, { ...widget, endpoint: seriesEndpoint });\n        const [seriesStatus, , seriesData] = await httpProxy(seriesURL, {\n          method: widgets[widget.type].mappings[seriesEndpointName].method || \"GET\",\n          headers,\n          body: \"{}\",\n          cookieHeader: \"X-Auth-Token\",\n        });\n\n        if (seriesStatus !== 200) {\n          return res.status(seriesStatus).send(data);\n        }\n\n        data.series = JSON.parse(Buffer.from(seriesData).toString());\n\n        const booksEndpointName = widget.version === 2 ? \"booksv2\" : \"books\";\n        const booksEndpoint = widgets[widget.type].mappings[booksEndpointName].endpoint;\n        const booksURL = formatApiCall(widgets?.[widget.type].api, { ...widget, endpoint: booksEndpoint });\n        const [booksStatus, , booksData] = await httpProxy(booksURL, {\n          method: widgets[widget.type].mappings[booksEndpointName].method || \"GET\",\n          headers,\n          body: \"{}\",\n          cookieHeader: \"X-Auth-Token\",\n        });\n\n        if (booksStatus !== 200) {\n          return res.status(booksStatus).send(data);\n        }\n\n        data.books = JSON.parse(Buffer.from(booksData).toString());\n\n        return res.send(data);\n      } catch (e) {\n        logger.error(\"Error communicating with Komga API: %s\", e);\n        return res.status(500).json({ error: \"Error communicating with Komga API\" });\n      }\n    }\n  }\n\n  return res.status(400).json({ error: \"Invalid proxy service type\" });\n}\n"
  },
  {
    "path": "src/widgets/komga/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  logger: {\n    error: vi.fn(),\n  },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    komga: {\n      api: \"{url}/{endpoint}\",\n      mappings: {\n        series: { endpoint: \"series\" },\n        books: { endpoint: \"books\" },\n        seriesv2: { endpoint: \"series/v2\" },\n        booksv2: { endpoint: \"books/v2\" },\n      },\n    },\n  },\n}));\n\nimport komgaProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/komga/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"fetches libraries, series, and books and returns aggregated data\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"komga\", url: \"http://kg\", key: \"k\" });\n\n    httpProxy\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(\n          JSON.stringify([\n            { id: 1, unavailable: false },\n            { id: 2, unavailable: true },\n          ]),\n        ),\n      ])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify([{ id: \"s1\" }]))])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify([{ id: \"b1\" }]))]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await komgaProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(3);\n    expect(httpProxy.mock.calls[0][1].headers[\"X-API-Key\"]).toBe(\"k\");\n    expect(res.body).toEqual({\n      libraries: [{ id: 1, unavailable: false }],\n      series: [{ id: \"s1\" }],\n      books: [{ id: \"b1\" }],\n    });\n  });\n});\n"
  },
  {
    "path": "src/widgets/komga/widget.js",
    "content": "import komgaProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/api/v1/{endpoint}\",\n  proxyHandler: komgaProxyHandler,\n\n  mappings: {\n    libraries: {\n      endpoint: \"libraries\",\n    },\n    series: {\n      endpoint: \"series\",\n      validate: [\"totalElements\"],\n    },\n    seriesv2: {\n      endpoint: \"series/list\",\n      method: \"POST\",\n      validate: [\"totalElements\"],\n    },\n    books: {\n      endpoint: \"books\",\n      validate: [\"totalElements\"],\n    },\n    booksv2: {\n      endpoint: \"books/list\",\n      method: \"POST\",\n      validate: [\"totalElements\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/komga/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"komga widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/komodo/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst MAX_ALLOWED_FIELDS = 4;\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const containersEndpoint = !(!widget.showSummary && widget.showStacks) ? \"containers\" : \"\";\n  const { data: containersData, error: containersError } = useWidgetAPI(widget, containersEndpoint);\n  const stacksEndpoint = widget.showSummary || widget.showStacks ? \"stacks\" : \"\";\n  const { data: stacksData, error: stacksError } = useWidgetAPI(widget, stacksEndpoint);\n  const serversEndpoint = widget.showSummary ? \"servers\" : \"\";\n  const { data: serversData, error: serversError } = useWidgetAPI(widget, serversEndpoint);\n\n  if (containersError || stacksError || serversError) {\n    return <Container service={service} error={containersError ?? stacksError ?? serversError} />;\n  }\n\n  if (!widget.fields || widget.fields.length === 0) {\n    widget.fields = widget.showSummary\n      ? [\"servers\", \"stacks\", \"containers\"]\n      : widget.showStacks\n        ? [\"total\", \"running\", \"down\", \"unhealthy\"]\n        : [\"total\", \"running\", \"stopped\", \"unhealthy\"];\n  } else if (widget.fields?.length > MAX_ALLOWED_FIELDS) {\n    widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);\n  }\n\n  if (\n    (!widget.showStacks && !containersData) ||\n    (widget.showSummary && (!containersData || !stacksData || !serversData)) ||\n    (widget.showStacks && !stacksData)\n  ) {\n    return widget.showSummary ? (\n      <Container service={service}>\n        <Block label=\"komodo.servers\" />\n        <Block label=\"komodo.stacks\" />\n        <Block label=\"komodo.containers\" />\n      </Container>\n    ) : widget.showStacks ? (\n      <Container service={service}>\n        <Block label=\"komodo.total\" />\n        <Block label=\"komodo.running\" />\n        <Block label=\"komodo.down\" />\n        <Block label=\"komodo.unhealthy\" />\n      </Container>\n    ) : (\n      <Container service={service}>\n        <Block label=\"komodo.total\" />\n        <Block label=\"komodo.running\" />\n        <Block label=\"komodo.stopped\" />\n        <Block label=\"komodo.unhealthy\" />\n      </Container>\n    );\n  }\n\n  return widget.showSummary ? (\n    <Container service={service}>\n      <Block label=\"komodo.servers\" value={`${serversData.healthy} / ${serversData.total}`} />\n      <Block label=\"komodo.stacks\" value={`${stacksData.running} / ${stacksData.total}`} />\n      <Block label=\"komodo.containers\" value={`${containersData.running} / ${containersData.total}`} />\n    </Container>\n  ) : widget.showStacks ? (\n    <Container service={service}>\n      <Block label=\"komodo.total\" value={t(\"common.number\", { value: stacksData.total })} />\n      <Block label=\"komodo.running\" value={t(\"common.number\", { value: stacksData.running })} />\n      <Block label=\"komodo.down\" value={t(\"common.number\", { value: stacksData.stopped + stacksData.down })} />\n      <Block label=\"komodo.unhealthy\" value={t(\"common.number\", { value: stacksData.unhealthy })} />\n      <Block label=\"komodo.unknown\" value={t(\"common.number\", { value: stacksData.unknown })} />\n    </Container>\n  ) : (\n    <Container service={service}>\n      <Block label=\"komodo.total\" value={t(\"common.number\", { value: containersData.total })} />\n      <Block label=\"komodo.running\" value={t(\"common.number\", { value: containersData.running })} />\n      <Block label=\"komodo.stopped\" value={t(\"common.number\", { value: containersData.stopped })} />\n      <Block label=\"komodo.unhealthy\" value={t(\"common.number\", { value: containersData.unhealthy })} />\n      <Block label=\"komodo.unknown\" value={t(\"common.number\", { value: containersData.unknown })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/komodo/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { findServiceBlockByLabel } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/komodo/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"defaults fields for stacks view and skips containers endpoint when showStacks=true and showSummary=false\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // containers (disabled)\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // stacks\n      .mockReturnValueOnce({ data: undefined, error: undefined }); // servers (disabled)\n\n    const service = { widget: { type: \"komodo\", showStacks: true, showSummary: false } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"total\", \"running\", \"down\", \"unhealthy\"]);\n    expect(useWidgetAPI.mock.calls[0][1]).toBe(\"\"); // containersEndpoint\n    expect(useWidgetAPI.mock.calls[1][1]).toBe(\"stacks\");\n    expect(useWidgetAPI.mock.calls[2][1]).toBe(\"\"); // serversEndpoint\n\n    // Default fields filter out \"unknown\" which is rendered but not in widget.fields.\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"komodo.total\")).toBeInTheDocument();\n    expect(screen.getByText(\"komodo.running\")).toBeInTheDocument();\n    expect(screen.getByText(\"komodo.down\")).toBeInTheDocument();\n    expect(screen.getByText(\"komodo.unhealthy\")).toBeInTheDocument();\n    expect(screen.queryByText(\"komodo.unknown\")).toBeNull();\n  });\n\n  it(\"renders computed down=stopped+down for stacks view\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // containers (disabled)\n      .mockReturnValueOnce({\n        data: { total: 10, running: 7, stopped: 1, down: 2, unhealthy: 3, unknown: 4 },\n        error: undefined,\n      })\n      .mockReturnValueOnce({ data: undefined, error: undefined }); // servers (disabled)\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"komodo\", showStacks: true, showSummary: false } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"10\")).toBeInTheDocument();\n    expect(screen.getByText(\"7\")).toBeInTheDocument();\n    const downBlock = findServiceBlockByLabel(container, \"komodo.down\");\n    expect(downBlock).toBeTruthy();\n    expect(downBlock.textContent).toContain(\"3\"); // stopped(1) + down(2)\n  });\n\n  it(\"renders summary view ratios when showSummary=true\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: { total: 5, running: 4 }, error: undefined }) // containers\n      .mockReturnValueOnce({ data: { total: 2, running: 1 }, error: undefined }) // stacks\n      .mockReturnValueOnce({ data: { total: 1, healthy: 1 }, error: undefined }); // servers\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"komodo\", showSummary: true } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"1 / 1\")).toBeInTheDocument();\n    expect(screen.getByText(\"1 / 2\")).toBeInTheDocument();\n    expect(screen.getByText(\"4 / 5\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/komodo/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall, sanitizeErrorURL } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport validateWidgetData from \"utils/proxy/validate-widget-data\";\nimport widgets from \"widgets/widgets\";\n\nconst logger = createLogger(\"komodoProxyHandler\");\n\nexport default async function komodoProxyHandler(req, res) {\n  const { group, service, endpoint, index } = req.query;\n\n  if (group && service) {\n    const widget = await getServiceWidget(group, service, index);\n    if (!widgets?.[widget.type]?.api) {\n      return res.status(403).json({ error: \"Service does not support API calls\" });\n    }\n\n    if (widget) {\n      // api uses unified read endpoint\n      const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint: \"read\", ...widget })).toString();\n\n      const headers = {\n        \"Content-Type\": \"application/json\",\n        \"X-API-Key\": `${widget.key}`,\n        \"X-API-Secret\": `${widget.secret}`,\n      };\n      const [status, contentType, data] = await httpProxy(url, {\n        method: \"POST\",\n        body: JSON.stringify(widgets[widget.type].mappings?.[endpoint]?.body || {}),\n        headers,\n      });\n\n      let resultData = data;\n\n      if (status >= 400) {\n        logger.error(\"HTTP Error %d calling %s\", status, sanitizeErrorURL(url));\n      }\n\n      if (status === 200) {\n        if (!validateWidgetData(widget, endpoint, resultData)) {\n          return res\n            .status(500)\n            .json({ error: { message: \"Invalid data\", url: sanitizeErrorURL(url), data: resultData } });\n        }\n      }\n\n      if (contentType) res.setHeader(\"Content-Type\", contentType);\n      return res.status(status).send(resultData);\n    }\n  }\n\n  logger.debug(\"Invalid or missing proxy service type '%s' in group '%s'\", service, group);\n  return res.status(400).json({ error: \"Invalid proxy service type\" });\n}\n"
  },
  {
    "path": "src/widgets/komodo/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, validateWidgetData, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  validateWidgetData: vi.fn(() => true),\n  logger: { debug: vi.fn(), error: vi.fn() },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\nvi.mock(\"utils/proxy/validate-widget-data\", () => ({\n  default: validateWidgetData,\n}));\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    komodo: {\n      api: \"{url}/{endpoint}\",\n      mappings: {\n        stats: { body: { hello: \"world\" } },\n      },\n    },\n  },\n}));\n\nimport komodoProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/komodo/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    validateWidgetData.mockReturnValue(true);\n  });\n\n  it(\"POSTs to the unified read endpoint with API key/secret\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"komodo\", url: \"http://komodo\", key: \"k\", secret: \"s\" });\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"ok\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"stats\", index: \"0\" } };\n    const res = createMockRes();\n\n    await komodoProxyHandler(req, res);\n\n    expect(httpProxy.mock.calls[0][0]).toBe(\"http://komodo/read\");\n    expect(httpProxy.mock.calls[0][1].headers[\"X-API-Key\"]).toBe(\"k\");\n    expect(httpProxy.mock.calls[0][1].headers[\"X-API-Secret\"]).toBe(\"s\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual(Buffer.from(\"ok\"));\n  });\n\n  it(\"returns 500 when data validation fails\", async () => {\n    validateWidgetData.mockReturnValue(false);\n    getServiceWidget.mockResolvedValue({ type: \"komodo\", url: \"http://komodo\", key: \"k\", secret: \"s\" });\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"bad\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"stats\", index: \"0\" } };\n    const res = createMockRes();\n\n    await komodoProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body.error.message).toBe(\"Invalid data\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/komodo/widget.js",
    "content": "import komodoProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/{endpoint}\",\n  proxyHandler: komodoProxyHandler,\n\n  mappings: {\n    containers: {\n      endpoint: \"containers\", // api actually uses unified read endpoint\n      body: {\n        type: \"GetDockerContainersSummary\",\n        params: {},\n      },\n    },\n    stacks: {\n      endpoint: \"stacks\", // api actually uses unified read endpoint\n      body: {\n        type: \"GetStacksSummary\",\n        params: {},\n      },\n    },\n    servers: {\n      endpoint: \"servers\", // api actually uses unified read endpoint\n      body: {\n        type: \"GetServersSummary\",\n        params: {},\n      },\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/komodo/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"komodo widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/kopia/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nfunction relativeDate(date) {\n  const seconds = Math.abs(Math.floor((new Date() - date) / 1000));\n\n  let interval = Math.abs(seconds / 31536000);\n\n  if (interval > 1) {\n    return `${Math.floor(interval)} y`;\n  }\n  interval = seconds / 2592000;\n  if (interval > 1) {\n    return `${Math.floor(interval)} mo`;\n  }\n  interval = seconds / 86400;\n  if (interval > 1) {\n    return `${Math.floor(interval)} d`;\n  }\n  interval = seconds / 3600;\n  if (interval > 1) {\n    return `${Math.floor(interval)} h`;\n  }\n  interval = seconds / 60;\n  if (interval > 1) {\n    return `${Math.floor(interval)} m`;\n  }\n  return `${Math.floor(seconds)} s`;\n}\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n  const { data: statusData, error: statusError } = useWidgetAPI(widget, \"status\");\n\n  if (statusError) {\n    return <Container service={service} error={statusError} />;\n  }\n\n  const snapshotHost = service.widget?.snapshotHost;\n  const snapshotPath = service.widget?.snapshotPath;\n\n  const source = statusData?.sources\n    .filter((el) => (snapshotHost ? el.source.host === snapshotHost : true))\n    .filter((el) => (snapshotPath ? el.source.path === snapshotPath : true))[0];\n\n  if (!statusData || !source) {\n    return (\n      <Container service={service}>\n        <Block label=\"kopia.status\" />\n        <Block label=\"kopia.size\" />\n        <Block label=\"kopia.lastrun\" />\n        <Block label=\"kopia.nextrun\" />\n      </Container>\n    );\n  }\n\n  const lastRun =\n    source.lastSnapshot.stats.errorCount === 0 ? new Date(source.lastSnapshot.startTime) : t(\"kopia.failed\");\n  const nextTime = source.nextSnapshotTime ? new Date(source.nextSnapshotTime) : null;\n\n  return (\n    <Container service={service}>\n      <Block label=\"kopia.status\" value={source.status} />\n      <Block\n        label=\"kopia.size\"\n        value={t(\"common.bbytes\", { value: source.lastSnapshot.stats.totalSize, maximumFractionDigits: 1 })}\n      />\n      <Block label=\"kopia.lastrun\" value={relativeDate(lastRun)} />\n      {nextTime && <Block label=\"kopia.nextrun\" value={relativeDate(nextTime)} />}\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/kopia/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/kopia/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    vi.useFakeTimers();\n    vi.setSystemTime(new Date(\"2020-01-01T00:00:00Z\"));\n  });\n\n  afterEach(() => {\n    vi.useRealTimers();\n  });\n\n  it(\"renders placeholders when status data is missing or source filter finds nothing\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"kopia\", snapshotHost: \"nope\" } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"kopia.status\")).toBeInTheDocument();\n    expect(screen.getByText(\"kopia.size\")).toBeInTheDocument();\n    expect(screen.getByText(\"kopia.lastrun\")).toBeInTheDocument();\n    expect(screen.getByText(\"kopia.nextrun\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"kopia\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders filtered snapshot status, size, and relative last/next run times\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        sources: [\n          {\n            source: { host: \"hostA\", path: \"/data\" },\n            status: \"OK\",\n            lastSnapshot: {\n              startTime: \"2019-12-31T22:00:00Z\", // 2 hours ago\n              stats: { errorCount: 0, totalSize: 1024 },\n            },\n            nextSnapshotTime: \"2020-01-01T00:30:00Z\", // 30 minutes ahead\n          },\n        ],\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"kopia\", snapshotHost: \"hostA\", snapshotPath: \"/data\" } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expectBlockValue(container, \"kopia.status\", \"OK\");\n    expectBlockValue(container, \"kopia.size\", 1024);\n    expectBlockValue(container, \"kopia.lastrun\", \"2 h\");\n    expectBlockValue(container, \"kopia.nextrun\", \"30 m\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/kopia/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    status: {\n      endpoint: \"api/v1/sources\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/kopia/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"kopia widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/kubernetes/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\nimport useSWR from \"swr\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n  const podSelectorString = widget.podSelector !== undefined ? `podSelector=${widget.podSelector}` : \"\";\n  const { data: statusData, error: statusError } = useSWR(\n    `/api/kubernetes/status/${widget.namespace}/${widget.app}?${podSelectorString}`,\n  );\n\n  const { data: statsData, error: statsError } = useSWR(\n    `/api/kubernetes/stats/${widget.namespace}/${widget.app}?${podSelectorString}`,\n  );\n\n  if (statsError || statusError) {\n    return <Container service={service} error={statsError ?? statusError ?? statusData} />;\n  }\n\n  if (\n    statusData &&\n    (!statusData.status || !(statusData.status.includes(\"running\") || statusData.status.includes(\"partial\")))\n  ) {\n    return (\n      <Container>\n        <Block label={t(\"widget.status\")} value={t(\"docker.offline\")} />\n      </Container>\n    );\n  }\n\n  if (!statsData || !statusData) {\n    return (\n      <Container service={service}>\n        <Block label=\"docker.cpu\" />\n        <Block label=\"docker.mem\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      {(statsData.stats.cpuLimit && (\n        <Block\n          label=\"docker.cpu\"\n          value={t(\"common.percent\", { value: statsData.stats.cpuUsage })}\n          highlightValue={statsData.stats.cpuUsage}\n        />\n      )) || (\n        <Block\n          label=\"docker.cpu\"\n          value={t(\"common.number\", { value: statsData.stats.cpu, maximumFractionDigits: 4 })}\n          highlightValue={statsData.stats.cpu}\n        />\n      )}\n      <Block\n        label=\"docker.mem\"\n        value={t(\"common.bytes\", { value: statsData.stats.mem })}\n        highlightValue={statsData.stats.mem}\n      />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/kubernetes/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));\nvi.mock(\"swr\", () => ({ default: useSWR }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/kubernetes/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"kubernetes\", namespace: \"ns\", app: \"app\" } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(useSWR.mock.calls[0][0]).toContain(\"/api/kubernetes/status/ns/app?\");\n    expect(useSWR.mock.calls[1][0]).toContain(\"/api/kubernetes/stats/ns/app?\");\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"docker.cpu\")).toBeInTheDocument();\n    expect(screen.getByText(\"docker.mem\")).toBeInTheDocument();\n  });\n\n  it(\"renders offline status when status endpoint reports non-running state\", () => {\n    useSWR.mockImplementation((key) => {\n      if (String(key).includes(\"/status/\")) return { data: { status: \"stopped\" }, error: undefined };\n      if (String(key).includes(\"/stats/\")) return { data: { stats: { cpu: 0.1, mem: 10 } }, error: undefined };\n      return { data: undefined, error: undefined };\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"kubernetes\", namespace: \"ns\", app: \"app\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"docker.offline\")).toBeInTheDocument();\n    expect(screen.getByText(\"widget.status\")).toBeInTheDocument();\n  });\n\n  it(\"renders cpu percent when cpuLimit is present, otherwise raw cpu number\", () => {\n    useSWR.mockImplementation((key) => {\n      if (String(key).includes(\"/status/\")) return { data: { status: \"running\" }, error: undefined };\n      if (String(key).includes(\"/stats/\"))\n        return {\n          data: { stats: { cpuLimit: true, cpuUsage: 12.3, cpu: 0.0001, mem: 1024 } },\n          error: undefined,\n        };\n      return { data: undefined, error: undefined };\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"kubernetes\", namespace: \"ns\", app: \"app\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    // With cpuLimit=true, cpuUsage is formatted via common.percent mock -> string value.\n    expect(screen.getByText(\"12.3\")).toBeInTheDocument();\n    expect(screen.getByText(\"1024\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/lidarr/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: artistsData, error: artistsError } = useWidgetAPI(widget, \"artist\");\n  const { data: wantedData, error: wantedError } = useWidgetAPI(widget, \"wanted/missing\");\n  const { data: queueData, error: queueError } = useWidgetAPI(widget, \"queue/status\");\n\n  if (artistsError || wantedError || queueError) {\n    const finalError = artistsError ?? wantedError ?? queueError;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!artistsData || !wantedData || !queueData) {\n    return (\n      <Container service={service}>\n        <Block label=\"lidarr.wanted\" />\n        <Block label=\"lidarr.queued\" />\n        <Block label=\"lidarr.artists\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"lidarr.wanted\" value={t(\"common.number\", { value: wantedData.totalRecords })} />\n      <Block label=\"lidarr.queued\" value={t(\"common.number\", { value: queueData.totalCount })} />\n      <Block label=\"lidarr.artists\" value={t(\"common.number\", { value: artistsData.length })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/lidarr/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/lidarr/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // artist\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // wanted/missing\n      .mockReturnValueOnce({ data: undefined, error: undefined }); // queue/status\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"lidarr\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"lidarr.wanted\")).toBeInTheDocument();\n    expect(screen.getByText(\"lidarr.queued\")).toBeInTheDocument();\n    expect(screen.getByText(\"lidarr.artists\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when any endpoint errors\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: undefined })\n      .mockReturnValueOnce({ data: undefined, error: { message: \"nope\" } })\n      .mockReturnValueOnce({ data: undefined, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"lidarr\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders wanted/queued/artist counts when loaded\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: [{ id: 1 }, { id: 2 }], error: undefined })\n      .mockReturnValueOnce({ data: { totalRecords: 10 }, error: undefined })\n      .mockReturnValueOnce({ data: { totalCount: 3 }, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"lidarr\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"lidarr.wanted\", 10);\n    expectBlockValue(container, \"lidarr.queued\", 3);\n    expectBlockValue(container, \"lidarr.artists\", 2);\n  });\n});\n"
  },
  {
    "path": "src/widgets/lidarr/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/v1/{endpoint}?apikey={key}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    artist: {\n      endpoint: \"artist\",\n    },\n    \"wanted/missing\": {\n      endpoint: \"wanted/missing\",\n    },\n    \"queue/status\": {\n      endpoint: \"queue/status\",\n    },\n    calendar: {\n      endpoint: \"calendar\",\n      params: [\"start\", \"end\", \"unmonitored\", \"includeArtist\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/lidarr/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"lidarr widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/linkwarden/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  const { data: collectionsStatsData, error: collectionsStatsError } = useWidgetAPI(widget, \"collections\");\n  const { data: tagsStatsData, error: tagsStatsError } = useWidgetAPI(widget, \"tags\");\n\n  // Some APIs return raw arrays, others wrap the payload (e.g. { response: [...] }).\n  const collections = collectionsStatsData?.response ?? collectionsStatsData;\n  const tags = tagsStatsData?.response ?? tagsStatsData;\n\n  const totalLinks = Array.isArray(collections)\n    ? collections.reduce((sum, collection) => sum + (collection._count?.links || 0), 0)\n    : null;\n  const collectionsTotal = Array.isArray(collections) ? collections.length : null;\n  const tagsTotal = Array.isArray(tags) ? tags.length : null;\n\n  if (collectionsStatsError || tagsStatsError) {\n    return <Container service={service} error={collectionsStatsError || tagsStatsError} />;\n  }\n\n  if (!tagsStatsData || !collectionsStatsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"linkwarden.links\" />\n        <Block label=\"linkwarden.collections\" />\n        <Block label=\"linkwarden.tags\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"linkwarden.links\" value={totalLinks} />\n      <Block label=\"linkwarden.collections\" value={collectionsTotal} />\n      <Block label=\"linkwarden.tags\" value={tagsTotal} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/linkwarden/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/linkwarden/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"linkwarden\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"linkwarden.links\")).toBeInTheDocument();\n    expect(screen.getByText(\"linkwarden.collections\")).toBeInTheDocument();\n    expect(screen.getByText(\"linkwarden.tags\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when either endpoint errors\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"tags\") return { data: undefined, error: { message: \"nope\" } };\n      return { data: undefined, error: undefined };\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"linkwarden\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"computes totals from collections + tags arrays\", async () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"collections\") {\n        return {\n          data: [{ _count: { links: 2 } }, { _count: { links: 3 } }],\n          error: undefined,\n        };\n      }\n\n      if (endpoint === \"tags\") {\n        return { data: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }], error: undefined };\n      }\n\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"linkwarden\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"linkwarden.links\", 5);\n    expectBlockValue(container, \"linkwarden.collections\", 2);\n    expectBlockValue(container, \"linkwarden.tags\", 4);\n  });\n});\n"
  },
  {
    "path": "src/widgets/linkwarden/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/v1/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    collections: {\n      endpoint: \"collections\",\n    },\n    tags: {\n      endpoint: \"tags\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/linkwarden/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"linkwarden widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/lubelogger/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  let { data: vehicleInfo, error } = useWidgetAPI(widget, \"vehicleinfo\");\n\n  if (error) {\n    return <Container service={service} error={error} />;\n  }\n\n  if (!vehicleInfo) {\n    return (\n      <Container service={service}>\n        <Block label=\"lubelogger.vehicles\" />\n        <Block label=\"lubelogger.serviceRecords\" />\n        <Block label=\"lubelogger.reminders\" />\n      </Container>\n    );\n  }\n\n  const { vehicleID } = widget;\n  if (vehicleID) {\n    vehicleInfo = vehicleInfo.filter((v) => v.vehicleData.id === vehicleID);\n  }\n  const totalReminders = vehicleInfo.reduce(\n    (acc, val) => acc + val.veryUrgentReminderCount + val.urgentReminderCount + val.notUrgentReminderCount,\n    0,\n  );\n  const totalServiceRecords = vehicleInfo.reduce((acc, val) => acc + val.serviceRecordCount, 0);\n\n  if (vehicleID) {\n    if (vehicleInfo.length === 0) {\n      error = { message: \"Vehicle not found\" };\n      return <Container service={service} error={error} />;\n    }\n\n    const nextReminder = vehicleInfo[0].nextReminder\n      ? t(\"common.date\", { value: vehicleInfo[0].nextReminder.dueDate })\n      : t(\"lubelogger.none\");\n    return (\n      <Container service={service}>\n        <Block\n          label=\"lubelogger.vehicle\"\n          value={`${vehicleInfo[0].vehicleData.year} ${vehicleInfo[0].vehicleData.model}`}\n        />\n        <Block label=\"lubelogger.serviceRecords\" value={totalServiceRecords} />\n        <Block label=\"lubelogger.reminders\" value={totalReminders} />\n        <Block label=\"lubelogger.nextReminder\" value={nextReminder} />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"lubelogger.vehicles\" value={vehicleInfo.length} />\n      <Block label=\"lubelogger.serviceRecords\" value={totalServiceRecords} />\n      <Block label=\"lubelogger.reminders\" value={totalReminders} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/lubelogger/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/lubelogger/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"lubelogger\", url: \"http://x\" } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"lubelogger.vehicles\")).toBeInTheDocument();\n    expect(screen.getByText(\"lubelogger.serviceRecords\")).toBeInTheDocument();\n    expect(screen.getByText(\"lubelogger.reminders\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"lubelogger\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"filters to vehicleID and renders next reminder details when found\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: [\n        {\n          vehicleData: { id: 1, year: 2020, model: \"Model A\" },\n          veryUrgentReminderCount: 1,\n          urgentReminderCount: 2,\n          notUrgentReminderCount: 3,\n          serviceRecordCount: 5,\n          nextReminder: { dueDate: 123 },\n        },\n        {\n          vehicleData: { id: 2, year: 2021, model: \"Model B\" },\n          veryUrgentReminderCount: 0,\n          urgentReminderCount: 0,\n          notUrgentReminderCount: 0,\n          serviceRecordCount: 1,\n          nextReminder: null,\n        },\n      ],\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"lubelogger\", url: \"http://x\", vehicleID: 1 } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expectBlockValue(container, \"lubelogger.vehicle\", \"2020 Model A\");\n    expectBlockValue(container, \"lubelogger.serviceRecords\", 5);\n    expectBlockValue(container, \"lubelogger.reminders\", 6);\n    expectBlockValue(container, \"lubelogger.nextReminder\", 123);\n  });\n\n  it(\"shows an error when vehicleID is set but not found\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: [\n        {\n          vehicleData: { id: 2, year: 2021, model: \"Model B\" },\n          veryUrgentReminderCount: 0,\n          urgentReminderCount: 0,\n          notUrgentReminderCount: 0,\n          serviceRecordCount: 0,\n        },\n      ],\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"lubelogger\", url: \"http://x\", vehicleID: 1 } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"Vehicle not found\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/lubelogger/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    vehicleinfo: {\n      endpoint: \"vehicle/info\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/lubelogger/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"lubelogger widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/mailcow/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n  const { data: resultData, error: resultError } = useWidgetAPI(widget, \"domains\");\n\n  if (resultError || (resultData && Object.keys(resultData).length === 0)) {\n    return <Container service={service} error={resultError ?? { message: \"No domains found\" }} />;\n  }\n\n  if (!resultData) {\n    return (\n      <Container service={service}>\n        <Block label=\"mailcow.mailboxes\" />\n        <Block label=\"mailcow.aliases\" />\n        <Block label=\"mailcow.quarantined\" />\n      </Container>\n    );\n  }\n\n  const domains = resultData.length;\n  const mailboxes = resultData.reduce((acc, val) => acc + parseInt(val.mboxes_in_domain, 10), 0);\n  const mails = resultData.reduce((acc, val) => acc + parseInt(val.msgs_total, 10), 0);\n  const storage = resultData.reduce((acc, val) => acc + parseInt(val.bytes_total, 10), 0);\n\n  return (\n    <Container service={service}>\n      <Block label=\"mailcow.domains\" value={t(\"common.number\", { value: domains })} />\n      <Block label=\"mailcow.mailboxes\" value={t(\"common.number\", { value: mailboxes })} />\n      <Block label=\"mailcow.mails\" value={t(\"common.number\", { value: mails })} />\n      <Block label=\"mailcow.storage\" value={t(\"common.bytes\", { value: storage })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/mailcow/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/mailcow/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"mailcow\", url: \"http://x\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"mailcow.mailboxes\")).toBeInTheDocument();\n    expect(screen.getByText(\"mailcow.aliases\")).toBeInTheDocument();\n    expect(screen.getByText(\"mailcow.quarantined\")).toBeInTheDocument();\n  });\n\n  it(\"shows a helpful error when the API returns no domains\", () => {\n    useWidgetAPI.mockReturnValue({ data: [], error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"mailcow\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"No domains found\")).toBeInTheDocument();\n  });\n\n  it(\"renders computed totals when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: [\n        { mboxes_in_domain: \"2\", msgs_total: \"10\", bytes_total: \"100\" },\n        { mboxes_in_domain: \"1\", msgs_total: \"5\", bytes_total: \"50\" },\n      ],\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"mailcow\", url: \"http://x\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n\n    expectBlockValue(container, \"mailcow.domains\", 2);\n    expectBlockValue(container, \"mailcow.mailboxes\", 3);\n    expectBlockValue(container, \"mailcow.mails\", 15);\n    expectBlockValue(container, \"mailcow.storage\", 150);\n  });\n});\n"
  },
  {
    "path": "src/widgets/mailcow/widget.js",
    "content": "import credentialedProxyHandler from \"../../utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/v1/get/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    domains: {\n      endpoint: \"domain/all\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/mailcow/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"mailcow widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/mastodon/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: statsData, error: statsError } = useWidgetAPI(widget, \"instance\");\n\n  if (statsError) {\n    return <Container service={service} error={statsError} />;\n  }\n\n  if (!statsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"mastodon.user_count\" />\n        <Block label=\"mastodon.status_count\" />\n        <Block label=\"mastodon.domain_count\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"mastodon.user_count\" value={t(\"common.number\", { value: statsData.stats.user_count })} />\n      <Block label=\"mastodon.status_count\" value={t(\"common.number\", { value: statsData.stats.status_count })} />\n      <Block label=\"mastodon.domain_count\" value={t(\"common.number\", { value: statsData.stats.domain_count })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/mastodon/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/mastodon/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"mastodon\", url: \"http://x\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"mastodon.user_count\")).toBeInTheDocument();\n    expect(screen.getByText(\"mastodon.status_count\")).toBeInTheDocument();\n    expect(screen.getByText(\"mastodon.domain_count\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"mastodon\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders instance stats when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { stats: { user_count: 1, status_count: 2, domain_count: 3 } },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"mastodon\", url: \"http://x\" } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n\n    expectBlockValue(container, \"mastodon.user_count\", 1);\n    expectBlockValue(container, \"mastodon.status_count\", 2);\n    expectBlockValue(container, \"mastodon.domain_count\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/mastodon/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/v1/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    instance: {\n      endpoint: \"instance\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/mastodon/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"mastodon widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/mealie/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const version = widget.version ?? 1;\n  const { data, error } = useWidgetAPI(widget, version === 1 ? \"statisticsv1\" : \"statisticsv2\");\n\n  if (error) {\n    return <Container service={service} error={error} />;\n  }\n\n  if (!data) {\n    return (\n      <Container service={service}>\n        <Block label=\"mealie.recipes\" />\n        <Block label=\"mealie.users\" />\n        <Block label=\"mealie.categories\" />\n        <Block label=\"mealie.tags\" />\n      </Container>\n    );\n  }\n  return (\n    <Container service={service}>\n      <Block label=\"mealie.recipes\" value={t(\"common.number\", { value: data.totalRecipes })} />\n      <Block label=\"mealie.users\" value={t(\"common.number\", { value: data.totalUsers })} />\n      <Block label=\"mealie.categories\" value={t(\"common.number\", { value: data.totalCategories })} />\n      <Block label=\"mealie.tags\" value={t(\"common.number\", { value: data.totalTags })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/mealie/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/mealie/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"uses v1 endpoint by default and renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"mealie\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(useWidgetAPI.mock.calls[0][1]).toBe(\"statisticsv1\");\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"mealie.recipes\")).toBeInTheDocument();\n    expect(screen.getByText(\"mealie.users\")).toBeInTheDocument();\n    expect(screen.getByText(\"mealie.categories\")).toBeInTheDocument();\n    expect(screen.getByText(\"mealie.tags\")).toBeInTheDocument();\n  });\n\n  it(\"uses v2 endpoint when widget.version === 2 and renders counts\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { totalRecipes: 1, totalUsers: 2, totalCategories: 3, totalTags: 4 },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"mealie\", url: \"http://x\", version: 2 } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(useWidgetAPI.mock.calls[0][1]).toBe(\"statisticsv2\");\n    expectBlockValue(container, \"mealie.recipes\", 1);\n    expectBlockValue(container, \"mealie.users\", 2);\n    expectBlockValue(container, \"mealie.categories\", 3);\n    expectBlockValue(container, \"mealie.tags\", 4);\n  });\n});\n"
  },
  {
    "path": "src/widgets/mealie/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    statisticsv1: {\n      endpoint: \"groups/statistics\",\n    },\n    statisticsv2: {\n      endpoint: \"households/statistics\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/mealie/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"mealie widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/medusa/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { data: statsData, error: statsError } = useWidgetAPI(widget, \"stats\");\n  const { data: futureData, error: futureError } = useWidgetAPI(widget, \"future\");\n\n  if (statsError || futureError) {\n    const finalError = statsError ?? futureError;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!statsData || !futureData) {\n    return (\n      <Container service={service}>\n        <Block label=\"medusa.wanted\" />\n        <Block label=\"medusa.queued\" />\n        <Block label=\"medusa.series\" />\n      </Container>\n    );\n  }\n\n  const { later, missed, soon, today } = futureData.data;\n  const future = later.length + missed.length + soon.length + today.length;\n\n  return (\n    <Container service={service}>\n      <Block label=\"medusa.wanted\" value={t(\"common.number\", { value: future })} />\n      <Block label=\"medusa.queued\" value={t(\"common.number\", { value: statsData.data.ep_snatched })} />\n      <Block label=\"medusa.series\" value={t(\"common.number\", { value: statsData.data.shows_active })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/medusa/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/medusa/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"medusa\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"medusa.wanted\")).toBeInTheDocument();\n    expect(screen.getByText(\"medusa.queued\")).toBeInTheDocument();\n    expect(screen.getByText(\"medusa.series\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when either endpoint errors\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"future\") return { data: undefined, error: { message: \"nope\" } };\n      return { data: undefined, error: undefined };\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"medusa\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"computes wanted total from future lists and renders stats\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"future\") {\n        return {\n          data: {\n            data: {\n              later: [{ id: 1 }],\n              missed: [{ id: 2 }, { id: 3 }],\n              soon: [],\n              today: [{ id: 4 }, { id: 5 }, { id: 6 }],\n            },\n          },\n          error: undefined,\n        };\n      }\n\n      if (endpoint === \"stats\") {\n        return { data: { data: { ep_snatched: 7, shows_active: 8 } }, error: undefined };\n      }\n\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"medusa\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"medusa.wanted\", 6);\n    expectBlockValue(container, \"medusa.queued\", 7);\n    expectBlockValue(container, \"medusa.series\", 8);\n  });\n});\n"
  },
  {
    "path": "src/widgets/medusa/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/v1/{key}/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    stats: {\n      endpoint: \"?cmd=shows.stats\",\n      validate: [\"data\"],\n    },\n    future: {\n      endpoint: \"?cmd=future\",\n      validate: [\"data\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/medusa/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"medusa widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/mikrotik/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: statsData, error: statsError } = useWidgetAPI(widget, \"system\");\n  const { data: leasesData, error: leasesError } = useWidgetAPI(widget, \"leases\");\n\n  if (statsError || leasesError) {\n    const finalError = statsError ?? leasesError;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!statsData || !leasesData) {\n    return (\n      <Container service={service}>\n        <Block label=\"mikrotik.uptime\" />\n        <Block label=\"mikrotik.cpuLoad\" />\n        <Block label=\"mikrotik.memoryUsed\" />\n        <Block label=\"mikrotik.numberOfLeases\" />\n      </Container>\n    );\n  }\n\n  const memoryUsed = 100 - (statsData[\"free-memory\"] / statsData[\"total-memory\"]) * 100;\n\n  const numberOfLeases = leasesData.length;\n\n  return (\n    <Container service={service}>\n      <Block label=\"mikrotik.uptime\" value={statsData.uptime} />\n      <Block\n        label=\"mikrotik.cpuLoad\"\n        value={t(\"common.percent\", { value: statsData[\"cpu-load\"] })}\n        highlightValue={statsData[\"cpu-load\"]}\n      />\n      <Block\n        label=\"mikrotik.memoryUsed\"\n        value={t(\"common.percent\", { value: memoryUsed })}\n        highlightValue={memoryUsed}\n      />\n      <Block label=\"mikrotik.numberOfLeases\" value={t(\"common.number\", { value: numberOfLeases })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/mikrotik/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/mikrotik/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"mikrotik\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"mikrotik.uptime\")).toBeInTheDocument();\n    expect(screen.getByText(\"mikrotik.cpuLoad\")).toBeInTheDocument();\n    expect(screen.getByText(\"mikrotik.memoryUsed\")).toBeInTheDocument();\n    expect(screen.getByText(\"mikrotik.numberOfLeases\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when either endpoint errors\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"leases\") return { data: undefined, error: { message: \"nope\" } };\n      return { data: undefined, error: undefined };\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"mikrotik\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders uptime, cpu load, memory used, and lease count\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"system\") {\n        return {\n          data: {\n            uptime: \"1d\",\n            \"cpu-load\": 10,\n            \"free-memory\": 25,\n            \"total-memory\": 100,\n          },\n          error: undefined,\n        };\n      }\n\n      if (endpoint === \"leases\") {\n        return { data: [{ id: 1 }, { id: 2 }, { id: 3 }], error: undefined };\n      }\n\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"mikrotik\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    // memoryUsed = 100 - (25/100)*100 = 75\n    expectBlockValue(container, \"mikrotik.uptime\", \"1d\");\n    expectBlockValue(container, \"mikrotik.cpuLoad\", 10);\n    expectBlockValue(container, \"mikrotik.memoryUsed\", 75);\n    expectBlockValue(container, \"mikrotik.numberOfLeases\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/mikrotik/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/rest/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    system: {\n      endpoint: \"system/resource\",\n      validate: [\"cpu-load\", \"free-memory\", \"total-memory\", \"uptime\"],\n    },\n    leases: {\n      endpoint: \"ip/dhcp-server/lease?.proplist=address\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/mikrotik/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"mikrotik widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/minecraft/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n  const { data: serverData, error: serverError } = useWidgetAPI(widget, \"status\");\n  const { t } = useTranslation();\n\n  if (serverError) {\n    return <Container service={service} error={serverError} />;\n  }\n  if (!serverData) {\n    return (\n      <Container service={service}>\n        <Block label=\"minecraft.status\" />\n        <Block label=\"minecraft.players\" />\n        <Block label=\"minecraft.version\" />\n      </Container>\n    );\n  }\n\n  const statusIndicator = serverData.online ? t(\"minecraft.up\") : t(\"minecraft.down\");\n  const players = serverData.players ? `${serverData.players.online} / ${serverData.players.max}` : \"-\";\n  const version = serverData.version || \"-\";\n\n  return (\n    <Container service={service}>\n      <Block label=\"minecraft.status\" value={statusIndicator} />\n      <Block label=\"minecraft.players\" value={players} />\n      <Block label=\"minecraft.version\" value={version} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/minecraft/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/minecraft/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"minecraft\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"minecraft.status\")).toBeInTheDocument();\n    expect(screen.getByText(\"minecraft.players\")).toBeInTheDocument();\n    expect(screen.getByText(\"minecraft.version\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when status endpoint errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"minecraft\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders status, players, and version when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        online: true,\n        players: { online: 2, max: 10 },\n        version: \"1.20.1\",\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"minecraft\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"minecraft.status\", \"minecraft.up\");\n    expectBlockValue(container, \"minecraft.players\", \"2 / 10\");\n    expectBlockValue(container, \"minecraft.version\", \"1.20.1\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/minecraft/proxy.js",
    "content": "import mc from \"minecraftstatuspinger\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\n\nconst proxyName = \"minecraftProxyHandler\";\nconst logger = createLogger(proxyName);\n\nexport default async function minecraftProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n  const serviceWidget = await getServiceWidget(group, service, index);\n  const url = new URL(serviceWidget.url);\n  try {\n    const pingResponse = await mc.lookup({\n      host: url.hostname,\n      port: url.port || 25565,\n    });\n    res.status(200).send({\n      version: pingResponse.status.version.name,\n      online: true,\n      players: pingResponse.status.players,\n    });\n  } catch (e) {\n    if (e) logger.error(e);\n    res.status(200).send({\n      version: undefined,\n      online: false,\n      players: undefined,\n    });\n  }\n}\n"
  },
  {
    "path": "src/widgets/minecraft/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { mc, getServiceWidget, logger } = vi.hoisted(() => ({\n  mc: { lookup: vi.fn() },\n  getServiceWidget: vi.fn(),\n  logger: { error: vi.fn() },\n}));\n\nvi.mock(\"minecraftstatuspinger\", () => ({\n  default: mc,\n  ...mc,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nimport minecraftProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/minecraft/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"returns online=true with version and player data when lookup succeeds\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://example.com:25565\" });\n    mc.lookup.mockResolvedValue({\n      status: {\n        version: { name: \"1.20\" },\n        players: { online: 1, max: 10 },\n      },\n    });\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await minecraftProxyHandler(req, res);\n\n    expect(mc.lookup).toHaveBeenCalledWith({ host: \"example.com\", port: \"25565\" });\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({\n      version: \"1.20\",\n      online: true,\n      players: { online: 1, max: 10 },\n    });\n  });\n\n  it(\"returns online=false when lookup fails\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://example.com:25565\" });\n    mc.lookup.mockRejectedValue(new Error(\"nope\"));\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await minecraftProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ version: undefined, online: false, players: undefined });\n  });\n});\n"
  },
  {
    "path": "src/widgets/minecraft/widget.js",
    "content": "import minecraftProxyHandler from \"./proxy\";\n\nconst widget = {\n  proxyHandler: minecraftProxyHandler,\n  allowedEndpoints: /status/,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/minecraft/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"minecraft widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/miniflux/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: minifluxData, error: minifluxError } = useWidgetAPI(widget, \"counters\");\n\n  if (minifluxError) {\n    return <Container service={service} error={minifluxError} />;\n  }\n\n  if (!minifluxData) {\n    return (\n      <Container service={service}>\n        <Block label=\"miniflux.unread\" />\n        <Block label=\"miniflux.read\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"miniflux.unread\" value={t(\"common.number\", { value: minifluxData.unread })} />\n      <Block label=\"miniflux.read\" value={t(\"common.number\", { value: minifluxData.read })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/miniflux/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/miniflux/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"miniflux\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"miniflux.unread\")).toBeInTheDocument();\n    expect(screen.getByText(\"miniflux.read\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when counters endpoint errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"miniflux\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders unread and read counters when loaded\", () => {\n    useWidgetAPI.mockReturnValue({ data: { unread: 3, read: 7 }, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"miniflux\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"miniflux.unread\", 3);\n    expectBlockValue(container, \"miniflux.read\", 7);\n  });\n});\n"
  },
  {
    "path": "src/widgets/miniflux/widget.js",
    "content": "import { asJson } from \"utils/proxy/api-helpers\";\nimport credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/v1/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    counters: {\n      endpoint: \"feeds/counters\",\n      map: (data) => ({\n        read: Object.values(asJson(data).reads).reduce((acc, i) => acc + i, 0),\n        unread: Object.values(asJson(data).unreads).reduce((acc, i) => acc + i, 0),\n      }),\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/miniflux/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"miniflux widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/mjpeg/component.jsx",
    "content": "import Image from \"next/image\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n  const { stream, fit = \"contain\" } = widget;\n\n  return (\n    <div>\n      <style>{`\n        .tv-static img {\n          display: none !important;\n        }\n        .tv-static {\n          margin: auto;\n          background-image: repeating-radial-gradient(circle at 17% 32%, white, black 0.00085px);\n          animation: tv-static 5s linear infinite;\n          width: 100%;\n          height: 100%;\n          position: absolute;\n          top: 0;\n          left: 0;\n        }\n        @keyframes tv-static {\n          from {\n            background-size: 100% 100%;\n          }\n          to {\n            background-size: 200% 200%;\n          }\n        }\n      `}</style>\n      <div className=\"absolute top-0 bottom-0 right-0 left-0\">\n        <Image\n          layout=\"fill\"\n          objectFit=\"fill\"\n          className=\"blur-md\"\n          src={stream}\n          alt=\"stream\"\n          onError={(e) => {\n            e.target.parentElement.parentElement.className = \"tv-static\";\n          }}\n        />\n        <Image layout=\"fill\" objectFit={fit} className=\"drop-shadow-2xl\" src={stream} alt=\"stream\" />\n      </div>\n      <div className=\"absolute top-0 right-0 bottom-0 left-0 overflow-clip shadow-[inset_0_0_200px_#000] shadow-theme-700/10 dark:shadow-theme-900/10\" />\n      <div className=\"h-[68px] overflow-clip\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/widgets/mjpeg/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\n// next/image requires Next runtime features; stub it for component tests.\nvi.mock(\"next/image\", () => ({\n  default: (props) => {\n    const { src, alt, objectFit, className, onError } = props;\n    // This is a unit-test stub for next/image; using <img> is intentional here.\n    // eslint-disable-next-line @next/next/no-img-element\n    return <img alt={alt} src={src} data-object-fit={objectFit} className={className} onError={onError} />;\n  },\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/mjpeg/component\", () => {\n  it(\"renders the stream images\", () => {\n    render(<Component service={{ widget: { type: \"mjpeg\", stream: \"http://example/stream.jpg\", fit: \"cover\" } }} />);\n\n    const imgs = screen.getAllByAltText(\"stream\");\n    expect(imgs).toHaveLength(2);\n    expect(imgs[0].getAttribute(\"src\")).toBe(\"http://example/stream.jpg\");\n    expect(imgs[1].getAttribute(\"src\")).toBe(\"http://example/stream.jpg\");\n\n    // Both renders pass through objectFit; the first is \"fill\", the second uses widget.fit.\n    expect(imgs[0].getAttribute(\"data-object-fit\")).toBe(\"fill\");\n    expect(imgs[1].getAttribute(\"data-object-fit\")).toBe(\"cover\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/mjpeg/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/mjpeg/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"mjpeg widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/moonraker/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: printStats, error: printStatsError } = useWidgetAPI(widget, \"print_stats\");\n  const { data: displayStatus, error: displayStatsError } = useWidgetAPI(widget, \"display_status\");\n  const { data: webHooks, error: webHooksError } = useWidgetAPI(widget, \"webhooks\");\n\n  if (printStatsError || displayStatsError || webHooksError) {\n    const finalError = printStatsError ?? displayStatsError ?? webHooksError;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!printStats || !displayStatus || !webHooks) {\n    return (\n      <Container service={service}>\n        <Block label=\"moonraker.printer_state\" />\n      </Container>\n    );\n  }\n\n  if (webHooks.result.status.webhooks.state === \"shutdown\") {\n    return (\n      <Container service={service}>\n        <Block label=\"moonraker.printer_state\" value={webHooks.result.status.webhooks.state} />\n      </Container>\n    );\n  }\n\n  const printStatsInfo = printStats.result.status.print_stats.info ?? {};\n  const { current_layer: currentLayer = \"-\", total_layer: totalLayer = \"-\" } = printStatsInfo;\n  const layers = printStats.result.status.print_stats.state === \"standby\" ? \"- / -\" : `${currentLayer} / ${totalLayer}`;\n  const progress =\n    printStats.result.status.print_stats.state === \"standby\"\n      ? \"-\"\n      : t(\"common.percent\", { value: displayStatus.result.status.display_status.progress * 100 });\n\n  return (\n    <Container service={service}>\n      <Block label=\"moonraker.layers\" value={layers} />\n      <Block label=\"moonraker.print_progress\" value={progress} />\n      <Block label=\"moonraker.print_status\" value={printStats.result.status.print_stats.state} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/moonraker/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/moonraker/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"moonraker\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(1);\n    expect(screen.getByText(\"moonraker.printer_state\")).toBeInTheDocument();\n  });\n\n  it(\"renders printer state as shutdown when webhook reports shutdown\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"print_stats\") {\n        return { data: { result: { status: { print_stats: { state: \"standby\", info: {} } } } }, error: undefined };\n      }\n      if (endpoint === \"display_status\") {\n        return { data: { result: { status: { display_status: { progress: 0 } } } }, error: undefined };\n      }\n      if (endpoint === \"webhooks\") {\n        return { data: { result: { status: { webhooks: { state: \"shutdown\" } } } }, error: undefined };\n      }\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"moonraker\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(1);\n    expectBlockValue(container, \"moonraker.printer_state\", \"shutdown\");\n  });\n\n  it(\"renders layers, progress and print status when active\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"print_stats\") {\n        return {\n          data: {\n            result: {\n              status: {\n                print_stats: { state: \"printing\", info: { current_layer: 1, total_layer: 2 } },\n              },\n            },\n          },\n          error: undefined,\n        };\n      }\n      if (endpoint === \"display_status\") {\n        return { data: { result: { status: { display_status: { progress: 0.25 } } } }, error: undefined };\n      }\n      if (endpoint === \"webhooks\") {\n        return { data: { result: { status: { webhooks: { state: \"ready\" } } } }, error: undefined };\n      }\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"moonraker\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expectBlockValue(container, \"moonraker.layers\", \"1 / 2\");\n    expectBlockValue(container, \"moonraker.print_progress\", 25);\n    expectBlockValue(container, \"moonraker.print_status\", \"printing\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/moonraker/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/printer/objects/query?{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    print_stats: {\n      endpoint: \"print_stats\",\n    },\n    display_status: {\n      endpoint: \"display_status\",\n    },\n    webhooks: {\n      endpoint: \"webhooks\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/moonraker/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"moonraker widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/mylar/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: seriesData, error: seriesError } = useWidgetAPI(widget, \"series\");\n  const { data: issuesData, error: issuesError } = useWidgetAPI(widget, \"issues\");\n  const { data: wantedData, error: wantedError } = useWidgetAPI(widget, \"wanted\");\n\n  if (seriesError || issuesError || wantedError) {\n    const finalError = seriesError ?? issuesError ?? wantedError;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!seriesData || !issuesData || !wantedData) {\n    return (\n      <Container service={service}>\n        <Block label=\"mylar.series\" />\n        <Block label=\"mylar.issues\" />\n        <Block label=\"mylar.wanted\" />\n      </Container>\n    );\n  }\n\n  const totalIssues = issuesData.data.reduce((acc, series) => acc + series.totalIssues, 0);\n\n  return (\n    <Container service={service}>\n      <Block label=\"mylar.series\" value={t(\"common.number\", { value: seriesData.data.length })} />\n      <Block label=\"mylar.issues\" value={t(\"common.number\", { value: totalIssues })} />\n      <Block label=\"mylar.wanted\" value={t(\"common.number\", { value: wantedData.issues.length })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/mylar/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/mylar/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"mylar\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"mylar.series\")).toBeInTheDocument();\n    expect(screen.getByText(\"mylar.issues\")).toBeInTheDocument();\n    expect(screen.getByText(\"mylar.wanted\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when any endpoint errors\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"issues\") return { data: undefined, error: { message: \"nope\" } };\n      return { data: undefined, error: undefined };\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"mylar\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders series count, total issues, and wanted issues\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"series\") return { data: { data: [{ id: 1 }, { id: 2 }] }, error: undefined };\n      if (endpoint === \"issues\") {\n        return { data: { data: [{ totalIssues: 3 }, { totalIssues: 4 }] }, error: undefined };\n      }\n      if (endpoint === \"wanted\") return { data: { issues: [{ id: 1 }] }, error: undefined };\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"mylar\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"mylar.series\", 2);\n    expectBlockValue(container, \"mylar.issues\", 7);\n    expectBlockValue(container, \"mylar.wanted\", 1);\n  });\n});\n"
  },
  {
    "path": "src/widgets/mylar/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api?cmd={endpoint}&apikey={key}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    issues: {\n      endpoint: \"getIndex\",\n    },\n    series: {\n      endpoint: \"seriesjsonListing\",\n    },\n    wanted: {\n      endpoint: \"getWanted\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/mylar/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"mylar widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/myspeed/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const { data, error } = useWidgetAPI(widget, \"info\");\n\n  if (error || (data && data.message) || (data && data[0] && data[0].error)) {\n    let finalError = error ?? data;\n    if (data && data[0] && data[0].error) {\n      try {\n        finalError = JSON.parse(data[0].error);\n      } catch (e) {\n        finalError = data[0].error;\n      }\n    }\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!data || (data && data.length === 0)) {\n    return (\n      <Container service={service}>\n        <Block label=\"myspeed.download\" />\n        <Block label=\"myspeed.upload\" />\n        <Block label=\"myspeed.ping\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block\n        label=\"myspeed.download\"\n        value={t(\"common.bitrate\", {\n          value: data[0].download * 1000 * 1000,\n          decimals: 2,\n        })}\n      />\n      <Block\n        label=\"myspeed.upload\"\n        value={t(\"common.bitrate\", {\n          value: data[0].upload * 1000 * 1000,\n          decimals: 2,\n        })}\n      />\n      <Block\n        label=\"myspeed.ping\"\n        value={t(\"common.ms\", {\n          value: data[0].ping,\n          style: \"unit\",\n          unit: \"millisecond\",\n        })}\n        highlightValue={data[0].ping}\n      />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/myspeed/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/myspeed/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"myspeed\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"myspeed.download\")).toBeInTheDocument();\n    expect(screen.getByText(\"myspeed.upload\")).toBeInTheDocument();\n    expect(screen.getByText(\"myspeed.ping\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when endpoint errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"myspeed\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders download, upload and ping when loaded\", () => {\n    useWidgetAPI.mockReturnValue({ data: [{ download: 1, upload: 2, ping: 3 }], error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"myspeed\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    // t(\"common.bitrate\") returns the raw value from setup; widget multiplies by 1e6.\n    expectBlockValue(container, \"myspeed.download\", 1000 * 1000);\n    expectBlockValue(container, \"myspeed.upload\", 2 * 1000 * 1000);\n    expectBlockValue(container, \"myspeed.ping\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/myspeed/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    info: {\n      endpoint: \"speedtests?limit=1\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/myspeed/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"myspeed widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/navidrome/component.jsx",
    "content": "import Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nfunction SinglePlayingEntry({ entry }) {\n  const { username, artist, title, album } = entry;\n  let fullTitle = title;\n  if (artist) fullTitle = `${artist} - ${title}`;\n  if (album) fullTitle += ` — ${album}`;\n  if (username) fullTitle += ` (${username})`;\n\n  return (\n    <div className=\"text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex\">\n      <div className=\"text-xs z-10 self-center ml-2 relative w-full h-4 grow mr-2\">\n        <div className=\"absolute w-full whitespace-nowrap text-ellipsis overflow-hidden\">{fullTitle}</div>\n      </div>\n    </div>\n  );\n}\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: navidromeData, error: navidromeError } = useWidgetAPI(widget, \"getNowPlaying\");\n\n  if (navidromeError || navidromeData?.[\"subsonic-response\"]?.error) {\n    return <Container service={service} error={navidromeError ?? navidromeData?.[\"subsonic-response\"]?.error} />;\n  }\n\n  if (!navidromeData) {\n    return <SinglePlayingEntry entry={{ title: t(\"navidrome.please_wait\") }} />;\n  }\n\n  const { nowPlaying } = navidromeData[\"subsonic-response\"];\n  if (!nowPlaying.entry) {\n    // nothing playing\n    return <SinglePlayingEntry entry={{ title: t(\"navidrome.nothing_streaming\") }} />;\n  }\n\n  const nowPlayingEntries = Object.values(nowPlaying.entry);\n\n  return (\n    <div className=\"flex flex-col pb-1 mx-1\">\n      {nowPlayingEntries.map((entry) => (\n        <SinglePlayingEntry key={entry.id} entry={entry} />\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/widgets/navidrome/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/navidrome/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders a waiting row while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"navidrome\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"navidrome.please_wait\")).toBeInTheDocument();\n  });\n\n  it(\"renders an error container when the API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"navidrome\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders now playing entries when present\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        \"subsonic-response\": {\n          nowPlaying: {\n            entry: {\n              0: { id: \"a\", title: \"Song\", artist: \"Artist\", album: \"Album\", username: \"user\" },\n            },\n          },\n        },\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"navidrome\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"Artist - Song — Album (user)\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/navidrome/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/rest/{endpoint}?u={user}&t={token}&s={salt}&v=1.16.1&c=homepage&f=json\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    getNowPlaying: {\n      endpoint: \"getNowPlaying\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/navidrome/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"navidrome widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/netalertx/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const dataEndpoint = widget?.version > 1 ? \"datav2\" : \"data\";\n\n  const { data: netalertxData, error: netalertxError } = useWidgetAPI(widget, dataEndpoint);\n\n  if (netalertxError) {\n    return <Container service={service} error={netalertxError} />;\n  }\n\n  if (!netalertxData) {\n    return (\n      <Container service={service}>\n        <Block label=\"netalertx.total\" />\n        <Block label=\"netalertx.connected\" />\n        <Block label=\"netalertx.new_devices\" />\n        <Block label=\"netalertx.down_alerts\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"netalertx.total\" value={t(\"common.number\", { value: parseInt(netalertxData[0], 10) })} />\n      <Block label=\"netalertx.connected\" value={t(\"common.number\", { value: parseInt(netalertxData[1], 10) })} />\n      <Block label=\"netalertx.new_devices\" value={t(\"common.number\", { value: parseInt(netalertxData[3], 10) })} />\n      <Block label=\"netalertx.down_alerts\" value={t(\"common.number\", { value: parseInt(netalertxData[4], 10) })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/netalertx/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/netalertx/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"netalertx\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"netalertx.total\")).toBeInTheDocument();\n    expect(screen.getByText(\"netalertx.connected\")).toBeInTheDocument();\n    expect(screen.getByText(\"netalertx.new_devices\")).toBeInTheDocument();\n    expect(screen.getByText(\"netalertx.down_alerts\")).toBeInTheDocument();\n  });\n\n  it(\"uses datav2 endpoint for version > 1 and renders parsed totals\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"datav2\") return { data: [\"10\", \"5\", \"0\", \"2\", \"1\"], error: undefined };\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"netalertx\", version: 2 } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(useWidgetAPI).toHaveBeenCalled();\n    expectBlockValue(container, \"netalertx.total\", 10);\n    expectBlockValue(container, \"netalertx.connected\", 5);\n    expectBlockValue(container, \"netalertx.new_devices\", 2);\n    expectBlockValue(container, \"netalertx.down_alerts\", 1);\n  });\n});\n"
  },
  {
    "path": "src/widgets/netalertx/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    data: {\n      endpoint: \"php/server/devices.php?action=getDevicesTotals\",\n    },\n    datav2: {\n      endpoint: \"devices/totals\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/netalertx/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"netalertx widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/netdata/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: netdataData, error: netdataError } = useWidgetAPI(widget, \"info\");\n\n  if (netdataError) {\n    return <Container service={service} error={netdataError} />;\n  }\n\n  if (!netdataData) {\n    return (\n      <Container service={service}>\n        <Block label=\"netdata.warnings\" />\n        <Block label=\"netdata.criticals\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"netdata.warnings\" value={t(\"common.number\", { value: netdataData.alarms.warning })} />\n      <Block label=\"netdata.criticals\" value={t(\"common.number\", { value: netdataData.alarms.critical })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/netdata/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/netdata/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"netdata\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"netdata.warnings\")).toBeInTheDocument();\n    expect(screen.getByText(\"netdata.criticals\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when endpoint errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"netdata\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders warning and critical alarm counts\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { alarms: { warning: 3, critical: 1 } },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"netdata\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"netdata.warnings\", 3);\n    expectBlockValue(container, \"netdata.criticals\", 1);\n  });\n});\n"
  },
  {
    "path": "src/widgets/netdata/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/v1/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    info: {\n      endpoint: \"info\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/netdata/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"netdata widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/nextcloud/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\nimport { useMemo } from \"react\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n  const { data: nextcloudData, error: nextcloudError } = useWidgetAPI(widget, \"serverinfo\");\n\n  // Support for deprecated fields (cpuload, memoryusage)\n  const [showCpuLoad, showMemoryUsage] = useMemo(() => {\n    // Default values if fields is not set\n    if (!widget.fields) return [false, false];\n\n    // Allows for backwards compatibility with existing values of fields\n    if (widget.fields.length <= 4) return [true, true];\n\n    // If all fields are enabled, drop cpuload and memoryusage\n    if (widget.fields.length === 6) return [false, false];\n\n    const hasCpuLoad = widget.fields?.includes(\"cpuload\");\n    const hasMemoryUsage = widget.fields?.includes(\"memoryusage\");\n\n    // If (for some reason) 5 fields are set, drop memoryusage\n    if (hasCpuLoad && hasMemoryUsage) return [true, false];\n    return [!hasCpuLoad, !hasMemoryUsage];\n  }, [widget.fields]);\n\n  if (nextcloudError) {\n    return <Container service={service} error={nextcloudError} />;\n  }\n\n  if (!nextcloudData) {\n    return (\n      <Container service={service}>\n        {showCpuLoad && <Block label=\"nextcloud.cpuload\" />}\n        {showMemoryUsage && <Block label=\"nextcloud.memoryusage\" />}\n        <Block label=\"nextcloud.freespace\" />\n        <Block label=\"nextcloud.activeusers\" />\n        <Block label=\"nextcloud.numfiles\" />\n        <Block label=\"nextcloud.numshares\" />\n      </Container>\n    );\n  }\n\n  const { nextcloud: nextcloudInfo, activeUsers } = nextcloudData.ocs.data;\n  const memoryUsage =\n    100 *\n    ((parseFloat(nextcloudInfo.system.mem_total) - parseFloat(nextcloudInfo.system.mem_free)) /\n      parseFloat(nextcloudInfo.system.mem_total));\n\n  return (\n    <Container service={service}>\n      {showCpuLoad && (\n        <Block\n          label=\"nextcloud.cpuload\"\n          value={t(\"common.percent\", { value: nextcloudInfo.system.cpuload[0] })}\n          highlightValue={nextcloudInfo.system.cpuload[0]}\n        />\n      )}\n      {showMemoryUsage && (\n        <Block\n          label=\"nextcloud.memoryusage\"\n          value={t(\"common.percent\", { value: memoryUsage })}\n          highlightValue={memoryUsage}\n        />\n      )}\n      <Block\n        label=\"nextcloud.freespace\"\n        value={t(\"common.bbytes\", { value: nextcloudInfo.system.freespace, maximumFractionDigits: 1 })}\n        highlightValue={nextcloudInfo.system.freespace}\n      />\n      <Block label=\"nextcloud.activeusers\" value={t(\"common.number\", { value: activeUsers.last24hours })} />\n      <Block label=\"nextcloud.numfiles\" value={t(\"common.number\", { value: nextcloudInfo.storage.num_files })} />\n      <Block label=\"nextcloud.numshares\" value={t(\"common.number\", { value: nextcloudInfo.shares.num_shares })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/nextcloud/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/nextcloud/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders default placeholders (no cpu/memory blocks when fields are unset)\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"nextcloud\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.queryByText(\"nextcloud.cpuload\")).toBeNull();\n    expect(screen.queryByText(\"nextcloud.memoryusage\")).toBeNull();\n    expect(screen.getByText(\"nextcloud.freespace\")).toBeInTheDocument();\n    expect(screen.getByText(\"nextcloud.activeusers\")).toBeInTheDocument();\n    expect(screen.getByText(\"nextcloud.numfiles\")).toBeInTheDocument();\n    expect(screen.getByText(\"nextcloud.numshares\")).toBeInTheDocument();\n  });\n\n  it(\"respects widget.fields and renders computed values\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        ocs: {\n          data: {\n            nextcloud: {\n              system: {\n                cpuload: [0.5],\n                mem_total: \"100\",\n                mem_free: \"50\",\n                freespace: 1024,\n              },\n              storage: { num_files: 1 },\n              shares: { num_shares: 2 },\n            },\n            activeUsers: { last24hours: 3 },\n          },\n        },\n      },\n      error: undefined,\n    });\n\n    // 4 fields triggers the legacy behavior where CPU + memory are shown;\n    // Container then filters to exactly these fields.\n    const service = {\n      widget: { type: \"nextcloud\", fields: [\"cpuload\", \"memoryusage\", \"freespace\", \"activeusers\"] },\n    };\n\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"nextcloud.cpuload\")).toBeInTheDocument();\n    expect(screen.getByText(\"nextcloud.memoryusage\")).toBeInTheDocument();\n    expect(screen.getByText(\"nextcloud.freespace\")).toBeInTheDocument();\n    expect(screen.getByText(\"nextcloud.activeusers\")).toBeInTheDocument();\n    expect(screen.queryByText(\"nextcloud.numfiles\")).toBeNull();\n    expect(screen.queryByText(\"nextcloud.numshares\")).toBeNull();\n\n    // Values: cpu load 0.5, memory usage 50, freespace 1024, active users 3.\n    expect(screen.getByText(\"0.5\")).toBeInTheDocument();\n    expect(screen.getByText(\"50\")).toBeInTheDocument();\n    expect(screen.getByText(\"1024\")).toBeInTheDocument();\n    expect(screen.getByText(\"3\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/nextcloud/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    serverinfo: {\n      endpoint: \"ocs/v2.php/apps/serverinfo/api/v1/info?format=json\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/nextcloud/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"nextcloud widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/nextdns/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: nextdnsData, error: nextdnsError } = useWidgetAPI(widget, \"analytics/status\");\n\n  if (nextdnsError) {\n    return <Container service={service} error={nextdnsError} />;\n  }\n\n  if (!nextdnsData) {\n    return (\n      <Container service={service}>\n        <Block key=\"status\" label=\"widget.status\" value={t(\"nextdns.wait\")} />\n      </Container>\n    );\n  }\n\n  if (!nextdnsData?.data?.length) {\n    return (\n      <Container service={service}>\n        <Block key=\"status\" label=\"widget.status\" value={t(\"nextdns.no_devices\")} />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      {nextdnsData.data.map((d) => (\n        <Block key={d.status} label={d.status} value={t(\"common.number\", { value: d.queries })} />\n      ))}\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/nextdns/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/nextdns/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders waiting status while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"nextdns\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"widget.status\")).toBeInTheDocument();\n    expect(screen.getByText(\"nextdns.wait\")).toBeInTheDocument();\n  });\n\n  it(\"renders no-devices status when data array is empty\", () => {\n    useWidgetAPI.mockReturnValue({ data: { data: [] }, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"nextdns\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"nextdns.no_devices\")).toBeInTheDocument();\n  });\n\n  it(\"renders a block per device status with query counts\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          { status: \"nextdns.active\", queries: 10 },\n          { status: \"nextdns.offline\", queries: 2 },\n        ],\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"nextdns\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"nextdns.active\")).toBeInTheDocument();\n    expect(screen.getByText(\"nextdns.offline\")).toBeInTheDocument();\n    expect(screen.getByText(\"10\")).toBeInTheDocument();\n    expect(screen.getByText(\"2\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/nextdns/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"https://api.nextdns.io/profiles/{profile}/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    \"analytics/status\": {\n      endpoint: \"analytics/status\",\n      validate: [\"data\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/nextdns/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"nextdns widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/npm/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  const { data: infoData, error: infoError } = useWidgetAPI(widget, \"hosts\");\n\n  if (infoError) {\n    return <Container service={service} error={infoError} />;\n  }\n\n  if (!infoData) {\n    return (\n      <Container service={service}>\n        <Block label=\"npm.enabled\" />\n        <Block label=\"npm.disabled\" />\n        <Block label=\"npm.total\" />\n      </Container>\n    );\n  }\n\n  const enabled = infoData.filter((c) => !!c.enabled).length;\n  const disabled = infoData.filter((c) => !c.enabled).length;\n  const total = infoData.length;\n\n  return (\n    <Container service={service}>\n      <Block label=\"npm.enabled\" value={enabled} />\n      <Block label=\"npm.disabled\" value={disabled} />\n      <Block label=\"npm.total\" value={total} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/npm/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/npm/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"npm\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"npm.enabled\")).toBeInTheDocument();\n    expect(screen.getByText(\"npm.disabled\")).toBeInTheDocument();\n    expect(screen.getByText(\"npm.total\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when endpoint errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"npm\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders enabled/disabled/total host counts\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: [{ enabled: true }, { enabled: false }, { enabled: 1 }, { enabled: 0 }, { enabled: true }],\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"npm\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"npm.enabled\", 3);\n    expectBlockValue(container, \"npm.disabled\", 2);\n    expectBlockValue(container, \"npm.total\", 5);\n  });\n});\n"
  },
  {
    "path": "src/widgets/npm/proxy.js",
    "content": "import cache from \"memory-cache\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst proxyName = \"npmProxyHandler\";\nconst tokenCacheKey = `${proxyName}__token`;\nconst logger = createLogger(proxyName);\n\nasync function login(loginUrl, username, password, service) {\n  const authResponse = await httpProxy(loginUrl, {\n    method: \"POST\",\n    body: JSON.stringify({ identity: username, secret: password }),\n    headers: {\n      \"Content-Type\": \"application/json\",\n    },\n  });\n\n  const status = authResponse[0];\n  let data = authResponse[2];\n\n  try {\n    data = JSON.parse(Buffer.from(authResponse[2]).toString());\n\n    if (status === 200) {\n      const expiration = new Date(data.expires) - Date.now();\n      cache.put(`${tokenCacheKey}.${service}`, data.token, expiration - 5 * 60 * 1000); // expiration -5 minutes\n    }\n  } catch (e) {\n    logger.error(`Error ${status} logging into npm`, JSON.stringify(authResponse[2]));\n  }\n  return [status, data.token ?? data];\n}\n\nexport default async function npmProxyHandler(req, res) {\n  const { group, service, endpoint, index } = req.query;\n\n  if (group && service) {\n    const widget = await getServiceWidget(group, service, index);\n\n    if (!widgets?.[widget.type]?.api) {\n      return res.status(403).json({ error: \"Service does not support API calls\" });\n    }\n\n    if (widget) {\n      const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));\n      const loginUrl = `${widget.url}/api/tokens`;\n\n      let status;\n      let data;\n\n      let token = cache.get(`${tokenCacheKey}.${service}`);\n      if (!token) {\n        [status, token] = await login(loginUrl, widget.username, widget.password, service);\n        if (status !== 200) {\n          logger.debug(`HTTP ${status} logging into npm api: ${token}`);\n          return res.status(status).send(token);\n        }\n      }\n\n      [status, , data] = await httpProxy(url, {\n        method: \"GET\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n          Authorization: `Bearer ${token}`,\n        },\n      });\n\n      if (status === 403) {\n        logger.debug(`HTTP ${status} retrieving data from npm api, logging in and trying again.`);\n        cache.del(`${tokenCacheKey}.${service}`);\n        [status, token] = await login(loginUrl, widget.username, widget.password, service);\n\n        if (status !== 200) {\n          logger.debug(`HTTP ${status} logging into npm api: ${data}`);\n          return res.status(status).send(data);\n        }\n\n        [status, , data] = await httpProxy(url, {\n          method: \"GET\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n            Authorization: `Bearer ${token}`,\n          },\n        });\n      }\n\n      if (status !== 200) {\n        return res.status(status).send(data);\n      }\n\n      return res.send(data);\n    }\n  }\n\n  return res.status(400).json({ error: \"Invalid proxy service type\" });\n}\n"
  },
  {
    "path": "src/widgets/npm/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {\n  const store = new Map();\n\n  return {\n    httpProxy: vi.fn(),\n    getServiceWidget: vi.fn(),\n    cache: {\n      get: vi.fn((k) => store.get(k)),\n      put: vi.fn((k, v) => store.set(k, v)),\n      del: vi.fn((k) => store.delete(k)),\n      _reset: () => store.clear(),\n    },\n    logger: {\n      debug: vi.fn(),\n      error: vi.fn(),\n    },\n  };\n});\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nvi.mock(\"memory-cache\", () => ({\n  default: cache,\n  ...cache,\n}));\n\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    npm: {\n      api: \"{url}/{endpoint}\",\n    },\n  },\n}));\n\nimport npmProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/npm/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    cache._reset();\n  });\n\n  it(\"logs in when token is missing and uses Bearer token for requests\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"npm\",\n      url: \"http://npm\",\n      username: \"u\",\n      password: \"p\",\n    });\n\n    httpProxy\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ token: \"t1\", expires: new Date(Date.now() + 60_000).toISOString() })),\n      ])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"data\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"api/v1/stats\", index: \"0\" } };\n    const res = createMockRes();\n\n    await npmProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(2);\n    expect(httpProxy.mock.calls[0][0]).toBe(\"http://npm/api/tokens\");\n    expect(httpProxy.mock.calls[1][1].headers.Authorization).toBe(\"Bearer t1\");\n    expect(res.body).toEqual(Buffer.from(\"data\"));\n  });\n\n  it(\"retries after a 403 response by clearing cache and logging in again\", async () => {\n    cache.put(\"npmProxyHandler__token.svc\", \"old\");\n\n    getServiceWidget.mockResolvedValue({\n      type: \"npm\",\n      url: \"http://npm\",\n      username: \"u\",\n      password: \"p\",\n    });\n\n    httpProxy\n      .mockResolvedValueOnce([403, \"application/json\", Buffer.from(\"nope\")])\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ token: \"new\", expires: new Date(Date.now() + 60_000).toISOString() })),\n      ])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"ok\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"api/v1/stats\", index: \"0\" } };\n    const res = createMockRes();\n\n    await npmProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(3);\n    expect(httpProxy.mock.calls[0][1].headers.Authorization).toBe(\"Bearer old\");\n    expect(httpProxy.mock.calls[1][0]).toBe(\"http://npm/api/tokens\");\n    expect(httpProxy.mock.calls[2][1].headers.Authorization).toBe(\"Bearer new\");\n    expect(res.body).toEqual(Buffer.from(\"ok\"));\n  });\n});\n"
  },
  {
    "path": "src/widgets/npm/widget.js",
    "content": "import npmProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: npmProxyHandler,\n\n  mappings: {\n    hosts: {\n      endpoint: \"nginx/proxy-hosts\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/npm/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"npm widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/nzbget/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation(\"common\");\n\n  const { widget } = service;\n\n  const { data: statusData, error: statusError } = useWidgetAPI(widget, \"status\");\n\n  if (statusError) {\n    return <Container service={service} error={statusError} />;\n  }\n\n  if (!statusData) {\n    return (\n      <Container service={service}>\n        <Block label=\"nzbget.rate\" />\n        <Block label=\"nzbget.remaining\" />\n        <Block label=\"nzbget.downloaded\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block\n        label=\"nzbget.rate\"\n        value={t(\"common.byterate\", { value: statusData.DownloadRate })}\n        highlightValue={statusData.DownloadRate}\n      />\n      <Block\n        label=\"nzbget.remaining\"\n        value={t(\"common.bytes\", { value: statusData.RemainingSizeMB * 1024 * 1024 })}\n        highlightValue={statusData.RemainingSizeMB * 1024 * 1024}\n      />\n      <Block\n        label=\"nzbget.downloaded\"\n        value={t(\"common.bytes\", { value: statusData.DownloadedSizeMB * 1024 * 1024 })}\n        highlightValue={statusData.DownloadedSizeMB * 1024 * 1024}\n      />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/nzbget/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/nzbget/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"nzbget\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"nzbget.rate\")).toBeInTheDocument();\n    expect(screen.getByText(\"nzbget.remaining\")).toBeInTheDocument();\n    expect(screen.getByText(\"nzbget.downloaded\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when endpoint errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"nzbget\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders rate and sizes when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { DownloadRate: 1234, RemainingSizeMB: 2, DownloadedSizeMB: 3 },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"nzbget\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"nzbget.rate\", 1234);\n    expectBlockValue(container, \"nzbget.remaining\", 2 * 1024 * 1024);\n    expectBlockValue(container, \"nzbget.downloaded\", 3 * 1024 * 1024);\n  });\n});\n"
  },
  {
    "path": "src/widgets/nzbget/widget.js",
    "content": "import jsonrpcProxyHandler from \"utils/proxy/handlers/jsonrpc\";\n\nconst widget = {\n  api: \"{url}/jsonrpc\",\n  proxyHandler: jsonrpcProxyHandler,\n  allowedEndpoints: /status/,\n\n  mappings: {\n    status: {\n      endpoint: \"status\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/nzbget/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"nzbget widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/octoprint/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  const { data: printerStats, error: printerStatsError } = useWidgetAPI(widget, \"printer_stats\");\n  const { data: jobStats, error: jobStatsError } = useWidgetAPI(widget, \"job_stats\");\n\n  if (printerStatsError && jobStats) {\n    return (\n      <Container service={service}>\n        <Block label=\"octoprint.printer_state\" value={jobStats.state} />\n      </Container>\n    );\n  }\n\n  if (printerStatsError) {\n    return <Container service={service} error={printerStatsError} />;\n  }\n\n  if (jobStatsError) {\n    return <Container service={service} error={jobStatsError} />;\n  }\n\n  const state = printerStats?.state?.text;\n  const tempTool = printerStats?.temperature?.tool0?.actual;\n  const tempBed = printerStats?.temperature?.bed?.actual;\n\n  if (!printerStats || !state || !tempTool || !tempBed) {\n    return (\n      <Container service={service}>\n        <Block label=\"octoprint.printer_state\" />\n      </Container>\n    );\n  }\n\n  const printingStateFalgs = [\"Printing\", \"Paused\", \"Pausing\", \"Resuming\"];\n\n  if (printingStateFalgs.includes(state)) {\n    const completion = jobStats?.progress?.completion;\n\n    if (!jobStats || !completion) {\n      return (\n        <Container service={service}>\n          <Block label=\"octoprint.printer_state\" />\n          <Block label=\"octoprint.temp_tool\" />\n          <Block label=\"octoprint.temp_bed\" />\n          <Block label=\"octoprint.job_completion\" />\n        </Container>\n      );\n    }\n\n    return (\n      <Container service={service}>\n        <Block label=\"octoprint.printer_state\" value={printerStats.state.text} />\n        <Block label=\"octoprint.temp_tool\" value={`${printerStats.temperature.tool0.actual} °C`} />\n        <Block label=\"octoprint.temp_bed\" value={`${printerStats.temperature.bed.actual} °C`} />\n        <Block label=\"octoprint.job_completion\" value={`${completion.toFixed(2)}%`} />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"octoprint.printer_state\" value={printerStats.state.text} />\n      <Block label=\"octoprint.temp_tool\" value={`${printerStats.temperature.tool0.actual} °C`} />\n      <Block label=\"octoprint.temp_bed\" value={`${printerStats.temperature.bed.actual} °C`} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/octoprint/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/octoprint/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders minimal placeholder while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"octoprint\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(1);\n    expect(screen.getByText(\"octoprint.printer_state\")).toBeInTheDocument();\n  });\n\n  it(\"renders state from job_stats when printer_stats errors but job_stats is available\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"printer_stats\") return { data: undefined, error: { message: \"printer nope\" } };\n      if (endpoint === \"job_stats\") return { data: { state: \"Paused\" }, error: undefined };\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"octoprint\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"octoprint.printer_state\", \"Paused\");\n    expect(screen.queryByText(\"printer nope\")).toBeNull();\n  });\n\n  it(\"renders job completion block when printing and completion is present\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"printer_stats\") {\n        return {\n          data: {\n            state: { text: \"Printing\" },\n            temperature: { tool0: { actual: 200 }, bed: { actual: 60 } },\n          },\n          error: undefined,\n        };\n      }\n      if (endpoint === \"job_stats\") return { data: { progress: { completion: 12.3456 } }, error: undefined };\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"octoprint\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expectBlockValue(container, \"octoprint.printer_state\", \"Printing\");\n    expectBlockValue(container, \"octoprint.temp_tool\", \"200\");\n    expectBlockValue(container, \"octoprint.temp_bed\", \"60\");\n    expectBlockValue(container, \"octoprint.job_completion\", \"12.35%\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/octoprint/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}?apikey={key}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    printer_stats: {\n      endpoint: \"printer\",\n    },\n    job_stats: {\n      endpoint: \"job\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/octoprint/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"octoprint widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/omada/component.jsx",
    "content": "import { useTranslation } from \"next-i18next\";\n\nimport Block from \"../../components/services/widget/block\";\nimport Container from \"../../components/services/widget/container\";\nimport useWidgetAPI from \"../../utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: omadaData, error: omadaAPIError } = useWidgetAPI(widget, \"info\", {\n    refreshInterval: 5000,\n  });\n\n  if (omadaAPIError) {\n    return <Container service={service} error={omadaAPIError} />;\n  }\n\n  if (!widget.fields) {\n    widget.fields = [\"connectedAp\", \"activeUser\", \"alerts\", \"connectedGateways\"];\n  } else if (widget.fields?.length > 4) {\n    widget.fields = widget.fields.slice(0, 4);\n  }\n\n  if (!omadaData) {\n    return (\n      <Container service={service}>\n        <Block label=\"omada.connectedAp\" />\n        <Block label=\"omada.activeUser\" />\n        <Block label=\"omada.alerts\" />\n        <Block label=\"omada.connectedGateways\" />\n        <Block label=\"omada.connectedSwitches\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"omada.connectedAp\" value={t(\"common.number\", { value: omadaData.connectedAp })} />\n      <Block label=\"omada.activeUser\" value={t(\"common.number\", { value: omadaData.activeUser })} />\n      <Block label=\"omada.alerts\" value={t(\"common.number\", { value: omadaData.alerts })} />\n      <Block label=\"omada.connectedGateways\" value={t(\"common.number\", { value: omadaData.connectedGateways })} />\n      <Block label=\"omada.connectedSwitches\" value={t(\"common.number\", { value: omadaData.connectedSwitches })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/omada/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"../../utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/omada/component\", () => {\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"omada\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n  });\n\n  it(\"renders placeholders while loading and defaults fields to 4 visible blocks\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"omada\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    // Default fields do not include connectedSwitches, so Container filters it out.\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"omada.connectedAp\")).toBeInTheDocument();\n    expect(screen.getByText(\"omada.activeUser\")).toBeInTheDocument();\n    expect(screen.getByText(\"omada.alerts\")).toBeInTheDocument();\n    expect(screen.getByText(\"omada.connectedGateways\")).toBeInTheDocument();\n    expect(screen.queryByText(\"omada.connectedSwitches\")).toBeNull();\n\n    // Values should be placeholders (\"-\") while loading.\n    expect(screen.getAllByText(\"-\")).toHaveLength(4);\n  });\n\n  it(\"renders values when loaded (formatted via common.number)\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        connectedAp: 1,\n        activeUser: 2,\n        alerts: 3,\n        connectedGateways: 4,\n        connectedSwitches: 5,\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"omada\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"1\")).toBeInTheDocument();\n    expect(screen.getByText(\"2\")).toBeInTheDocument();\n    expect(screen.getByText(\"3\")).toBeInTheDocument();\n    expect(screen.getByText(\"4\")).toBeInTheDocument();\n    expect(screen.queryByText(\"5\")).toBeNull(); // connectedSwitches filtered by default fields\n  });\n});\n"
  },
  {
    "path": "src/widgets/omada/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { httpProxy } from \"utils/proxy/http\";\n\nconst proxyName = \"omadaProxyHandler\";\n\nconst logger = createLogger(proxyName);\n\nasync function login(loginUrl, username, password, controllerVersionMajor) {\n  const params = {\n    username,\n    password,\n  };\n\n  if (controllerVersionMajor === 3) {\n    params.method = \"login\";\n    params.params = {\n      name: username,\n      password,\n    };\n  }\n\n  const [status, contentType, data] = await httpProxy(loginUrl, {\n    method: \"POST\",\n    body: JSON.stringify(params),\n    headers: {\n      \"Content-Type\": \"application/json\",\n    },\n  });\n\n  return [status, JSON.parse(data.toString())];\n}\n\nexport default async function omadaProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n\n  if (group && service) {\n    const widget = await getServiceWidget(group, service, index);\n\n    if (widget) {\n      const { url } = widget;\n\n      const controllerInfoURL = `${url}/api/info`;\n\n      let [status, contentType, data] = await httpProxy(controllerInfoURL, {\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      });\n\n      if (status !== 200) {\n        logger.error(\"Unable to retrieve Omada controller info\");\n        return res.status(status).json({ error: { message: `HTTP Error ${status}`, url: controllerInfoURL, data } });\n      }\n\n      let cId;\n      let controllerVersion;\n\n      try {\n        cId = JSON.parse(data).result.omadacId;\n        controllerVersion = JSON.parse(data).result.controllerVer;\n      } catch (e) {\n        controllerVersion = \"3.2.x\";\n      }\n\n      const controllerVersionMajor = parseInt(controllerVersion.split(\".\")[0], 10);\n\n      if (![3, 4, 5, 6].includes(controllerVersionMajor)) {\n        return res.status(500).json({ error: { message: \"Error determining controller version\", data } });\n      }\n\n      let loginUrl;\n\n      switch (controllerVersionMajor) {\n        case 3:\n          loginUrl = `${url}/api/user/login?ajax`;\n          break;\n        case 4:\n          loginUrl = `${url}/api/v2/login`;\n          break;\n        case 5:\n        case 6:\n          loginUrl = `${url}/${cId}/api/v2/login`;\n          break;\n        default:\n          break;\n      }\n\n      const [loginStatus, loginResponseData] = await login(\n        loginUrl,\n        widget.username,\n        widget.password,\n        controllerVersionMajor,\n      );\n\n      if (loginStatus !== 200 || loginResponseData.errorCode > 0) {\n        return res\n          .status(loginStatus)\n          .json({ error: { message: \"Error logging in to Omada controller\", url: loginUrl, data: loginResponseData } });\n      }\n\n      const { token } = loginResponseData.result;\n\n      let sitesUrl;\n      let body = {};\n      let params = { token };\n      let headers = { \"Csrf-Token\": token };\n      let method = \"GET\";\n\n      switch (controllerVersionMajor) {\n        case 3:\n          sitesUrl = `${url}/web/v1/controller?ajax=&token=${token}`;\n          body = {\n            method: \"getUserSites\",\n            params: {\n              userName: widget.username,\n            },\n          };\n          method = \"POST\";\n          break;\n        case 4:\n          sitesUrl = `${url}/api/v2/sites?token=${token}&currentPage=1&currentPageSize=1000`;\n          break;\n        case 5:\n        case 6:\n          sitesUrl = `${url}/${cId}/api/v2/sites?token=${token}&currentPage=1&currentPageSize=1000`;\n          break;\n        default:\n          break;\n      }\n\n      [status, contentType, data] = await httpProxy(sitesUrl, {\n        method,\n        params,\n        body: JSON.stringify(body),\n        headers,\n      });\n\n      const sitesResponseData = JSON.parse(data);\n\n      if (status !== 200 || sitesResponseData.errorCode > 0) {\n        logger.debug(`HTTP ${status} getting sites list: ${sitesResponseData.msg}`);\n        return res\n          .status(status)\n          .json({ error: { message: \"Error getting sites list\", url, data: sitesResponseData } });\n      }\n\n      const site =\n        controllerVersionMajor === 3\n          ? sitesResponseData.result.siteList.find((s) => s.name === widget.site)\n          : sitesResponseData.result.data.find((s) => s.name === widget.site);\n\n      if (!site) {\n        return res.status(status).json({ error: { message: `Site ${widget.site} is not found`, url: sitesUrl, data } });\n      }\n\n      let siteResponseData;\n\n      let connectedAp;\n      let activeUser;\n      let connectedSwitches;\n      let connectedGateways;\n      let alerts;\n\n      if (controllerVersionMajor === 3) {\n        // Omada v3 controller requires switching site\n        const switchUrl = `${url}/web/v1/controller?ajax=&token=${token}`;\n        method = \"POST\";\n        body = {\n          method: \"switchSite\",\n          params: {\n            siteName: site.siteName,\n            userName: widget.username,\n          },\n        };\n        headers = { \"Content-Type\": \"application/json\" };\n        params = { token };\n\n        [status, contentType, data] = await httpProxy(switchUrl, {\n          method,\n          params,\n          body: JSON.stringify(body),\n          headers,\n        });\n\n        const switchResponseData = JSON.parse(data);\n        if (status !== 200 || switchResponseData.errorCode > 0) {\n          logger.error(`HTTP ${status} getting sites list: ${data}`);\n          return res.status(status).json({ error: { message: \"Error switching site\", url: switchUrl, data } });\n        }\n\n        const statsUrl = `${url}/web/v1/controller?getGlobalStat=&token=${token}`;\n        [status, contentType, data] = await httpProxy(statsUrl, {\n          method,\n          params,\n          body: JSON.stringify({\n            method: \"getGlobalStat\",\n          }),\n          headers,\n        });\n\n        siteResponseData = JSON.parse(data);\n\n        if (status !== 200 || siteResponseData.errorCode > 0) {\n          return res.status(status).json({ error: { message: \"Error getting stats\", url: statsUrl, data } });\n        }\n\n        connectedAp = siteResponseData.result.connectedAp;\n        activeUser = siteResponseData.result.activeUser;\n        alerts = siteResponseData.result.alerts;\n      } else if ([4, 5, 6].includes(controllerVersionMajor)) {\n        const siteName = controllerVersionMajor > 4 ? site.id : site.key;\n        const siteStatsUrl =\n          controllerVersionMajor === 4\n            ? `${url}/api/v2/sites/${siteName}/dashboard/overviewDiagram?token=${token}&currentPage=1&currentPageSize=1000`\n            : `${url}/${cId}/api/v2/sites/${siteName}/dashboard/overviewDiagram?token=${token}&currentPage=1&currentPageSize=1000`;\n\n        [status, contentType, data] = await httpProxy(siteStatsUrl, {\n          headers: {\n            \"Csrf-Token\": token,\n          },\n        });\n\n        siteResponseData = JSON.parse(data);\n\n        if (status !== 200 || siteResponseData.errorCode > 0) {\n          logger.debug(`HTTP ${status} getting stats for site ${widget.site} with message ${siteResponseData.msg}`);\n          return res.status(status === 200 ? 500 : status).json({\n            error: {\n              message: \"Error getting stats\",\n              url: siteStatsUrl,\n              data: siteResponseData,\n            },\n          });\n        }\n\n        const alertUrl =\n          controllerVersionMajor === 4\n            ? `${url}/api/v2/sites/${siteName}/alerts/num?token=${token}&currentPage=1&currentPageSize=1000`\n            : `${url}/${cId}/api/v2/sites/${siteName}/alerts/num?token=${token}&currentPage=1&currentPageSize=1000`;\n\n        [status, contentType, data] = await httpProxy(alertUrl, {\n          headers: {\n            \"Csrf-Token\": token,\n          },\n        });\n        const alertResponseData = JSON.parse(data);\n\n        activeUser = siteResponseData.result.totalClientNum;\n        connectedAp = siteResponseData.result.connectedApNum;\n        connectedGateways = siteResponseData.result.connectedGatewayNum;\n        connectedSwitches = siteResponseData.result.connectedSwitchNum;\n        alerts = alertResponseData.result.alertNum;\n      }\n\n      return res.send(\n        JSON.stringify({\n          connectedAp,\n          activeUser,\n          alerts,\n          connectedGateways,\n          connectedSwitches,\n        }),\n      );\n    }\n  }\n\n  return res.status(400).json({ error: \"Invalid proxy service type\" });\n}\n"
  },
  {
    "path": "src/widgets/omada/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  logger: {\n    debug: vi.fn(),\n    error: vi.fn(),\n  },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nimport omadaProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/omada/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    // Clear one-off implementations between tests (some branches return early).\n    httpProxy.mockReset();\n    getServiceWidget.mockReset();\n  });\n\n  it(\"fetches controller info, logs in, selects site, and returns overview stats (v4)\", async () => {\n    getServiceWidget.mockResolvedValue({\n      url: \"http://omada\",\n      username: \"u\",\n      password: \"p\",\n      site: \"Default\",\n    });\n\n    httpProxy\n      // controller info\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        JSON.stringify({ result: { omadacId: \"cid\", controllerVer: \"4.5.6\" } }),\n      ])\n      // login\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ errorCode: 0, result: { token: \"t\" } })),\n      ])\n      // sites list\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        JSON.stringify({ errorCode: 0, result: { data: [{ name: \"Default\", key: \"sitekey\" }] } }),\n      ])\n      // overview diagram\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        JSON.stringify({\n          errorCode: 0,\n          result: {\n            totalClientNum: 10,\n            connectedApNum: 2,\n            connectedGatewayNum: 1,\n            connectedSwitchNum: 3,\n          },\n        }),\n      ])\n      // alert count\n      .mockResolvedValueOnce([200, \"application/json\", JSON.stringify({ errorCode: 0, result: { alertNum: 4 } })]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await omadaProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(5);\n    expect(res.statusCode).toBe(null); // uses res.send directly without setting status\n    expect(res.body).toBe(\n      JSON.stringify({\n        connectedAp: 2,\n        activeUser: 10,\n        alerts: 4,\n        connectedGateways: 1,\n        connectedSwitches: 3,\n      }),\n    );\n  });\n\n  it(\"returns an error when controller info cannot be retrieved\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://omada\", username: \"u\", password: \"p\", site: \"Default\" });\n\n    httpProxy.mockResolvedValueOnce([503, \"application/json\", \"down\"]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await omadaProxyHandler(req, res);\n\n    expect(logger.error).toHaveBeenCalledWith(\"Unable to retrieve Omada controller info\");\n    expect(res.statusCode).toBe(503);\n    expect(res.body).toEqual({\n      error: { message: \"HTTP Error 503\", url: \"http://omada/api/info\", data: \"down\" },\n    });\n  });\n\n  it(\"returns an error when controller version cannot be determined\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://omada\", username: \"u\", password: \"p\", site: \"Default\" });\n\n    httpProxy\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        JSON.stringify({ result: { omadacId: \"cid\", controllerVer: \"7.0.0\" } }),\n      ])\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ errorCode: 0, result: { token: \"t\" } })),\n      ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await omadaProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body.error.message).toBe(\"Error determining controller version\");\n  });\n\n  it(\"returns an error when login fails (errorCode > 0)\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://omada\", username: \"u\", password: \"p\", site: \"Default\" });\n\n    httpProxy\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        JSON.stringify({ result: { omadacId: \"cid\", controllerVer: \"4.5.6\" } }),\n      ])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ errorCode: 1, msg: \"nope\" }))]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await omadaProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body.error.message).toBe(\"Error logging in to Omada controller\");\n    expect(res.body.error.url).toBe(\"http://omada/api/v2/login\");\n    expect(res.body.error.data).toEqual({ errorCode: 1, msg: \"nope\" });\n  });\n\n  it(\"returns an error when sites list retrieval fails\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://omada\", username: \"u\", password: \"p\", site: \"Default\" });\n\n    httpProxy\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        JSON.stringify({ result: { omadacId: \"cid\", controllerVer: \"4.5.6\" } }),\n      ])\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ errorCode: 0, result: { token: \"t\" } })),\n      ])\n      .mockResolvedValueOnce([200, \"application/json\", JSON.stringify({ errorCode: 2, msg: \"bad\" })]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await omadaProxyHandler(req, res);\n\n    expect(logger.debug).toHaveBeenCalledWith(\"HTTP 200 getting sites list: bad\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body.error.message).toBe(\"Error getting sites list\");\n  });\n\n  it(\"returns an error when the site is not found\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://omada\", username: \"u\", password: \"p\", site: \"Missing\" });\n\n    httpProxy\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        JSON.stringify({ result: { omadacId: \"cid\", controllerVer: \"4.5.6\" } }),\n      ])\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ errorCode: 0, result: { token: \"t\" } })),\n      ])\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        JSON.stringify({ errorCode: 0, result: { data: [{ name: \"Default\", key: \"sitekey\" }] } }),\n      ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await omadaProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body.error.message).toContain(\"Site Missing is not found\");\n  });\n\n  it(\"handles the v3 controller flow: login, getUserSites, switchSite, and getGlobalStat\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://omada\", username: \"u\", password: \"p\", site: \"Default\" });\n\n    httpProxy\n      // controller info parse fails -> defaults to 3.2.x\n      .mockResolvedValueOnce([200, \"application/json\", \"not-json\"])\n      // login v3\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ errorCode: 0, result: { token: \"t\" } })),\n      ])\n      // getUserSites\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        JSON.stringify({ errorCode: 0, result: { siteList: [{ name: \"Default\", siteName: \"site1\" }] } }),\n      ])\n      // switchSite\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ errorCode: 0 }))])\n      // getGlobalStat\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        JSON.stringify({ errorCode: 0, result: { connectedAp: 3, activeUser: 11, alerts: 2 } }),\n      ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await omadaProxyHandler(req, res);\n\n    // login body includes v3 RPC shape\n    expect(JSON.parse(httpProxy.mock.calls[1][1].body)).toMatchObject({\n      username: \"u\",\n      password: \"p\",\n      method: \"login\",\n      params: { name: \"u\", password: \"p\" },\n    });\n\n    expect(res.body).toBe(\n      JSON.stringify({\n        connectedAp: 3,\n        activeUser: 11,\n        alerts: 2,\n        connectedGateways: undefined,\n        connectedSwitches: undefined,\n      }),\n    );\n  });\n\n  it(\"returns an error when v3 site switching fails\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://omada\", username: \"u\", password: \"p\", site: \"Default\" });\n\n    httpProxy\n      .mockResolvedValueOnce([200, \"application/json\", \"not-json\"])\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ errorCode: 0, result: { token: \"t\" } })),\n      ])\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        JSON.stringify({ errorCode: 0, result: { siteList: [{ name: \"Default\", siteName: \"site1\" }] } }),\n      ])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ errorCode: 1 }))]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await omadaProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body.error.message).toBe(\"Error switching site\");\n  });\n\n  it(\"returns a structured error when overview stats retrieval fails (v5)\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://omada\", username: \"u\", password: \"p\", site: \"Default\" });\n\n    httpProxy\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        JSON.stringify({ result: { omadacId: \"cid\", controllerVer: \"5.0.0\" } }),\n      ])\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ errorCode: 0, result: { token: \"t\" } })),\n      ])\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        JSON.stringify({ errorCode: 0, result: { data: [{ name: \"Default\", id: \"siteid\" }] } }),\n      ])\n      // overview fails\n      .mockResolvedValueOnce([200, \"application/json\", JSON.stringify({ errorCode: 1, msg: \"bad\" })]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await omadaProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({\n      error: {\n        message: \"Error getting stats\",\n        url: \"http://omada/cid/api/v2/sites/siteid/dashboard/overviewDiagram?token=t&currentPage=1&currentPageSize=1000\",\n        data: { errorCode: 1, msg: \"bad\" },\n      },\n    });\n  });\n});\n"
  },
  {
    "path": "src/widgets/omada/widget.js",
    "content": "import omadaProxyHandler from \"./proxy\";\n\nconst widget = {\n  proxyHandler: omadaProxyHandler,\n\n  mappings: {\n    info: {\n      endpoint: \"api/info\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/omada/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"omada widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/ombi/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  const { data: statsData, error: statsError } = useWidgetAPI(widget, \"Request/count\");\n\n  if (statsError) {\n    return <Container service={service} error={statsError} />;\n  }\n\n  if (!statsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"ombi.pending\" />\n        <Block label=\"ombi.approved\" />\n        <Block label=\"ombi.available\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"ombi.pending\" value={statsData.pending} />\n      <Block label=\"ombi.approved\" value={statsData.approved} />\n      <Block label=\"ombi.available\" value={statsData.available} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/ombi/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/ombi/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"ombi\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"ombi.pending\")).toBeInTheDocument();\n    expect(screen.getByText(\"ombi.approved\")).toBeInTheDocument();\n    expect(screen.getByText(\"ombi.available\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when endpoint errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"ombi\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders request counts when loaded\", () => {\n    useWidgetAPI.mockReturnValue({ data: { pending: 1, approved: 2, available: 3 }, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"ombi\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"ombi.pending\", 1);\n    expectBlockValue(container, \"ombi.approved\", 2);\n    expectBlockValue(container, \"ombi.available\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/ombi/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/v1/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    \"Request/count\": {\n      endpoint: \"Request/count\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/ombi/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"ombi widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/opendtu/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: opendtuData, error: opendtuError } = useWidgetAPI(widget);\n\n  if (opendtuError) {\n    return <Container service={service} error={opendtuError} />;\n  }\n\n  if (!opendtuData) {\n    return (\n      <Container service={service}>\n        <Block label=\"opendtu.yieldDay\" />\n        <Block label=\"opendtu.relativePower\" />\n        <Block label=\"opendtu.absolutePower\" />\n        <Block label=\"opendtu.limit\" />\n      </Container>\n    );\n  }\n\n  const yieldDay = opendtuData.total.YieldDay.v;\n  const yieldDayUnit = opendtuData.total.YieldDay.u;\n\n  const power = opendtuData.total.Power.v;\n  const powerUnit = opendtuData.total.Power.u;\n\n  const totalLimit = opendtuData.inverters.map((inverter) => inverter.limit_absolute).reduce((a, b) => a + b);\n  const totalLimitUnit = \"W\";\n\n  const powerPercentage = (power / totalLimit) * 100;\n\n  return (\n    <Container service={service}>\n      <Block\n        label=\"opendtu.yieldDay\"\n        value={`${t(\"common.number\", { value: Math.round(yieldDay), style: \"unit\" })}${yieldDayUnit}`}\n      />\n      <Block\n        label=\"opendtu.relativePower\"\n        value={t(\"common.number\", { value: Math.round(powerPercentage), style: \"unit\", unit: \"percent\" })}\n      />\n      <Block\n        label=\"opendtu.absolutePower\"\n        value={`${t(\"common.number\", { value: Math.round(power), style: \"unit\" })}${powerUnit}`}\n      />\n      <Block\n        label=\"opendtu.limit\"\n        value={`${t(\"common.number\", { value: Math.round(totalLimit), style: \"unit\" })}${totalLimitUnit}`}\n      />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/opendtu/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/opendtu/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"opendtu\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"opendtu.yieldDay\")).toBeInTheDocument();\n    expect(screen.getByText(\"opendtu.relativePower\")).toBeInTheDocument();\n    expect(screen.getByText(\"opendtu.absolutePower\")).toBeInTheDocument();\n    expect(screen.getByText(\"opendtu.limit\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when endpoint errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"opendtu\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders totals and computed relative power\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        total: {\n          YieldDay: { v: 12.4, u: \"kWh\" },\n          Power: { v: 250, u: \"W\" },\n        },\n        inverters: [{ limit_absolute: 200 }, { limit_absolute: 300 }],\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"opendtu\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    // yieldDay is rounded and has unit appended.\n    expectBlockValue(container, \"opendtu.yieldDay\", \"12kWh\");\n    // relative power is percent of power / totalLimit (250/500*100 = 50)\n    expectBlockValue(container, \"opendtu.relativePower\", \"50\");\n    expectBlockValue(container, \"opendtu.absolutePower\", \"250W\");\n    expectBlockValue(container, \"opendtu.limit\", \"500W\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/opendtu/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/livedata/status\",\n  proxyHandler: genericProxyHandler,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/opendtu/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"opendtu widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/openmediavault/component.jsx",
    "content": "import DownloaderGetDownloadList from \"./methods/downloader_get_downloadlist\";\nimport ServicesGetStatus from \"./methods/services_get_status\";\nimport SmartGetList from \"./methods/smart_get_list\";\n\nexport default function Component({ service }) {\n  switch (service.widget.method) {\n    case \"services.getStatus\":\n      return <ServicesGetStatus service={service} />;\n    case \"smart.getListBg\":\n      return <SmartGetList service={service} />;\n    case \"downloader.getDownloadList\":\n      return <DownloaderGetDownloadList service={service} />;\n    default:\n      return null;\n  }\n}\n"
  },
  {
    "path": "src/widgets/openmediavault/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nconst { ServicesGetStatus, SmartGetList, DownloaderGetDownloadList } = vi.hoisted(() => ({\n  ServicesGetStatus: vi.fn(() => <div data-testid=\"services.getStatus\" />),\n  SmartGetList: vi.fn(() => <div data-testid=\"smart.getListBg\" />),\n  DownloaderGetDownloadList: vi.fn(() => <div data-testid=\"downloader.getDownloadList\" />),\n}));\n\nvi.mock(\"./methods/services_get_status\", () => ({ default: ServicesGetStatus }));\nvi.mock(\"./methods/smart_get_list\", () => ({ default: SmartGetList }));\nvi.mock(\"./methods/downloader_get_downloadlist\", () => ({ default: DownloaderGetDownloadList }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/openmediavault/component\", () => {\n  it(\"routes services.getStatus method to ServicesGetStatus\", () => {\n    render(<Component service={{ widget: { type: \"openmediavault\", method: \"services.getStatus\" } }} />);\n    expect(screen.getByTestId(\"services.getStatus\")).toBeInTheDocument();\n  });\n\n  it(\"routes smart.getListBg method to SmartGetList\", () => {\n    render(<Component service={{ widget: { type: \"openmediavault\", method: \"smart.getListBg\" } }} />);\n    expect(screen.getByTestId(\"smart.getListBg\")).toBeInTheDocument();\n  });\n\n  it(\"routes downloader.getDownloadList method to DownloaderGetDownloadList\", () => {\n    render(<Component service={{ widget: { type: \"openmediavault\", method: \"downloader.getDownloadList\" } }} />);\n    expect(screen.getByTestId(\"downloader.getDownloadList\")).toBeInTheDocument();\n  });\n\n  it(\"returns null for unknown methods\", () => {\n    const { container } = render(<Component service={{ widget: { type: \"openmediavault\", method: \"nope\" } }} />);\n    expect(container.firstChild).toBeNull();\n  });\n});\n"
  },
  {
    "path": "src/widgets/openmediavault/methods/downloader_get_downloadlist.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst downloadReduce = (acc, e) => {\n  if (e.downloading) {\n    return acc + 1;\n  }\n  return acc;\n};\n\nconst items = [\n  { label: \"openmediavault.downloading\", getNumber: (data) => (!data ? null : data.reduce(downloadReduce, 0)) },\n  { label: \"openmediavault.total\", getNumber: (data) => (!data ? null : data?.length) },\n];\n\nexport default function Component({ service }) {\n  const { data, error } = useWidgetAPI(service.widget);\n\n  if (error) {\n    return <Container service={service} error={error} />;\n  }\n\n  const itemsWithData = items.map((item) => ({\n    ...item,\n    number: item.getNumber(data?.response?.data),\n  }));\n\n  return (\n    <Container service={service}>\n      {itemsWithData.map((e) => (\n        <Block key={e.label} label={e.label} value={e.number} />\n      ))}\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/openmediavault/methods/downloader_get_downloadlist.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./downloader_get_downloadlist\";\n\ndescribe(\"widgets/openmediavault/methods/downloader_get_downloadlist\", () => {\n  it(\"renders '-' values when data is missing\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"openmediavault\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"openmediavault.downloading\")).toBeInTheDocument();\n    expect(screen.getByText(\"openmediavault.total\")).toBeInTheDocument();\n    expect(screen.getAllByText(\"-\").length).toBeGreaterThan(0);\n  });\n\n  it(\"counts downloading and total items\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { response: { data: [{ downloading: true }, { downloading: false }, { downloading: true }] } },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"openmediavault\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"2\")).toBeInTheDocument();\n    expect(screen.getByText(\"3\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/openmediavault/methods/services_get_status.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst isRunningReduce = (acc, e) => {\n  if (e.running) {\n    return acc + 1;\n  }\n  return acc;\n};\nconst notRunningReduce = (acc, e) => {\n  if (!e.running) {\n    return acc + 1;\n  }\n  return acc;\n};\n\nconst items = [\n  { label: \"openmediavault.running\", getNumber: (data) => (!data ? null : data.reduce(isRunningReduce, 0)) },\n  { label: \"openmediavault.stopped\", getNumber: (data) => (!data ? null : data.reduce(notRunningReduce, 0)) },\n  { label: \"openmediavault.total\", getNumber: (data) => (!data ? null : data?.length) },\n];\n\nexport default function Component({ service }) {\n  const { data, error } = useWidgetAPI(service.widget);\n\n  if (error) {\n    return <Container service={service} error={error} />;\n  }\n\n  const itemsWithData = items.map((item) => ({\n    ...item,\n    number: item.getNumber(data?.response?.data),\n  }));\n\n  return (\n    <Container service={service}>\n      {itemsWithData.map((e) => (\n        <Block key={e.label} label={e.label} value={e.number} />\n      ))}\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/openmediavault/methods/services_get_status.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./services_get_status\";\n\ndescribe(\"widgets/openmediavault/methods/services_get_status\", () => {\n  it(\"counts running/stopped/total services\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { response: { data: [{ running: true }, { running: false }, { running: true }] } },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"openmediavault\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"openmediavault.running\")).toBeInTheDocument();\n    expect(screen.getByText(\"openmediavault.stopped\")).toBeInTheDocument();\n    expect(screen.getByText(\"openmediavault.total\")).toBeInTheDocument();\n\n    expect(screen.getByText(\"2\")).toBeInTheDocument();\n    expect(screen.getByText(\"1\")).toBeInTheDocument();\n    expect(screen.getByText(\"3\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/openmediavault/methods/smart_get_list.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst passedReduce = (acc, e) => {\n  if (e.monitor && e.overallstatus === \"GOOD\") {\n    return acc + 1;\n  }\n  return acc;\n};\nconst failedReduce = (acc, e) => {\n  if (e.monitor && e.overallstatus !== \"GOOD\") {\n    return acc + 1;\n  }\n  return acc;\n};\n\nconst items = [\n  { label: \"openmediavault.passed\", getNumber: (data) => (!data ? null : data.reduce(passedReduce, 0)) },\n  { label: \"openmediavault.failed\", getNumber: (data) => (!data ? null : data.reduce(failedReduce, 0)) },\n];\n\nexport default function Component({ service }) {\n  const { data, error } = useWidgetAPI(service.widget);\n\n  if (error) {\n    return <Container service={service} error={error} />;\n  }\n\n  const itemsWithData = items.map((item) => ({\n    ...item,\n    number: item.getNumber(JSON.parse(data?.response?.output || \"{}\")?.data),\n  }));\n\n  return (\n    <Container service={service}>\n      {itemsWithData.map((e) => (\n        <Block key={e.label} label={e.label} value={e.number} />\n      ))}\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/openmediavault/methods/smart_get_list.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./smart_get_list\";\n\ndescribe(\"widgets/openmediavault/methods/smart_get_list\", () => {\n  it(\"counts passed/failed monitored disks\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        response: {\n          output: JSON.stringify({\n            data: [\n              { monitor: true, overallstatus: \"GOOD\" },\n              { monitor: true, overallstatus: \"BAD\" },\n              { monitor: false, overallstatus: \"BAD\" },\n            ],\n          }),\n        },\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"openmediavault\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"openmediavault.passed\")).toBeInTheDocument();\n    expect(screen.getByText(\"openmediavault.failed\")).toBeInTheDocument();\n    expect(screen.getAllByText(\"1\")).toHaveLength(2);\n  });\n});\n"
  },
  {
    "path": "src/widgets/openmediavault/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { addCookieToJar, setCookieHeader } from \"utils/proxy/cookie-jar\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst PROXY_NAME = \"OMVProxyHandler\";\nconst BG_MAX_RETRIES = 50;\nconst BG_POLL_PERIOD = 500;\n\nconst logger = createLogger(PROXY_NAME);\n\nasync function getWidget(req) {\n  const { group, service, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return null;\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return null;\n  }\n\n  return widget;\n}\n\nasync function rpc(url, request) {\n  const params = {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify(request),\n  };\n  setCookieHeader(url, params);\n  const [status, contentType, data, headers] = await httpProxy(url, params);\n\n  return { status, contentType, data, headers };\n}\n\nasync function poll(attemptsLeft, makeReqByPos, pos = 0) {\n  if (attemptsLeft <= 0) {\n    return null;\n  }\n\n  const resp = await makeReqByPos(pos);\n\n  const data = JSON.parse(resp.data.toString()).response;\n  if (data.running === true || data.outputPending) {\n    await new Promise((resolve) => {\n      setTimeout(resolve, BG_POLL_PERIOD);\n    });\n    return poll(attemptsLeft - 1, makeReqByPos, data.pos);\n  }\n  return resp;\n}\n\nasync function tryLogin(widget) {\n  const url = new URL(formatApiCall(widgets?.[widget.type]?.api, { ...widget }));\n  const { username, password } = widget;\n  const resp = await rpc(url, {\n    method: \"login\",\n    service: \"session\",\n    params: { username: username.toString(), password: password.toString() },\n  });\n\n  if (resp.status !== 200) {\n    logger.error(\"HTTP %d logging in to OpenMediaVault. Data: %s\", resp.status, resp.data);\n    return [false, resp];\n  }\n\n  const json = JSON.parse(resp.data.toString());\n  if (json.response.authenticated !== true) {\n    logger.error(\"Login error in OpenMediaVault. Data: %s\", resp.data);\n    resp.status = 401;\n    return [false, resp];\n  }\n\n  return [true, resp];\n}\nasync function processBg(url, filename) {\n  const resp = await poll(BG_MAX_RETRIES, (pos) =>\n    rpc(url, {\n      service: \"exec\",\n      method: \"getOutput\",\n      params: { pos, filename },\n    }),\n  );\n\n  if (resp == null) {\n    const errText = \"The maximum number of attempts to receive a response from Bg data has been exceeded.\";\n    logger.error(errText);\n    return errText;\n  }\n  if (resp.status !== 200) {\n    logger.error(\"HTTP %d getting Bg data from OpenMediaVault RPC. Data: %s\", resp.status, resp.data);\n  }\n  return resp;\n}\n\nexport default async function proxyHandler(req, res) {\n  const widget = await getWidget(req);\n  if (!widget) {\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const api = widgets?.[widget.type]?.api;\n  if (!api) {\n    return res.status(403).json({ error: \"Service does not support RPC calls\" });\n  }\n\n  const url = new URL(formatApiCall(api, { ...widget }));\n  const [service, method] = widget.method.split(\".\");\n  const rpcReq = { params: { limit: -1, start: 0 }, service, method };\n\n  let resp = await rpc(url, rpcReq);\n\n  if (resp.status === 401) {\n    logger.debug(\"Session not authenticated.\");\n    const [success, lResp] = await tryLogin(widget);\n\n    if (success) {\n      addCookieToJar(url, lResp.headers);\n    } else {\n      return res.status(lResp.status).json({ error: { message: `HTTP Error ${lResp.status}`, url, data: lResp.data } });\n    }\n\n    logger.debug(\"Retrying OpenMediaVault request after login.\");\n    resp = await rpc(url, rpcReq);\n  }\n\n  if (resp.status !== 200) {\n    logger.error(\"HTTP %d getting data from OpenMediaVault RPC. Data: %s\", resp.status, resp.data);\n    return res.status(resp.status).json({ error: { message: `HTTP Error ${resp.status}`, url, data: resp.data } });\n  }\n\n  if (method.endsWith(\"Bg\")) {\n    const json = JSON.parse(resp.data.toString());\n    const bgResp = await processBg(url, json.response);\n\n    if (typeof bgResp === \"string\") {\n      return res.status(400).json({ error: bgResp });\n    }\n    return res.status(bgResp.status).send(bgResp.data);\n  }\n\n  return res.status(resp.status).send(resp.data);\n}\n"
  },
  {
    "path": "src/widgets/openmediavault/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, cookieJar, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  cookieJar: {\n    addCookieToJar: vi.fn(),\n    setCookieHeader: vi.fn(),\n  },\n  logger: {\n    debug: vi.fn(),\n    error: vi.fn(),\n  },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\nvi.mock(\"utils/proxy/cookie-jar\", () => cookieJar);\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    openmediavault: {\n      api: \"{url}/rpc.php\",\n    },\n  },\n}));\n\nimport openmediavaultProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/openmediavault/proxy\", () => {\n  beforeEach(() => {\n    httpProxy.mockReset();\n    getServiceWidget.mockReset();\n    vi.clearAllMocks();\n  });\n\n  it(\"returns 400 when the query is missing group or service\", async () => {\n    const req = { query: { service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await openmediavaultProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Invalid proxy service type\" });\n    expect(getServiceWidget).not.toHaveBeenCalled();\n  });\n\n  it(\"returns 400 when the widget cannot be resolved\", async () => {\n    getServiceWidget.mockResolvedValue(null);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await openmediavaultProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(400);\n    expect(res.body).toEqual({ error: \"Invalid proxy service type\" });\n  });\n\n  it(\"returns 403 when the service type does not support RPC\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"not-openmediavault\",\n      url: \"http://omv\",\n      username: \"u\",\n      password: \"p\",\n      method: \"foo.bar\",\n    });\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await openmediavaultProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(403);\n    expect(res.body).toEqual({ error: \"Service does not support RPC calls\" });\n    expect(httpProxy).not.toHaveBeenCalled();\n  });\n\n  it(\"returns an HTTP error when the RPC call fails\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"openmediavault\",\n      url: \"http://omv\",\n      username: \"u\",\n      password: \"p\",\n      method: \"foo.bar\",\n    });\n\n    httpProxy.mockResolvedValueOnce([500, \"application/json\", Buffer.from(\"nope\"), {}]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await openmediavaultProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual(\n      expect.objectContaining({\n        error: expect.objectContaining({\n          message: \"HTTP Error 500\",\n          url: expect.any(URL),\n          data: expect.any(Buffer),\n        }),\n      }),\n    );\n  });\n\n  it(\"logs in after a 401 and retries the RPC call\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"openmediavault\",\n      url: \"http://omv\",\n      username: \"u\",\n      password: \"p\",\n      method: \"foo.bar\",\n    });\n\n    httpProxy\n      // initial rpc unauthorized\n      .mockResolvedValueOnce([401, \"application/json\", Buffer.from(JSON.stringify({ response: {} })), {}])\n      // login rpc\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ response: { authenticated: true } })),\n        { \"set-cookie\": [\"sid=1\"] },\n      ])\n      // retry rpc\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ response: { ok: true } })), {}]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await openmediavaultProxyHandler(req, res);\n\n    expect(cookieJar.setCookieHeader).toHaveBeenCalled();\n    expect(cookieJar.addCookieToJar).toHaveBeenCalled();\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual(Buffer.from(JSON.stringify({ response: { ok: true } })));\n  });\n\n  it(\"returns after a failed login attempt (non-200 response)\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"openmediavault\",\n      url: \"http://omv\",\n      username: \"u\",\n      password: \"p\",\n      method: \"foo.bar\",\n    });\n\n    httpProxy\n      // initial rpc unauthorized\n      .mockResolvedValueOnce([401, \"application/json\", Buffer.from(JSON.stringify({ response: {} })), {}])\n      // login rpc fails\n      .mockResolvedValueOnce([500, \"application/json\", Buffer.from(\"nope\"), {}]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await openmediavaultProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual(\n      expect.objectContaining({\n        error: expect.objectContaining({\n          message: \"HTTP Error 500\",\n        }),\n      }),\n    );\n    expect(cookieJar.addCookieToJar).not.toHaveBeenCalled();\n    expect(httpProxy).toHaveBeenCalledTimes(2);\n  });\n\n  it(\"returns after a failed login attempt (not authenticated)\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"openmediavault\",\n      url: \"http://omv\",\n      username: \"u\",\n      password: \"p\",\n      method: \"foo.bar\",\n    });\n\n    httpProxy\n      // initial rpc unauthorized\n      .mockResolvedValueOnce([401, \"application/json\", Buffer.from(JSON.stringify({ response: {} })), {}])\n      // login rpc returns authenticated false\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ response: { authenticated: false } })),\n        {},\n      ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await openmediavaultProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(401);\n    expect(res.body).toEqual(\n      expect.objectContaining({\n        error: expect.objectContaining({\n          message: \"HTTP Error 401\",\n        }),\n      }),\n    );\n    expect(cookieJar.addCookieToJar).not.toHaveBeenCalled();\n    expect(httpProxy).toHaveBeenCalledTimes(2);\n  });\n\n  it(\"handles background methods by polling for output\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"openmediavault\",\n      url: \"http://omv\",\n      username: \"u\",\n      password: \"p\",\n      method: \"foo.barBg\",\n    });\n\n    httpProxy\n      // initial rpc returns filename for Bg output\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ response: \"bg-1\" })), {}])\n      // exec.getOutput returns ready output\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ response: { running: false, outputPending: false, pos: 0, output: \"ok\" } })),\n        {},\n      ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await openmediavaultProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual(\n      Buffer.from(JSON.stringify({ response: { running: false, outputPending: false, pos: 0, output: \"ok\" } })),\n    );\n  });\n\n  it(\"polls until background output is ready\", async () => {\n    vi.useFakeTimers();\n\n    getServiceWidget.mockResolvedValue({\n      type: \"openmediavault\",\n      url: \"http://omv\",\n      username: \"u\",\n      password: \"p\",\n      method: \"foo.barBg\",\n    });\n\n    httpProxy\n      // initial rpc returns filename for Bg output\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ response: \"bg-2\" })), {}])\n      // running: true -> triggers poll sleep and retry\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ response: { running: true, outputPending: true, pos: 1 } })),\n        {},\n      ])\n      // second poll completes\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ response: { running: false, outputPending: false, pos: 2, output: \"done\" } })),\n        {},\n      ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    const promise = openmediavaultProxyHandler(req, res);\n    await vi.runAllTimersAsync();\n    await promise;\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual(\n      Buffer.from(JSON.stringify({ response: { running: false, outputPending: false, pos: 2, output: \"done\" } })),\n    );\n\n    vi.useRealTimers();\n  });\n});\n"
  },
  {
    "path": "src/widgets/openmediavault/widget.js",
    "content": "import proxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/rpc.php\",\n  proxyHandler,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/openmediavault/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"openmediavault widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/openwrt/component.jsx",
    "content": "import Interface from \"./methods/interface\";\nimport System from \"./methods/system\";\n\nexport default function Component({ service }) {\n  if (service.widget.interfaceName) {\n    return <Interface service={service} />;\n  }\n  return <System service={service} />;\n}\n"
  },
  {
    "path": "src/widgets/openwrt/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nconst { Interface, System } = vi.hoisted(() => ({\n  Interface: vi.fn(() => <div data-testid=\"openwrt.interface\" />),\n  System: vi.fn(() => <div data-testid=\"openwrt.system\" />),\n}));\n\nvi.mock(\"./methods/interface\", () => ({ default: Interface }));\nvi.mock(\"./methods/system\", () => ({ default: System }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/openwrt/component\", () => {\n  it(\"renders System when interfaceName is not set\", () => {\n    render(<Component service={{ widget: { type: \"openwrt\" } }} />);\n    expect(screen.getByTestId(\"openwrt.system\")).toBeInTheDocument();\n  });\n\n  it(\"renders Interface when interfaceName is set\", () => {\n    render(<Component service={{ widget: { type: \"openwrt\", interfaceName: \"eth0\" } }} />);\n    expect(screen.getByTestId(\"openwrt.interface\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/openwrt/methods/interface.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { data, error } = useWidgetAPI(service.widget);\n\n  if (error) {\n    return <Container service={service} error={error} />;\n  }\n\n  if (!data) {\n    return null;\n  }\n\n  const { up, bytesTx, bytesRx } = data;\n\n  return (\n    <Container service={service}>\n      <Block label=\"widget.status\" value={up ? t(\"openwrt.up\") : t(\"openwrt.down\")} />\n      <Block label=\"openwrt.bytesTx\" value={t(\"common.bytes\", { value: bytesTx })} highlightValue={bytesTx} />\n      <Block label=\"openwrt.bytesRx\" value={t(\"common.bytes\", { value: bytesRx })} highlightValue={bytesRx} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/openwrt/methods/interface.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./interface\";\n\ndescribe(\"widgets/openwrt/methods/interface\", () => {\n  it(\"returns null while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"openwrt\" } }} />);\n    expect(container.firstChild).toBeNull();\n  });\n\n  it(\"renders status and byte counters when loaded\", () => {\n    useWidgetAPI.mockReturnValue({ data: { up: true, bytesTx: 100, bytesRx: 200 }, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"openwrt\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"widget.status\")).toBeInTheDocument();\n    expect(screen.getByText(\"openwrt.bytesTx\")).toBeInTheDocument();\n    expect(screen.getByText(\"openwrt.bytesRx\")).toBeInTheDocument();\n\n    // t(\"common.bytes\") mock returns the numeric value as a string.\n    expect(screen.getByText(\"100\")).toBeInTheDocument();\n    expect(screen.getByText(\"200\")).toBeInTheDocument();\n    expect(screen.getByText(\"openwrt.up\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/openwrt/methods/system.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { data, error } = useWidgetAPI(service.widget);\n\n  if (error) {\n    return <Container service={service} error={error} />;\n  }\n\n  if (!data) {\n    return null;\n  }\n\n  const { uptime, cpuLoad } = data;\n\n  return (\n    <Container service={service}>\n      <Block label=\"openwrt.uptime\" value={t(\"common.duration\", { value: uptime })} />\n      <Block label=\"openwrt.cpuLoad\" value={cpuLoad} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/openwrt/methods/system.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./system\";\n\ndescribe(\"widgets/openwrt/methods/system\", () => {\n  it(\"returns null while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"openwrt\" } }} />);\n    expect(container.firstChild).toBeNull();\n  });\n\n  it(\"renders uptime and cpu load when loaded\", () => {\n    useWidgetAPI.mockReturnValue({ data: { uptime: 123, cpuLoad: \"0.5\" }, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"openwrt\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"openwrt.uptime\")).toBeInTheDocument();\n    expect(screen.getByText(\"openwrt.cpuLoad\")).toBeInTheDocument();\n    expect(screen.getByText(\"123\")).toBeInTheDocument();\n    expect(screen.getByText(\"0.5\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/openwrt/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { sendJsonRpcRequest } from \"utils/proxy/handlers/jsonrpc\";\nimport widgets from \"widgets/widgets\";\n\nconst PROXY_NAME = \"OpenWRTProxyHandler\";\nconst logger = createLogger(PROXY_NAME);\nconst LOGIN_PARAMS = [\"00000000000000000000000000000000\", \"session\", \"login\"];\nconst RPC_METHOD = \"call\";\n\nlet authToken = \"00000000000000000000000000000000\";\n\nconst PARAMS = {\n  system: [\"system\", \"info\", {}],\n  device: [\"network.device\", \"status\", {}],\n};\n\nasync function getWidget(req) {\n  const { group, service, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return null;\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return null;\n  }\n\n  return widget;\n}\n\nfunction isUnauthorized(data) {\n  const json = JSON.parse(data.toString());\n  return json?.error?.code === -32002;\n}\n\nasync function login(url, username, password) {\n  const response = await sendJsonRpcRequest(url, RPC_METHOD, [...LOGIN_PARAMS, { username, password }]);\n\n  if (response[0] === 200) {\n    const responseData = JSON.parse(response[2]);\n    authToken = responseData[1].ubus_rpc_session;\n  }\n\n  return response;\n}\n\nasync function fetchInterface(url, interfaceName) {\n  const [, contentType, data] = await sendJsonRpcRequest(url, RPC_METHOD, [authToken, ...PARAMS.device]);\n  if (isUnauthorized(data)) {\n    return [401, contentType, data];\n  }\n  const response = JSON.parse(data.toString())[1];\n  const networkInterface = response[interfaceName];\n  if (!networkInterface) {\n    return [404, contentType, { error: \"Interface not found\" }];\n  }\n\n  const interfaceInfo = {\n    up: networkInterface.up,\n    bytesRx: networkInterface.statistics.rx_bytes,\n    bytesTx: networkInterface.statistics.tx_bytes,\n  };\n  return [200, contentType, interfaceInfo];\n}\n\nasync function fetchSystem(url) {\n  const [, contentType, data] = await sendJsonRpcRequest(url, RPC_METHOD, [authToken, ...PARAMS.system]);\n  if (isUnauthorized(data)) {\n    return [401, contentType, data];\n  }\n  const systemResponse = JSON.parse(data.toString())[1];\n  const response = {\n    uptime: systemResponse.uptime,\n    cpuLoad: (systemResponse.load[1] / 65536.0).toFixed(2),\n  };\n  return [200, contentType, response];\n}\n\nasync function fetchData(url, widget) {\n  let response;\n  if (widget.interfaceName) {\n    response = await fetchInterface(url, widget.interfaceName);\n  } else {\n    response = await fetchSystem(url);\n  }\n  return response;\n}\n\nexport default async function proxyHandler(req, res) {\n  const { group, service } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getWidget(req);\n\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const api = widgets?.[widget.type]?.api;\n  const url = new URL(formatApiCall(api, { ...widget }));\n\n  let [status, , data] = await fetchData(url, widget);\n\n  if (status === 401) {\n    const [loginStatus, , loginData] = await login(url, widget.username, widget.password);\n    if (loginStatus !== 200) {\n      return res.status(loginStatus).end(loginData);\n    }\n    [status, , data] = await fetchData(url, widget);\n\n    if (status === 401) {\n      return res.status(401).json({ error: \"Unauthorized\" });\n    }\n  }\n\n  return res.status(200).end(JSON.stringify(data));\n}\n"
  },
  {
    "path": "src/widgets/openwrt/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { sendJsonRpcRequest, getServiceWidget, logger } = vi.hoisted(() => ({\n  sendJsonRpcRequest: vi.fn(),\n  getServiceWidget: vi.fn(),\n  logger: { debug: vi.fn() },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/handlers/jsonrpc\", () => ({\n  sendJsonRpcRequest,\n}));\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    openwrt: {\n      api: \"{url}\",\n    },\n  },\n}));\n\nimport openwrtProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/openwrt/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"logs in and retries after an unauthorized response\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"openwrt\", url: \"http://openwrt\", username: \"u\", password: \"p\" });\n\n    sendJsonRpcRequest\n      // initial call -> unauthorized\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ error: { code: -32002 } }))])\n      // login -> sets ubus token\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify([0, { ubus_rpc_session: \"sess\" }]))])\n      // retry system info -> ok\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify([0, { uptime: 1, load: [0, 131072, 0] }])),\n      ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await openwrtProxyHandler(req, res);\n\n    expect(sendJsonRpcRequest).toHaveBeenCalledTimes(3);\n    expect(res.statusCode).toBe(200);\n    expect(JSON.parse(res.body).cpuLoad).toBe(\"2.00\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/openwrt/widget.js",
    "content": "import proxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/ubus\",\n  proxyHandler,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/openwrt/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"openwrt widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/opnsense/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: activityData, error: activityError } = useWidgetAPI(widget, \"activity\");\n  const { data: interfaceData, error: interfaceError } = useWidgetAPI(widget, \"interface\");\n\n  if (activityError || interfaceError) {\n    const finalError = activityError ?? interfaceError;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!activityData || !interfaceData) {\n    return (\n      <Container service={service}>\n        <Block label=\"opnsense.cpu\" />\n        <Block label=\"opnsense.memory\" />\n        <Block label=\"opnsense.wanUpload\" />\n        <Block label=\"opnsense.wanDownload\" />\n      </Container>\n    );\n  }\n\n  const cpuIdle = activityData.headers[2].match(/ ([0-9.]+)% idle/)[1];\n  const cpu = 100 - parseFloat(cpuIdle);\n  const memory = activityData.headers[3].match(/Mem: (.+) Active,/)[1];\n\n  const wan = widget.wan ? interfaceData.interfaces[widget.wan] : interfaceData.interfaces.wan;\n\n  return (\n    <Container service={service}>\n      <Block label=\"opnsense.cpu\" value={t(\"common.percent\", { value: cpu.toFixed(2) })} highlightValue={cpu} />\n      <Block label=\"opnsense.memory\" value={memory} />\n      {wan && (\n        <Block\n          label=\"opnsense.wanUpload\"\n          value={t(\"common.bytes\", { value: wan[\"bytes transmitted\"] })}\n          highlightValue={wan[\"bytes transmitted\"]}\n        />\n      )}\n      {wan && (\n        <Block\n          label=\"opnsense.wanDownload\"\n          value={t(\"common.bytes\", { value: wan[\"bytes received\"] })}\n          highlightValue={wan[\"bytes received\"]}\n        />\n      )}\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/opnsense/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/opnsense/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"opnsense\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"opnsense.cpu\")).toBeInTheDocument();\n    expect(screen.getByText(\"opnsense.memory\")).toBeInTheDocument();\n    expect(screen.getByText(\"opnsense.wanUpload\")).toBeInTheDocument();\n    expect(screen.getByText(\"opnsense.wanDownload\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when either endpoint errors\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"activity\") return { data: undefined, error: { message: \"nope\" } };\n      return { data: undefined, error: undefined };\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"opnsense\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"parses activity headers and renders WAN rx/tx for selected interface\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"activity\") {\n        return {\n          data: {\n            headers: [\"\", \"\", \"CPU: 75.00% idle\", \"Mem: 123M Active, 456M Inact, 789M Wired\"],\n          },\n          error: undefined,\n        };\n      }\n\n      if (endpoint === \"interface\") {\n        return {\n          data: {\n            interfaces: {\n              wan2: { \"bytes transmitted\": 1000, \"bytes received\": 2000 },\n              wan: { \"bytes transmitted\": 1, \"bytes received\": 2 },\n            },\n          },\n          error: undefined,\n        };\n      }\n\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"opnsense\", wan: \"wan2\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"opnsense.cpu\", \"25.00\");\n    expectBlockValue(container, \"opnsense.memory\", \"123M\");\n    expectBlockValue(container, \"opnsense.wanUpload\", 1000);\n    expectBlockValue(container, \"opnsense.wanDownload\", 2000);\n  });\n});\n"
  },
  {
    "path": "src/widgets/opnsense/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    activity: {\n      endpoint: \"diagnostics/activity/getActivity\",\n      validate: [\"headers\"],\n    },\n    interface: {\n      endpoint: \"diagnostics/traffic/interface\",\n      validate: [\"interfaces\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/opnsense/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"opnsense widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/overseerr/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { data: statsData, error: statsError } = useWidgetAPI(widget, \"request/count\");\n\n  if (statsError) {\n    return <Container service={service} error={statsError} />;\n  }\n\n  if (!statsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"overseerr.pending\" />\n        <Block label=\"overseerr.processing\" />\n        <Block label=\"overseerr.approved\" />\n        <Block label=\"overseerr.available\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"overseerr.pending\" value={t(\"common.number\", { value: statsData.pending })} />\n      <Block label=\"overseerr.processing\" value={t(\"common.number\", { value: statsData.processing })} />\n      <Block label=\"overseerr.approved\" value={t(\"common.number\", { value: statsData.approved })} />\n      <Block label=\"overseerr.available\" value={t(\"common.number\", { value: statsData.available })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/overseerr/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/overseerr/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"overseerr\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"overseerr.pending\")).toBeInTheDocument();\n    expect(screen.getByText(\"overseerr.processing\")).toBeInTheDocument();\n    expect(screen.getByText(\"overseerr.approved\")).toBeInTheDocument();\n    expect(screen.getByText(\"overseerr.available\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when endpoint errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"overseerr\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders request counts when loaded\", () => {\n    useWidgetAPI.mockReturnValue({ data: { pending: 1, processing: 2, approved: 3, available: 4 }, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"overseerr\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"overseerr.pending\", 1);\n    expectBlockValue(container, \"overseerr.processing\", 2);\n    expectBlockValue(container, \"overseerr.approved\", 3);\n    expectBlockValue(container, \"overseerr.available\", 4);\n  });\n});\n"
  },
  {
    "path": "src/widgets/overseerr/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/v1/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    \"request/count\": {\n      endpoint: \"request/count\",\n      validate: [\"pending\", \"processing\", \"approved\", \"available\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/overseerr/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"overseerr widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/pangolin/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst MAX_ALLOWED_FIELDS = 4;\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  if (!widget.fields) {\n    widget.fields = [\"sites\", \"resources\", \"targets\", \"traffic\"];\n  } else if (widget.fields?.length > MAX_ALLOWED_FIELDS) {\n    widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);\n  }\n\n  const { data: sitesData, error: sitesError } = useWidgetAPI(widget, \"sites\");\n  const { data: resourcesData, error: resourcesError } = useWidgetAPI(widget, \"resources\");\n\n  if (sitesError || resourcesError) {\n    return <Container service={service} error={sitesError || resourcesError} />;\n  }\n\n  if (!sitesData || !resourcesData) {\n    return (\n      <Container service={service}>\n        <Block label=\"pangolin.sites\" />\n        <Block label=\"pangolin.resources\" />\n        <Block label=\"pangolin.targets\" />\n        <Block label=\"pangolin.traffic\" />\n        <Block label=\"pangolin.in\" />\n        <Block label=\"pangolin.out\" />\n      </Container>\n    );\n  }\n\n  const sites = sitesData.data?.sites || [];\n  const resources = resourcesData.data?.resources || [];\n\n  const sitesTotal = sites.length;\n  const sitesOnline = sites.filter((s) => s.online).length;\n\n  const resourcesTotal = resources.length;\n  const resourcesHealthy = resources.filter(\n    (r) => r.targets?.some((t) => t.healthStatus !== \"unhealthy\") || !r.targets?.length,\n  ).length;\n\n  const targetsTotal = resources.reduce((sum, r) => sum + (r.targets?.length || 0), 0);\n  const targetsHealthy = resources.reduce(\n    (sum, r) => sum + (r.targets?.filter((t) => t.healthStatus !== \"unhealthy\").length || 0),\n    0,\n  );\n\n  const trafficIn = sites.reduce((sum, s) => sum + (s.megabytesIn || 0), 0) * 1_000_000;\n  const trafficOut = sites.reduce((sum, s) => sum + (s.megabytesOut || 0), 0) * 1_000_000;\n  const trafficTotal = trafficIn + trafficOut;\n\n  return (\n    <Container service={service}>\n      <Block label=\"pangolin.sites\" value={`${sitesOnline} / ${sitesTotal}`} />\n      <Block label=\"pangolin.resources\" value={`${resourcesHealthy} / ${resourcesTotal}`} />\n      <Block label=\"pangolin.targets\" value={`${targetsHealthy} / ${targetsTotal}`} />\n      <Block label=\"pangolin.traffic\" value={t(\"common.bytes\", { value: trafficTotal })} />\n      <Block label=\"pangolin.in\" value={t(\"common.bytes\", { value: trafficIn })} />\n      <Block label=\"pangolin.out\" value={t(\"common.bytes\", { value: trafficOut })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/pangolin/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue, findServiceBlockByLabel } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/pangolin/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"defaults fields to 4 entries and renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"pangolin\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"sites\", \"resources\", \"targets\", \"traffic\"]);\n    // Container filters by widget.fields, so only the default 4 blocks are visible.\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"pangolin.sites\")).toBeInTheDocument();\n    expect(screen.getByText(\"pangolin.resources\")).toBeInTheDocument();\n    expect(screen.getByText(\"pangolin.targets\")).toBeInTheDocument();\n    expect(screen.getByText(\"pangolin.traffic\")).toBeInTheDocument();\n    expect(screen.queryByText(\"pangolin.in\")).toBeNull();\n    expect(screen.queryByText(\"pangolin.out\")).toBeNull();\n  });\n\n  it(\"caps widget.fields at 4 entries\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"pangolin\", fields: [\"sites\", \"resources\", \"targets\", \"traffic\", \"extra\"] } };\n    renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"sites\", \"resources\", \"targets\", \"traffic\"]);\n  });\n\n  it(\"renders computed site/resource/target totals and traffic bytes\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"sites\") {\n        return {\n          data: {\n            data: {\n              sites: [\n                { online: true, megabytesIn: 1, megabytesOut: 2 },\n                { online: false, megabytesIn: 3, megabytesOut: 4 },\n              ],\n            },\n          },\n          error: undefined,\n        };\n      }\n\n      if (endpoint === \"resources\") {\n        return {\n          data: {\n            data: {\n              resources: [\n                { targets: [{ healthStatus: \"healthy\" }, { healthStatus: \"unhealthy\" }] },\n                { targets: [] }, // counts as healthy\n              ],\n            },\n          },\n          error: undefined,\n        };\n      }\n\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"pangolin\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    // sites: 1 / 2 online\n    expectBlockValue(container, \"pangolin.sites\", \"1 / 2\");\n    // resources: both healthy => 2 / 2\n    expectBlockValue(container, \"pangolin.resources\", \"2 / 2\");\n    // targets: 1 healthy out of 2 total\n    expectBlockValue(container, \"pangolin.targets\", \"1 / 2\");\n\n    // traffic bytes: (1+3) MB in + (2+4) MB out = 10MB => 10_000_000 bytes.\n    expectBlockValue(container, \"pangolin.traffic\", 10_000_000);\n    expect(findServiceBlockByLabel(container, \"pangolin.in\")).toBeUndefined();\n    expect(findServiceBlockByLabel(container, \"pangolin.out\")).toBeUndefined();\n  });\n\n  it(\"can show in/out traffic when selected via widget.fields\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"sites\") {\n        return {\n          data: { data: { sites: [{ online: true, megabytesIn: 1, megabytesOut: 2 }] } },\n          error: undefined,\n        };\n      }\n\n      if (endpoint === \"resources\") {\n        return { data: { data: { resources: [] } }, error: undefined };\n      }\n\n      return { data: undefined, error: undefined };\n    });\n\n    const service = { widget: { type: \"pangolin\", fields: [\"sites\", \"resources\", \"in\", \"out\"] } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expectBlockValue(container, \"pangolin.in\", 1_000_000);\n    expectBlockValue(container, \"pangolin.out\", 2_000_000);\n  });\n});\n"
  },
  {
    "path": "src/widgets/pangolin/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/v1/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    sites: {\n      endpoint: \"org/{org}/sites\",\n    },\n    resources: {\n      endpoint: \"org/{org}/resources\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/pangolin/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"pangolin widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/paperlessngx/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  const { data: statisticsData, error: statisticsError } = useWidgetAPI(widget, \"statistics\");\n\n  if (statisticsError) {\n    return <Container service={service} error={statisticsError} />;\n  }\n\n  if (!statisticsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"paperlessngx.inbox\" />\n        <Block label=\"paperlessngx.total\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      {statisticsData.documents_inbox !== undefined && (\n        <Block label=\"paperlessngx.inbox\" value={statisticsData.documents_inbox} />\n      )}\n      <Block label=\"paperlessngx.total\" value={statisticsData.documents_total} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/paperlessngx/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue, findServiceBlockByLabel } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/paperlessngx/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"paperlessngx\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"paperlessngx.inbox\")).toBeInTheDocument();\n    expect(screen.getByText(\"paperlessngx.total\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when endpoint errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"paperlessngx\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders total and inbox when present\", () => {\n    useWidgetAPI.mockReturnValue({ data: { documents_inbox: 2, documents_total: 10 }, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"paperlessngx\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"paperlessngx.inbox\", 2);\n    expectBlockValue(container, \"paperlessngx.total\", 10);\n  });\n\n  it(\"omits inbox block when documents_inbox is undefined\", () => {\n    useWidgetAPI.mockReturnValue({ data: { documents_total: 10 }, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"paperlessngx\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(findServiceBlockByLabel(container, \"paperlessngx.inbox\")).toBeUndefined();\n    expectBlockValue(container, \"paperlessngx.total\", 10);\n  });\n});\n"
  },
  {
    "path": "src/widgets/paperlessngx/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    statistics: {\n      endpoint: \"statistics/?format=json\",\n      validate: [\"documents_total\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/paperlessngx/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"paperlessngx widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/peanut/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n  const { t } = useTranslation();\n\n  const { data: upsData, error: upsError } = useWidgetAPI(widget, \"devices\");\n\n  if (upsError) {\n    return <Container service={service} error={upsError} />;\n  }\n\n  if (!upsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"peanut.battery_charge\" />\n        <Block label=\"peanut.ups_load\" />\n        <Block label=\"peanut.ups_status\" />\n      </Container>\n    );\n  }\n\n  // backwards compatibility with peanut v1\n  if (\"battery.charge\" in upsData) {\n    upsData.battery_charge = upsData[\"battery.charge\"];\n  }\n  if (\"ups.load\" in upsData) {\n    upsData.ups_load = upsData[\"ups.load\"];\n  }\n  if (\"ups.status\" in upsData) {\n    upsData.ups_status = upsData[\"ups.status\"];\n  }\n\n  let status;\n  switch (upsData.ups_status) {\n    case \"OL\":\n      status = t(\"peanut.online\");\n      break;\n    case \"OB\":\n      status = t(\"peanut.on_battery\");\n      break;\n    case \"LB\":\n      status = t(\"peanut.low_battery\");\n      break;\n    default:\n      status = upsData.ups_status;\n  }\n\n  return (\n    <Container service={service}>\n      <Block\n        label=\"peanut.battery_charge\"\n        value={t(\"common.percent\", { value: upsData.battery_charge })}\n        highlightValue={upsData.battery_charge}\n      />\n      <Block\n        label=\"peanut.ups_load\"\n        value={t(\"common.percent\", { value: upsData.ups_load })}\n        highlightValue={upsData.ups_load}\n      />\n      <Block label=\"peanut.ups_status\" value={status} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/peanut/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/peanut/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"peanut\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"peanut.battery_charge\")).toBeInTheDocument();\n    expect(screen.getByText(\"peanut.ups_load\")).toBeInTheDocument();\n    expect(screen.getByText(\"peanut.ups_status\")).toBeInTheDocument();\n  });\n\n  it(\"renders legacy field mapping and status translation\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        \"battery.charge\": 55,\n        \"ups.load\": 12,\n        \"ups.status\": \"OL\",\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"peanut\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"55\")).toBeInTheDocument();\n    expect(screen.getByText(\"12\")).toBeInTheDocument();\n    expect(screen.getByText(\"peanut.online\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/peanut/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/v1/{endpoint}/{key}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    devices: {\n      endpoint: \"devices\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/peanut/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"peanut widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/pfsense/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const version = widget.version ?? 1;\n  const { data: systemData, error: systemError } = useWidgetAPI(widget, version === 1 ? \"system\" : \"systemv2\");\n  const { data: interfaceData, error: interfaceError } = useWidgetAPI(\n    widget,\n    version === 1 ? \"interface\" : \"interfacev2\",\n  );\n\n  const showWanIP = widget.fields?.filter((f) => f !== \"wanIP\").length <= 4 && widget.fields?.includes(\"wanIP\");\n  const showDiskUsage = widget.fields?.filter((f) => f !== \"disk\").length <= 4 && widget.fields?.includes(\"disk\");\n\n  if (systemError || interfaceError) {\n    const finalError = systemError ?? interfaceError;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!systemData || !interfaceData) {\n    return (\n      <Container service={service}>\n        <Block label=\"pfsense.load\" />\n        <Block label=\"pfsense.memory\" />\n        <Block label=\"pfsense.temp\" />\n        <Block label=\"pfsense.wanStatus\" />\n        {showWanIP && <Block label=\"pfsense.wanIP\" />}\n        {showDiskUsage && <Block label=\"pfsense.disk\" />}\n      </Container>\n    );\n  }\n\n  const wan = interfaceData.data.filter((l) => l.hwif === widget.wan)[0];\n  let memUsage = systemData?.data.mem_usage;\n  let diskUsage = systemData.data.disk_usage;\n  if (version === 1) {\n    memUsage *= 100;\n    diskUsage *= 100;\n  }\n\n  return (\n    <Container service={service}>\n      <Block\n        label=\"pfsense.load\"\n        value={version === 1 ? systemData.data.load_avg[0] : systemData.data.cpu_load_avg[0]}\n      />\n      <Block\n        label=\"pfsense.memory\"\n        value={t(\"common.percent\", { value: memUsage.toFixed(2) })}\n        highlightValue={memUsage}\n      />\n      <Block\n        label=\"pfsense.temp\"\n        value={t(\"common.number\", { value: systemData.data.temp_c, style: \"unit\", unit: \"celsius\" })}\n        highlightValue={systemData.data.temp_c}\n      />\n      <Block label=\"pfsense.wanStatus\" value={wan.status === \"up\" ? t(\"pfsense.up\") : t(\"pfsense.down\")} />\n      {showWanIP && <Block label=\"pfsense.wanIP\" value={wan.ipaddr} />}\n      {showDiskUsage && (\n        <Block\n          label=\"pfsense.disk\"\n          value={t(\"common.percent\", { value: diskUsage.toFixed(2) })}\n          highlightValue={diskUsage}\n        />\n      )}\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/pfsense/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue, findServiceBlockByLabel } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/pfsense/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders only optional blocks when widget.fields filters to wanIP + disk\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"pfsense\", fields: [\"wanIP\", \"disk\"] } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    // Container filters children based on widget.fields.\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.queryByText(\"pfsense.load\")).toBeNull();\n    expect(screen.queryByText(\"pfsense.memory\")).toBeNull();\n    expect(screen.queryByText(\"pfsense.temp\")).toBeNull();\n    expect(screen.queryByText(\"pfsense.wanStatus\")).toBeNull();\n    expect(screen.getByText(\"pfsense.wanIP\")).toBeInTheDocument();\n    expect(screen.getByText(\"pfsense.disk\")).toBeInTheDocument();\n  });\n\n  it(\"renders values for version 2 (systemv2/interfacev2)\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"systemv2\") {\n        return {\n          data: {\n            data: {\n              cpu_load_avg: [0.5],\n              mem_usage: 12.3456,\n              disk_usage: 78.9,\n              temp_c: 40,\n            },\n          },\n          error: undefined,\n        };\n      }\n      if (endpoint === \"interfacev2\") {\n        return { data: { data: [{ hwif: \"wan0\", status: \"up\", ipaddr: \"1.2.3.4\" }] }, error: undefined };\n      }\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"pfsense\", version: 2, wan: \"wan0\" } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expectBlockValue(container, \"pfsense.load\", \"0.5\");\n    expectBlockValue(container, \"pfsense.memory\", \"12.35\");\n    expectBlockValue(container, \"pfsense.temp\", \"40\");\n    expectBlockValue(container, \"pfsense.wanStatus\", \"pfsense.up\");\n    expect(findServiceBlockByLabel(container, \"pfsense.wanIP\")).toBeUndefined();\n    expect(findServiceBlockByLabel(container, \"pfsense.disk\")).toBeUndefined();\n  });\n});\n"
  },
  {
    "path": "src/widgets/pfsense/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    system: {\n      endpoint: \"v1/status/system\",\n      validate: [\"data\"],\n    },\n    interface: {\n      endpoint: \"v1/status/interface\",\n      validate: [\"data\"],\n    },\n    systemv2: {\n      endpoint: \"v2/status/system\",\n      validate: [\"data\"],\n    },\n    interfacev2: {\n      endpoint: \"v2/status/interfaces?limit=0&offset=0\",\n      validate: [\"data\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/pfsense/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"pfsense widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/photoprism/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: photoprismData, error: photoprismError } = useWidgetAPI(widget);\n\n  if (photoprismError) {\n    return <Container service={service} error={photoprismError} />;\n  }\n\n  if (!photoprismData) {\n    return (\n      <Container service={service}>\n        <Block label=\"photoprism.albums\" />\n        <Block label=\"photoprism.photos\" />\n        <Block label=\"photoprism.videos\" />\n        <Block label=\"photoprism.people\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"photoprism.albums\" value={t(\"common.number\", { value: photoprismData.albums })} />\n      <Block label=\"photoprism.photos\" value={t(\"common.number\", { value: photoprismData.photos })} />\n      <Block label=\"photoprism.videos\" value={t(\"common.number\", { value: photoprismData.videos })} />\n      <Block label=\"photoprism.people\" value={t(\"common.number\", { value: photoprismData.people })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/photoprism/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/photoprism/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"photoprism\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"photoprism.albums\")).toBeInTheDocument();\n    expect(screen.getByText(\"photoprism.photos\")).toBeInTheDocument();\n    expect(screen.getByText(\"photoprism.videos\")).toBeInTheDocument();\n    expect(screen.getByText(\"photoprism.people\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when endpoint errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"photoprism\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders counts when loaded\", () => {\n    useWidgetAPI.mockReturnValue({ data: { albums: 1, photos: 2, videos: 3, people: 4 }, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"photoprism\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"photoprism.albums\", 1);\n    expectBlockValue(container, \"photoprism.photos\", 2);\n    expectBlockValue(container, \"photoprism.videos\", 3);\n    expectBlockValue(container, \"photoprism.people\", 4);\n  });\n});\n"
  },
  {
    "path": "src/widgets/photoprism/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\n\nconst logger = createLogger(\"photoprismProxyHandler\");\n\nexport default async function photoprismProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const url = new URL(formatApiCall(\"{url}/api/v1/session\", { ...widget }));\n  const params = {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: null,\n  };\n\n  if (widget.username && widget.password) {\n    params.body = JSON.stringify({\n      username: widget.username,\n      password: widget.password,\n    });\n  } else if (widget.key) {\n    params.headers.Authorization = `Bearer ${widget.key}`;\n    params.body = JSON.stringify({\n      authToken: widget.key,\n    });\n  }\n\n  const [status, contentType, data] = await httpProxy(url, params);\n\n  if (status !== 200) {\n    logger.error(\"HTTP %d getting data from PhotoPrism. Data: %s\", status, data);\n    return res.status(status).json({ error: { message: `HTTP Error ${status}`, url, data } });\n  }\n\n  const json = JSON.parse(data.toString());\n\n  if (contentType) res.setHeader(\"Content-Type\", contentType);\n  return res.status(200).send(json?.config?.count);\n}\n"
  },
  {
    "path": "src/widgets/photoprism/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  logger: { debug: vi.fn(), error: vi.fn() },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nimport photoprismProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/photoprism/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"supports bearer-token auth and returns config count\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://pp\", key: \"k\" });\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ config: { count: 123 } }))]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await photoprismProxyHandler(req, res);\n\n    expect(httpProxy.mock.calls[0][1].headers.Authorization).toBe(\"Bearer k\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toBe(123);\n  });\n});\n"
  },
  {
    "path": "src/widgets/photoprism/widget.js",
    "content": "import photoprismProxyHandler from \"./proxy\";\n\nconst widget = {\n  proxyHandler: photoprismProxyHandler,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/photoprism/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"photoprism widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/pihole/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: piholeData, error: piholeError } = useWidgetAPI(widget);\n\n  if (piholeError) {\n    return <Container service={service} error={piholeError} />;\n  }\n\n  if (!widget.fields) {\n    widget.fields = [\"queries\", \"blocked\", \"gravity\"];\n  }\n\n  if (!piholeData) {\n    return (\n      <Container service={service}>\n        <Block label=\"pihole.queries\" />\n        <Block label=\"pihole.blocked\" />\n        <Block label=\"pihole.blocked_percent\" />\n        <Block label=\"pihole.gravity\" />\n      </Container>\n    );\n  }\n\n  let blockedValue = `${t(\"common.number\", { value: parseInt(piholeData.ads_blocked_today, 10) })}`;\n  if (!widget.fields.includes(\"blocked_percent\")) {\n    blockedValue += ` (${t(\"common.percent\", { value: parseFloat(piholeData.ads_percentage_today).toPrecision(3) })})`;\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"pihole.queries\" value={t(\"common.number\", { value: parseInt(piholeData.dns_queries_today, 10) })} />\n      <Block label=\"pihole.blocked\" value={blockedValue} />\n      <Block\n        label=\"pihole.blocked_percent\"\n        value={t(\"common.percent\", { value: parseFloat(piholeData.ads_percentage_today).toPrecision(3) })}\n      />\n      <Block\n        label=\"pihole.gravity\"\n        value={t(\"common.number\", { value: parseInt(piholeData.domains_being_blocked, 10) })}\n      />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/pihole/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/pihole/component\", () => {\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"pihole\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n  });\n\n  it(\"renders placeholders while loading and defaults fields (3 visible blocks)\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"pihole\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    // Default fields are queries/blocked/gravity; blocked_percent is present in JSX but filtered out by Container.\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"pihole.queries\")).toBeInTheDocument();\n    expect(screen.getByText(\"pihole.blocked\")).toBeInTheDocument();\n    expect(screen.getByText(\"pihole.gravity\")).toBeInTheDocument();\n    expect(screen.queryByText(\"pihole.blocked_percent\")).toBeNull();\n\n    expect(screen.getAllByText(\"-\")).toHaveLength(3);\n  });\n\n  it(\"renders values and appends percent to blocked when blocked_percent is not a field\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        ads_blocked_today: \"5\",\n        ads_percentage_today: \"12.345\",\n        dns_queries_today: \"99\",\n        domains_being_blocked: \"123\",\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"pihole\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n\n    // common.number/common.percent are formatted by the test i18n stub in vitest.setup.js\n    expect(screen.getByText(\"99\")).toBeInTheDocument();\n    expect(screen.getByText(\"123\")).toBeInTheDocument();\n    expect(screen.getByText(\"5 (12.3)\")).toBeInTheDocument();\n  });\n\n  it(\"renders blocked_percent as its own block when configured\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        ads_blocked_today: \"5\",\n        ads_percentage_today: \"12.345\",\n        dns_queries_today: \"99\",\n        domains_being_blocked: \"123\",\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(\n      <Component\n        service={{\n          widget: { type: \"pihole\", url: \"http://x\", fields: [\"queries\", \"blocked\", \"blocked_percent\", \"gravity\"] },\n        }}\n      />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"5\")).toBeInTheDocument(); // blocked (no percent appended)\n    expect(screen.getByText(\"12.3\")).toBeInTheDocument(); // blocked_percent\n  });\n});\n"
  },
  {
    "path": "src/widgets/pihole/proxy.js",
    "content": "import cache from \"memory-cache\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst proxyName = \"piholeProxyHandler\";\nconst logger = createLogger(proxyName);\nconst sessionSIDCacheKey = `${proxyName}__sessionSID`;\n\nasync function login(widget, service) {\n  const url = formatApiCall(widgets[widget.type].api, { ...widget, endpoint: \"auth\" });\n  const [status, , data] = await httpProxy(url, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n    },\n    body: JSON.stringify({\n      password: widget.key,\n    }),\n  });\n\n  const dataParsed = JSON.parse(data);\n\n  if (status !== 200 || !dataParsed.session) {\n    logger.error(\"Failed to login to Pi-Hole API, status: %d\", status);\n    cache.del(`${sessionSIDCacheKey}.${service}`);\n  } else {\n    cache.put(\n      `${sessionSIDCacheKey}.${service}`,\n      dataParsed.session.sid,\n      Math.min(2147483647, dataParsed.session.validity * 1000), // https://github.com/ptarjan/node-cache/issues/84\n    );\n  }\n}\n\nexport default async function piholeProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n  let endpoint = \"stats/summary\";\n\n  if (!group || !service) {\n    logger.error(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n  if (!widget) {\n    logger.error(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid widget configuration\" });\n  }\n\n  let status;\n  let data;\n  if (!widget.version || widget.version < 6) {\n    // pihole v5\n    endpoint = \"summaryRaw\";\n    [status, , data] = await httpProxy(formatApiCall(widgets[widget.type].apiv5, { ...widget, endpoint }));\n    return res.status(status).send(data);\n  }\n\n  // pihole v6\n  if (!cache.get(`${sessionSIDCacheKey}.${service}`) && widget.key) {\n    await login(widget, service);\n  }\n\n  const sid = cache.get(`${sessionSIDCacheKey}.${service}`);\n  if (widget.key && !sid) {\n    return res.status(500).json({ error: \"Failed to authenticate with Pi-hole\" });\n  }\n\n  try {\n    logger.debug(\"Calling Pi-hole API endpoint: %s\", endpoint);\n    const headers = {\n      \"Content-Type\": \"application/json\",\n    };\n    if (sid) {\n      headers[\"X-FTL-SID\"] = sid;\n    } else {\n      logger.debug(\"Pi-hole request is unauthenticated\");\n    }\n    [status, , data] = await httpProxy(formatApiCall(widgets[widget.type].api, { ...widget, endpoint }), {\n      headers,\n    });\n\n    if (status !== 200) {\n      logger.error(\"Error calling Pi-Hole API: %d. Data: %s\", status, data);\n      return res.status(status).json({ error: \"Pi-Hole API Error\", data });\n    }\n\n    const dataParsed = JSON.parse(data);\n    return res.status(status).json({\n      domains_being_blocked: dataParsed.gravity.domains_being_blocked,\n      ads_blocked_today: dataParsed.queries.blocked,\n      ads_percentage_today: dataParsed.queries.percent_blocked,\n      dns_queries_today: dataParsed.queries.total,\n    });\n  } catch (error) {\n    logger.error(\"Exception calling Pi-Hole API: %s\", error.message);\n    return res.status(500).json({ error: \"Pi-Hole API Error\", message: error.message });\n  }\n}\n"
  },
  {
    "path": "src/widgets/pihole/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {\n  const store = new Map();\n\n  return {\n    httpProxy: vi.fn(),\n    getServiceWidget: vi.fn(),\n    cache: {\n      get: vi.fn((k) => store.get(k)),\n      put: vi.fn((k, v) => store.set(k, v)),\n      del: vi.fn((k) => store.delete(k)),\n      _reset: () => store.clear(),\n    },\n    logger: {\n      debug: vi.fn(),\n      error: vi.fn(),\n    },\n  };\n});\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nvi.mock(\"memory-cache\", () => ({\n  default: cache,\n  ...cache,\n}));\n\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    pihole: {\n      apiv5: \"{url}/{endpoint}\",\n      api: \"{url}/{endpoint}\",\n    },\n  },\n}));\n\nimport piholeProxyHandler from \"./proxy\";\n\nfunction createRes() {\n  const res = {\n    statusCode: null,\n    body: null,\n  };\n\n  res.status = vi.fn((code) => {\n    res.statusCode = code;\n    return res;\n  });\n  res.json = vi.fn((body) => {\n    res.body = body;\n    return res;\n  });\n  res.send = vi.fn((body) => {\n    res.body = body;\n    return res;\n  });\n\n  return res;\n}\n\ndescribe(\"widgets/pihole/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    cache._reset();\n  });\n\n  it(\"proxies Pi-hole v5 via apiv5 summaryRaw and returns raw data\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"pihole\", version: 5, url: \"http://pi\" });\n    httpProxy.mockResolvedValue([200, \"application/json\", '{\"ok\":true}']);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createRes();\n\n    await piholeProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledWith(\"http://pi/summaryRaw\");\n    expect(res.status).toHaveBeenCalledWith(200);\n    expect(res.send).toHaveBeenCalledWith('{\"ok\":true}');\n  });\n\n  it(\"proxies Pi-hole v6 without auth when key is missing\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"pihole\", version: 6, url: \"http://pi\" });\n    httpProxy.mockResolvedValue([\n      200,\n      \"application/json\",\n      JSON.stringify({\n        gravity: { domains_being_blocked: 123 },\n        queries: { blocked: 4, percent_blocked: 5.5, total: 99 },\n      }),\n    ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createRes();\n\n    await piholeProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledWith(\"http://pi/stats/summary\", {\n      headers: { \"Content-Type\": \"application/json\" },\n    });\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({\n      domains_being_blocked: 123,\n      ads_blocked_today: 4,\n      ads_percentage_today: 5.5,\n      dns_queries_today: 99,\n    });\n  });\n\n  it(\"returns 500 when key is provided but login fails and no SID is cached\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"pihole\", version: 6, url: \"http://pi\", key: \"pw\" });\n    httpProxy.mockResolvedValueOnce([401, \"application/json\", JSON.stringify({ session: null })]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createRes();\n\n    await piholeProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: \"Failed to authenticate with Pi-hole\" });\n  });\n\n  it(\"logs in and uses X-FTL-SID header for Pi-hole v6 when key is provided\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"pihole\", version: 6, url: \"http://pi\", key: \"pw\" });\n    httpProxy\n      .mockResolvedValueOnce([200, \"application/json\", JSON.stringify({ session: { sid: \"sid123\", validity: 1000 } })])\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        JSON.stringify({\n          gravity: { domains_being_blocked: 1 },\n          queries: { blocked: 2, percent_blocked: 3, total: 4 },\n        }),\n      ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createRes();\n\n    await piholeProxyHandler(req, res);\n\n    // First call: login endpoint\n    expect(httpProxy).toHaveBeenNthCalledWith(\n      1,\n      \"http://pi/auth\",\n      expect.objectContaining({\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({ password: \"pw\" }),\n      }),\n    );\n\n    // Second call: stats/summary with SID header\n    expect(httpProxy).toHaveBeenNthCalledWith(\n      2,\n      \"http://pi/stats/summary\",\n      expect.objectContaining({\n        headers: { \"Content-Type\": \"application/json\", \"X-FTL-SID\": \"sid123\" },\n      }),\n    );\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body.dns_queries_today).toBe(4);\n  });\n});\n"
  },
  {
    "path": "src/widgets/pihole/widget.js",
    "content": "import piholeProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  apiv5: \"{url}/admin/api.php?{endpoint}&auth={key}\",\n  proxyHandler: piholeProxyHandler,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/pihole/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"pihole widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/plantit/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: plantitData, error: plantitError } = useWidgetAPI(widget, \"plantit\");\n\n  if (plantitError) {\n    return <Container service={service} error={plantitError} />;\n  }\n\n  if (!plantitData) {\n    return (\n      <Container service={service}>\n        <Block label=\"plantit.events\" />\n        <Block label=\"plantit.plants\" />\n        <Block label=\"plantit.photos\" />\n        <Block label=\"plantit.species\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"plantit.events\" value={t(\"common.number\", { value: plantitData.diaryEntryCount })} />\n      <Block label=\"plantit.plants\" value={t(\"common.number\", { value: plantitData.plantCount })} />\n      <Block label=\"plantit.photos\" value={t(\"common.number\", { value: plantitData.imageCount })} />\n      <Block label=\"plantit.species\" value={t(\"common.number\", { value: plantitData.botanicalInfoCount })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/plantit/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/plantit/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"plantit\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"plantit.events\")).toBeInTheDocument();\n    expect(screen.getByText(\"plantit.plants\")).toBeInTheDocument();\n    expect(screen.getByText(\"plantit.photos\")).toBeInTheDocument();\n    expect(screen.getByText(\"plantit.species\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when endpoint errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"plantit\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders counts when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { diaryEntryCount: 1, plantCount: 2, imageCount: 3, botanicalInfoCount: 4 },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"plantit\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"plantit.events\", 1);\n    expectBlockValue(container, \"plantit.plants\", 2);\n    expectBlockValue(container, \"plantit.photos\", 3);\n    expectBlockValue(container, \"plantit.species\", 4);\n  });\n});\n"
  },
  {
    "path": "src/widgets/plantit/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    plantit: {\n      endpoint: \"stats\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/plantit/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"plantit widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/plex/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: plexData, error: plexAPIError } = useWidgetAPI(widget, \"unified\", {\n    refreshInterval: 5000,\n  });\n\n  if (plexAPIError) {\n    return <Container service={service} error={plexAPIError} />;\n  }\n\n  if (!plexData) {\n    return (\n      <Container service={service}>\n        <Block label=\"plex.streams\" />\n        <Block label=\"plex.albums\" />\n        <Block label=\"plex.movies\" />\n        <Block label=\"plex.tv\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"plex.streams\" value={t(\"common.number\", { value: plexData.streams })} />\n      <Block label=\"plex.albums\" value={t(\"common.number\", { value: plexData.albums })} />\n      <Block label=\"plex.movies\" value={t(\"common.number\", { value: plexData.movies })} />\n      <Block label=\"plex.tv\" value={t(\"common.number\", { value: plexData.tv })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/plex/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/plex/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"plex\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"plex.streams\")).toBeInTheDocument();\n    expect(screen.getByText(\"plex.albums\")).toBeInTheDocument();\n    expect(screen.getByText(\"plex.movies\")).toBeInTheDocument();\n    expect(screen.getByText(\"plex.tv\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when endpoint errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"plex\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders plex unified counts when loaded\", () => {\n    useWidgetAPI.mockReturnValue({ data: { streams: 1, albums: 2, movies: 3, tv: 4 }, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"plex\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"plex.streams\", 1);\n    expectBlockValue(container, \"plex.albums\", 2);\n    expectBlockValue(container, \"plex.movies\", 3);\n    expectBlockValue(container, \"plex.tv\", 4);\n  });\n});\n"
  },
  {
    "path": "src/widgets/plex/proxy.js",
    "content": "import cache from \"memory-cache\";\nimport { xml2json } from \"xml-js\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst proxyName = \"plexProxyHandler\";\nconst librariesCacheKey = `${proxyName}__libraries`;\nconst albumsCacheKey = `${proxyName}__albums`;\nconst moviesCacheKey = `${proxyName}__movies`;\nconst tvCacheKey = `${proxyName}__tv`;\nconst logger = createLogger(proxyName);\n\nasync function getWidget(req) {\n  const { group, service, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return null;\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return null;\n  }\n\n  return widget;\n}\n\nasync function fetchFromPlexAPI(endpoint, widget) {\n  const api = widgets?.[widget.type]?.api;\n  if (!api) {\n    return [403, null];\n  }\n\n  const url = new URL(formatApiCall(api, { endpoint, ...widget }));\n\n  const [status, contentType, data] = await httpProxy(url, {\n    headers: {\n      \"X-Plex-Container-Start\": `0`,\n      \"X-Plex-Container-Size\": `500`,\n    },\n  });\n\n  if (status !== 200) {\n    logger.error(\"HTTP %d communicating with Plex. Data: %s\", status, data.toString());\n    return [status, data];\n  }\n\n  try {\n    const dataDecoded = xml2json(data.toString(), { compact: true });\n    return [status, JSON.parse(dataDecoded), contentType];\n  } catch (e) {\n    logger.error(\"Error decoding Plex API data. Data: %s\", data.toString());\n    return [status, null];\n  }\n}\n\nexport default async function plexProxyHandler(req, res) {\n  const widget = await getWidget(req);\n\n  const { service, index } = req.query;\n\n  if (!widget) {\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  logger.debug(\"Getting streams from Plex API\");\n  let streams;\n  let [status, apiData] = await fetchFromPlexAPI(\"/status/sessions\", widget);\n\n  if (status !== 200) {\n    return res\n      .status(status)\n      .json({ error: { message: \"HTTP error communicating with Plex API\", data: Buffer.from(apiData).toString() } });\n  }\n\n  if (apiData && apiData.MediaContainer) {\n    streams = apiData.MediaContainer._attributes.size;\n  }\n\n  let libraries = cache.get(`${librariesCacheKey}.${service}.${index}`);\n  if (libraries === null) {\n    logger.debug(\"Getting libraries from Plex API\");\n    [status, apiData] = await fetchFromPlexAPI(\"/library/sections\", widget);\n    if (apiData && apiData.MediaContainer) {\n      libraries = [].concat(apiData.MediaContainer.Directory);\n      cache.put(`${librariesCacheKey}.${service}.${index}`, libraries, 1000 * 60 * 60 * 6);\n    }\n  }\n\n  let albums = cache.get(`${albumsCacheKey}.${service}.${index}`);\n  let movies = cache.get(`${moviesCacheKey}.${service}.${index}`);\n  let tv = cache.get(`${tvCacheKey}.${service}.${index}`);\n  if (albums === null || movies === null || tv === null) {\n    albums = 0;\n    movies = 0;\n    tv = 0;\n    logger.debug(\"Getting counts from Plex API\");\n    const movieTVLibraries = libraries.filter((l) => [\"movie\", \"show\", \"artist\"].includes(l._attributes.type));\n    await Promise.all(\n      movieTVLibraries.map(async (library) => {\n        const libraryURL = [\"movie\", \"show\"].includes(library._attributes.type)\n          ? `/library/sections/${library._attributes.key}/all` // tv + movies\n          : `/library/sections/${library._attributes.key}/albums`; // music\n        [status, apiData] = await fetchFromPlexAPI(libraryURL, widget);\n        if (apiData && apiData.MediaContainer) {\n          const sizeProp = apiData.MediaContainer._attributes[\"totalSize\"] ? \"totalSize\" : \"size\";\n          const size = parseInt(apiData.MediaContainer._attributes[sizeProp], 10);\n          if (library._attributes.type === \"movie\") {\n            movies += size;\n          } else if (library._attributes.type === \"show\") {\n            tv += size;\n          } else if (library._attributes.type === \"artist\") {\n            albums += size;\n          }\n        }\n      }),\n    );\n    cache.put(`${albumsCacheKey}.${service}.${index}`, albums, 1000 * 60 * 10);\n    cache.put(`${tvCacheKey}.${service}.${index}`, tv, 1000 * 60 * 10);\n    cache.put(`${moviesCacheKey}.${service}.${index}`, movies, 1000 * 60 * 10);\n  }\n\n  const data = {\n    streams,\n    albums,\n    movies,\n    tv,\n  };\n\n  return res.status(status).send(data);\n}\n"
  },
  {
    "path": "src/widgets/plex/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, cache, xml2json, logger } = vi.hoisted(() => {\n  const store = new Map();\n  return {\n    httpProxy: vi.fn(),\n    getServiceWidget: vi.fn(),\n    cache: {\n      get: vi.fn((k) => (store.has(k) ? store.get(k) : null)),\n      put: vi.fn((k, v) => store.set(k, v)),\n      del: vi.fn((k) => store.delete(k)),\n      _reset: () => store.clear(),\n    },\n    xml2json: vi.fn((xml) => {\n      if (xml === \"sessions\") return JSON.stringify({ MediaContainer: { _attributes: { size: \"2\" } } });\n      if (xml === \"libraries\")\n        return JSON.stringify({\n          MediaContainer: {\n            Directory: [\n              { _attributes: { type: \"movie\", key: \"1\" } },\n              { _attributes: { type: \"show\", key: \"2\" } },\n              { _attributes: { type: \"artist\", key: \"3\" } },\n            ],\n          },\n        });\n      if (xml === \"movies\") return JSON.stringify({ MediaContainer: { _attributes: { size: \"10\" } } });\n      if (xml === \"tv\") return JSON.stringify({ MediaContainer: { _attributes: { totalSize: \"20\" } } });\n      if (xml === \"albums\") return JSON.stringify({ MediaContainer: { _attributes: { size: \"30\" } } });\n      return JSON.stringify({ MediaContainer: { _attributes: { size: \"0\" } } });\n    }),\n    logger: { debug: vi.fn(), error: vi.fn() },\n  };\n});\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\nvi.mock(\"memory-cache\", () => ({\n  default: cache,\n  ...cache,\n}));\nvi.mock(\"xml-js\", () => ({\n  xml2json,\n}));\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    plex: {\n      api: \"{url}{endpoint}\",\n    },\n  },\n}));\n\nimport plexProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/plex/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    cache._reset();\n  });\n\n  it(\"fetches sessions and library counts, caching intermediate results\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"plex\", url: \"http://plex\" });\n\n    httpProxy\n      // sessions\n      .mockResolvedValueOnce([200, \"application/xml\", Buffer.from(\"sessions\")])\n      // libraries\n      .mockResolvedValueOnce([200, \"application/xml\", Buffer.from(\"libraries\")])\n      // movies\n      .mockResolvedValueOnce([200, \"application/xml\", Buffer.from(\"movies\")])\n      // tv\n      .mockResolvedValueOnce([200, \"application/xml\", Buffer.from(\"tv\")])\n      // albums\n      .mockResolvedValueOnce([200, \"application/xml\", Buffer.from(\"albums\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await plexProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ streams: \"2\", albums: 30, movies: 10, tv: 20 });\n    expect(cache.put).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/widgets/plex/widget.js",
    "content": "import plexProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}{endpoint}?X-Plex-Token={key}\",\n  proxyHandler: plexProxyHandler,\n\n  mappings: {\n    unified: {\n      endpoint: \"/\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/plex/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"plex widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/portainer/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  if (!widget.fields) {\n    widget.fields = widget.kubernetes ? [\"applications\", \"services\", \"namespaces\"] : [\"running\", \"stopped\", \"total\"];\n  }\n\n  const { data: containersCount, error: containersError } = useWidgetAPI(\n    widget,\n    widget.kubernetes ? \"\" : \"docker/containers\",\n    {\n      all: 1,\n    },\n  );\n\n  const { data: applicationsCount, error: applicationsError } = useWidgetAPI(\n    widget,\n    widget.kubernetes ? \"kubernetes/applications\" : \"\",\n  );\n\n  const { data: servicesCount, error: servicesError } = useWidgetAPI(\n    widget,\n    widget.kubernetes ? \"kubernetes/services\" : \"\",\n  );\n\n  const { data: namespacesCount, error: namespacesError } = useWidgetAPI(\n    widget,\n    widget.kubernetes ? \"kubernetes/namespaces\" : \"\",\n  );\n\n  if (widget.kubernetes) {\n    const error = applicationsError ?? servicesError ?? namespacesError;\n    // count can be an error object\n    if (error || typeof applicationsCount === \"object\") {\n      return <Container service={service} error={error ?? applicationsCount} />;\n    }\n\n    if (applicationsCount == undefined || servicesCount == undefined || namespacesCount == undefined) {\n      return (\n        <Container service={service}>\n          <Block label=\"portainer.applications\" />\n          <Block label=\"portainer.services\" />\n          <Block label=\"portainer.namespaces\" />\n        </Container>\n      );\n    }\n\n    return (\n      <Container service={service}>\n        <Block label=\"portainer.applications\" value={applicationsCount ?? 0} />\n        <Block label=\"portainer.services\" value={servicesCount ?? 0} />\n        <Block label=\"portainer.namespaces\" value={namespacesCount ?? 0} />\n      </Container>\n    );\n  }\n\n  if (containersError) {\n    return <Container service={service} error={containersError} />;\n  }\n\n  if (!containersCount) {\n    return (\n      <Container service={service}>\n        <Block label=\"portainer.running\" />\n        <Block label=\"portainer.stopped\" />\n        <Block label=\"portainer.total\" />\n      </Container>\n    );\n  }\n\n  if (containersCount.error || containersCount.message) {\n    // containersData can be itself an error object e.g. if environment fails\n    return <Container service={service} error={containersCount?.error ?? containersCount} />;\n  }\n\n  const running = containersCount.filter((c) => c.State === \"running\").length;\n  const stopped = containersCount.filter((c) => c.State === \"exited\").length;\n  const total = containersCount.length;\n\n  return (\n    <Container service={service}>\n      <Block label=\"portainer.running\" value={running} />\n      <Block label=\"portainer.stopped\" value={stopped} />\n      <Block label=\"portainer.total\" value={total} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/portainer/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/portainer/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"defaults fields for non-kubernetes and renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"portainer\", kubernetes: false } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"running\", \"stopped\", \"total\"]);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"portainer.running\")).toBeInTheDocument();\n    expect(screen.getByText(\"portainer.stopped\")).toBeInTheDocument();\n    expect(screen.getByText(\"portainer.total\")).toBeInTheDocument();\n  });\n\n  it(\"renders running/stopped/total when container list is loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: [{ State: \"running\" }, { State: \"running\" }, { State: \"exited\" }],\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"portainer\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"portainer.running\", 2);\n    expectBlockValue(container, \"portainer.stopped\", 1);\n    expectBlockValue(container, \"portainer.total\", 3);\n  });\n\n  it(\"renders kubernetes placeholders when enabled\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"portainer\", kubernetes: true } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"applications\", \"services\", \"namespaces\"]);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"portainer.applications\")).toBeInTheDocument();\n    expect(screen.getByText(\"portainer.services\")).toBeInTheDocument();\n    expect(screen.getByText(\"portainer.namespaces\")).toBeInTheDocument();\n  });\n\n  it(\"renders kubernetes counts when loaded\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"kubernetes/applications\") return { data: 1, error: undefined };\n      if (endpoint === \"kubernetes/services\") return { data: 2, error: undefined };\n      if (endpoint === \"kubernetes/namespaces\") return { data: 3, error: undefined };\n      // container count isn't used in kubernetes mode (endpoint = \"\" => url null)\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"portainer\", kubernetes: true } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n\n    expectBlockValue(container, \"portainer.applications\", 1);\n    expectBlockValue(container, \"portainer.services\", 2);\n    expectBlockValue(container, \"portainer.namespaces\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/portainer/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    \"docker/containers\": {\n      endpoint: \"endpoints/{env}/docker/containers/json\",\n      params: [\"all\"],\n    },\n    \"kubernetes/applications\": {\n      endpoint: \"kubernetes/{env}/applications/count\",\n    },\n    \"kubernetes/services\": {\n      endpoint: \"kubernetes/{env}/services/count\",\n    },\n    \"kubernetes/namespaces\": {\n      endpoint: \"kubernetes/{env}/namespaces/count\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/portainer/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"portainer widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/prometheus/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n  const { data: targetsData, error: targetsError } = useWidgetAPI(widget, \"targets\");\n\n  if (targetsError) {\n    return <Container service={service} error={targetsError} />;\n  }\n\n  if (!targetsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"prometheus.targets_up\" />\n        <Block label=\"prometheus.targets_down\" />\n        <Block label=\"prometheus.targets_total\" />\n      </Container>\n    );\n  }\n\n  const upCount = targetsData.data.activeTargets.filter((a) => a.health === \"up\").length;\n  const downCount = targetsData.data.activeTargets.filter((a) => a.health === \"down\").length;\n  const totalCount = targetsData.data.activeTargets.length;\n\n  return (\n    <Container service={service}>\n      <Block label=\"prometheus.targets_up\" value={t(\"common.number\", { value: upCount })} />\n      <Block label=\"prometheus.targets_down\" value={t(\"common.number\", { value: downCount })} />\n      <Block label=\"prometheus.targets_total\" value={t(\"common.number\", { value: totalCount })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/prometheus/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/prometheus/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"prometheus\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"prometheus.targets_up\")).toBeInTheDocument();\n    expect(screen.getByText(\"prometheus.targets_down\")).toBeInTheDocument();\n    expect(screen.getByText(\"prometheus.targets_total\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when endpoint errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"prometheus\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders up/down/total counts from activeTargets\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { data: { activeTargets: [{ health: \"up\" }, { health: \"down\" }, { health: \"up\" }] } },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"prometheus\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"prometheus.targets_up\", 2);\n    expectBlockValue(container, \"prometheus.targets_down\", 1);\n    expectBlockValue(container, \"prometheus.targets_total\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/prometheus/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/v1/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    targets: {\n      endpoint: \"targets?state=active\",\n      validate: [\"data\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/prometheus/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"prometheus widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/prometheusmetric/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nfunction formatValue(t, metric, rawValue) {\n  if (!metric?.format) return rawValue;\n  if (!rawValue) return \"-\";\n\n  let value = rawValue;\n\n  // Scale the value. Accepts either a number to multiply by or a string\n  // like \"12/345\".\n  const scale = metric?.format?.scale;\n  if (typeof scale === \"number\") {\n    value *= scale;\n  } else if (typeof scale === \"string\" && scale.includes(\"/\")) {\n    const parts = scale.split(\"/\");\n    const numerator = parts[0] ? parseFloat(parts[0]) : 1;\n    const denominator = parts[1] ? parseFloat(parts[1]) : 1;\n    value = (value * numerator) / denominator;\n  } else {\n    value = parseFloat(value);\n  }\n\n  // Format the value using a known type and optional options.\n  switch (metric?.format?.type) {\n    case \"text\":\n      break;\n    default:\n      value = t(`common.${metric.format.type}`, { value, ...metric.format?.options });\n  }\n\n  // Apply fixed prefix.\n  const prefix = metric?.format?.prefix;\n  if (prefix) {\n    value = `${prefix}${value}`;\n  }\n\n  // Apply fixed suffix.\n  const suffix = metric?.format?.suffix;\n  if (suffix) {\n    value = `${value}${suffix}`;\n  }\n\n  return value;\n}\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { metrics = [], refreshInterval = 10000 } = widget;\n\n  let prometheusmetricError;\n\n  const prometheusmetricData = new Map(\n    metrics.slice(0, 4).map((metric) => {\n      // disable the rule that hooks should not be called from a callback,\n      // because we don't need a strong guarantee of hook execution order here.\n      // eslint-disable-next-line react-hooks/rules-of-hooks\n      const { data: resultData, error: resultError } = useWidgetAPI(widget, \"query\", {\n        query: metric.query,\n        refreshInterval: Math.max(1000, metric.refreshInterval ?? refreshInterval),\n      });\n      if (resultError) {\n        prometheusmetricError = resultError;\n      }\n      return [metric.key ?? metric.label, resultData];\n    }),\n  );\n\n  if (prometheusmetricError) {\n    return <Container service={service} error={prometheusmetricError} />;\n  }\n\n  if (!prometheusmetricData) {\n    return (\n      <Container service={service}>\n        {metrics.slice(0, 4).map((item) => (\n          <Block label={item.label} key={item.label} />\n        ))}\n      </Container>\n    );\n  }\n\n  function getResultValue(data) {\n    // Fetches the first metric result from the Prometheus query result data.\n    // The first element in the result value is the timestamp which is ignored here.\n    const resultType = data?.data?.resultType;\n    const result = data?.data?.result;\n\n    switch (resultType) {\n      case \"vector\":\n        return result?.[0]?.value?.[1];\n      case \"scalar\":\n        return result?.[1];\n      default:\n        return \"\";\n    }\n  }\n\n  return (\n    <Container service={service}>\n      {metrics.map((metric) => (\n        <Block\n          label={metric.label}\n          key={metric.key ?? metric.label}\n          value={formatValue(t, metric, getResultValue(prometheusmetricData.get(metric.key ?? metric.label)))}\n        />\n      ))}\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/prometheusmetric/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/prometheusmetric/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders blocks for configured metrics even when data is not yet available\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = {\n      widget: {\n        type: \"prometheusmetric\",\n        metrics: [\n          { label: \"prometheusmetric.metricA\", query: \"metric_a\" },\n          { label: \"prometheusmetric.metricB\", query: \"metric_b\" },\n        ],\n      },\n    };\n\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    // Component renders one Block per metric; with no widget.fields, Container does not filter.\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"prometheusmetric.metricA\")).toBeInTheDocument();\n    expect(screen.getByText(\"prometheusmetric.metricB\")).toBeInTheDocument();\n  });\n\n  it(\"formats scalar and vector query results (scale + prefix + suffix)\", () => {\n    useWidgetAPI.mockImplementation((_widget, _endpoint, params) => {\n      if (params?.query === \"scalar_q\") {\n        return {\n          data: { data: { resultType: \"scalar\", result: [0, \"5\"] } },\n          error: undefined,\n        };\n      }\n\n      if (params?.query === \"vector_q\") {\n        return {\n          data: { data: { resultType: \"vector\", result: [{ value: [0, \"3\"] }] } },\n          error: undefined,\n        };\n      }\n\n      return { data: undefined, error: undefined };\n    });\n\n    const service = {\n      widget: {\n        type: \"prometheusmetric\",\n        metrics: [\n          {\n            label: \"prometheusmetric.scalar\",\n            query: \"scalar_q\",\n            format: { type: \"number\", scale: 2, prefix: \"~\", suffix: \"x\" },\n          },\n          {\n            label: \"prometheusmetric.vector\",\n            query: \"vector_q\",\n            format: { type: \"number\", scale: \"1/2\" },\n          },\n        ],\n      },\n    };\n\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    // scalar \"5\" * 2 => 10 => \"~10x\"\n    expectBlockValue(container, \"prometheusmetric.scalar\", \"~10x\");\n    // vector \"3\" * (1/2) => 1.5\n    expectBlockValue(container, \"prometheusmetric.vector\", 1.5);\n  });\n\n  it(\"renders error UI when any query errors\", () => {\n    useWidgetAPI.mockImplementation((_widget, _endpoint, params) => {\n      if (params?.query === \"bad\") return { data: undefined, error: { message: \"nope\" } };\n      return { data: { data: { resultType: \"scalar\", result: [0, \"1\"] } }, error: undefined };\n    });\n\n    renderWithProviders(\n      <Component\n        service={{\n          widget: {\n            type: \"prometheusmetric\",\n            metrics: [\n              { label: \"prometheusmetric.ok\", query: \"ok\" },\n              { label: \"prometheusmetric.bad\", query: \"bad\" },\n            ],\n          },\n        }}\n      />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/prometheusmetric/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/v1/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    query: {\n      method: \"GET\",\n      endpoint: \"query\",\n      params: [\"query\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/prometheusmetric/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"prometheusmetric widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/prowlarr/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { data: grabsData, error: grabsError } = useWidgetAPI(widget, \"indexerstats\");\n\n  if (grabsError) {\n    return <Container service={service} error={grabsError} />;\n  }\n\n  if (!grabsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"prowlarr.numberOfGrabs\" />\n        <Block label=\"prowlarr.numberOfQueries\" />\n        <Block label=\"prowlarr.numberOfFailGrabs\" />\n        <Block label=\"prowlarr.numberOfFailQueries\" />\n      </Container>\n    );\n  }\n\n  let numberOfGrabs = 0;\n  let numberOfQueries = 0;\n  let numberOfFailedGrabs = 0;\n  let numberOfFailedQueries = 0;\n  grabsData?.indexers?.forEach((element) => {\n    numberOfGrabs += element.numberOfGrabs;\n    numberOfQueries += element.numberOfQueries;\n    numberOfFailedGrabs += element.numberOfFailedGrabs;\n    numberOfFailedQueries += element.numberOfFailedQueries;\n  });\n\n  return (\n    <Container service={service}>\n      <Block label=\"prowlarr.numberOfGrabs\" value={t(\"common.number\", { value: numberOfGrabs })} />\n      <Block label=\"prowlarr.numberOfQueries\" value={t(\"common.number\", { value: numberOfQueries })} />\n      <Block label=\"prowlarr.numberOfFailGrabs\" value={t(\"common.number\", { value: numberOfFailedGrabs })} />\n      <Block label=\"prowlarr.numberOfFailQueries\" value={t(\"common.number\", { value: numberOfFailedQueries })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/prowlarr/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/prowlarr/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"prowlarr\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"prowlarr.numberOfGrabs\")).toBeInTheDocument();\n    expect(screen.getByText(\"prowlarr.numberOfQueries\")).toBeInTheDocument();\n    expect(screen.getByText(\"prowlarr.numberOfFailGrabs\")).toBeInTheDocument();\n    expect(screen.getByText(\"prowlarr.numberOfFailQueries\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when endpoint errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"prowlarr\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"sums grabs/queries and failed counts across indexers\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        indexers: [\n          { numberOfGrabs: 1, numberOfQueries: 2, numberOfFailedGrabs: 3, numberOfFailedQueries: 4 },\n          { numberOfGrabs: 10, numberOfQueries: 20, numberOfFailedGrabs: 30, numberOfFailedQueries: 40 },\n        ],\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"prowlarr\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"prowlarr.numberOfGrabs\", 11);\n    expectBlockValue(container, \"prowlarr.numberOfQueries\", 22);\n    expectBlockValue(container, \"prowlarr.numberOfFailGrabs\", 33);\n    expectBlockValue(container, \"prowlarr.numberOfFailQueries\", 44);\n  });\n});\n"
  },
  {
    "path": "src/widgets/prowlarr/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/v1/{endpoint}?apikey={key}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    indexer: {\n      endpoint: \"indexer\",\n    },\n    indexerstats: {\n      endpoint: \"indexerstats\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/prowlarr/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"prowlarr widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/proxmox/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nfunction calcRunning(total, current) {\n  return current.status === \"running\" ? total + 1 : total;\n}\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: clusterData, error: clusterError } = useWidgetAPI(widget, \"cluster/resources\");\n\n  if (clusterError) {\n    return <Container service={service} error={clusterError} />;\n  }\n\n  if (!clusterData || !clusterData.data) {\n    return (\n      <Container service={service}>\n        <Block label=\"proxmox.vms\" />\n        <Block label=\"proxmox.lxc\" />\n        <Block label=\"resources.cpu\" />\n        <Block label=\"resources.mem\" />\n      </Container>\n    );\n  }\n\n  const { data } = clusterData;\n  const vms =\n    data.filter(\n      (item) => item.type === \"qemu\" && item.template === 0 && (widget.node === undefined || widget.node === item.node),\n    ) || [];\n  const lxc =\n    data.filter(\n      (item) => item.type === \"lxc\" && item.template === 0 && (widget.node === undefined || widget.node === item.node),\n    ) || [];\n  const nodes =\n    data.filter(\n      (item) =>\n        item.type === \"node\" && item.status === \"online\" && (widget.node === undefined || widget.node === item.node),\n    ) || [];\n  const runningVMs = vms.reduce(calcRunning, 0);\n  const runningLXC = lxc.reduce(calcRunning, 0);\n\n  if (nodes.length === 0) {\n    return (\n      <Container service={service}>\n        <Block label=\"proxmox.vms\" value={`${runningVMs} / ${vms.length}`} />\n        <Block label=\"proxmox.lxc\" value={`${runningLXC} / ${lxc.length}`} />\n        <Block label=\"resources.cpu\" />\n        <Block label=\"resources.mem\" />\n      </Container>\n    );\n  }\n\n  const maxMemory = nodes.reduce((sum, n) => n.maxmem + sum, 0);\n  const usedMemory = nodes.reduce((sum, n) => n.mem + sum, 0);\n  const maxCpu = nodes.reduce((sum, n) => n.maxcpu + sum, 0);\n  const usedCpu = nodes.reduce((sum, n) => n.cpu * n.maxcpu + sum, 0);\n\n  return (\n    <Container service={service}>\n      <Block label=\"proxmox.vms\" value={`${runningVMs} / ${vms.length}`} />\n      <Block label=\"proxmox.lxc\" value={`${runningLXC} / ${lxc.length}`} />\n      <Block\n        label=\"resources.cpu\"\n        value={t(\"common.percent\", { value: (usedCpu / maxCpu) * 100 })}\n        highlightValue={(usedCpu / maxCpu) * 100}\n      />\n      <Block\n        label=\"resources.mem\"\n        value={t(\"common.percent\", { value: (usedMemory / maxMemory) * 100 })}\n        highlightValue={(usedMemory / maxMemory) * 100}\n      />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/proxmox/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/proxmox/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"proxmox\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"proxmox.vms\")).toBeInTheDocument();\n    expect(screen.getByText(\"proxmox.lxc\")).toBeInTheDocument();\n    expect(screen.getByText(\"resources.cpu\")).toBeInTheDocument();\n    expect(screen.getByText(\"resources.mem\")).toBeInTheDocument();\n  });\n\n  it(\"renders VM/LXC totals and aggregated cpu/mem when nodes are present\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          { type: \"qemu\", template: 0, node: \"n1\", status: \"running\" },\n          { type: \"qemu\", template: 0, node: \"n1\", status: \"stopped\" },\n          { type: \"lxc\", template: 0, node: \"n1\", status: \"running\" },\n          { type: \"node\", node: \"n1\", status: \"online\", maxmem: 100, mem: 50, maxcpu: 4, cpu: 0.25 },\n        ],\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"proxmox\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"proxmox.vms\", \"1 / 2\");\n    expectBlockValue(container, \"proxmox.lxc\", \"1 / 1\");\n    // cpu% = (usedCpu / maxCpu)*100 = ((0.25*4)/4)*100 = 25\n    expectBlockValue(container, \"resources.cpu\", 25);\n    // mem% = (50/100)*100 = 50\n    expectBlockValue(container, \"resources.mem\", 50);\n  });\n});\n"
  },
  {
    "path": "src/widgets/proxmox/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api2/json/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    \"cluster/resources\": {\n      endpoint: \"cluster/resources\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/proxmox/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"proxmox widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/proxmoxbackupserver/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: datastoreData, error: datastoreError } = useWidgetAPI(widget, \"status/datastore-usage\");\n  const { data: tasksData, error: tasksError } = useWidgetAPI(widget, \"nodes/localhost/tasks\");\n  const { data: hostData, error: hostError } = useWidgetAPI(widget, \"nodes/localhost/status\");\n\n  if (datastoreError || tasksError || hostError) {\n    const finalError = tasksError ?? datastoreError ?? hostError;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!datastoreData || !tasksData || !hostData) {\n    return (\n      <Container service={service}>\n        <Block label=\"proxmoxbackupserver.datastore_usage\" />\n        <Block label=\"proxmoxbackupserver.failed_tasks_24h\" />\n        <Block label=\"proxmoxbackupserver.cpu_usage\" />\n        <Block label=\"proxmoxbackupserver.memory_usage\" />\n      </Container>\n    );\n  }\n\n  const datastoreIndex = !!widget.datastore\n    ? datastoreData.data.findIndex(function (ds) {\n        return ds.store == widget.datastore;\n      })\n    : -1;\n  const datastoreUsage =\n    datastoreIndex > -1\n      ? (datastoreData.data[datastoreIndex].used / datastoreData.data[datastoreIndex].total) * 100\n      : (datastoreData.data.reduce((sum, datastore) => sum + datastore.used, 0) /\n          datastoreData.data.reduce((sum, datastore) => sum + datastore.total, 0)) *\n        100;\n\n  const cpuUsage = hostData.data.cpu * 100;\n  const memoryUsage = (hostData.data.memory.used / hostData.data.memory.total) * 100;\n  const failedTasks = tasksData.total >= 100 ? \"99+\" : tasksData.total;\n\n  return (\n    <Container service={service}>\n      <Block\n        label=\"proxmoxbackupserver.datastore_usage\"\n        value={t(\"common.percent\", { value: datastoreUsage })}\n        highlightValue={datastoreUsage}\n      />\n      <Block label=\"proxmoxbackupserver.failed_tasks_24h\" value={failedTasks} />\n      <Block\n        label=\"proxmoxbackupserver.cpu_usage\"\n        value={t(\"common.percent\", { value: cpuUsage })}\n        highlightValue={cpuUsage}\n      />\n      <Block\n        label=\"proxmoxbackupserver.memory_usage\"\n        value={t(\"common.percent\", { value: memoryUsage })}\n        highlightValue={memoryUsage}\n      />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/proxmoxbackupserver/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/proxmoxbackupserver/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // datastore\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // tasks\n      .mockReturnValueOnce({ data: undefined, error: undefined }); // host\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"proxmoxbackupserver\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"proxmoxbackupserver.datastore_usage\")).toBeInTheDocument();\n    expect(screen.getByText(\"proxmoxbackupserver.failed_tasks_24h\")).toBeInTheDocument();\n    expect(screen.getByText(\"proxmoxbackupserver.cpu_usage\")).toBeInTheDocument();\n    expect(screen.getByText(\"proxmoxbackupserver.memory_usage\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when any endpoint errors\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: undefined })\n      .mockReturnValueOnce({ data: undefined, error: { message: \"nope\" } })\n      .mockReturnValueOnce({ data: undefined, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"proxmoxbackupserver\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n  });\n\n  it(\"renders computed values and caps failed tasks at 99+\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({\n        data: {\n          data: [\n            { store: \"ds1\", used: 50, total: 100 },\n            { store: \"ds2\", used: 25, total: 50 },\n          ],\n        },\n        error: undefined,\n      })\n      .mockReturnValueOnce({ data: { total: 1000 }, error: undefined })\n      .mockReturnValueOnce({ data: { data: { cpu: 0.2, memory: { used: 1, total: 4 } } }, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"proxmoxbackupserver\", datastore: \"ds2\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    // datastore usage for ds2: 25/50*100 = 50\n    expect(screen.getByText(\"50\")).toBeInTheDocument();\n    expect(screen.getByText(\"20\")).toBeInTheDocument(); // cpu usage\n    expect(screen.getByText(\"25\")).toBeInTheDocument(); // memory usage\n    expect(screen.getByText(\"99+\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/proxmoxbackupserver/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst since = Date.now() - 24 * 60 * 60 * 1000;\n\nconst widget = {\n  api: \"{url}/api2/json/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    \"status/datastore-usage\": {\n      endpoint: \"status/datastore-usage\",\n    },\n    \"nodes/localhost/tasks\": {\n      endpoint: `nodes/localhost/tasks?errors=true&limit=100&since=${since}`,\n    },\n    \"nodes/localhost/status\": {\n      endpoint: \"nodes/localhost/status\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/proxmoxbackupserver/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"proxmoxbackupserver widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/proxmoxvm/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\nimport useSWR from \"swr\";\n\nexport default function ProxmoxVM({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data, error } = useSWR(`/api/proxmox/stats/${widget.node}/${widget.vmid}?type=${widget.type || \"qemu\"}`);\n\n  if (error) {\n    return <Container service={service} error={error} />;\n  }\n\n  if (!data) {\n    return (\n      <Container service={service}>\n        <Block label=\"resources.cpu\" />\n        <Block label=\"resources.mem\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block\n        label=\"resources.cpu\"\n        value={t(\"common.percent\", { value: data.cpu * 100 })}\n        highlightValue={data.cpu * 100}\n      />\n      <Block label=\"resources.mem\" value={t(\"common.bytes\", { value: data.mem })} highlightValue={data.mem} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/proxmoxvm/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));\nvi.mock(\"swr\", () => ({ default: useSWR }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/proxmoxvm/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useSWR.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"proxmoxvm\", node: \"n1\", vmid: \"100\" } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"resources.cpu\")).toBeInTheDocument();\n    expect(screen.getByText(\"resources.mem\")).toBeInTheDocument();\n  });\n\n  it(\"renders cpu percent and mem bytes when loaded\", () => {\n    useSWR.mockReturnValue({ data: { cpu: 0.5, mem: 1024 }, error: undefined });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"proxmoxvm\", node: \"n1\", vmid: \"100\" } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expectBlockValue(container, \"resources.cpu\", 50);\n    expectBlockValue(container, \"resources.mem\", 1024);\n  });\n});\n"
  },
  {
    "path": "src/widgets/pterodactyl/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  const { data: nodesData, error: nodesError } = useWidgetAPI(widget, \"nodes\");\n\n  if (nodesError) {\n    return <Container service={service} error={nodesError} />;\n  }\n\n  if (!nodesData) {\n    return (\n      <Container service={service}>\n        <Block label=\"pterodactyl.nodes\" />\n        <Block label=\"pterodactyl.servers\" />\n      </Container>\n    );\n  }\n\n  const totalServers = nodesData.data.reduce(\n    (total, node) => (node.attributes?.relationships?.servers?.data?.length ?? 0) + total,\n    0,\n  );\n\n  return (\n    <Container service={service}>\n      <Block label=\"pterodactyl.nodes\" value={nodesData.data.length} />\n      <Block label=\"pterodactyl.servers\" value={totalServers} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/pterodactyl/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/pterodactyl/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"pterodactyl\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"pterodactyl.nodes\")).toBeInTheDocument();\n    expect(screen.getByText(\"pterodactyl.servers\")).toBeInTheDocument();\n  });\n\n  it(\"renders nodes and derived servers total when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          { attributes: { relationships: { servers: { data: [{ id: 1 }, { id: 2 }] } } } },\n          { attributes: { relationships: { servers: { data: [{ id: 3 }] } } } },\n        ],\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"pterodactyl\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"pterodactyl.nodes\", 2);\n    expectBlockValue(container, \"pterodactyl.servers\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/pterodactyl/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/application/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    nodes: {\n      endpoint: \"nodes?include=servers\",\n      validate: [\"data\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/pterodactyl/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"pterodactyl widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/pyload/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const { data: pyloadData, error: pyloadError } = useWidgetAPI(widget, \"status\");\n\n  if (pyloadError) {\n    return <Container service={service} error={pyloadError} />;\n  }\n\n  if (!pyloadData) {\n    return (\n      <Container service={service}>\n        <Block label=\"pyload.speed\" />\n        <Block label=\"pyload.active\" />\n        <Block label=\"pyload.queue\" />\n        <Block label=\"pyload.total\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block\n        label=\"pyload.speed\"\n        value={t(\"common.byterate\", { value: pyloadData.speed })}\n        highlightValue={pyloadData.speed}\n      />\n      <Block label=\"pyload.active\" value={t(\"common.number\", { value: pyloadData.active })} />\n      <Block label=\"pyload.queue\" value={t(\"common.number\", { value: pyloadData.queue })} />\n      <Block label=\"pyload.total\" value={t(\"common.number\", { value: pyloadData.total })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/pyload/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/pyload/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"pyload\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"pyload.speed\")).toBeInTheDocument();\n    expect(screen.getByText(\"pyload.active\")).toBeInTheDocument();\n    expect(screen.getByText(\"pyload.queue\")).toBeInTheDocument();\n    expect(screen.getByText(\"pyload.total\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when endpoint errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"pyload\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders status counts when loaded\", () => {\n    useWidgetAPI.mockReturnValue({ data: { speed: 100, active: 1, queue: 2, total: 3 }, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"pyload\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"pyload.speed\", 100);\n    expectBlockValue(container, \"pyload.active\", 1);\n    expectBlockValue(container, \"pyload.queue\", 2);\n    expectBlockValue(container, \"pyload.total\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/pyload/proxy.js",
    "content": "import cache from \"memory-cache\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst proxyName = \"pyloadProxyHandler\";\nconst logger = createLogger(proxyName);\nconst sessionCacheKey = `${proxyName}__sessionId`;\nconst isNgCacheKey = `${proxyName}__isNg`;\n\nfunction parsePyloadResponse(url, data) {\n  try {\n    return JSON.parse(Buffer.from(data).toString());\n  } catch (e) {\n    logger.error(`Error communicating with pyload API at ${url}, returned: ${JSON.stringify(data)}`);\n    return data;\n  }\n}\n\nasync function fetchFromPyloadAPI(url, sessionId, params, service) {\n  const options = {\n    body: params\n      ? Object.keys(params)\n          .map((prop) => `${prop}=${encodeURIComponent(params[prop])}`)\n          .join(\"&\")\n      : `session=${sessionId}`,\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/x-www-form-urlencoded\",\n    },\n  };\n\n  // see https://github.com/gethomepage/homepage/issues/517\n  const isNg = cache.get(`${isNgCacheKey}.${service}`);\n  if (isNg && !params) {\n    delete options.body;\n    options.headers.Cookie = cache.get(`${sessionCacheKey}.${service}`);\n  }\n\n  const [status, contentType, data, responseHeaders] = await httpProxy(url, options);\n  const returnData = parsePyloadResponse(url, data);\n  return [status, returnData, responseHeaders];\n}\n\nasync function fetchFromPyloadAPIBasic(url, params, username, password) {\n  const parsedUrl = new URL(url);\n  const isGetRequest = !params || Object.keys(params).length === 0;\n\n  const options = {\n    method: isGetRequest ? \"GET\" : \"POST\",\n    headers: {\n      Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString(\"base64\")}`,\n    },\n  };\n\n  if (isGetRequest) {\n    if (params) {\n      Object.keys(params).forEach((key) => parsedUrl.searchParams.append(key, params[key]));\n    }\n  } else {\n    options.headers[\"Content-Type\"] = \"application/json\";\n    options.body = JSON.stringify(params);\n  }\n\n  const [status, contentType, data, responseHeaders] = await httpProxy(parsedUrl, options);\n  const returnData = parsePyloadResponse(parsedUrl, data);\n  return [status, returnData, responseHeaders];\n}\n\nasync function login(loginUrl, service, username, password = \"\") {\n  const [status, sessionId, responseHeaders] = await fetchFromPyloadAPI(\n    loginUrl,\n    null,\n    { username, password },\n    service,\n  );\n\n  // this API actually returns status 200 even on login failure\n  if (status !== 200 || sessionId === false) {\n    logger.error(`HTTP ${status} logging into Pyload API, returned: ${JSON.stringify(sessionId)}`);\n  } else if (responseHeaders[\"set-cookie\"]?.join().includes(\"pyload_session\")) {\n    // Support pyload-ng, see https://github.com/gethomepage/homepage/issues/517\n    cache.put(`${isNgCacheKey}.${service}`, true);\n    const sessionCookie = responseHeaders[\"set-cookie\"][0];\n    cache.put(`${sessionCacheKey}.${service}`, sessionCookie, 60 * 60 * 23 * 1000); // cache for 23h\n  } else {\n    cache.put(`${sessionCacheKey}.${service}`, sessionId);\n  }\n\n  return sessionId;\n}\n\nexport default async function pyloadProxyHandler(req, res, map = {}) {\n  const { group, service, endpoint, index } = req.query;\n  const { ngEndpoint } = map;\n\n  try {\n    if (group && service) {\n      const widget = await getServiceWidget(group, service, index);\n\n      if (widget) {\n        const apiTemplate = widgets[widget.type].api;\n        const url = new URL(formatApiCall(apiTemplate, { endpoint, ...widget }));\n        const ngUrl = ngEndpoint ? new URL(formatApiCall(apiTemplate, { endpoint: ngEndpoint, ...widget })) : url;\n        const loginUrl = `${widget.url}/api/login`;\n        const hasCredentials = widget.username && widget.password;\n\n        if (hasCredentials) {\n          const [status, data] = await fetchFromPyloadAPIBasic(ngUrl, null, widget.username, widget.password);\n\n          if (status === 200 && !data?.error) {\n            cache.put(`${isNgCacheKey}.${service}`, true);\n            return res.json(data);\n          }\n\n          if (status === 401) {\n            return res\n              .status(status)\n              .send({ error: { message: \"Invalid credentials communicating with Pyload API\", data } });\n          }\n        }\n\n        let sessionId =\n          cache.get(`${sessionCacheKey}.${service}`) ??\n          (await login(loginUrl, service, widget.username, widget.password));\n        let [status, data] = await fetchFromPyloadAPI(url, sessionId, null, service);\n\n        if (status === 403 || status === 401 || (status === 400 && data?.error?.includes(\"CSRF token\"))) {\n          logger.info(\"Failed to retrieve data from Pyload API with session auth, trying to login again...\");\n          cache.del(`${sessionCacheKey}.${service}`);\n          sessionId = await login(loginUrl, service, widget.username, widget.password);\n          [status, data] = await fetchFromPyloadAPI(url, sessionId, null, service);\n        }\n\n        if (data?.error || status !== 200) {\n          try {\n            return res.status(status).send({\n              error: { message: \"HTTP error communicating with Pyload API\", data: Buffer.from(data).toString() },\n            });\n          } catch (e) {\n            return res.status(status).send({ error: { message: \"HTTP error communicating with Pyload API\", data } });\n          }\n        }\n\n        return res.json(data);\n      }\n    }\n  } catch (e) {\n    if (e) logger.error(e);\n    return res.status(500).send({ error: { message: `Error communicating with Pyload API: ${e.toString()}` } });\n  }\n\n  return res.status(400).json({ error: \"Invalid proxy service type\" });\n}\n"
  },
  {
    "path": "src/widgets/pyload/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {\n  const store = new Map();\n\n  return {\n    httpProxy: vi.fn(),\n    getServiceWidget: vi.fn(),\n    cache: {\n      get: vi.fn((k) => store.get(k)),\n      put: vi.fn((k, v) => store.set(k, v)),\n      del: vi.fn((k) => store.delete(k)),\n      _reset: () => store.clear(),\n    },\n    logger: {\n      error: vi.fn(),\n      info: vi.fn(),\n    },\n  };\n});\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nvi.mock(\"memory-cache\", () => ({\n  default: cache,\n  ...cache,\n}));\n\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    pyload: {\n      api: \"{url}/api/{endpoint}\",\n    },\n  },\n}));\n\nimport pyloadProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/pyload/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    cache._reset();\n  });\n\n  it(\"uses Basic auth when credentials work and returns data\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"pyload\",\n      url: \"http://pyload\",\n      username: \"u\",\n      password: \"p\",\n    });\n\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ ok: true })), {}]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"status\", index: \"0\" } };\n    const res = createMockRes();\n\n    await pyloadProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(1);\n    expect(httpProxy.mock.calls[0][1].headers.Authorization).toMatch(/^Basic /);\n    expect(cache.put).toHaveBeenCalledWith(\"pyloadProxyHandler__isNg.svc\", true);\n    expect(res.body).toEqual({ ok: true });\n  });\n\n  it(\"retries after 403 by clearing session and logging in again\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"pyload\",\n      url: \"http://pyload\",\n      username: \"u\",\n      password: \"\",\n    });\n\n    httpProxy\n      // login -> sessionId\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify(\"sid1\")), {}])\n      // fetch -> unauthorized\n      .mockResolvedValueOnce([403, \"application/json\", Buffer.from(JSON.stringify({ error: \"bad\" })), {}])\n      // relogin -> sessionId\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify(\"sid2\")), {}])\n      // retry fetch -> ok\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ ok: true })), {}]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"status\", index: \"0\" } };\n    const res = createMockRes();\n\n    await pyloadProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(4);\n    expect(cache.del).toHaveBeenCalledWith(\"pyloadProxyHandler__sessionId.svc\");\n    expect(res.body).toEqual({ ok: true });\n  });\n});\n"
  },
  {
    "path": "src/widgets/pyload/widget.js",
    "content": "import pyloadProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: pyloadProxyHandler,\n\n  mappings: {\n    status: {\n      endpoint: \"statusServer\",\n      map: { ngEndpoint: \"status_server\" },\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/pyload/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"pyload widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/qbittorrent/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport QueueEntry from \"../../components/widgets/queue/queueEntry\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { data: transferData, error: transferError } = useWidgetAPI(widget, \"transfer\");\n  const { data: totalCountData, error: totalCountError } = useWidgetAPI(widget, \"torrentCount\");\n  const { data: completedCountData, error: completedCountError } = useWidgetAPI(widget, \"torrentCount\", {\n    filter: \"completed\",\n  });\n  const { data: leechTorrentData, error: leechTorrentError } = useWidgetAPI(\n    widget,\n    widget?.enableLeechProgress ? \"torrents\" : \"\",\n    widget?.enableLeechProgress ? { filter: \"downloading\" } : undefined,\n  );\n\n  const apiError = transferError || totalCountError || completedCountError || leechTorrentError;\n  if (apiError) {\n    return <Container service={service} error={apiError} />;\n  }\n\n  if (\n    !transferData ||\n    totalCountData === undefined ||\n    completedCountData === undefined ||\n    (widget?.enableLeechProgress && !leechTorrentData)\n  ) {\n    return (\n      <Container service={service}>\n        <Block label=\"qbittorrent.leech\" />\n        <Block label=\"qbittorrent.download\" />\n        <Block label=\"qbittorrent.seed\" />\n        <Block label=\"qbittorrent.upload\" />\n      </Container>\n    );\n  }\n\n  const rateDl = Number(transferData?.dl_info_speed ?? 0);\n  const rateUl = Number(transferData?.up_info_speed ?? 0);\n  const totalCount = Number(totalCountData?.all ?? totalCountData?.count ?? totalCountData ?? 0);\n  const completedCount = Number(\n    completedCountData?.completed ?? completedCountData?.count ?? completedCountData?.all ?? completedCountData ?? 0,\n  );\n  const leech = Math.max(0, totalCount - completedCount);\n\n  const leechTorrents = Array.isArray(leechTorrentData) ? [...leechTorrentData] : [];\n  const statePriority = [\n    \"downloading\",\n    \"forcedDL\",\n    \"metaDL\",\n    \"forcedMetaDL\",\n    \"checkingDL\",\n    \"stalledDL\",\n    \"queuedDL\",\n    \"pausedDL\",\n  ];\n  leechTorrents.sort((firstTorrent, secondTorrent) => {\n    const firstStateIndex = statePriority.indexOf(firstTorrent.state);\n    const secondStateIndex = statePriority.indexOf(secondTorrent.state);\n    if (firstStateIndex !== secondStateIndex) {\n      return firstStateIndex - secondStateIndex;\n    }\n    return secondTorrent.progress - firstTorrent.progress;\n  });\n\n  return (\n    <>\n      <Container service={service}>\n        <Block label=\"qbittorrent.leech\" value={t(\"common.number\", { value: leech })} />\n        <Block\n          label=\"qbittorrent.download\"\n          value={t(\"common.bibyterate\", { value: rateDl, decimals: 1 })}\n          highlightValue={rateDl}\n        />\n        <Block label=\"qbittorrent.seed\" value={t(\"common.number\", { value: completedCount })} />\n        <Block\n          label=\"qbittorrent.upload\"\n          value={t(\"common.bibyterate\", { value: rateUl, decimals: 1 })}\n          highlightValue={rateUl}\n        />\n      </Container>\n      {widget?.enableLeechProgress &&\n        leechTorrents.map((queueEntry) => (\n          <QueueEntry\n            progress={queueEntry.progress * 100}\n            timeLeft={t(\"common.duration\", { value: queueEntry.eta })}\n            title={queueEntry.name}\n            activity={queueEntry.state}\n            size={\n              widget?.enableLeechSize\n                ? t(\"common.bbytes\", { value: queueEntry.size, maximumFractionDigits: 1 })\n                : undefined\n            }\n            key={`${queueEntry.name}-${queueEntry.amount_left}`}\n          />\n        ))}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/widgets/qbittorrent/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nvi.mock(\"../../components/widgets/queue/queueEntry\", () => ({\n  default: ({ title }) => <div data-testid=\"queue-entry\">{title}</div>,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/qbittorrent/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"qbittorrent\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"qbittorrent.leech\")).toBeInTheDocument();\n    expect(screen.getByText(\"qbittorrent.download\")).toBeInTheDocument();\n    expect(screen.getByText(\"qbittorrent.seed\")).toBeInTheDocument();\n    expect(screen.getByText(\"qbittorrent.upload\")).toBeInTheDocument();\n  });\n\n  it(\"uses lightweight endpoints for counts/rates and filtered torrents for leech progress\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint, query) => {\n      if (endpoint === \"transfer\") {\n        return { data: { dl_info_speed: 15, up_info_speed: 3 }, error: undefined };\n      }\n      if (endpoint === \"torrentCount\" && !query) {\n        return { data: 2, error: undefined };\n      }\n      if (endpoint === \"torrentCount\" && query?.filter === \"completed\") {\n        return { data: 1, error: undefined };\n      }\n      if (endpoint === \"torrents\" && query?.filter === \"downloading\") {\n        return {\n          data: [\n            {\n              name: \"B\",\n              progress: 0.5,\n              state: \"downloading\",\n              eta: 60,\n              size: 100,\n              amount_left: 50,\n            },\n          ],\n          error: undefined,\n        };\n      }\n      return { data: undefined, error: undefined };\n    });\n\n    const service = { widget: { type: \"qbittorrent\", enableLeechProgress: true } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expectBlockValue(container, \"qbittorrent.leech\", 1);\n    expectBlockValue(container, \"qbittorrent.seed\", 1);\n    expectBlockValue(container, \"qbittorrent.download\", 15);\n    expectBlockValue(container, \"qbittorrent.upload\", 3);\n\n    expect(screen.getAllByTestId(\"queue-entry\").map((el) => el.textContent)).toEqual([\"B\"]);\n  });\n});\n"
  },
  {
    "path": "src/widgets/qbittorrent/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\n\nconst logger = createLogger(\"qbittorrentProxyHandler\");\n\nasync function login(widget) {\n  logger.debug(\"qBittorrent is rejecting the request, logging in.\");\n  const loginUrl = new URL(`${widget.url}/api/v2/auth/login`).toString();\n  const loginBody = `username=${encodeURIComponent(widget.username)}&password=${encodeURIComponent(widget.password)}`;\n  const loginParams = {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n    body: loginBody,\n  };\n\n  const [status, contentType, data] = await httpProxy(loginUrl, loginParams);\n  return [status, data];\n}\n\nexport default async function qbittorrentProxyHandler(req, res) {\n  const { group, service, endpoint, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const url = new URL(formatApiCall(\"{url}/api/v2/{endpoint}\", { endpoint, ...widget }));\n  const params = { method: \"GET\", headers: {} };\n\n  let [status, contentType, data] = await httpProxy(url, params);\n  if (status === 403) {\n    [status, data] = await login(widget);\n\n    if (status !== 200) {\n      logger.error(\"HTTP %d logging in to qBittorrent.  Data: %s\", status, data);\n      return res.status(status).end(data);\n    }\n\n    if (data.toString() !== \"Ok.\") {\n      logger.error(\"Error logging in to qBittorrent: Data: %s\", data);\n      return res.status(401).end(data);\n    }\n\n    [status, contentType, data] = await httpProxy(url, params);\n  }\n\n  if (status !== 200) {\n    logger.error(\"HTTP %d getting data from qBittorrent.  Data: %s\", status, data);\n  }\n\n  if (contentType) res.setHeader(\"Content-Type\", contentType);\n  return res.status(status).send(data);\n}\n"
  },
  {
    "path": "src/widgets/qbittorrent/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  logger: {\n    debug: vi.fn(),\n    error: vi.fn(),\n  },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nimport qbittorrentProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/qbittorrent/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"logs in and retries after a 403 response\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://qb\", username: \"u\", password: \"p\" });\n\n    httpProxy\n      .mockResolvedValueOnce([403, \"application/json\", Buffer.from(\"nope\")])\n      .mockResolvedValueOnce([200, \"text/plain\", Buffer.from(\"Ok.\")])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"data\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"torrents/info\", index: \"0\" } };\n    const res = createMockRes();\n\n    await qbittorrentProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(3);\n    expect(httpProxy.mock.calls[1][0]).toBe(\"http://qb/api/v2/auth/login\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual(Buffer.from(\"data\"));\n  });\n\n  it(\"returns 401 when login succeeds but response body is not Ok.\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://qb\", username: \"u\", password: \"p\" });\n\n    httpProxy\n      .mockResolvedValueOnce([403, \"application/json\", Buffer.from(\"nope\")])\n      .mockResolvedValueOnce([200, \"text/plain\", Buffer.from(\"Denied\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"torrents/info\", index: \"0\" } };\n    const res = createMockRes();\n\n    await qbittorrentProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(401);\n    expect(res.body).toEqual(Buffer.from(\"Denied\"));\n  });\n});\n"
  },
  {
    "path": "src/widgets/qbittorrent/widget.js",
    "content": "import qbittorrentProxyHandler from \"./proxy\";\n\nconst widget = {\n  proxyHandler: qbittorrentProxyHandler,\n\n  mappings: {\n    transfer: {\n      endpoint: \"transfer/info\",\n    },\n    torrentCount: {\n      endpoint: \"torrents/count\",\n      optionalParams: [\"filter\"],\n    },\n    torrents: {\n      endpoint: \"torrents/info\",\n      optionalParams: [\"filter\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/qbittorrent/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"qbittorrent widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/qnap/component.jsx",
    "content": "/* eslint no-underscore-dangle: [\"error\", { \"allow\": [\"_text\", \"_cdata\"] }] */\n\nimport Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation(\"common\");\n\n  const { widget } = service;\n\n  const { data: statusData, error: statusError } = useWidgetAPI(widget, \"status\");\n\n  if (statusError) {\n    return <Container service={service} error={statusError} />;\n  }\n\n  if (!statusData) {\n    return (\n      <Container service={service}>\n        <Block label=\"qnap.cpuUsage\" />\n        <Block label=\"qnap.memUsage\" />\n        <Block label=\"qnap.systemTempC\" />\n        <Block label={widget.volume ? \"qnap.volumeUsage\" : \"qnap.poolUsage\"} />\n      </Container>\n    );\n  }\n\n  const cpuUsage = statusData.system.cpu_usage._cdata.replace(\" %\", \"\");\n  const totalMemory = statusData.system.total_memory._cdata;\n  const freeMemory = statusData.system.free_memory._cdata;\n  const systemTempC = statusData.system.sys_tempc._text;\n  let volumeTotalSize = 0;\n  let volumeFreeSize = 0;\n  let validVolume = true;\n\n  if (Array.isArray(statusData.volume.volumeUseList.volumeUse)) {\n    if (widget.volume) {\n      const volumeSelected = statusData.volume.volumeList.volume.findIndex(\n        (vl) => vl.volumeLabel._cdata === widget.volume,\n      );\n      if (volumeSelected !== -1) {\n        volumeTotalSize = statusData.volume.volumeUseList.volumeUse[volumeSelected].total_size._cdata;\n        volumeFreeSize = statusData.volume.volumeUseList.volumeUse[volumeSelected].free_size._cdata;\n      } else {\n        validVolume = false;\n      }\n    } else {\n      statusData.volume.volumeUseList.volumeUse.forEach((volume) => {\n        volumeTotalSize += parseInt(volume.total_size._cdata, 10);\n        volumeFreeSize += parseInt(volume.free_size._cdata, 10);\n      });\n    }\n  } else {\n    volumeTotalSize = statusData.volume.volumeUseList.volumeUse.total_size._cdata;\n    volumeFreeSize = statusData.volume.volumeUseList.volumeUse.free_size._cdata;\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"qnap.cpuUsage\" value={t(\"common.percent\", { value: cpuUsage })} />\n      <Block\n        label=\"qnap.memUsage\"\n        value={t(\"common.percent\", { value: (((totalMemory - freeMemory) / totalMemory) * 100).toFixed(0) })}\n      />\n      <Block\n        label=\"qnap.systemTempC\"\n        value={t(\"common.number\", { value: systemTempC, maximumFractionDigits: 1, style: \"unit\", unit: \"celsius\" })}\n      />\n      <Block\n        label={widget.volume ? \"qnap.volumeUsage\" : \"qnap.poolUsage\"}\n        value={\n          validVolume\n            ? t(\"common.percent\", { value: (((volumeTotalSize - volumeFreeSize) / volumeTotalSize) * 100).toFixed(0) })\n            : t(\"qnap.invalid\")\n        }\n      />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/qnap/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/qnap/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"qnap\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"qnap.cpuUsage\")).toBeInTheDocument();\n    expect(screen.getByText(\"qnap.memUsage\")).toBeInTheDocument();\n    expect(screen.getByText(\"qnap.systemTempC\")).toBeInTheDocument();\n    expect(screen.getByText(\"qnap.poolUsage\")).toBeInTheDocument();\n  });\n\n  it(\"renders computed mem and pool usage for multi-volume payload\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        system: {\n          cpu_usage: { _cdata: \"50 %\" },\n          total_memory: { _cdata: 100 },\n          free_memory: { _cdata: 25 },\n          sys_tempc: { _text: 40 },\n        },\n        volume: {\n          volumeUseList: { volumeUse: [{ total_size: { _cdata: \"100\" }, free_size: { _cdata: \"50\" } }] },\n          volumeList: { volume: [{ volumeLabel: { _cdata: \"DataVol1\" } }] },\n        },\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"qnap\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"qnap.cpuUsage\", \"50\");\n    // mem% = ((100-25)/100)*100 = 75\n    expectBlockValue(container, \"qnap.memUsage\", \"75\");\n    // pool% = ((100-50)/100)*100 = 50\n    expectBlockValue(container, \"qnap.poolUsage\", \"50\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/qnap/proxy.js",
    "content": "/* eslint no-underscore-dangle: [\"error\", { \"allow\": [\"_text\", \"_cdata\"] }] */\n\nimport cache from \"memory-cache\";\nimport { xml2json } from \"xml-js\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\n\nconst proxyName = \"qnapProxyHandler\";\nconst sessionTokenCacheKey = `${proxyName}__sessionToken`;\nconst logger = createLogger(proxyName);\n\nasync function login(widget, service) {\n  const endpoint = \"{url}/cgi-bin/authLogin.cgi\";\n  const loginUrl = new URL(formatApiCall(endpoint, widget));\n  const headers = { \"Content-Type\": \"application/x-www-form-urlencoded\" };\n\n  const [, , data] = await httpProxy(loginUrl, {\n    method: \"POST\",\n    body: new URLSearchParams({\n      user: widget.username,\n      pwd: Buffer.from(`${widget.password}`).toString(\"base64\"),\n    }).toString(),\n    headers,\n  });\n\n  try {\n    const dataDecoded = xml2json(data.toString(), { compact: true });\n    const jsonData = JSON.parse(dataDecoded);\n    const token = jsonData.QDocRoot.authSid._cdata;\n    cache.put(`${sessionTokenCacheKey}.${service}`, token);\n    return { token };\n  } catch (e) {\n    logger.error(\"Unable to login to QNAP API: %s\", e);\n  }\n\n  return { token: false };\n}\n\nasync function apiCall(widget, endpoint, service) {\n  let key = cache.get(`${sessionTokenCacheKey}.${service}`);\n\n  let apiUrl = new URL(formatApiCall(`${endpoint}&sid=${key}`, widget));\n  let [status, contentType, data, responseHeaders] = await httpProxy(apiUrl);\n\n  if (status === 404) {\n    logger.error(\"QNAP API rejected the request, attempting to obtain new session token\");\n    key = await login(widget, service);\n    apiUrl = new URL(formatApiCall(`${endpoint}&sid=${key}`, widget));\n    [status, contentType, data, responseHeaders] = await httpProxy(apiUrl);\n  }\n\n  if (status !== 200) {\n    logger.error(\"Error getting data from QNAP: %s status %d. Data: %s\", apiUrl, status, data);\n    return { status, contentType, data: null, responseHeaders };\n  }\n\n  let dataDecoded = JSON.parse(xml2json(data.toString(), { compact: true }).toString());\n\n  if (dataDecoded.QDocRoot.authPassed._cdata === \"0\") {\n    logger.error(\"QNAP API rejected the request, attempting to obtain new session token\");\n    key = await login(widget, service);\n    apiUrl = new URL(formatApiCall(`${endpoint}&sid=${key}`, widget));\n    [status, contentType, data, responseHeaders] = await httpProxy(apiUrl);\n\n    if (status !== 200) {\n      logger.error(\"Error getting data from QNAP: %s status %d. Data: %s\", apiUrl, status, data);\n      return { status, contentType, data: null, responseHeaders };\n    }\n\n    dataDecoded = JSON.parse(xml2json(data.toString(), { compact: true }).toString());\n  }\n\n  return { status, contentType, data: dataDecoded, responseHeaders };\n}\n\nexport default async function qnapProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  if (!cache.get(`${sessionTokenCacheKey}.${service}`)) {\n    await login(widget, service);\n  }\n\n  const { data: systemStatsData } = await apiCall(\n    widget,\n    \"{url}/cgi-bin/management/manaRequest.cgi?subfunc=sysinfo&hd=no&multicpu=1\",\n    service,\n  );\n  const { data: volumeStatsData } = await apiCall(\n    widget,\n    \"{url}/cgi-bin/management/chartReq.cgi?chart_func=disk_usage&disk_select=all&include=all\",\n    service,\n  );\n\n  return res.status(200).send({\n    system: systemStatsData.QDocRoot.func.ownContent.root,\n    volume: volumeStatsData.QDocRoot,\n  });\n}\n"
  },
  {
    "path": "src/widgets/qnap/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, cache, xml2json, logger } = vi.hoisted(() => {\n  const store = new Map();\n  return {\n    httpProxy: vi.fn(),\n    getServiceWidget: vi.fn(),\n    cache: {\n      get: vi.fn((k) => store.get(k)),\n      put: vi.fn((k, v) => store.set(k, v)),\n      del: vi.fn((k) => store.delete(k)),\n      _reset: () => store.clear(),\n    },\n    xml2json: vi.fn((xml) => {\n      if (xml === \"login\") {\n        return JSON.stringify({ QDocRoot: { authSid: { _cdata: \"sid1\" } } });\n      }\n      if (xml === \"system\") {\n        return JSON.stringify({\n          QDocRoot: {\n            authPassed: { _cdata: \"1\" },\n            func: { ownContent: { root: { cpu: 1 } } },\n          },\n        });\n      }\n      if (xml === \"volume\") {\n        return JSON.stringify({ QDocRoot: { authPassed: { _cdata: \"1\" }, volume: { ok: true } } });\n      }\n      return JSON.stringify({ QDocRoot: { authPassed: { _cdata: \"1\" } } });\n    }),\n    logger: { debug: vi.fn(), error: vi.fn() },\n  };\n});\n\nvi.mock(\"memory-cache\", () => ({\n  default: cache,\n  ...cache,\n}));\nvi.mock(\"xml-js\", () => ({\n  xml2json,\n}));\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nimport qnapProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/qnap/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    cache._reset();\n  });\n\n  it(\"logs in and returns system + volume data\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://qnap\", username: \"u\", password: \"p\" });\n\n    httpProxy\n      // login\n      .mockResolvedValueOnce([200, \"application/xml\", Buffer.from(\"login\")])\n      // system\n      .mockResolvedValueOnce([200, \"application/xml\", Buffer.from(\"system\")])\n      // volume\n      .mockResolvedValueOnce([200, \"application/xml\", Buffer.from(\"volume\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await qnapProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body.system).toEqual({ cpu: 1 });\n    expect(res.body.volume).toEqual(expect.objectContaining({ authPassed: { _cdata: \"1\" } }));\n  });\n});\n"
  },
  {
    "path": "src/widgets/qnap/widget.js",
    "content": "import qnapProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}\",\n  proxyHandler: qnapProxyHandler,\n  allowedEndpoints: /status/,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/qnap/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"qnap widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/radarr/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\nimport { useCallback } from \"react\";\n\nimport QueueEntry from \"../../components/widgets/queue/queueEntry\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nfunction getProgress(sizeLeft, size) {\n  return sizeLeft === 0 ? 100 : (1 - sizeLeft / size) * 100;\n}\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { data: moviesData, error: moviesError } = useWidgetAPI(widget, \"movie\");\n  const { data: queuedData, error: queuedError } = useWidgetAPI(widget, \"queue/status\");\n  const { data: queueDetailsData, error: queueDetailsError } = useWidgetAPI(widget, \"queue/details\");\n\n  const formatDownloadState = useCallback((downloadState) => {\n    switch (downloadState) {\n      case \"importPending\":\n        return \"import pending\";\n      case \"failedPending\":\n        return \"failed pending\";\n      default:\n        return downloadState;\n    }\n  }, []);\n\n  if (moviesError || queuedError || queueDetailsError) {\n    const finalError = moviesError ?? queuedError ?? queueDetailsError;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!moviesData || !queuedData || !queueDetailsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"radarr.wanted\" />\n        <Block label=\"radarr.missing\" />\n        <Block label=\"radarr.queued\" />\n        <Block label=\"radarr.movies\" />\n      </Container>\n    );\n  }\n\n  const enableQueue = widget?.enableQueue && Array.isArray(queueDetailsData) && queueDetailsData.length > 0;\n\n  return (\n    <>\n      <Container service={service}>\n        <Block label=\"radarr.wanted\" value={t(\"common.number\", { value: moviesData.wanted })} />\n        <Block label=\"radarr.missing\" value={t(\"common.number\", { value: moviesData.missing })} />\n        <Block label=\"radarr.queued\" value={t(\"common.number\", { value: queuedData.totalCount })} />\n        <Block label=\"radarr.movies\" value={t(\"common.number\", { value: moviesData.have })} />\n      </Container>\n      {enableQueue &&\n        queueDetailsData.map((queueEntry) => (\n          <QueueEntry\n            progress={getProgress(queueEntry.sizeLeft, queueEntry.size)}\n            timeLeft={queueEntry.timeLeft}\n            title={moviesData.all.find((entry) => entry.id === queueEntry.movieId)?.title ?? t(\"radarr.unknown\")}\n            activity={formatDownloadState(queueEntry.trackedDownloadState)}\n            key={`${queueEntry.movieId}-${queueEntry.sizeLeft}`}\n          />\n        ))}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/widgets/radarr/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nvi.mock(\"../../components/widgets/queue/queueEntry\", () => ({\n  default: ({ title }) => <div data-testid=\"queue-entry\">{title}</div>,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/radarr/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"radarr\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"radarr.wanted\")).toBeInTheDocument();\n    expect(screen.getByText(\"radarr.missing\")).toBeInTheDocument();\n    expect(screen.getByText(\"radarr.queued\")).toBeInTheDocument();\n    expect(screen.getByText(\"radarr.movies\")).toBeInTheDocument();\n  });\n\n  it(\"renders counts and queue entries when enabled\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"movie\")\n        return { data: { wanted: 1, missing: 2, have: 3, all: [{ id: 10, title: \"Movie\" }] }, error: undefined };\n      if (endpoint === \"queue/status\") return { data: { totalCount: 1 }, error: undefined };\n      if (endpoint === \"queue/details\")\n        return {\n          data: [{ movieId: 10, sizeLeft: 50, size: 100, timeLeft: \"1m\", trackedDownloadState: \"importPending\" }],\n          error: undefined,\n        };\n      return { data: undefined, error: undefined };\n    });\n\n    const service = { widget: { type: \"radarr\", enableQueue: true } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expectBlockValue(container, \"radarr.wanted\", 1);\n    expectBlockValue(container, \"radarr.missing\", 2);\n    expectBlockValue(container, \"radarr.queued\", 1);\n    expectBlockValue(container, \"radarr.movies\", 3);\n    expect(screen.getAllByTestId(\"queue-entry\").map((el) => el.textContent)).toEqual([\"Movie\"]);\n  });\n});\n"
  },
  {
    "path": "src/widgets/radarr/widget.js",
    "content": "import { asJson, jsonArrayFilter } from \"utils/proxy/api-helpers\";\nimport genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/v3/{endpoint}?apikey={key}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    movie: {\n      endpoint: \"movie\",\n      map: (data) => ({\n        wanted: jsonArrayFilter(data, (item) => item.monitored && !item.hasFile && item.isAvailable).length,\n        have: jsonArrayFilter(data, (item) => item.hasFile).length,\n        missing: jsonArrayFilter(data, (item) => item.monitored && !item.hasFile).length,\n        all: asJson(data).map((entry) => ({\n          title: entry.title,\n          id: entry.id,\n        })),\n      }),\n    },\n    \"queue/status\": {\n      endpoint: \"queue/status\",\n      validate: [\"totalCount\"],\n    },\n    \"queue/details\": {\n      endpoint: \"queue/details\",\n      map: (data) =>\n        asJson(data)\n          .map((entry) => ({\n            trackedDownloadState: entry.trackedDownloadState,\n            trackedDownloadStatus: entry.trackedDownloadStatus,\n            timeLeft: entry.timeleft,\n            size: entry.size,\n            sizeLeft: entry.sizeleft,\n            movieId: entry.movieId ?? entry.id,\n            status: entry.status,\n          }))\n          .sort((a, b) => {\n            const downloadingA = a.trackedDownloadState === \"downloading\";\n            const downloadingB = b.trackedDownloadState === \"downloading\";\n            if (downloadingA && !downloadingB) {\n              return -1;\n            }\n            if (downloadingB && !downloadingA) {\n              return 1;\n            }\n\n            const percentA = a.sizeLeft / a.size;\n            const percentB = b.sizeLeft / b.size;\n            if (percentA < percentB) {\n              return -1;\n            }\n            if (percentA > percentB) {\n              return 1;\n            }\n            return 0;\n          }),\n    },\n    calendar: {\n      endpoint: \"calendar\",\n      params: [\"start\", \"end\", \"unmonitored\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/radarr/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"radarr widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/readarr/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: booksData, error: booksError } = useWidgetAPI(widget, \"book\");\n  const { data: wantedData, error: wantedError } = useWidgetAPI(widget, \"wanted/missing\");\n  const { data: queueData, error: queueError } = useWidgetAPI(widget, \"queue/status\");\n\n  if (booksError || wantedError || queueError) {\n    const finalError = booksError ?? wantedError ?? queueError;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!booksData || !wantedData || !queueData) {\n    return (\n      <Container service={service}>\n        <Block label=\"readarr.wanted\" />\n        <Block label=\"readarr.queued\" />\n        <Block label=\"readarr.books\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"readarr.wanted\" value={t(\"common.number\", { value: wantedData.totalRecords })} />\n      <Block label=\"readarr.queued\" value={t(\"common.number\", { value: queueData.totalCount })} />\n      <Block label=\"readarr.books\" value={t(\"common.number\", { value: booksData.have })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/readarr/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/readarr/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"readarr\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"readarr.wanted\")).toBeInTheDocument();\n    expect(screen.getByText(\"readarr.queued\")).toBeInTheDocument();\n    expect(screen.getByText(\"readarr.books\")).toBeInTheDocument();\n  });\n\n  it(\"renders counts when loaded\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"book\") return { data: { have: 10 }, error: undefined };\n      if (endpoint === \"wanted/missing\") return { data: { totalRecords: 2 }, error: undefined };\n      if (endpoint === \"queue/status\") return { data: { totalCount: 3 }, error: undefined };\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"readarr\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"readarr.wanted\", 2);\n    expectBlockValue(container, \"readarr.queued\", 3);\n    expectBlockValue(container, \"readarr.books\", 10);\n  });\n});\n"
  },
  {
    "path": "src/widgets/readarr/widget.js",
    "content": "import { jsonArrayFilter } from \"utils/proxy/api-helpers\";\nimport genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/v1/{endpoint}?apikey={key}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    book: {\n      endpoint: \"book\",\n      map: (data) => ({\n        have: jsonArrayFilter(data, (item) => item?.statistics?.bookFileCount > 0).length,\n      }),\n    },\n    \"queue/status\": {\n      endpoint: \"queue/status\",\n    },\n    \"wanted/missing\": {\n      endpoint: \"wanted/missing\",\n    },\n    calendar: {\n      endpoint: \"calendar\",\n      params: [\"start\", \"end\", \"unmonitored\", \"includeAuthor\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/readarr/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"readarr widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/romm/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst ROMM_DEFAULT_FIELDS = [\"platforms\", \"totalRoms\", \"saves\", \"states\"];\nconst MAX_ALLOWED_FIELDS = 4;\n\nexport default function Component({ service }) {\n  const { widget } = service;\n  const { t } = useTranslation();\n  const { data: response, error: responseError } = useWidgetAPI(widget, \"statistics\");\n\n  if (responseError) {\n    return <Container service={service} error={responseError} />;\n  }\n\n  if (!widget.fields?.length > 0) {\n    widget.fields = ROMM_DEFAULT_FIELDS;\n  } else if (widget.fields.length > MAX_ALLOWED_FIELDS) {\n    widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);\n  }\n\n  if (!response) {\n    return (\n      <Container service={service}>\n        <Block label=\"romm.platforms\" />\n        <Block label=\"romm.totalRoms\" />\n        <Block label=\"romm.saves\" />\n        <Block label=\"romm.states\" />\n        <Block label=\"romm.screenshots\" />\n        <Block label=\"romm.totalfilesize\" />\n      </Container>\n    );\n  }\n\n  if (response) {\n    return (\n      <Container service={service}>\n        <Block label=\"romm.platforms\" value={t(\"common.number\", { value: response.PLATFORMS })} />\n        <Block label=\"romm.totalRoms\" value={t(\"common.number\", { value: response.ROMS })} />\n        <Block label=\"romm.saves\" value={t(\"common.number\", { value: response.SAVES })} />\n        <Block label=\"romm.states\" value={t(\"common.number\", { value: response.STATES })} />\n        <Block label=\"romm.screenshots\" value={t(\"common.number\", { value: response.SCREENSHOTS })} />\n        <Block\n          label=\"romm.totalfilesize\"\n          value={t(\"common.bytes\", { value: response.FILESIZE ?? response.TOTAL_FILESIZE_BYTES })}\n        />\n      </Container>\n    );\n  }\n}\n"
  },
  {
    "path": "src/widgets/romm/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/romm/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"defaults fields to 4 and shows placeholders (container filters to selected fields)\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"romm\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"platforms\", \"totalRoms\", \"saves\", \"states\"]);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"romm.platforms\")).toBeInTheDocument();\n    expect(screen.getByText(\"romm.totalRoms\")).toBeInTheDocument();\n    expect(screen.getByText(\"romm.saves\")).toBeInTheDocument();\n    expect(screen.getByText(\"romm.states\")).toBeInTheDocument();\n    expect(screen.queryByText(\"romm.screenshots\")).toBeNull();\n    expect(screen.queryByText(\"romm.totalfilesize\")).toBeNull();\n  });\n\n  it(\"caps widget.fields at 4 entries\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"romm\", fields: [\"platforms\", \"totalRoms\", \"saves\", \"states\", \"screenshots\"] } };\n    renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"platforms\", \"totalRoms\", \"saves\", \"states\"]);\n  });\n\n  it(\"renders values when loaded (and includes additional fields when explicitly selected)\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { PLATFORMS: 1, ROMS: 2, SAVES: 3, STATES: 4, SCREENSHOTS: 5, FILESIZE: 6 },\n      error: undefined,\n    });\n\n    const service = { widget: { type: \"romm\", fields: [\"platforms\", \"totalRoms\", \"screenshots\", \"totalfilesize\"] } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expectBlockValue(container, \"romm.platforms\", 1);\n    expectBlockValue(container, \"romm.totalRoms\", 2);\n    expectBlockValue(container, \"romm.screenshots\", 5);\n    expectBlockValue(container, \"romm.totalfilesize\", 6);\n  });\n});\n"
  },
  {
    "path": "src/widgets/romm/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    statistics: {\n      endpoint: \"stats\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/romm/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"romm widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/rutorrent/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: statusData, error: statusError } = useWidgetAPI(widget);\n\n  if (statusError) {\n    return <Container service={service} error={statusError} />;\n  }\n\n  if (!statusData) {\n    return (\n      <Container service={service}>\n        <Block label=\"rutorrent.active\" />\n        <Block label=\"rutorrent.upload\" />\n        <Block label=\"rutorrent.download\" />\n      </Container>\n    );\n  }\n\n  const upload = statusData.reduce((acc, torrent) => acc + parseInt(torrent[\"d.get_up_rate\"], 10), 0);\n\n  const download = statusData.reduce((acc, torrent) => acc + parseInt(torrent[\"d.get_down_rate\"], 10), 0);\n\n  const active = statusData.filter((torrent) => torrent[\"d.get_state\"] === \"1\");\n\n  return (\n    <Container service={service}>\n      <Block label=\"rutorrent.active\" value={active.length} />\n      <Block label=\"rutorrent.upload\" value={t(\"common.byterate\", { value: upload })} highlightValue={upload} />\n      <Block label=\"rutorrent.download\" value={t(\"common.byterate\", { value: download })} highlightValue={download} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/rutorrent/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/rutorrent/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"rutorrent\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"rutorrent.active\")).toBeInTheDocument();\n    expect(screen.getByText(\"rutorrent.upload\")).toBeInTheDocument();\n    expect(screen.getByText(\"rutorrent.download\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"rutorrent\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n  });\n\n  it(\"renders computed active/upload/download values\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: [\n        { \"d.get_state\": \"1\", \"d.get_up_rate\": \"10\", \"d.get_down_rate\": \"5\" },\n        { \"d.get_state\": \"0\", \"d.get_up_rate\": \"20\", \"d.get_down_rate\": \"15\" },\n        { \"d.get_state\": \"1\", \"d.get_up_rate\": \"0\", \"d.get_down_rate\": \"0\" },\n      ],\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"rutorrent\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"2\")).toBeInTheDocument(); // active torrents\n    expect(screen.getByText(\"30\")).toBeInTheDocument(); // upload sum (common.byterate mocked)\n    expect(screen.getByText(\"20\")).toBeInTheDocument(); // download sum (common.byterate mocked)\n  });\n});\n"
  },
  {
    "path": "src/widgets/rutorrent/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst logger = createLogger(\"rutorrentProxyHandler\");\n\n// from https://github.com/ctessier/node-rutorrent-promise/blob/next/utils.js\nconst getTorrentInfo = (data) => ({\n  \"d.is_open\": data[0],\n  \"d.is_hash_checking\": data[1],\n  \"d.is_hash_checked\": data[2],\n  \"d.get_state\": data[3],\n  \"d.get_name\": data[4],\n  \"d.get_size_bytes\": data[5],\n  \"d.get_completed_chunks\": data[6],\n  \"d.get_size_chunks\": data[7],\n  \"d.get_bytes_done\": data[8],\n  \"d.get_up_total\": data[9],\n  \"d.get_ratio\": data[10],\n  \"d.get_up_rate\": data[11],\n  \"d.get_down_rate\": data[12],\n  \"d.get_chunk_size\": data[13],\n  \"d.get_custom1\": data[14],\n  \"d.get_peers_accounted\": data[15],\n  \"d.get_peers_not_connected\": data[16],\n  \"d.get_peers_connected\": data[17],\n  \"d.get_peers_complete\": data[18],\n  \"d.get_left_bytes\": data[19],\n  \"d.get_priority\": data[20],\n  \"d.get_state_changed\": data[21],\n  \"d.get_skip_total\": data[22],\n  \"d.get_hashing\": data[23],\n  \"d.get_chunks_hashed\": data[24],\n  \"d.get_base_path\": data[25],\n  \"d.get_creation_date\": data[26],\n  \"d.get_tracker_focus\": data[27],\n  \"d.is_active\": data[28],\n  \"d.get_message\": data[29],\n  \"d.get_custom2\": data[30],\n  \"d.get_free_diskspace\": data[31],\n  \"d.is_private\": data[32],\n  \"d.is_multi_file\": data[33],\n});\n\nexport default async function rutorrentProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n\n  if (group && service) {\n    const widget = await getServiceWidget(group, service, index);\n\n    if (widget) {\n      const api = widgets?.[widget.type]?.api;\n      const url = new URL(formatApiCall(api, { ...widget }));\n\n      const headers = {};\n      if (widget.username) {\n        headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString(\"base64\")}`;\n      }\n\n      const [status, , data] = await httpProxy(url, {\n        method: \"POST\",\n        headers,\n        body: \"mode=list\",\n      });\n\n      if (status !== 200) {\n        logger.error(\"HTTP Error %d calling %s\", status, url.toString());\n        return res.status(status).json({ error: { message: \"HTTP Error\", url, data } });\n      }\n\n      try {\n        const rawData = JSON.parse(data);\n        const parsedData = Object.keys(rawData.t).map((hashString) => getTorrentInfo(rawData.t[hashString]));\n\n        return res.status(200).send(parsedData);\n      } catch (e) {\n        return res.status(500).json({ error: { message: e?.toString() ?? \"Error parsing rutorrent data\", url, data } });\n      }\n    }\n  }\n\n  return res.status(500).json({ error: \"Invalid proxy service type\" });\n}\n"
  },
  {
    "path": "src/widgets/rutorrent/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  logger: { error: vi.fn() },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    rutorrent: {\n      api: \"{url}\",\n    },\n  },\n}));\n\nimport rutorrentProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/rutorrent/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"parses torrent list data into an array\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"rutorrent\", url: \"http://ru\", username: \"u\", password: \"p\" });\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", JSON.stringify({ t: { hash1: Array(34).fill(0) } })]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await rutorrentProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(Array.isArray(res.body)).toBe(true);\n    expect(res.body[0][\"d.get_name\"]).toBe(0);\n  });\n});\n"
  },
  {
    "path": "src/widgets/rutorrent/widget.js",
    "content": "import rutorrentProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/plugins/httprpc/action.php\",\n  proxyHandler: rutorrentProxyHandler,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/rutorrent/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"rutorrent widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/sabnzbd/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nfunction fromUnits(value) {\n  const units = [\"B\", \"K\", \"M\", \"G\", \"T\", \"P\"];\n  const [number, unit] = value.split(\" \");\n  const index = units.indexOf(unit);\n  if (index === -1) {\n    return 0;\n  }\n  return parseFloat(number) * 1024 ** index;\n}\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: queueData, error: queueError } = useWidgetAPI(widget, \"queue\");\n\n  if (queueError) {\n    return <Container service={service} error={queueError} />;\n  }\n\n  if (!queueData) {\n    return (\n      <Container service={service}>\n        <Block label=\"sabnzbd.rate\" />\n        <Block label=\"sabnzbd.queue\" />\n        <Block label=\"sabnzbd.timeleft\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"sabnzbd.rate\" value={t(\"common.byterate\", { value: fromUnits(queueData.queue.speed) })} />\n      <Block label=\"sabnzbd.queue\" value={t(\"common.number\", { value: queueData.queue.noofslots })} />\n      <Block label=\"sabnzbd.timeleft\" value={queueData.queue.timeleft} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/sabnzbd/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/sabnzbd/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"sabnzbd\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"sabnzbd.rate\")).toBeInTheDocument();\n    expect(screen.getByText(\"sabnzbd.queue\")).toBeInTheDocument();\n    expect(screen.getByText(\"sabnzbd.timeleft\")).toBeInTheDocument();\n  });\n\n  it(\"renders error UI when endpoint errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"sabnzbd\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders speed, queue count and time left when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { queue: { speed: \"1.0 M\", noofslots: 2, timeleft: \"00:01:00\" } },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"sabnzbd\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    // fromUnits(\"1.0 M\") => 1 * 1024**2\n    expectBlockValue(container, \"sabnzbd.rate\", 1024 ** 2);\n    expectBlockValue(container, \"sabnzbd.queue\", 2);\n    expectBlockValue(container, \"sabnzbd.timeleft\", \"00:01:00\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/sabnzbd/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/?apikey={key}&output=json&mode={endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    queue: {\n      endpoint: \"queue\",\n      validate: [\"queue\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/sabnzbd/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"sabnzbd widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/scrutiny/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\n// @see https://github.com/AnalogJ/scrutiny/blob/d8d56f77f9e868127c4849dac74d65512db658e8/webapp/frontend/src/app/shared/device-status.pipe.ts\nconst DeviceStatus = {\n  passed: 0,\n  failed_smart: 1,\n  failed_scrutiny: 2,\n  failed_both: 3,\n\n  isFailed(s) {\n    return s > this.passed && s <= this.failed_both;\n  },\n  isUnknown(s) {\n    return s < this.passed || s > this.failed_both;\n  },\n};\n\n// @see https://github.com/AnalogJ/scrutiny/blob/d8d56f77f9e868127c4849dac74d65512db658e8/webapp/frontend/src/app/core/config/app.config.ts\nconst DeviceStatusThreshold = {\n  smart: 1,\n  scrutiny: 2,\n  both: 3,\n};\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  const { data: scrutinySettings, error: scrutinySettingsError } = useWidgetAPI(widget, \"settings\");\n  const { data: scrutinyData, error: scrutinyError } = useWidgetAPI(widget, \"summary\");\n\n  if (scrutinyError || scrutinySettingsError) {\n    const finalError = scrutinyError ?? scrutinySettingsError;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!scrutinyData || !scrutinySettings) {\n    return (\n      <Container service={service}>\n        <Block label=\"scrutiny.passed\" />\n        <Block label=\"scrutiny.failed\" />\n        <Block label=\"scrutiny.unknown\" />\n      </Container>\n    );\n  }\n\n  const deviceIds = Object.values(scrutinyData.data.summary);\n  const statusThreshold = scrutinySettings.settings.metrics.status_threshold;\n\n  const failed =\n    deviceIds.filter(\n      (deviceId) =>\n        (DeviceStatus.isFailed(deviceId.device.device_status) && statusThreshold === DeviceStatusThreshold.both) ||\n        [statusThreshold, DeviceStatus.failed_both].includes(deviceId.device.device_status),\n    )?.length || 0;\n  const unknown = deviceIds.filter((deviceId) => DeviceStatus.isUnknown(deviceId.device.device_status))?.length || 0;\n  const passed = deviceIds.length - (failed + unknown);\n\n  return (\n    <Container service={service}>\n      <Block label=\"scrutiny.passed\" value={passed} />\n      <Block label=\"scrutiny.failed\" value={failed} />\n      <Block label=\"scrutiny.unknown\" value={unknown} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/scrutiny/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/scrutiny/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"scrutiny\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"scrutiny.passed\")).toBeInTheDocument();\n    expect(screen.getByText(\"scrutiny.failed\")).toBeInTheDocument();\n    expect(screen.getByText(\"scrutiny.unknown\")).toBeInTheDocument();\n  });\n\n  it(\"counts passed/failed/unknown based on status_threshold\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"settings\") {\n        return { data: { settings: { metrics: { status_threshold: 2 } } }, error: undefined };\n      }\n      if (endpoint === \"summary\") {\n        return {\n          data: {\n            data: {\n              summary: {\n                // passed=0, failed_smart=1, failed_scrutiny=2, unknown=99\n                a: { device: { device_status: 0 } },\n                b: { device: { device_status: 2 } },\n                c: { device: { device_status: 99 } },\n              },\n            },\n          },\n          error: undefined,\n        };\n      }\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"scrutiny\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"scrutiny.passed\", 1);\n    expectBlockValue(container, \"scrutiny.failed\", 1);\n    expectBlockValue(container, \"scrutiny.unknown\", 1);\n  });\n});\n"
  },
  {
    "path": "src/widgets/scrutiny/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    summary: {\n      endpoint: \"summary\",\n      validate: [\"data\"],\n    },\n    settings: {\n      endpoint: \"settings\",\n      validate: [\"settings\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/scrutiny/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"scrutiny widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/seerr/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport const seerrDefaultFields = [\"pending\", \"approved\", \"completed\"];\nconst MAX_ALLOWED_FIELDS = 4;\n\nexport default function Component({ service }) {\n  const { widget } = service;\n  widget.fields = widget?.fields?.length ? widget.fields.slice(0, MAX_ALLOWED_FIELDS) : seerrDefaultFields;\n  const isIssueEnabled = widget.fields.includes(\"issues\");\n\n  const { data: statsData, error: statsError } = useWidgetAPI(widget, \"request/count\");\n  const { data: issueData, error: issueError } = useWidgetAPI(widget, isIssueEnabled ? \"issue/count\" : \"\");\n  if (statsError || (isIssueEnabled && issueError)) {\n    return <Container service={service} error={statsError ? statsError : issueError} />;\n  }\n\n  if (!statsData || (isIssueEnabled && !issueData)) {\n    return (\n      <Container service={service}>\n        <Block field=\"seerr.pending\" label=\"seerr.pending\" />\n        <Block field=\"seerr.approved\" label=\"seerr.approved\" />\n        <Block field=\"seerr.available\" label=\"seerr.available\" />\n        <Block field=\"seerr.completed\" label=\"seerr.completed\" />\n        <Block field=\"seerr.processing\" label=\"seerr.processing\" />\n        <Block field=\"seerr.issues\" label=\"seerr.issues\" />\n      </Container>\n    );\n  }\n\n  if (statsData.completed === undefined) {\n    // Newer versions added \"completed\", fallback to available\n    widget.fields = widget.fields.filter((field) => field !== \"completed\");\n    widget.fields.push(\"available\");\n  }\n\n  return (\n    <Container service={service}>\n      <Block field=\"seerr.pending\" label=\"seerr.pending\" value={statsData.pending} />\n      <Block field=\"seerr.approved\" label=\"seerr.approved\" value={statsData.approved} />\n      <Block field=\"seerr.available\" label=\"seerr.available\" value={statsData.available} />\n      <Block field=\"seerr.completed\" label=\"seerr.completed\" value={statsData.completed} />\n      <Block field=\"seerr.processing\" label=\"seerr.processing\" value={statsData.processing} />\n      <Block field=\"seerr.issues\" label=\"seerr.issues\" value={`${issueData?.open} / ${issueData?.total}`} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/seerr/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component, { seerrDefaultFields } from \"./component\";\n\ndescribe(\"widgets/seerr/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"defaults fields and filters to 3 blocks while loading when issues are not enabled\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // request/count\n      .mockReturnValueOnce({ data: undefined, error: undefined }); // issue/count disabled (endpoint = \"\")\n\n    const service = { widget: { type: \"seerr\", url: \"http://x\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual(seerrDefaultFields);\n    expect(useWidgetAPI.mock.calls[1][1]).toBe(\"\");\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"seerr.pending\")).toBeInTheDocument();\n    expect(screen.getByText(\"seerr.approved\")).toBeInTheDocument();\n    expect(screen.getByText(\"seerr.completed\")).toBeInTheDocument();\n    expect(screen.queryByText(\"seerr.available\")).toBeNull();\n    expect(screen.queryByText(\"seerr.processing\")).toBeNull();\n    expect(screen.queryByText(\"seerr.issues\")).toBeNull();\n  });\n\n  it(\"supports jellyseerr as a legacy alias to seerr\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // request/count\n      .mockReturnValueOnce({ data: undefined, error: undefined }); // issue/count disabled (endpoint = \"\")\n\n    const service = { widget: { type: \"jellyseerr\", url: \"http://x\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual(seerrDefaultFields);\n    expect(useWidgetAPI.mock.calls[1][1]).toBe(\"\");\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"seerr.pending\")).toBeInTheDocument();\n    expect(screen.getByText(\"seerr.approved\")).toBeInTheDocument();\n    expect(screen.getByText(\"seerr.completed\")).toBeInTheDocument();\n  });\n\n  it(\"supports overseerr as a legacy alias with the same default fields\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: undefined, error: undefined }) // request/count\n      .mockReturnValueOnce({ data: undefined, error: undefined }); // issue/count disabled (endpoint = \"\")\n\n    const service = { widget: { type: \"overseerr\", url: \"http://x\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual(seerrDefaultFields);\n    expect(useWidgetAPI.mock.calls[1][1]).toBe(\"\");\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"seerr.pending\")).toBeInTheDocument();\n    expect(screen.getByText(\"seerr.approved\")).toBeInTheDocument();\n    expect(screen.getByText(\"seerr.completed\")).toBeInTheDocument();\n  });\n\n  it(\"keeps processing as a separate optional field\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: { pending: 1, processing: 2, approved: 3, available: 4 }, error: undefined })\n      .mockReturnValueOnce({ data: undefined, error: undefined }); // issue/count disabled (endpoint = \"\")\n\n    const service = {\n      widget: { type: \"overseerr\", url: \"http://x\", fields: [\"pending\", \"processing\", \"approved\", \"available\"] },\n    };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(useWidgetAPI.mock.calls[1][1]).toBe(\"\");\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"seerr.processing\")).toBeInTheDocument();\n    expect(screen.getByText(\"2\")).toBeInTheDocument();\n    expect(screen.queryByText(\"seerr.completed\")).toBeNull();\n  });\n\n  it(\"renders issues when enabled (and calls the issue/count endpoint)\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: { pending: 1, approved: 2, available: 3, completed: 4 }, error: undefined })\n      .mockReturnValueOnce({ data: { open: 1, total: 2 }, error: undefined });\n\n    const service = {\n      widget: { type: \"seerr\", url: \"http://x\", fields: [\"pending\", \"approved\", \"completed\", \"issues\"] },\n    };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(useWidgetAPI.mock.calls[1][1]).toBe(\"issue/count\");\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"1 / 2\")).toBeInTheDocument();\n    expect(screen.getByText(\"4\")).toBeInTheDocument();\n  });\n\n  it(\"falls back from completed to available on older Seerr responses\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: { pending: 1, approved: 2, available: 3 }, error: undefined })\n      .mockReturnValueOnce({ data: undefined, error: undefined });\n\n    const service = {\n      widget: { type: \"seerr\", url: \"http://x\", fields: [\"pending\", \"approved\", \"completed\"] },\n    };\n\n    renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"pending\", \"approved\", \"available\"]);\n    expect(screen.getByText(\"3\")).toBeInTheDocument();\n    expect(screen.queryByText(\"seerr.completed\")).toBeNull();\n  });\n\n  it(\"renders error UI when issues are enabled and issue/count errors\", () => {\n    useWidgetAPI\n      .mockReturnValueOnce({ data: { pending: 0, approved: 0, available: 0 }, error: undefined })\n      .mockReturnValueOnce({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"seerr\", url: \"http://x\", fields: [\"issues\"] } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/seerr/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/v1/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    \"request/count\": {\n      endpoint: \"request/count\",\n      validate: [\"pending\", \"approved\", \"available\"],\n    },\n    \"issue/count\": {\n      endpoint: \"issue/count\",\n      validate: [\"open\", \"total\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/seerr/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"seerr widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/slskd/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst slskdDefaultFields = [\"slskStatus\", \"downloads\", \"uploads\", \"sharedFiles\"];\nconst MAX_ALLOWED_FIELDS = 4;\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { data: appData, error: appError } = useWidgetAPI(widget, \"application\");\n  const { data: downData, error: downError } = useWidgetAPI(widget, \"downloads\");\n  const { data: upData, error: upError } = useWidgetAPI(widget, \"uploads\");\n\n  if (appError || downError || upError) {\n    return <Container service={service} error={appError ?? downError ?? upError} />;\n  }\n\n  if (!widget.fields || widget.fields.length === 0) {\n    widget.fields = slskdDefaultFields;\n  } else if (widget.fields?.length > MAX_ALLOWED_FIELDS) {\n    widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);\n  }\n\n  if (!appData || !downData || !upData) {\n    return (\n      <Container service={service}>\n        <Block label=\"slskd.slskStatus\" />\n        <Block label=\"slskd.updateStatus\" />\n        <Block label=\"slskd.downloads\" />\n        <Block label=\"slskd.uploads\" />\n        <Block label=\"slskd.sharedFiles\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block\n        label=\"slskd.slskStatus\"\n        value={appData.server?.isConnected ? t(\"slskd.connected\") : t(\"slskd.disconnected\")}\n      />\n      <Block\n        label=\"slskd.updateStatus\"\n        value={appData.version?.isUpdateAvailable ? t(\"slskd.update_yes\") : t(\"slskd.update_no\")}\n      />\n      <Block label=\"slskd.downloads\" value={t(\"common.number\", { value: downData.length ?? 0 })} />\n      <Block label=\"slskd.uploads\" value={t(\"common.number\", { value: upData.length ?? 0 })} />\n      <Block label=\"slskd.sharedFiles\" value={t(\"common.number\", { value: appData.shares?.files ?? 0 })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/slskd/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/slskd/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"defaults fields to 4 and renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"slskd\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"slskStatus\", \"downloads\", \"uploads\", \"sharedFiles\"]);\n    // Container filters children by widget.fields.\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"slskd.slskStatus\")).toBeInTheDocument();\n    expect(screen.getByText(\"slskd.downloads\")).toBeInTheDocument();\n    expect(screen.getByText(\"slskd.uploads\")).toBeInTheDocument();\n    expect(screen.getByText(\"slskd.sharedFiles\")).toBeInTheDocument();\n    expect(screen.queryByText(\"slskd.updateStatus\")).toBeNull();\n  });\n\n  it(\"caps widget.fields at 4 entries\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"slskd\", fields: [\"a\", \"b\", \"c\", \"d\", \"e\"] } };\n    renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"a\", \"b\", \"c\", \"d\"]);\n  });\n\n  it(\"renders status and counts when loaded\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"application\") {\n        return {\n          data: {\n            server: { isConnected: true },\n            version: { isUpdateAvailable: false },\n            shares: { files: 12 },\n          },\n          error: undefined,\n        };\n      }\n      if (endpoint === \"downloads\") return { data: [{ id: 1 }], error: undefined };\n      if (endpoint === \"uploads\") return { data: [{ id: 1 }, { id: 2 }], error: undefined };\n      return { data: undefined, error: undefined };\n    });\n\n    const service = { widget: { type: \"slskd\", fields: [\"slskStatus\", \"downloads\", \"uploads\", \"sharedFiles\"] } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expectBlockValue(container, \"slskd.slskStatus\", \"slskd.connected\");\n    expectBlockValue(container, \"slskd.downloads\", 1);\n    expectBlockValue(container, \"slskd.uploads\", 2);\n    expectBlockValue(container, \"slskd.sharedFiles\", 12);\n  });\n});\n"
  },
  {
    "path": "src/widgets/slskd/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: `{url}/api/v0/{endpoint}`,\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    application: {\n      endpoint: \"application\",\n    },\n    downloads: {\n      endpoint: \"transfers/downloads\",\n    },\n    uploads: {\n      endpoint: \"transfers/uploads\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/slskd/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"slskd widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/sonarr/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\nimport { useCallback } from \"react\";\n\nimport QueueEntry from \"../../components/widgets/queue/queueEntry\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nfunction getProgress(sizeLeft, size) {\n  return sizeLeft === 0 ? 100 : (1 - sizeLeft / size) * 100;\n}\n\nfunction getTitle(queueEntry, seriesData) {\n  let title = \"\";\n  const seriesTitle = seriesData.find((entry) => entry.id === queueEntry.seriesId)?.title;\n  if (seriesTitle) title += `${seriesTitle}: `;\n  const { episodeTitle } = queueEntry;\n  if (episodeTitle) title += episodeTitle;\n  if (title === \"\") return null;\n  return title;\n}\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { data: wantedData, error: wantedError } = useWidgetAPI(widget, \"wanted/missing\");\n  const { data: queuedData, error: queuedError } = useWidgetAPI(widget, \"queue\");\n  const { data: seriesData, error: seriesError } = useWidgetAPI(widget, \"series\");\n  const { data: queueDetailsData, error: queueDetailsError } = useWidgetAPI(widget, \"queue/details\");\n\n  const formatDownloadState = useCallback((downloadState) => {\n    switch (downloadState) {\n      case \"importPending\":\n        return \"import pending\";\n      case \"failedPending\":\n        return \"failed pending\";\n      default:\n        return downloadState;\n    }\n  }, []);\n\n  if (wantedError || queuedError || seriesError || queueDetailsError) {\n    const finalError = wantedError ?? queuedError ?? seriesError ?? queueDetailsError;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!wantedData || !queuedData || !seriesData || !queueDetailsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"sonarr.wanted\" />\n        <Block label=\"sonarr.queued\" />\n        <Block label=\"sonarr.series\" />\n      </Container>\n    );\n  }\n\n  const enableQueue = widget?.enableQueue && Array.isArray(queueDetailsData) && queueDetailsData.length > 0;\n\n  return (\n    <>\n      <Container service={service}>\n        <Block label=\"sonarr.wanted\" value={t(\"common.number\", { value: wantedData.totalRecords })} />\n        <Block label=\"sonarr.queued\" value={t(\"common.number\", { value: queuedData.totalRecords })} />\n        <Block label=\"sonarr.series\" value={t(\"common.number\", { value: seriesData.length })} />\n      </Container>\n      {enableQueue &&\n        queueDetailsData.map((queueEntry) => (\n          <QueueEntry\n            progress={getProgress(queueEntry.sizeLeft, queueEntry.size)}\n            timeLeft={queueEntry.timeLeft}\n            title={getTitle(queueEntry, seriesData) ?? t(\"sonarr.unknown\")}\n            activity={formatDownloadState(queueEntry.trackedDownloadState)}\n            key={`${queueEntry.seriesId}-${queueEntry.episodeId}`}\n          />\n        ))}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/widgets/sonarr/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nvi.mock(\"../../components/widgets/queue/queueEntry\", () => ({\n  default: ({ title }) => <div data-testid=\"queue-entry\">{title}</div>,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/sonarr/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"sonarr\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"sonarr.wanted\")).toBeInTheDocument();\n    expect(screen.getByText(\"sonarr.queued\")).toBeInTheDocument();\n    expect(screen.getByText(\"sonarr.series\")).toBeInTheDocument();\n  });\n\n  it(\"renders counts and queue entries when enabled\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"wanted/missing\") return { data: { totalRecords: 1 }, error: undefined };\n      if (endpoint === \"queue\") return { data: { totalRecords: 2 }, error: undefined };\n      if (endpoint === \"series\") return { data: [{ id: 10, title: \"Show\" }], error: undefined };\n      if (endpoint === \"queue/details\") {\n        return {\n          data: [\n            {\n              seriesId: 10,\n              episodeId: 1,\n              episodeTitle: \"Ep\",\n              sizeLeft: 50,\n              size: 100,\n              timeLeft: \"1m\",\n              trackedDownloadState: \"importPending\",\n            },\n          ],\n          error: undefined,\n        };\n      }\n      return { data: undefined, error: undefined };\n    });\n\n    const service = { widget: { type: \"sonarr\", enableQueue: true } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expectBlockValue(container, \"sonarr.wanted\", 1);\n    expectBlockValue(container, \"sonarr.queued\", 2);\n    expectBlockValue(container, \"sonarr.series\", 1);\n    expect(screen.getAllByTestId(\"queue-entry\").map((el) => el.textContent)).toEqual([\"Show: Ep\"]);\n  });\n});\n"
  },
  {
    "path": "src/widgets/sonarr/widget.js",
    "content": "import { asJson } from \"utils/proxy/api-helpers\";\nimport genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/v3/{endpoint}?apikey={key}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    series: {\n      endpoint: \"series\",\n      map: (data) =>\n        asJson(data).map((entry) => ({\n          title: entry.title,\n          id: entry.id,\n        })),\n    },\n    queue: {\n      endpoint: \"queue\",\n      validate: [\"totalRecords\"],\n    },\n    \"wanted/missing\": {\n      endpoint: \"wanted/missing\",\n      validate: [\"totalRecords\"],\n    },\n    \"queue/details\": {\n      endpoint: \"queue/details\",\n      map: (data) =>\n        asJson(data)\n          .map((entry) => ({\n            trackedDownloadState: entry.trackedDownloadState,\n            trackedDownloadStatus: entry.trackedDownloadStatus,\n            timeLeft: entry.timeleft,\n            size: entry.size,\n            sizeLeft: entry.sizeleft,\n            seriesId: entry.seriesId,\n            episodeTitle: entry.episode?.title ?? entry.title,\n            episodeId: entry.episodeId ?? entry.id,\n            status: entry.status,\n          }))\n          .sort((a, b) => {\n            const downloadingA = a.trackedDownloadState === \"downloading\";\n            const downloadingB = b.trackedDownloadState === \"downloading\";\n            if (downloadingA && !downloadingB) {\n              return -1;\n            }\n            if (downloadingB && !downloadingA) {\n              return 1;\n            }\n\n            const percentA = a.sizeLeft / a.size;\n            const percentB = b.sizeLeft / b.size;\n            if (percentA < percentB) {\n              return -1;\n            }\n            if (percentA > percentB) {\n              return 1;\n            }\n            return 0;\n          }),\n    },\n    calendar: {\n      endpoint: \"calendar\",\n      params: [\"start\", \"end\", \"unmonitored\", \"includeSeries\", \"includeEpisodeFile\", \"includeEpisodeImages\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/sonarr/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"sonarr widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/sparkyfitness/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n  const { data, error } = useWidgetAPI(widget, \"stats\");\n\n  if (error) {\n    return <Container service={service} error={error} />;\n  }\n\n  if (!data) {\n    return (\n      <Container service={service}>\n        <Block label=\"sparkyfitness.eaten\" />\n        <Block label=\"sparkyfitness.burned\" />\n        <Block label=\"sparkyfitness.remaining\" />\n        <Block label=\"sparkyfitness.steps\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label={t(\"sparkyfitness.eaten\", \"Eaten\")} value={t(\"common.number\", { value: data.eaten })} />\n      <Block label={t(\"sparkyfitness.burned\", \"Burned\")} value={t(\"common.number\", { value: data.burned })} />\n      <Block label={t(\"sparkyfitness.remaining\", \"Remaining\")} value={t(\"common.number\", { value: data.remaining })} />\n      <Block label={t(\"sparkyfitness.steps\", \"Steps\")} value={t(\"common.number\", { value: data.steps })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/sparkyfitness/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/sparkyfitness/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"calls the stats endpoint and renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"sparkyfitness\", url: \"http://x\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(useWidgetAPI).toHaveBeenCalledWith(service.widget, \"stats\");\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"sparkyfitness.eaten\")).toBeInTheDocument();\n    expect(screen.getByText(\"sparkyfitness.burned\")).toBeInTheDocument();\n    expect(screen.getByText(\"sparkyfitness.remaining\")).toBeInTheDocument();\n    expect(screen.getByText(\"sparkyfitness.steps\")).toBeInTheDocument();\n    expect(screen.getAllByText(\"-\")).toHaveLength(4);\n  });\n\n  it(\"renders error UI when widget API errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"sparkyfitness\", url: \"http://x\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"renders numeric values when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { eaten: 100, burned: 200, remaining: 300, steps: 400 },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"sparkyfitness\", url: \"http://x\" } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expectBlockValue(container, \"sparkyfitness.eaten\", 100);\n    expectBlockValue(container, \"sparkyfitness.burned\", 200);\n    expectBlockValue(container, \"sparkyfitness.remaining\", 300);\n    expectBlockValue(container, \"sparkyfitness.steps\", 400);\n  });\n});\n"
  },
  {
    "path": "src/widgets/sparkyfitness/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    stats: {\n      endpoint: \"api/dashboard/stats\",\n      validate: [\"eaten\", \"burned\", \"remaining\", \"steps\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/sparkyfitness/widget.test.js",
    "content": "import { describe, expect, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"sparkyfitness widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n\n    const statsMapping = widget.mappings?.stats;\n    expect(statsMapping?.endpoint).toBe(\"api/dashboard/stats\");\n    expect(statsMapping?.validate).toEqual([\"eaten\", \"burned\", \"remaining\", \"steps\"]);\n  });\n});\n"
  },
  {
    "path": "src/widgets/speedtest/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const endpoint = widget.version === 2 ? \"latestv2\" : \"latestv1\";\n  const { data: speedtestData, error: speedtestError } = useWidgetAPI(widget, endpoint);\n\n  const bitratePrecision =\n    !widget?.bitratePrecision || Number.isNaN(widget?.bitratePrecision) || widget?.bitratePrecision < 0\n      ? 0\n      : widget.bitratePrecision;\n\n  if (speedtestError || speedtestData?.error) {\n    return <Container service={service} error={speedtestError ?? speedtestData.error} />;\n  }\n\n  if (!speedtestData?.data) {\n    return (\n      <Container service={service}>\n        <Block label=\"speedtest.download\" />\n        <Block label=\"speedtest.upload\" />\n        <Block label=\"speedtest.ping\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block\n        label=\"speedtest.download\"\n        value={t(\"common.bitrate\", {\n          value: widget.version === 2 ? speedtestData.data.download * 8 : speedtestData.data.download * 1000 * 1000,\n          decimals: bitratePrecision,\n        })}\n      />\n      <Block\n        label=\"speedtest.upload\"\n        value={t(\"common.bitrate\", {\n          value: widget.version === 2 ? speedtestData.data.upload * 8 : speedtestData.data.upload * 1000 * 1000,\n          decimals: bitratePrecision,\n        })}\n      />\n      <Block\n        label=\"speedtest.ping\"\n        value={t(\"common.ms\", {\n          value: speedtestData.data.ping,\n          style: \"unit\",\n          unit: \"millisecond\",\n        })}\n        highlightValue={speedtestData.data.ping}\n      />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/speedtest/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/speedtest/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"speedtest\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"speedtest.download\")).toBeInTheDocument();\n    expect(screen.getByText(\"speedtest.upload\")).toBeInTheDocument();\n    expect(screen.getByText(\"speedtest.ping\")).toBeInTheDocument();\n  });\n\n  it(\"renders values for version 2 endpoint (multiples by 8)\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { data: { download: 10, upload: 20, ping: 3 } },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"speedtest\", version: 2 } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"speedtest.download\", 80);\n    expectBlockValue(container, \"speedtest.upload\", 160);\n    expectBlockValue(container, \"speedtest.ping\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/speedtest/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    latestv1: {\n      endpoint: \"speedtest/latest\",\n      validate: [\"data\"],\n    },\n    latestv2: {\n      endpoint: \"v1/results/latest\",\n      validate: [\"data\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/speedtest/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"speedtest widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/spoolman/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  // eslint-disable-next-line prefer-const\n  let { data: spoolData, error: spoolError } = useWidgetAPI(widget, \"spools\");\n\n  if (spoolError) {\n    return <Container service={service} error={spoolError} />;\n  }\n\n  if (!spoolData) {\n    const nBlocksGuess = widget.spoolIds?.length ?? 4;\n    return (\n      <Container service={service}>\n        {[...Array(nBlocksGuess)].map((_, i) => (\n          // eslint-disable-next-line react/no-array-index-key\n          <Block key={i} label=\"spoolman.loading\" />\n        ))}\n      </Container>\n    );\n  }\n\n  if (spoolData.error || spoolData.message) {\n    return <Container service={service} error={spoolData?.error ?? spoolData} />;\n  }\n\n  if (spoolData.length === 0) {\n    return (\n      <Container service={service}>\n        <Block label=\"spoolman.noSpools\" />\n      </Container>\n    );\n  }\n\n  if (widget.spoolIds?.length) {\n    spoolData = spoolData.filter((spool) => widget.spoolIds.includes(spool.id));\n  }\n\n  if (spoolData.length > 4) {\n    spoolData = spoolData.slice(0, 4);\n  }\n\n  return (\n    <Container service={service}>\n      {spoolData.map((spool) => (\n        <Block\n          key={spool.id}\n          label={spool.filament.name}\n          value={t(\"common.percent\", {\n            value: (spool.remaining_weight / spool.initial_weight) * 100,\n          })}\n        />\n      ))}\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/spoolman/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/spoolman/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders guessed loading blocks while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"spoolman\", spoolIds: [1, 2] } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getAllByText(\"spoolman.loading\")).toHaveLength(2);\n  });\n\n  it(\"renders no-spools message when API returns an empty list\", () => {\n    useWidgetAPI.mockReturnValue({ data: [], error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"spoolman\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"spoolman.noSpools\")).toBeInTheDocument();\n  });\n\n  it(\"filters to selected spoolIds and caps at 4 entries\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: [\n        { id: 1, remaining_weight: 50, initial_weight: 100, filament: { name: \"A\" } },\n        { id: 2, remaining_weight: 25, initial_weight: 100, filament: { name: \"B\" } },\n        { id: 3, remaining_weight: 10, initial_weight: 100, filament: { name: \"C\" } },\n        { id: 4, remaining_weight: 10, initial_weight: 100, filament: { name: \"D\" } },\n        { id: 5, remaining_weight: 10, initial_weight: 100, filament: { name: \"E\" } },\n      ],\n      error: undefined,\n    });\n\n    const service = { widget: { type: \"spoolman\", spoolIds: [2, 3, 4, 5, 1] } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    // After filtering and capping to 4, we should see 4 blocks.\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expectBlockValue(container, \"A\", 50);\n    expectBlockValue(container, \"B\", 25);\n  });\n});\n"
  },
  {
    "path": "src/widgets/spoolman/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/v1/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    spools: {\n      endpoint: \"spool\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/spoolman/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"spoolman widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/stash/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\nimport { useEffect, useState } from \"react\";\n\nimport { formatProxyUrl } from \"utils/proxy/api-helpers\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n  const [stats, setStats] = useState(null);\n\n  useEffect(() => {\n    async function fetchStats() {\n      const url = formatProxyUrl(widget, \"stats\");\n      const res = await fetch(url, { method: \"POST\" });\n      setStats(await res.json());\n    }\n    if (!stats) {\n      fetchStats();\n    }\n  }, [widget, stats]);\n\n  if (!stats) {\n    return (\n      <Container service={service}>\n        <Block label=\"stash.scenes\" />\n        <Block label=\"stash.images\" />\n      </Container>\n    );\n  }\n\n  // Provide a default if not set in the config\n  if (!widget.fields) {\n    widget.fields = [\"scenes\", \"images\"];\n  }\n\n  // Limit to a maximum of 4 at a time\n  if (widget.fields.length > 4) {\n    widget.fields = widget.fields.slice(0, 4);\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"stash.scenes\" value={t(\"common.number\", { value: stats.scene_count })} />\n      <Block label=\"stash.scenesPlayed\" value={t(\"common.number\", { value: stats.scenes_played })} />\n      <Block label=\"stash.playCount\" value={t(\"common.number\", { value: stats.total_play_count })} />\n      <Block label=\"stash.playDuration\" value={t(\"common.duration\", { value: stats.total_play_duration })} />\n      <Block\n        label=\"stash.sceneSize\"\n        value={t(\"common.bbytes\", { value: stats.scenes_size, maximumFractionDigits: 1 })}\n      />\n      <Block label=\"stash.sceneDuration\" value={t(\"common.duration\", { value: stats.scenes_duration })} />\n\n      <Block label=\"stash.images\" value={t(\"common.number\", { value: stats.image_count })} />\n      <Block\n        label=\"stash.imageSize\"\n        value={t(\"common.bbytes\", { value: stats.images_size, maximumFractionDigits: 1 })}\n      />\n\n      <Block label=\"stash.galleries\" value={t(\"common.number\", { value: stats.gallery_count })} />\n      <Block label=\"stash.performers\" value={t(\"common.number\", { value: stats.performer_count })} />\n      <Block label=\"stash.studios\" value={t(\"common.number\", { value: stats.studio_count })} />\n      <Block label=\"stash.movies\" value={t(\"common.number\", { value: stats.movie_count })} />\n      <Block label=\"stash.tags\" value={t(\"common.number\", { value: stats.tag_count })} />\n      <Block label=\"stash.oCount\" value={t(\"common.number\", { value: stats.total_o_count })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/stash/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen, waitFor } from \"@testing-library/react\";\nimport { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/stash/component\", () => {\n  const originalFetch = globalThis.fetch;\n\n  beforeEach(() => {\n    vi.restoreAllMocks();\n  });\n\n  afterEach(() => {\n    globalThis.fetch = originalFetch;\n  });\n\n  it(\"renders placeholders initially, then renders stats after fetch\", async () => {\n    globalThis.fetch = vi.fn(async () => ({\n      json: async () => ({\n        scene_count: 1,\n        scenes_played: 2,\n        total_play_count: 3,\n        total_play_duration: 4,\n        scenes_size: 5,\n        scenes_duration: 6,\n        image_count: 7,\n        images_size: 8,\n        gallery_count: 9,\n        performer_count: 10,\n        studio_count: 11,\n        movie_count: 12,\n        tag_count: 13,\n        total_o_count: 14,\n      }),\n    }));\n\n    const service = { widget: { type: \"stash\", url: \"http://x\", key: \"k\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"stash.scenes\")).toBeInTheDocument();\n    expect(screen.getByText(\"stash.images\")).toBeInTheDocument();\n\n    await waitFor(() => {\n      expectBlockValue(container, \"stash.scenes\", 1);\n      expectBlockValue(container, \"stash.images\", 7);\n    });\n  });\n});\n"
  },
  {
    "path": "src/widgets/stash/widget.js",
    "content": "import { asJson } from \"utils/proxy/api-helpers\";\nimport genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/{endpoint}?apikey={key}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    stats: {\n      method: \"POST\",\n      endpoint: \"graphql\",\n      headers: {\n        \"content-type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        query: `{\n          stats {\n            scene_count\n            scenes_size\n            scenes_duration\n            image_count\n            images_size\n            gallery_count\n            performer_count\n            studio_count\n            movie_count\n            tag_count\n            total_o_count\n            total_play_duration\n            total_play_count\n            scenes_played\n          }\n        }`,\n      }),\n      map: (data) => asJson(data).data.stats,\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/stash/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"stash widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/stocks/component.jsx",
    "content": "import classNames from \"classnames\";\nimport Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nfunction MarketStatus({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { data, error } = useWidgetAPI(widget, \"status\", {\n    exchange: \"US\",\n  });\n\n  if (error || data?.error) {\n    return <Container service={service} error={error} />;\n  }\n\n  if (!data) {\n    return (\n      <Container service={service}>\n        <Block value={t(\"stocks.loading\")} />\n      </Container>\n    );\n  }\n\n  const { isOpen } = data;\n\n  if (isOpen) {\n    return (\n      <span className=\"inline-flex items-center rounded-md bg-green-500/10 px-2 py-1 text-xs font-medium text-green-400/90 ring-1 ring-inset ring-green-500/20\">\n        {t(\"stocks.open\")}\n      </span>\n    );\n  }\n\n  return (\n    <span className=\"inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400/60 ring-1 ring-inset ring-red-400/10\">\n      {t(\"stocks.closed\")}\n    </span>\n  );\n}\n\nfunction StockItem({ service, ticker }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { data, error } = useWidgetAPI(widget, \"quote\", { symbol: ticker });\n\n  if (error || data?.error) {\n    return <Container service={service} error={error} />;\n  }\n\n  if (!data) {\n    return (\n      <Container service={service}>\n        <Block value={t(\"stocks.loading\")} />\n      </Container>\n    );\n  }\n\n  return (\n    <div className=\"bg-theme-200/50 dark:bg-theme-900/20 rounded-sm flex flex-1 items-center justify-between m-1 p-1 text-xs\">\n      <span className=\"font-thin ml-2 flex-none\">{ticker}</span>\n      <div className=\"flex items-center flex-row-reverse mr-2 text-right\">\n        <span className={`font-bold ml-2 w-10 ${data.dp > 0 ? \"text-emerald-300\" : \"text-rose-300\"}`}>\n          {data.dp?.toFixed(2) ? `${data.dp?.toFixed(2)}%` : t(\"widget.api_error\")}\n        </span>\n        <span className=\"font-bold\">\n          {data.c\n            ? t(\"common.number\", {\n                value: data?.c,\n                style: \"currency\",\n                currency: \"USD\",\n              })\n            : t(\"widget.api_error\")}\n        </span>\n      </div>\n    </div>\n  );\n}\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n  const { watchlist, showUSMarketStatus } = widget;\n\n  if (!watchlist || !watchlist.length || watchlist.length > 28 || new Set(watchlist).size !== watchlist.length) {\n    return (\n      <Container service={service}>\n        <Block value={t(\"stocks.invalidConfiguration\")} />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <div className={classNames(service.description ? \"-top-10\" : \"-top-8\", \"absolute right-1 z-20\")}>\n        {showUSMarketStatus === true && <MarketStatus service={service} />}\n      </div>\n\n      <div className=\"flex flex-col w-full\">\n        {watchlist.map((ticker) => (\n          <StockItem key={ticker} service={service} ticker={ticker} />\n        ))}\n      </div>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/stocks/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/stocks/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders invalid configuration message when watchlist is empty\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"stocks\", watchlist: [] } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"stocks.invalidConfiguration\")).toBeInTheDocument();\n  });\n\n  it(\"renders stock items for a valid watchlist\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"quote\") return { data: { dp: 1.23, c: 100 }, error: undefined };\n      if (endpoint === \"status\") return { data: { isOpen: true }, error: undefined };\n      return { data: undefined, error: undefined };\n    });\n\n    renderWithProviders(\n      <Component service={{ widget: { type: \"stocks\", watchlist: [\"AAPL\"], showUSMarketStatus: true } }} />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(screen.getByText(\"AAPL\")).toBeInTheDocument();\n    expect(screen.getByText(\"1.23%\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/stocks/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: `https://finnhub.io/api/{endpoint}`,\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    quote: {\n      // https://finnhub.io/docs/api/quote\n      endpoint: \"v1/quote\",\n      params: [\"symbol\"],\n    },\n    status: {\n      // https://finnhub.io/docs/api/market-status\n      endpoint: \"v1/stock/market-status\",\n      params: [\"exchange\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/stocks/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"stocks widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/strelaysrv/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: statsData, error: statsError } = useWidgetAPI(widget, \"status\");\n\n  if (statsError) {\n    return <Container service={service} error={statsError} />;\n  }\n\n  if (!statsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"strelaysrv.numActiveSessions\" />\n        <Block label=\"strelaysrv.numConnections\" />\n        <Block label=\"strelaysrv.bytesProxied\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"strelaysrv.numActiveSessions\" value={t(\"common.number\", { value: statsData.numActiveSessions })} />\n      <Block label=\"strelaysrv.numConnections\" value={t(\"common.number\", { value: statsData.numConnections })} />\n      <Block\n        label=\"strelaysrv.dataRelayed\"\n        value={t(\"common.bytes\", { value: statsData.bytesProxied })}\n        highlightValue={statsData.bytesProxied}\n      />\n      <Block\n        label=\"strelaysrv.transferRate\"\n        value={t(\"common.bitrate\", { value: statsData.kbps10s1m5m15m30m60m[5] })}\n        highlightValue={statsData.kbps10s1m5m15m30m60m[5]}\n      />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/strelaysrv/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/strelaysrv/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"strelaysrv\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"strelaysrv.numActiveSessions\")).toBeInTheDocument();\n    expect(screen.getByText(\"strelaysrv.numConnections\")).toBeInTheDocument();\n    expect(screen.getByText(\"strelaysrv.bytesProxied\")).toBeInTheDocument();\n  });\n\n  it(\"renders metrics when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { numActiveSessions: 1, numConnections: 2, bytesProxied: 3, kbps10s1m5m15m30m60m: [0, 0, 0, 0, 0, 123] },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"strelaysrv\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"strelaysrv.numActiveSessions\", 1);\n    expectBlockValue(container, \"strelaysrv.numConnections\", 2);\n    expectBlockValue(container, \"strelaysrv.dataRelayed\", 3);\n    expectBlockValue(container, \"strelaysrv.transferRate\", 123);\n  });\n});\n"
  },
  {
    "path": "src/widgets/strelaysrv/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    status: {\n      endpoint: \"status\",\n      validate: [\"numActiveSessions\", \"numConnections\", \"bytesProxied\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/strelaysrv/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"strelaysrv widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/suwayomi/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: suwayomiData, error: suwayomiError } = useWidgetAPI(widget);\n\n  if (suwayomiError) {\n    return <Container service={service} error={suwayomiError} />;\n  }\n\n  if (!suwayomiData) {\n    if (!widget.fields || widget.fields.length === 0) {\n      widget.fields = [\"download\", \"nondownload\", \"read\", \"unread\"];\n    } else if (widget.fields.length > 4) {\n      widget.fields = widget.fields.slice(0, 4);\n    }\n    return (\n      <Container service={service}>\n        {widget.fields.map((field) => (\n          <Block key={field} label={`suwayomi.${field}`} />\n        ))}\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      {suwayomiData.map((data) => (\n        <Block key={data.label} label={data.label} value={t(\"common.number\", { value: data.count })} />\n      ))}\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/suwayomi/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/suwayomi/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"defaults fields while loading and renders placeholders\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"suwayomi\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"download\", \"nondownload\", \"read\", \"unread\"]);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"suwayomi.download\")).toBeInTheDocument();\n    expect(screen.getByText(\"suwayomi.nondownload\")).toBeInTheDocument();\n    expect(screen.getByText(\"suwayomi.read\")).toBeInTheDocument();\n    expect(screen.getByText(\"suwayomi.unread\")).toBeInTheDocument();\n  });\n\n  it(\"renders mapped label blocks when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: [\n        { label: \"suwayomi.download\", count: 1 },\n        { label: \"suwayomi.read\", count: 2 },\n      ],\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"suwayomi\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"suwayomi.download\", 1);\n    expectBlockValue(container, \"suwayomi.read\", 2);\n  });\n});\n"
  },
  {
    "path": "src/widgets/suwayomi/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst proxyName = \"suwayomiProxyHandler\";\nconst logger = createLogger(proxyName);\n\nconst countsToExtract = {\n  download: {\n    condition: (c) => c.isDownloaded,\n    gqlCondition: \"isDownloaded: true\",\n  },\n  nondownload: {\n    condition: (c) => !c.isDownloaded,\n    gqlCondition: \"isDownloaded: false\",\n  },\n  read: {\n    condition: (c) => c.isRead,\n    gqlCondition: \"isRead: true\",\n  },\n  unread: {\n    condition: (c) => !c.isRead,\n    gqlCondition: \"isRead: false\",\n  },\n  downloadedread: {\n    condition: (c) => c.isDownloaded && c.isRead,\n    gqlCondition: \"isDownloaded: true, isRead: true\",\n  },\n  downloadedunread: {\n    condition: (c) => c.isDownloaded && !c.isRead,\n    gqlCondition: \"isDownloaded: true, isRead: false\",\n  },\n  nondownloadedread: {\n    condition: (c) => !c.isDownloaded && c.isRead,\n    gqlCondition: \"isDownloaded: false, isRead: true\",\n  },\n  nondownloadedunread: {\n    condition: (c) => !c.isDownloaded && !c.isRead,\n    gqlCondition: \"isDownloaded: false, isRead: false\",\n  },\n};\n\nfunction makeBody(fields, category = \"all\") {\n  if (Number.isNaN(Number(category))) {\n    let query = \"\";\n    fields.forEach((field) => {\n      query += `\n      ${field}: chapters(\n        condition: {${countsToExtract[field].gqlCondition}}\n        filter: {inLibrary: {equalTo: true}}\n      ) {\n        totalCount\n      }`;\n    });\n    return JSON.stringify({\n      operationName: \"Counts\",\n      query: `\n      query Counts {\n        ${query}\n      }`,\n    });\n  }\n\n  return JSON.stringify({\n    operationName: \"category\",\n    query: `\n    query category($id: Int!) {\n      category(id: $id) {\n        # name\n        mangas {\n          nodes {\n            chapters {\n              nodes {\n                isRead\n                isDownloaded\n              }\n            }\n          }\n        }\n      }\n    }`,\n    variables: {\n      id: Number(category),\n    },\n  });\n}\n\nfunction extractCounts(responseJSON, fields) {\n  if (!(\"category\" in responseJSON.data)) {\n    return fields.map((field) => ({\n      count: responseJSON.data[field].totalCount,\n      label: `suwayomi.${field}`,\n    }));\n  }\n  const tmp = responseJSON.data.category.mangas.nodes.reduce(\n    (accumulator, manga) => {\n      manga.chapters.nodes.forEach((chapter) => {\n        fields.forEach((field, i) => {\n          if (countsToExtract[field].condition(chapter)) {\n            accumulator[i] += 1;\n          }\n        });\n      });\n      return accumulator;\n    },\n    [0, 0, 0, 0],\n  );\n  return fields.map((field, i) => ({\n    count: tmp[i],\n    label: `suwayomi.${field}`,\n  }));\n}\n\nexport default async function suwayomiProxyHandler(req, res) {\n  const { group, service, endpoint, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  if (!widget.fields || widget.fields.length === 0) {\n    widget.fields = [\"download\", \"nondownload\", \"read\", \"unread\"];\n  } else if (widget.fields.length > 4) {\n    widget.fields = widget.fields.slice(0, 4);\n  }\n\n  const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));\n\n  const body = makeBody(widget.fields, widget.category);\n\n  const headers = {\n    \"Content-Type\": \"application/json\",\n  };\n\n  if (widget.username && widget.password) {\n    headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString(\"base64\")}`;\n  }\n\n  const [status, contentType, data] = await httpProxy(url, {\n    method: \"POST\",\n    body,\n    headers,\n  });\n\n  if (status === 401) {\n    logger.error(\"Invalid or missing username or password for service '%s' in group '%s'\", service, group);\n    return res.status(status).send({ error: { message: \"401: unauthorized, username or password is incorrect.\" } });\n  }\n\n  if (status !== 200) {\n    logger.error(\n      \"Error getting data from Suwayomi for service '%s' in group '%s': %d.  Data: %s\",\n      service,\n      group,\n      status,\n      data,\n    );\n    return res.status(status).send({ error: { message: \"Error getting data. body: %s, data: %s\", body, data } });\n  }\n\n  const returnData = extractCounts(JSON.parse(data), widget.fields);\n\n  if (contentType) res.setHeader(\"Content-Type\", contentType);\n  return res.status(status).send(returnData);\n}\n"
  },
  {
    "path": "src/widgets/suwayomi/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  logger: { debug: vi.fn(), error: vi.fn() },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    suwayomi: {\n      api: \"{url}/graphql\",\n    },\n  },\n}));\n\nimport suwayomiProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/suwayomi/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"returns extracted counts from GraphQL response (no category)\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"suwayomi\", url: \"http://su\", fields: [\"download\", \"unread\"] });\n\n    httpProxy.mockResolvedValueOnce([\n      200,\n      \"application/json\",\n      Buffer.from(JSON.stringify({ data: { download: { totalCount: 2 }, unread: { totalCount: 5 } } })),\n    ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"graphql\", index: \"0\" } };\n    const res = createMockRes();\n\n    await suwayomiProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual([\n      { count: 2, label: \"suwayomi.download\" },\n      { count: 5, label: \"suwayomi.unread\" },\n    ]);\n  });\n\n  it(\"returns 401 when credentials are invalid\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"suwayomi\", url: \"http://su\", username: \"u\", password: \"p\" });\n    httpProxy.mockResolvedValueOnce([401, \"application/json\", Buffer.from(\"{}\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"graphql\", index: \"0\" } };\n    const res = createMockRes();\n\n    await suwayomiProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(401);\n    expect(res.body.error.message).toContain(\"unauthorized\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/suwayomi/widget.js",
    "content": "import suwayomiProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/api/graphql\",\n  proxyHandler: suwayomiProxyHandler,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/suwayomi/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"suwayomi widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/swagdashboard/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  const { data: swagData, error: swagError } = useWidgetAPI(widget, \"overview\");\n\n  if (swagError) {\n    return <Container service={service} error={swagError} />;\n  }\n\n  if (!swagData) {\n    return (\n      <Container service={service}>\n        <Block label=\"swagdashboard.proxied\" />\n        <Block label=\"swagdashboard.auth\" />\n        <Block label=\"swagdashboard.outdated\" />\n        <Block label=\"swagdashboard.banned\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"swagdashboard.proxied\" value={swagData.proxied} />\n      <Block label=\"swagdashboard.auth\" value={swagData.auth} />\n      <Block label=\"swagdashboard.outdated\" value={swagData.outdated} />\n      <Block label=\"swagdashboard.banned\" value={swagData.banned} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/swagdashboard/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/swagdashboard/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"swagdashboard\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"swagdashboard.proxied\")).toBeInTheDocument();\n    expect(screen.getByText(\"swagdashboard.auth\")).toBeInTheDocument();\n    expect(screen.getByText(\"swagdashboard.outdated\")).toBeInTheDocument();\n    expect(screen.getByText(\"swagdashboard.banned\")).toBeInTheDocument();\n  });\n\n  it(\"renders values when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { proxied: 1, auth: 2, outdated: 3, banned: 4 },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"swagdashboard\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"1\")).toBeInTheDocument();\n    expect(screen.getByText(\"2\")).toBeInTheDocument();\n    expect(screen.getByText(\"3\")).toBeInTheDocument();\n    expect(screen.getByText(\"4\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/swagdashboard/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/?stats=true\",\n  proxyHandler: genericProxyHandler,\n  allowedEndpoints: /overview/,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/swagdashboard/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"swagdashboard widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/tailscale/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: statsData, error: statsError } = useWidgetAPI(widget, \"device\");\n\n  if (statsError || statsData?.message) {\n    return <Container service={service} error={statsError ?? statsData} />;\n  }\n\n  if (!statsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"tailscale.address\" />\n        <Block label=\"tailscale.last_seen\" />\n        <Block label=\"tailscale.expires\" />\n      </Container>\n    );\n  }\n\n  const {\n    addresses: [address],\n    keyExpiryDisabled,\n    lastSeen,\n    expires,\n  } = statsData;\n\n  const now = new Date();\n  const compareDifferenceInTwoDates = (priorDate, futureDate) => {\n    const diff = futureDate.getTime() - priorDate.getTime();\n    const diffInYears = Math.ceil(diff / (1000 * 60 * 60 * 24 * 365));\n    if (diffInYears > 1) return t(\"tailscale.years\", { number: diffInYears });\n    const diffInWeeks = Math.ceil(diff / (1000 * 60 * 60 * 24 * 7));\n    if (diffInWeeks > 1) return t(\"tailscale.weeks\", { number: diffInWeeks });\n    const diffInDays = Math.ceil(diff / (1000 * 60 * 60 * 24));\n    if (diffInDays > 1) return t(\"tailscale.days\", { number: diffInDays });\n    const diffInHours = Math.ceil(diff / (1000 * 60 * 60));\n    if (diffInHours > 1) return t(\"tailscale.hours\", { number: diffInHours });\n    const diffInMinutes = Math.ceil(diff / (1000 * 60));\n    if (diffInMinutes > 1) return t(\"tailscale.minutes\", { number: diffInMinutes });\n    const diffInSeconds = Math.ceil(diff / 1000);\n    if (diffInSeconds > 10) return t(\"tailscale.seconds\", { number: diffInSeconds });\n    return \"Now\";\n  };\n\n  const getLastSeen = () => {\n    const date = new Date(lastSeen);\n    const diff = compareDifferenceInTwoDates(date, now);\n    return diff === \"Now\" ? t(\"tailscale.now\") : t(\"tailscale.ago\", { value: diff });\n  };\n\n  const getExpiry = () => {\n    if (keyExpiryDisabled) return t(\"tailscale.never\");\n    const date = new Date(expires);\n    return compareDifferenceInTwoDates(now, date);\n  };\n\n  return (\n    <Container service={service}>\n      <Block label=\"tailscale.address\" value={address} />\n      <Block label=\"tailscale.last_seen\" value={getLastSeen()} />\n      <Block label=\"tailscale.expires\" value={getExpiry()} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/tailscale/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue, findServiceBlockByLabel } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/tailscale/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    vi.useFakeTimers();\n    vi.setSystemTime(new Date(\"2020-01-01T00:00:00Z\"));\n  });\n\n  afterEach(() => {\n    vi.useRealTimers();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"tailscale\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"tailscale.address\")).toBeInTheDocument();\n    expect(screen.getByText(\"tailscale.last_seen\")).toBeInTheDocument();\n    expect(screen.getByText(\"tailscale.expires\")).toBeInTheDocument();\n  });\n\n  it(\"renders address and expiry/last-seen strings when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        addresses: [\"100.64.0.1\"],\n        keyExpiryDisabled: true,\n        lastSeen: \"2019-12-31T23:00:00Z\",\n        expires: \"2021-01-01T00:00:00Z\",\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"tailscale\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"tailscale.address\", \"100.64.0.1\");\n    expect(findServiceBlockByLabel(container, \"tailscale.last_seen\")?.textContent).toContain(\"tailscale.ago\");\n    expectBlockValue(container, \"tailscale.expires\", \"tailscale.never\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/tailscale/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"https://api.tailscale.com/api/v2/{endpoint}/{deviceid}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    device: {\n      endpoint: \"device\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/tailscale/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"tailscale widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/tandoor/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  const { data: spaceData, error: spaceError } = useWidgetAPI(widget, \"space\");\n  const { data: keywordData, error: keywordError } = useWidgetAPI(widget, \"keyword\");\n\n  if (spaceError || keywordError) {\n    const finalError = spaceError ?? keywordError;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!spaceData || !keywordData) {\n    return (\n      <Container service={service}>\n        <Block label=\"tandoor.users\" />\n        <Block label=\"tandoor.recipes\" />\n        <Block label=\"tandoor.keywords\" />\n      </Container>\n    );\n  }\n\n  const space = spaceData.results ? spaceData.results[0] : spaceData[0];\n\n  return (\n    <Container service={service}>\n      <Block label=\"tandoor.users\" value={space?.user_count} />\n      <Block label=\"tandoor.recipes\" value={space?.recipe_count} />\n      <Block label=\"tandoor.keywords\" value={keywordData.count} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/tandoor/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/tandoor/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"tandoor\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"tandoor.users\")).toBeInTheDocument();\n    expect(screen.getByText(\"tandoor.recipes\")).toBeInTheDocument();\n    expect(screen.getByText(\"tandoor.keywords\")).toBeInTheDocument();\n  });\n\n  it(\"renders values when loaded (spaceData.results shape)\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"space\") return { data: { results: [{ user_count: 1, recipe_count: 2 }] }, error: undefined };\n      if (endpoint === \"keyword\") return { data: { count: 3 }, error: undefined };\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"tandoor\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"tandoor.users\", 1);\n    expectBlockValue(container, \"tandoor.recipes\", 2);\n    expectBlockValue(container, \"tandoor.keywords\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/tandoor/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}/\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    space: {\n      endpoint: \"space\",\n    },\n    keyword: {\n      endpoint: \"keyword\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/tandoor/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"tandoor widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/tautulli/component.jsx",
    "content": "/* eslint-disable camelcase */\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\nimport { BsCpu, BsFillCpuFill, BsFillPlayFill, BsPauseFill } from \"react-icons/bs\";\nimport { MdOutlineSmartDisplay, MdSmartDisplay } from \"react-icons/md\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nfunction millisecondsToTime(milliseconds) {\n  const seconds = Math.floor((milliseconds / 1000) % 60);\n  const minutes = Math.floor((milliseconds / (1000 * 60)) % 60);\n  const hours = Math.floor((milliseconds / (1000 * 60 * 60)) % 24);\n  return { hours, minutes, seconds };\n}\n\nfunction millisecondsToString(milliseconds) {\n  const { hours, minutes, seconds } = millisecondsToTime(milliseconds);\n  const parts = [];\n  if (hours > 0) {\n    parts.push(hours);\n  }\n  parts.push(minutes);\n  parts.push(seconds);\n\n  return parts.map((part) => part.toString().padStart(2, \"0\")).join(\":\");\n}\n\nfunction generateStreamTitle(session, enableUser, showEpisodeNumber) {\n  let stream_title = \"\";\n  const { media_type, parent_media_index, media_index, title, grandparent_title, full_title, friendly_name } = session;\n  if (media_type === \"episode\" && showEpisodeNumber) {\n    const season_str = `S${parent_media_index.toString().padStart(2, \"0\")}`;\n    const episode_str = `E${media_index.toString().padStart(2, \"0\")}`;\n    stream_title = `${grandparent_title}: ${season_str} · ${episode_str} - ${title}`;\n  } else {\n    stream_title = full_title;\n  }\n\n  return enableUser ? `${stream_title} (${friendly_name})` : stream_title;\n}\n\nfunction SingleSessionEntry({ session, enableUser, showEpisodeNumber }) {\n  const { duration, view_offset, progress_percent, state, video_decision, audio_decision } = session;\n\n  const stream_title = generateStreamTitle(session, enableUser, showEpisodeNumber);\n\n  return (\n    <>\n      <div className=\"text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex\">\n        <div className=\"text-xs z-10 self-center ml-2 relative w-full h-4 grow mr-2\">\n          <div className=\"absolute w-full whitespace-nowrap text-ellipsis overflow-hidden\" title={stream_title}>\n            {stream_title}\n          </div>\n        </div>\n        <div className=\"self-center text-xs flex justify-end mr-1.5 pl-1\">\n          {video_decision === \"direct play\" && audio_decision === \"direct play\" && (\n            <MdSmartDisplay className=\"opacity-50\" />\n          )}\n          {video_decision === \"copy\" && audio_decision === \"copy\" && <MdOutlineSmartDisplay className=\"opacity-50\" />}\n          {video_decision !== \"copy\" &&\n            video_decision !== \"direct play\" &&\n            (audio_decision !== \"copy\" || audio_decision !== \"direct play\") && <BsFillCpuFill className=\"opacity-50\" />}\n          {(video_decision === \"copy\" || video_decision === \"direct play\") &&\n            audio_decision !== \"copy\" &&\n            audio_decision !== \"direct play\" && <BsCpu className=\"opacity-50\" />}\n        </div>\n      </div>\n\n      <div className=\"text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex\">\n        <div\n          className=\"absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0\"\n          style={{\n            width: `${progress_percent}%`,\n          }}\n        />\n        <div className=\"text-xs z-10 self-center ml-1\">\n          {state === \"paused\" && (\n            <BsPauseFill className=\"inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80\" />\n          )}\n          {state !== \"paused\" && (\n            <BsFillPlayFill className=\"inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80\" />\n          )}\n        </div>\n        <div className=\"grow \" />\n        <div className=\"self-center text-xs flex justify-end mr-2 z-10\">\n          {millisecondsToString(view_offset)}\n          <span className=\"mx-0.5 text-[8px]\">/</span>\n          {millisecondsToString(duration)}\n        </div>\n      </div>\n    </>\n  );\n}\n\nfunction SessionEntry({ session, enableUser, showEpisodeNumber }) {\n  const { view_offset, progress_percent, state, video_decision, audio_decision } = session;\n\n  const stream_title = generateStreamTitle(session, enableUser, showEpisodeNumber);\n\n  return (\n    <div className=\"text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex\">\n      <div\n        className=\"absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0\"\n        style={{\n          width: `${progress_percent}%`,\n        }}\n      />\n      <div className=\"text-xs z-10 self-center ml-1\">\n        {state === \"paused\" && (\n          <BsPauseFill className=\"inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80\" />\n        )}\n        {state !== \"paused\" && (\n          <BsFillPlayFill className=\"inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80\" />\n        )}\n      </div>\n      <div className=\"text-xs z-10 self-center ml-2 relative w-full h-4 grow mr-2\">\n        <div className=\"absolute w-full whitespace-nowrap text-ellipsis overflow-hidden\" title={stream_title}>\n          {stream_title}\n        </div>\n      </div>\n      <div className=\"self-center text-xs flex justify-end mr-1.5 pl-1 z-10\">\n        {video_decision === \"direct play\" && audio_decision === \"direct play\" && (\n          <MdSmartDisplay className=\"opacity-50\" />\n        )}\n        {video_decision === \"copy\" && audio_decision === \"copy\" && <MdOutlineSmartDisplay className=\"opacity-50\" />}\n        {video_decision !== \"copy\" &&\n          video_decision !== \"direct play\" &&\n          (audio_decision !== \"copy\" || audio_decision !== \"direct play\") && <BsFillCpuFill className=\"opacity-50\" />}\n        {(video_decision === \"copy\" || video_decision === \"direct play\") &&\n          audio_decision !== \"copy\" &&\n          audio_decision !== \"direct play\" && <BsCpu className=\"opacity-50\" />}\n      </div>\n      <div className=\"self-center text-xs flex justify-end mr-2 z-10\">{millisecondsToString(view_offset)}</div>\n    </div>\n  );\n}\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: activityData, error: activityError } = useWidgetAPI(widget, \"get_activity\", {\n    refreshInterval: 5000,\n  });\n\n  const enableUser = !!service.widget?.enableUser; // default is false\n  const expandOneStreamToTwoRows = service.widget?.expandOneStreamToTwoRows !== false; // default is true\n  const showEpisodeNumber = !!service.widget?.showEpisodeNumber; // default is false\n\n  if (activityError || (activityData && Object.keys(activityData.response.data).length === 0)) {\n    return <Container service={service} error={activityError ?? { message: t(\"tautulli.plex_connection_error\") }} />;\n  }\n\n  if (!activityData) {\n    return (\n      <div className=\"flex flex-col pb-1 mx-1\">\n        <div className=\"text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1\">\n          <span className=\"absolute left-2 text-xs mt-[2px]\">-</span>\n        </div>\n        {expandOneStreamToTwoRows && (\n          <div className=\"text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1\">\n            <span className=\"absolute left-2 text-xs mt-[2px]\">-</span>\n          </div>\n        )}\n      </div>\n    );\n  }\n\n  const playing = activityData.response.data.sessions.sort((a, b) => {\n    if (a.view_offset > b.view_offset) {\n      return 1;\n    }\n    if (a.view_offset < b.view_offset) {\n      return -1;\n    }\n    return 0;\n  });\n\n  if (playing.length === 0) {\n    return (\n      <div className=\"flex flex-col pb-1 mx-1\">\n        <div className=\"text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1\">\n          <span className=\"absolute left-2 text-xs mt-[2px]\">{t(\"tautulli.no_active\")}</span>\n        </div>\n        {expandOneStreamToTwoRows && (\n          <div className=\"text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1\">\n            <span className=\"absolute left-2 text-xs mt-[2px]\">-</span>\n          </div>\n        )}\n      </div>\n    );\n  }\n\n  if (expandOneStreamToTwoRows && playing.length === 1) {\n    const session = playing[0];\n    return (\n      <div className=\"flex flex-col pb-1 mx-1\">\n        <SingleSessionEntry session={session} enableUser={enableUser} showEpisodeNumber={showEpisodeNumber} />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex flex-col pb-1 mx-1\">\n      {playing.map((session) => (\n        <SessionEntry\n          key={session.session_key}\n          session={session}\n          enableUser={enableUser}\n          showEpisodeNumber={showEpisodeNumber}\n        />\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/widgets/tautulli/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/tautulli/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholder rows while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"tautulli\" } }} />, { settings: { hideErrors: false } });\n\n    // Default behavior shows 2 placeholder rows, but just assert we see at least one.\n    expect(screen.getAllByText(\"-\").length).toBeGreaterThan(0);\n  });\n\n  it(\"renders no-active message when there are no sessions\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { response: { data: { sessions: [] } } },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"tautulli\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"tautulli.no_active\")).toBeInTheDocument();\n  });\n\n  it(\"renders an expanded two-row entry when a single session is playing\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        response: {\n          data: {\n            sessions: [\n              {\n                session_key: \"1\",\n                full_title: \"Movie\",\n                media_type: \"movie\",\n                duration: 2000,\n                view_offset: 1000,\n                progress_percent: 50,\n                state: \"playing\",\n                video_decision: \"direct play\",\n                audio_decision: \"direct play\",\n              },\n            ],\n          },\n        },\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"tautulli\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"Movie\")).toBeInTheDocument();\n    // view_offset 1s => \"00:01\", duration 2s => \"00:02\"\n    expect(screen.getByText(/00:01/)).toBeInTheDocument();\n    expect(screen.getByText(/00:02/)).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/tautulli/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/v2?apikey={key}&cmd={endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    get_activity: {\n      endpoint: \"get_activity\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/tautulli/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"tautulli widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/tdarr/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: tdarrData, error: tdarrError } = useWidgetAPI(widget);\n\n  if (tdarrError) {\n    return <Container service={service} error={tdarrError} />;\n  }\n\n  if (!tdarrData) {\n    return (\n      <Container service={service}>\n        <Block label=\"tdarr.queue\" />\n        <Block label=\"tdarr.processed\" />\n        <Block label=\"tdarr.errored\" />\n        <Block label=\"tdarr.saved\" />\n      </Container>\n    );\n  }\n\n  // use viewable count if it exists, which removes file count of any disabled libraries etc\n  // only shows items which are viewable in the tables in the UI\n\n  const table1Count = tdarrData.table1ViewableCount || tdarrData.table1Count;\n  const table2Count = tdarrData.table2ViewableCount || tdarrData.table2Count;\n  const table3Count = tdarrData.table3ViewableCount || tdarrData.table3Count;\n  const table4Count = tdarrData.table4ViewableCount || tdarrData.table4Count;\n  const table5Count = tdarrData.table5ViewableCount || tdarrData.table5Count;\n  const table6Count = tdarrData.table6ViewableCount || tdarrData.table6Count;\n\n  const queue = parseInt(table1Count, 10) + parseInt(table4Count, 10);\n  const processed = parseInt(table2Count, 10) + parseInt(table5Count, 10);\n  const errored = parseInt(table3Count, 10) + parseInt(table6Count, 10);\n  const saved = parseFloat(tdarrData.sizeDiff, 10) * 1000000000;\n\n  return (\n    <Container service={service}>\n      <Block label=\"tdarr.queue\" value={t(\"common.number\", { value: queue })} />\n      <Block label=\"tdarr.processed\" value={t(\"common.number\", { value: processed })} />\n      <Block label=\"tdarr.errored\" value={t(\"common.number\", { value: errored })} />\n      <Block label=\"tdarr.saved\" value={t(\"common.bytes\", { value: saved })} highlightValue={saved} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/tdarr/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/tdarr/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"tdarr\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"tdarr.queue\")).toBeInTheDocument();\n    expect(screen.getByText(\"tdarr.processed\")).toBeInTheDocument();\n    expect(screen.getByText(\"tdarr.errored\")).toBeInTheDocument();\n    expect(screen.getByText(\"tdarr.saved\")).toBeInTheDocument();\n  });\n\n  it(\"computes queue/processed/errored/saved when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        table1Count: \"1\",\n        table2Count: \"2\",\n        table3Count: \"3\",\n        table4Count: \"4\",\n        table5Count: \"5\",\n        table6Count: \"6\",\n        sizeDiff: \"1.5\",\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"tdarr\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    // queue = 1+4, processed = 2+5, errored = 3+6\n    expectBlockValue(container, \"tdarr.queue\", 5);\n    expectBlockValue(container, \"tdarr.processed\", 7);\n    expectBlockValue(container, \"tdarr.errored\", 9);\n    // saved = 1.5 * 1e9\n    expectBlockValue(container, \"tdarr.saved\", 1_500_000_000);\n  });\n});\n"
  },
  {
    "path": "src/widgets/tdarr/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst proxyName = \"tdarrProxyHandler\";\nconst logger = createLogger(proxyName);\n\nexport default async function tdarrProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n  const headers = {\n    \"content-type\": \"application/json\",\n  };\n  if (widget.key) {\n    headers[\"x-api-key\"] = `${widget.key}`;\n  }\n  const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint: undefined, ...widget }));\n  const [status, contentType, data] = await httpProxy(url, {\n    method: \"POST\",\n    body: JSON.stringify({\n      data: {\n        collection: \"StatisticsJSONDB\",\n        mode: \"getById\",\n        docID: \"statistics\",\n      },\n    }),\n    headers,\n  });\n\n  if (status !== 200) {\n    logger.error(\"Error getting data from Tdarr: %d.  Data: %s\", status, data);\n    return res.status(500).send({ error: { message: \"Error getting data from Tdarr\", url, data } });\n  }\n\n  if (contentType) res.setHeader(\"Content-Type\", contentType);\n  return res.status(status).send(data);\n}\n"
  },
  {
    "path": "src/widgets/tdarr/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  logger: { debug: vi.fn(), error: vi.fn() },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    tdarr: {\n      api: \"{url}/api\",\n    },\n  },\n}));\n\nimport tdarrProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/tdarr/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"POSTs the stats request and includes the API key header\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"tdarr\", url: \"http://td\", key: \"k\" });\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"ok\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await tdarrProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(1);\n    expect(httpProxy.mock.calls[0][0].toString()).toBe(\"http://td/api\");\n    expect(httpProxy.mock.calls[0][1].headers[\"x-api-key\"]).toBe(\"k\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual(Buffer.from(\"ok\"));\n  });\n});\n"
  },
  {
    "path": "src/widgets/tdarr/widget.js",
    "content": "import tdarrProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/api/v2/cruddb\",\n  proxyHandler: tdarrProxyHandler,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/tdarr/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"tdarr widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/technitium/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst MAX_ALLOWED_FIELDS = 4;\n\nexport const technitiumDefaultFields = [\"totalQueries\", \"totalAuthoritative\", \"totalCached\", \"totalServerFailure\"];\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const params = {\n    type: widget.range ?? \"LastHour\",\n  };\n\n  const { data: statsData, error: statsError } = useWidgetAPI(widget, \"stats\", params);\n\n  // Default fields\n  if (!widget.fields?.length > 0) {\n    widget.fields = technitiumDefaultFields;\n  }\n\n  // Limits max number of displayed fields\n  if (widget.fields?.length > MAX_ALLOWED_FIELDS) {\n    widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);\n  }\n\n  if (statsError) {\n    return <Container service={service} error={statsError} />;\n  }\n\n  if (!statsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"technitium.totalQueries\" />\n        <Block label=\"technitium.totalNoError\" />\n        <Block label=\"technitium.totalServerFailure\" />\n        <Block label=\"technitium.totalNxDomain\" />\n        <Block label=\"technitium.totalRefused\" />\n        <Block label=\"technitium.totalAuthoritative\" />\n        <Block label=\"technitium.totalRecursive\" />\n        <Block label=\"technitium.totalCached\" />\n        <Block label=\"technitium.totalBlocked\" />\n        <Block label=\"technitium.totalDropped\" />\n        <Block label=\"technitium.totalClients\" />\n      </Container>\n    );\n  }\n\n  function toPercent(value, total) {\n    return t(\"common.percent\", {\n      value: !Number.isNaN(value / total) ? 100 * (value / total) : 0,\n      maximumFractionDigits: 2,\n    });\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"technitium.totalQueries\" value={`${t(\"common.number\", { value: statsData.totalQueries })}`} />\n      <Block\n        label=\"technitium.totalNoError\"\n        value={`${t(\"common.number\", { value: statsData.totalNoError })} (${toPercent(\n          statsData.totalNoError,\n          statsData.totalQueries,\n        )})`}\n      />\n      <Block\n        label=\"technitium.totalServerFailure\"\n        value={`${t(\"common.number\", { value: statsData.totalServerFailure })} (${toPercent(\n          statsData.totalServerFailure,\n          statsData.totalQueries,\n        )})`}\n      />\n      <Block\n        label=\"technitium.totalNxDomain\"\n        value={`${t(\"common.number\", { value: statsData.totalNxDomain })} (${toPercent(\n          statsData.totalNxDomain,\n          statsData.totalQueries,\n        )})`}\n      />\n      <Block\n        label=\"technitium.totalRefused\"\n        value={`${t(\"common.number\", { value: statsData.totalRefused })} (${toPercent(\n          statsData.totalRefused,\n          statsData.totalQueries,\n        )})`}\n      />\n      <Block\n        label=\"technitium.totalAuthoritative\"\n        value={`${t(\"common.number\", { value: statsData.totalAuthoritative })} (${toPercent(\n          statsData.totalAuthoritative,\n          statsData.totalQueries,\n        )})`}\n      />\n      <Block\n        label=\"technitium.totalRecursive\"\n        value={`${t(\"common.number\", { value: statsData.totalRecursive })} (${toPercent(\n          statsData.totalRecursive,\n          statsData.totalQueries,\n        )})`}\n      />\n      <Block\n        label=\"technitium.totalCached\"\n        value={`${t(\"common.number\", { value: statsData.totalCached })} (${toPercent(\n          statsData.totalCached,\n          statsData.totalQueries,\n        )})`}\n      />\n      <Block\n        label=\"technitium.totalBlocked\"\n        value={`${t(\"common.number\", { value: statsData.totalBlocked })} (${toPercent(\n          statsData.totalBlocked,\n          statsData.totalQueries,\n        )})`}\n      />\n      <Block\n        label=\"technitium.totalDropped\"\n        value={`${t(\"common.number\", { value: statsData.totalDropped })} (${toPercent(\n          statsData.totalDropped,\n          statsData.totalQueries,\n        )})`}\n      />\n      <Block label=\"technitium.totalClients\" value={`${t(\"common.number\", { value: statsData.totalClients })}`} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/technitium/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue, findServiceBlockByLabel } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component, { technitiumDefaultFields } from \"./component\";\n\ndescribe(\"widgets/technitium/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"defaults fields to 4 and filters loading placeholders accordingly\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"technitium\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual(technitiumDefaultFields);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"technitium.totalQueries\")).toBeInTheDocument();\n    expect(screen.getByText(\"technitium.totalAuthoritative\")).toBeInTheDocument();\n    expect(screen.getByText(\"technitium.totalCached\")).toBeInTheDocument();\n    expect(screen.getByText(\"technitium.totalServerFailure\")).toBeInTheDocument();\n    expect(screen.queryByText(\"technitium.totalNoError\")).toBeNull();\n  });\n\n  it(\"renders selected totals with percentages when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        totalQueries: 100,\n        totalNoError: 50,\n        totalServerFailure: 25,\n        totalNxDomain: 25,\n      },\n      error: undefined,\n    });\n\n    const service = {\n      widget: { type: \"technitium\", fields: [\"totalQueries\", \"totalNoError\", \"totalServerFailure\", \"totalNxDomain\"] },\n    };\n\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expectBlockValue(container, \"technitium.totalQueries\", 100);\n    expectBlockValue(container, \"technitium.totalNoError\", \"50\");\n    expectBlockValue(container, \"technitium.totalNoError\", \"50\");\n    expectBlockValue(container, \"technitium.totalServerFailure\", \"25\");\n    expectBlockValue(container, \"technitium.totalNxDomain\", \"25\");\n    // Percent strings are included in parens, e.g. \"50 (50)\"\n    expect(findServiceBlockByLabel(container, \"technitium.totalNoError\")?.textContent).toContain(\"(\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/technitium/widget.js",
    "content": "import { asJson } from \"utils/proxy/api-helpers\";\nimport genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}?token={key}&utc=true\",\n  proxyHandler: genericProxyHandler,\n  mappings: {\n    stats: {\n      endpoint: \"dashboard/stats/get\",\n      validate: [\"response\", \"status\"],\n      params: [\"type\"],\n      map: (data) => asJson(data).response?.stats,\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/technitium/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"technitium widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/tracearr/component.jsx",
    "content": "/* eslint-disable camelcase */\nimport Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\nimport { BsCpu, BsFillCpuFill, BsFillPlayFill, BsPauseFill } from \"react-icons/bs\";\nimport { MdOutlineSmartDisplay, MdSmartDisplay } from \"react-icons/md\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nfunction millisecondsToTime(milliseconds) {\n  const seconds = Math.floor((milliseconds / 1000) % 60);\n  const minutes = Math.floor((milliseconds / (1000 * 60)) % 60);\n  const hours = Math.floor((milliseconds / (1000 * 60 * 60)) % 24);\n  return { hours, minutes, seconds };\n}\n\nfunction millisecondsToString(milliseconds) {\n  const { hours, minutes, seconds } = millisecondsToTime(milliseconds);\n  const parts = [];\n  if (hours > 0) {\n    parts.push(hours);\n  }\n  parts.push(minutes);\n  parts.push(seconds);\n\n  return parts.map((part) => part.toString().padStart(2, \"0\")).join(\":\");\n}\n\nfunction generateStreamTitle(session, enableUser, showEpisodeNumber) {\n  let stream_title = \"\";\n  const { mediaType, mediaTitle, showTitle, seasonNumber, episodeNumber, username } = session;\n\n  if (mediaType === \"episode\" && showEpisodeNumber) {\n    const season_str = `S${seasonNumber.toString().padStart(2, \"0\")}`;\n    const episode_str = `E${episodeNumber.toString().padStart(2, \"0\")}`;\n    stream_title = `${showTitle}: ${season_str} · ${episode_str} - ${mediaTitle}`;\n  } else if (mediaType === \"episode\") {\n    stream_title = `${showTitle} - ${mediaTitle}`;\n  } else {\n    stream_title = mediaTitle;\n  }\n\n  return enableUser ? `${stream_title} (${username})` : stream_title;\n}\n\nfunction SingleSessionEntry({ session, enableUser, showEpisodeNumber }) {\n  const { durationMs, progressMs, state, videoDecision, audioDecision } = session;\n  const progress_percent = durationMs > 0 ? (progressMs / durationMs) * 100 : 0;\n\n  const stream_title = generateStreamTitle(session, enableUser, showEpisodeNumber);\n\n  return (\n    <>\n      <div className=\"text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex\">\n        <div className=\"text-xs z-10 self-center ml-2 relative w-full h-4 grow mr-2\">\n          <div className=\"absolute w-full whitespace-nowrap text-ellipsis overflow-hidden\" title={stream_title}>\n            {stream_title}\n          </div>\n        </div>\n        <div className=\"self-center text-xs flex justify-end mr-1.5 pl-1\">\n          {videoDecision === \"directplay\" && audioDecision === \"directplay\" && (\n            <MdSmartDisplay className=\"opacity-50\" />\n          )}\n          {videoDecision === \"copy\" && audioDecision === \"copy\" && <MdOutlineSmartDisplay className=\"opacity-50\" />}\n          {videoDecision !== \"copy\" &&\n            videoDecision !== \"directplay\" &&\n            (audioDecision !== \"copy\" || audioDecision !== \"directplay\") && <BsFillCpuFill className=\"opacity-50\" />}\n          {(videoDecision === \"copy\" || videoDecision === \"directplay\") &&\n            audioDecision !== \"copy\" &&\n            audioDecision !== \"directplay\" && <BsCpu className=\"opacity-50\" />}\n        </div>\n      </div>\n\n      <div className=\"text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex\">\n        <div\n          className=\"absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0\"\n          style={{\n            width: `${progress_percent}%`,\n          }}\n        />\n        <div className=\"text-xs z-10 self-center ml-1\">\n          {state === \"paused\" && (\n            <BsPauseFill className=\"inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80\" />\n          )}\n          {state !== \"paused\" && (\n            <BsFillPlayFill className=\"inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80\" />\n          )}\n        </div>\n        <div className=\"grow \" />\n        <div className=\"self-center text-xs flex justify-end mr-2 z-10\">\n          {millisecondsToString(progressMs)}\n          <span className=\"mx-0.5 text-[8px]\">/</span>\n          {millisecondsToString(durationMs)}\n        </div>\n      </div>\n    </>\n  );\n}\n\nfunction SessionEntry({ session, enableUser, showEpisodeNumber }) {\n  const { durationMs, progressMs, state, videoDecision, audioDecision } = session;\n  const progress_percent = durationMs > 0 ? (progressMs / durationMs) * 100 : 0;\n\n  const stream_title = generateStreamTitle(session, enableUser, showEpisodeNumber);\n\n  return (\n    <div className=\"text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex\">\n      <div\n        className=\"absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0\"\n        style={{\n          width: `${progress_percent}%`,\n        }}\n      />\n      <div className=\"text-xs z-10 self-center ml-1\">\n        {state === \"paused\" && (\n          <BsPauseFill className=\"inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80\" />\n        )}\n        {state !== \"paused\" && (\n          <BsFillPlayFill className=\"inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80\" />\n        )}\n      </div>\n      <div className=\"text-xs z-10 self-center ml-2 relative w-full h-4 grow mr-2\">\n        <div className=\"absolute w-full whitespace-nowrap text-ellipsis overflow-hidden\" title={stream_title}>\n          {stream_title}\n        </div>\n      </div>\n      <div className=\"self-center text-xs flex justify-end mr-1.5 pl-1 z-10\">\n        {videoDecision === \"directplay\" && audioDecision === \"directplay\" && <MdSmartDisplay className=\"opacity-50\" />}\n        {videoDecision === \"copy\" && audioDecision === \"copy\" && <MdOutlineSmartDisplay className=\"opacity-50\" />}\n        {videoDecision !== \"copy\" &&\n          videoDecision !== \"directplay\" &&\n          (audioDecision !== \"copy\" || audioDecision !== \"directplay\") && <BsFillCpuFill className=\"opacity-50\" />}\n        {(videoDecision === \"copy\" || videoDecision === \"directplay\") &&\n          audioDecision !== \"copy\" &&\n          audioDecision !== \"directplay\" && <BsCpu className=\"opacity-50\" />}\n      </div>\n      <div className=\"self-center text-xs flex justify-end mr-2 z-10\">{millisecondsToString(progressMs)}</div>\n    </div>\n  );\n}\n\nfunction SummaryView({ service, summary, t }) {\n  return (\n    <Container service={service}>\n      <Block label=\"tracearr.streams\" value={t(\"common.number\", { value: summary.total })} />\n      <Block label=\"tracearr.transcodes\" value={t(\"common.number\", { value: summary.transcodes })} />\n      <Block label=\"tracearr.directplay\" value={t(\"common.number\", { value: summary.directPlays })} />\n      <Block label=\"tracearr.bitrate\" value={summary.totalBitrate} />\n    </Container>\n  );\n}\n\nfunction DetailsView({ playing, enableUser, showEpisodeNumber, expandOneStreamToTwoRows, t }) {\n  if (playing.length === 0) {\n    return (\n      <div className=\"flex flex-col pb-1 mx-1\">\n        <div className=\"text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1\">\n          <span className=\"absolute left-2 text-xs mt-[2px]\">{t(\"tracearr.no_active\")}</span>\n        </div>\n        {expandOneStreamToTwoRows && (\n          <div className=\"text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1\">\n            <span className=\"absolute left-2 text-xs mt-[2px]\">-</span>\n          </div>\n        )}\n      </div>\n    );\n  }\n\n  if (expandOneStreamToTwoRows && playing.length === 1) {\n    const session = playing[0];\n    return (\n      <div className=\"flex flex-col pb-1 mx-1\">\n        <SingleSessionEntry session={session} enableUser={enableUser} showEpisodeNumber={showEpisodeNumber} />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex flex-col pb-1 mx-1\">\n      {playing.map((session) => (\n        <SessionEntry\n          key={session.id}\n          session={session}\n          enableUser={enableUser}\n          showEpisodeNumber={showEpisodeNumber}\n        />\n      ))}\n    </div>\n  );\n}\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: activityData, error: activityError } = useWidgetAPI(widget, \"streams\", {\n    refreshInterval: 5000,\n  });\n\n  const enableUser = !!service.widget?.enableUser;\n  const expandOneStreamToTwoRows = service.widget?.expandOneStreamToTwoRows !== false;\n  const showEpisodeNumber = !!service.widget?.showEpisodeNumber;\n  const view = service.widget?.view ?? \"details\";\n\n  if (activityError) {\n    return <Container service={service} error={activityError} />;\n  }\n\n  // Loading state\n  if (!activityData || !activityData.data) {\n    if (view === \"summary\") {\n      return (\n        <Container service={service}>\n          <Block label=\"tracearr.streams\" />\n          <Block label=\"tracearr.transcodes\" />\n          <Block label=\"tracearr.directplay\" />\n          <Block label=\"tracearr.bitrate\" />\n        </Container>\n      );\n    }\n    return (\n      <div className=\"flex flex-col pb-1 mx-1\">\n        <div className=\"text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1\">\n          <span className=\"absolute left-2 text-xs mt-[2px]\">-</span>\n        </div>\n        {expandOneStreamToTwoRows && (\n          <div className=\"text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1\">\n            <span className=\"absolute left-2 text-xs mt-[2px]\">-</span>\n          </div>\n        )}\n      </div>\n    );\n  }\n\n  const playing = activityData.data.sort((a, b) => a.progressMs - b.progressMs);\n  const { summary } = activityData;\n\n  if (view === \"summary\") {\n    return <SummaryView service={service} summary={summary} t={t} />;\n  }\n\n  if (view === \"both\") {\n    return (\n      <>\n        <SummaryView service={service} summary={summary} t={t} />\n        <DetailsView\n          playing={playing}\n          enableUser={enableUser}\n          showEpisodeNumber={showEpisodeNumber}\n          expandOneStreamToTwoRows={expandOneStreamToTwoRows}\n          t={t}\n        />\n      </>\n    );\n  }\n\n  // Default: details view\n  return (\n    <DetailsView\n      playing={playing}\n      enableUser={enableUser}\n      showEpisodeNumber={showEpisodeNumber}\n      expandOneStreamToTwoRows={expandOneStreamToTwoRows}\n      t={t}\n    />\n  );\n}\n"
  },
  {
    "path": "src/widgets/tracearr/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nvi.mock(\"react-icons/bs\", () => ({\n  BsCpu: (props) => <svg data-testid=\"BsCpu\" {...props} />,\n  BsFillCpuFill: (props) => <svg data-testid=\"BsFillCpuFill\" {...props} />,\n  BsFillPlayFill: (props) => <svg data-testid=\"BsFillPlayFill\" {...props} />,\n  BsPauseFill: (props) => <svg data-testid=\"BsPauseFill\" {...props} />,\n}));\n\nvi.mock(\"react-icons/md\", () => ({\n  MdOutlineSmartDisplay: (props) => <svg data-testid=\"MdOutlineSmartDisplay\" {...props} />,\n  MdSmartDisplay: (props) => <svg data-testid=\"MdSmartDisplay\" {...props} />,\n}));\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/tracearr/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholder rows while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"tracearr\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(\"-\").length).toBeGreaterThan(0);\n  });\n\n  it(\"renders placeholder blocks while loading in summary view\", () => {\n    useWidgetAPI.mockReturnValue({ data: { data: null }, error: undefined });\n\n    renderWithProviders(<Component service={{ widget: { type: \"tracearr\", view: \"summary\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"tracearr.streams\")).toBeInTheDocument();\n    expect(screen.getByText(\"tracearr.transcodes\")).toBeInTheDocument();\n    expect(screen.getByText(\"tracearr.directplay\")).toBeInTheDocument();\n    expect(screen.getByText(\"tracearr.bitrate\")).toBeInTheDocument();\n  });\n\n  it(\"renders errors from the widget API\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"boom\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"tracearr\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(/widget\\.api_error\\s+widget\\.information/)).toBeInTheDocument();\n    expect(screen.getByText(/boom/)).toBeInTheDocument();\n  });\n\n  it(\"renders no-active message when there are no streams\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { data: [], summary: { total: 0, transcodes: 0, directPlays: 0, totalBitrate: \"0 Mbps\" } },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"tracearr\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"tracearr.no_active\")).toBeInTheDocument();\n  });\n\n  it(\"renders an expanded two-row entry when a single stream is playing\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          {\n            id: \"1\",\n            mediaTitle: \"Inception\",\n            mediaType: \"movie\",\n            durationMs: 7200000, // 2 hours\n            progressMs: 2700000, // 45 minutes in\n            state: \"playing\",\n            videoDecision: \"directplay\",\n            audioDecision: \"directplay\",\n          },\n        ],\n        summary: { total: 1, transcodes: 0, directPlays: 1, totalBitrate: \"20 Mbps\" },\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"tracearr\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"Inception\")).toBeInTheDocument();\n    expect(screen.getByText(/45:00/)).toBeInTheDocument(); // 45 minutes in\n    expect(screen.getByText(/02:00:00/)).toBeInTheDocument(); // 2 hour duration\n  });\n\n  it(\"uses 0% progress when duration is 0 in expanded view\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          {\n            id: \"1\",\n            mediaTitle: \"Short Clip\",\n            mediaType: \"movie\",\n            durationMs: 0,\n            progressMs: 5000,\n            state: \"playing\",\n            videoDecision: \"directplay\",\n            audioDecision: \"directplay\",\n          },\n        ],\n        summary: { total: 1, transcodes: 0, directPlays: 1, totalBitrate: \"1 Mbps\" },\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"tracearr\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    const bars = container.querySelectorAll('div[style*=\"width\"]');\n    expect(bars.length).toBeGreaterThan(0);\n    expect(bars[0]).toHaveStyle({ width: \"0%\" });\n  });\n\n  it(\"renders episode title with season/episode and username when configured\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          {\n            id: \"2\",\n            mediaTitle: \"Ozymandias\",\n            showTitle: \"Breaking Bad\",\n            mediaType: \"episode\",\n            seasonNumber: 5,\n            episodeNumber: 14,\n            durationMs: 2700000,\n            progressMs: 1200000,\n            state: \"playing\",\n            videoDecision: \"directplay\",\n            audioDecision: \"directplay\",\n            username: \"Walter\",\n          },\n        ],\n        summary: { total: 1, transcodes: 0, directPlays: 1, totalBitrate: \"10 Mbps\" },\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(\n      <Component\n        service={{\n          widget: { type: \"tracearr\", enableUser: true, showEpisodeNumber: true, expandOneStreamToTwoRows: false },\n        }}\n      />,\n      { settings: { hideErrors: false } },\n    );\n\n    expect(screen.getByText(\"Breaking Bad: S05 · E14 - Ozymandias (Walter)\")).toBeInTheDocument();\n  });\n\n  it(\"renders multiple streams including movie and tv episode\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          {\n            id: \"1\",\n            mediaTitle: \"Inception\",\n            mediaType: \"movie\",\n            durationMs: 7200000, // 2 hours\n            progressMs: 2700000, // 45 minutes in\n            state: \"playing\",\n            videoDecision: \"directplay\",\n            audioDecision: \"directplay\",\n          },\n          {\n            id: \"2\",\n            mediaTitle: \"Ozymandias\",\n            showTitle: \"Breaking Bad\",\n            mediaType: \"episode\",\n            seasonNumber: 5,\n            episodeNumber: 14,\n            durationMs: 2700000, // 45 minutes\n            progressMs: 1200000, // 20 minutes in\n            state: \"playing\",\n            videoDecision: \"transcode\",\n            audioDecision: \"directplay\",\n            username: \"Walter\",\n          },\n        ],\n        summary: { total: 2, transcodes: 1, directPlays: 1, totalBitrate: \"35 Mbps\" },\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"tracearr\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByText(\"Inception\")).toBeInTheDocument();\n    expect(screen.getByText(\"Breaking Bad - Ozymandias\")).toBeInTheDocument();\n  });\n\n  it.each([\n    [\"copy/copy shows copy icon\", { videoDecision: \"copy\", audioDecision: \"copy\" }, \"MdOutlineSmartDisplay\"],\n    [\"transcode shows cpu fill icon\", { videoDecision: \"transcode\", audioDecision: \"directplay\" }, \"BsFillCpuFill\"],\n    [\"transcode+copy shows cpu fill icon\", { videoDecision: \"transcode\", audioDecision: \"copy\" }, \"BsFillCpuFill\"],\n    [\"mixed transcode shows cpu icon\", { videoDecision: \"directplay\", audioDecision: \"transcode\" }, \"BsCpu\"],\n  ])(\"renders transcoding indicators in expanded view: %s\", (_label, decisions, expectedIcon) => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          {\n            id: \"1\",\n            mediaTitle: \"Inception\",\n            mediaType: \"movie\",\n            durationMs: 7200000,\n            progressMs: 2700000,\n            state: \"playing\",\n            ...decisions,\n          },\n        ],\n        summary: { total: 1, transcodes: 0, directPlays: 1, totalBitrate: \"20 Mbps\" },\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"tracearr\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByTestId(expectedIcon)).toBeInTheDocument();\n  });\n\n  it(\"renders a pause icon when a stream is paused in expanded view\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          {\n            id: \"1\",\n            mediaTitle: \"Inception\",\n            mediaType: \"movie\",\n            durationMs: 7200000,\n            progressMs: 2700000,\n            state: \"paused\",\n            videoDecision: \"directplay\",\n            audioDecision: \"directplay\",\n          },\n        ],\n        summary: { total: 1, transcodes: 0, directPlays: 1, totalBitrate: \"20 Mbps\" },\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"tracearr\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getByTestId(\"BsPauseFill\")).toBeInTheDocument();\n  });\n\n  it.each([\n    [\"copy/copy shows copy icon\", { videoDecision: \"copy\", audioDecision: \"copy\" }, \"MdOutlineSmartDisplay\"],\n    [\"transcode shows cpu fill icon\", { videoDecision: \"transcode\", audioDecision: \"directplay\" }, \"BsFillCpuFill\"],\n    [\"transcode+copy shows cpu fill icon\", { videoDecision: \"transcode\", audioDecision: \"copy\" }, \"BsFillCpuFill\"],\n    [\"mixed transcode shows cpu icon\", { videoDecision: \"directplay\", audioDecision: \"transcode\" }, \"BsCpu\"],\n  ])(\"renders transcoding indicators in single-row view: %s\", (_label, decisions, expectedIcon) => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          {\n            id: \"1\",\n            mediaTitle: \"Inception\",\n            mediaType: \"movie\",\n            durationMs: 7200000,\n            progressMs: 2700000,\n            state: \"playing\",\n            ...decisions,\n          },\n        ],\n        summary: { total: 1, transcodes: 0, directPlays: 1, totalBitrate: \"20 Mbps\" },\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"tracearr\", expandOneStreamToTwoRows: false } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByTestId(expectedIcon)).toBeInTheDocument();\n  });\n\n  it(\"renders a pause icon when a stream is paused in single-row view\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          {\n            id: \"1\",\n            mediaTitle: \"Inception\",\n            mediaType: \"movie\",\n            durationMs: 7200000,\n            progressMs: 2700000,\n            state: \"paused\",\n            videoDecision: \"directplay\",\n            audioDecision: \"directplay\",\n          },\n        ],\n        summary: { total: 1, transcodes: 0, directPlays: 1, totalBitrate: \"20 Mbps\" },\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"tracearr\", expandOneStreamToTwoRows: false } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByTestId(\"BsPauseFill\")).toBeInTheDocument();\n  });\n\n  it(\"uses 0% progress when duration is 0 in single-row view\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          {\n            id: \"1\",\n            mediaTitle: \"Short Clip\",\n            mediaType: \"movie\",\n            durationMs: 0,\n            progressMs: 5000,\n            state: \"playing\",\n            videoDecision: \"directplay\",\n            audioDecision: \"directplay\",\n          },\n        ],\n        summary: { total: 1, transcodes: 0, directPlays: 1, totalBitrate: \"1 Mbps\" },\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"tracearr\", expandOneStreamToTwoRows: false } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n\n    const bars = container.querySelectorAll('div[style*=\"width\"]');\n    expect(bars.length).toBeGreaterThan(0);\n    expect(bars[0]).toHaveStyle({ width: \"0%\" });\n  });\n\n  it(\"renders summary view when view option is set to summary\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [],\n        summary: { total: 5, transcodes: 2, directPlays: 3, totalBitrate: \"45 Mbps\" },\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"tracearr\", view: \"summary\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"tracearr.streams\")).toBeInTheDocument();\n    expect(screen.getByText(\"tracearr.bitrate\")).toBeInTheDocument();\n  });\n\n  it(\"renders both summary and details when view option is set to both\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          {\n            id: \"1\",\n            mediaTitle: \"Inception\",\n            mediaType: \"movie\",\n            durationMs: 7200000,\n            progressMs: 2700000,\n            state: \"playing\",\n            videoDecision: \"directplay\",\n            audioDecision: \"directplay\",\n          },\n        ],\n        summary: { total: 1, transcodes: 0, directPlays: 1, totalBitrate: \"20 Mbps\" },\n      },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"tracearr\", view: \"both\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"tracearr.streams\")).toBeInTheDocument();\n    expect(screen.getByText(\"Inception\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/tracearr/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/v1/public/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    streams: {\n      endpoint: \"streams\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/tracearr/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"tracearr widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/traefik/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  const { data: traefikData, error: traefikError } = useWidgetAPI(widget, \"overview\");\n\n  if (traefikError) {\n    return <Container service={service} error={traefikError} />;\n  }\n\n  if (!traefikData) {\n    return (\n      <Container service={service}>\n        <Block label=\"traefik.routers\" />\n        <Block label=\"traefik.services\" />\n        <Block label=\"traefik.middleware\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"traefik.routers\" value={traefikData.http.routers.total} />\n      <Block label=\"traefik.services\" value={traefikData.http.services.total} />\n      <Block label=\"traefik.middleware\" value={traefikData.http.middlewares.total} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/traefik/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/traefik/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"traefik\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"traefik.routers\")).toBeInTheDocument();\n    expect(screen.getByText(\"traefik.services\")).toBeInTheDocument();\n    expect(screen.getByText(\"traefik.middleware\")).toBeInTheDocument();\n  });\n\n  it(\"renders totals when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { http: { routers: { total: 1 }, services: { total: 2 }, middlewares: { total: 3 } } },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"traefik\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"traefik.routers\", 1);\n    expectBlockValue(container, \"traefik.services\", 2);\n    expectBlockValue(container, \"traefik.middleware\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/traefik/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    overview: {\n      endpoint: \"overview\",\n      validate: [\"http\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/traefik/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"traefik widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/transmission/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: torrentData, error: torrentError } = useWidgetAPI(widget);\n\n  if (torrentError) {\n    return <Container service={service} error={torrentError} />;\n  }\n\n  if (!torrentData) {\n    return (\n      <Container service={service}>\n        <Block label=\"transmission.leech\" />\n        <Block label=\"transmission.download\" />\n        <Block label=\"transmission.seed\" />\n        <Block label=\"transmission.upload\" />\n      </Container>\n    );\n  }\n\n  const { torrents } = torrentData.arguments;\n\n  const rateDl = torrents.reduce((acc, torrent) => acc + torrent.rateDownload, 0);\n  const rateUl = torrents.reduce((acc, torrent) => acc + torrent.rateUpload, 0);\n  const completed = torrents.filter((torrent) => torrent.percentDone === 1)?.length || 0;\n  const leech = torrents.length - completed || 0;\n\n  return (\n    <Container service={service}>\n      <Block label=\"transmission.leech\" value={t(\"common.number\", { value: leech })} />\n      <Block label=\"transmission.download\" value={t(\"common.byterate\", { value: rateDl })} highlightValue={rateDl} />\n      <Block label=\"transmission.seed\" value={t(\"common.number\", { value: completed })} />\n      <Block label=\"transmission.upload\" value={t(\"common.byterate\", { value: rateUl })} highlightValue={rateUl} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/transmission/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/transmission/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"transmission\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"transmission.leech\")).toBeInTheDocument();\n    expect(screen.getByText(\"transmission.download\")).toBeInTheDocument();\n    expect(screen.getByText(\"transmission.seed\")).toBeInTheDocument();\n    expect(screen.getByText(\"transmission.upload\")).toBeInTheDocument();\n  });\n\n  it(\"computes leech/seed counts and upload/download rates when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        arguments: {\n          torrents: [\n            { rateDownload: 10, rateUpload: 1, percentDone: 1 },\n            { rateDownload: 5, rateUpload: 2, percentDone: 0.5 },\n          ],\n        },\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"transmission\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"transmission.leech\", 1);\n    expectBlockValue(container, \"transmission.seed\", 1);\n    expectBlockValue(container, \"transmission.download\", 15);\n    expectBlockValue(container, \"transmission.upload\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/transmission/proxy.js",
    "content": "import cache from \"memory-cache\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst proxyName = \"transmissionProxyHandler\";\nconst headerCacheKey = `${proxyName}__headers`;\nconst logger = createLogger(proxyName);\n\nexport default async function transmissionProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  let headers = cache.get(`${headerCacheKey}.${service}`);\n  if (!headers) {\n    headers = {\n      \"content-type\": \"application/json\",\n    };\n    cache.put(`${headerCacheKey}.${service}`, headers);\n  }\n\n  const api = `${widget.url}${widget.rpcUrl || widgets[widget.type].rpcUrl}rpc`;\n\n  const url = new URL(formatApiCall(api, { endpoint: undefined, ...widget }));\n  const csrfHeaderName = \"x-transmission-session-id\";\n\n  const method = \"POST\";\n  const auth = `${widget.username}:${widget.password}`;\n  const body = JSON.stringify({\n    method: \"torrent-get\",\n    arguments: {\n      fields: [\"percentDone\", \"status\", \"rateDownload\", \"rateUpload\"],\n    },\n  });\n\n  let [status, contentType, data, responseHeaders] = await httpProxy(url, {\n    method,\n    auth,\n    body,\n    headers,\n  });\n\n  if (status === 409) {\n    logger.debug(\"Transmission is rejecting the request, but returning a CSRF token\");\n    headers[csrfHeaderName] = responseHeaders[csrfHeaderName];\n    cache.put(`${headerCacheKey}.${service}`, headers);\n\n    // retry the request, now with the CSRF token\n    [status, contentType, data, responseHeaders] = await httpProxy(url, {\n      method,\n      auth,\n      body,\n      headers,\n    });\n  }\n\n  if (status !== 200) {\n    logger.error(\"Error getting data from Transmission: %d.  Data: %s\", status, data);\n    return res.status(500).send({ error: { message: \"Error getting data from Transmission\", url, data } });\n  }\n\n  if (contentType) res.setHeader(\"Content-Type\", contentType);\n  return res.status(status).send(data);\n}\n"
  },
  {
    "path": "src/widgets/transmission/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {\n  const store = new Map();\n\n  return {\n    httpProxy: vi.fn(),\n    getServiceWidget: vi.fn(),\n    cache: {\n      get: vi.fn((k) => store.get(k)),\n      put: vi.fn((k, v) => store.set(k, v)),\n      del: vi.fn((k) => store.delete(k)),\n      _reset: () => store.clear(),\n    },\n    logger: {\n      debug: vi.fn(),\n      error: vi.fn(),\n    },\n  };\n});\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nvi.mock(\"memory-cache\", () => ({\n  default: cache,\n  ...cache,\n}));\n\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    transmission: {\n      rpcUrl: \"/transmission/\",\n    },\n  },\n}));\n\nimport transmissionProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/transmission/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    cache._reset();\n  });\n\n  it(\"retries after a 409 response by caching the CSRF header\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"transmission\",\n      url: \"http://tr\",\n      username: \"u\",\n      password: \"p\",\n    });\n\n    httpProxy\n      .mockResolvedValueOnce([409, \"application/json\", Buffer.from(\"nope\"), { \"x-transmission-session-id\": \"csrf\" }])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"ok\"), {}]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await transmissionProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(2);\n    expect(httpProxy.mock.calls[1][1].headers[\"x-transmission-session-id\"]).toBe(\"csrf\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual(Buffer.from(\"ok\"));\n  });\n});\n"
  },
  {
    "path": "src/widgets/transmission/widget.js",
    "content": "import transmissionProxyHandler from \"./proxy\";\n\nconst widget = {\n  rpcUrl: \"/transmission/\",\n  proxyHandler: transmissionProxyHandler,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/transmission/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"transmission widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/trilium/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { data: metricsData, error: metricsError } = useWidgetAPI(widget, \"metrics\");\n\n  if (metricsError) {\n    return <Container service={service} error={metricsError} />;\n  }\n\n  if (!metricsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"trilium.version\" />\n        <Block label=\"trilium.notesCount\" />\n        <Block label=\"trilium.dbSize\" />\n      </Container>\n    );\n  }\n\n  const version = metricsData.version?.app;\n  const notesCount = metricsData.database?.activeNotes || 0;\n  const databaseSizeBytes = metricsData.statistics?.databaseSizeBytes || 0;\n\n  return (\n    <Container service={service}>\n      <Block label=\"trilium.version\" value={version ? `v${version}` : t(\"trilium.unknown\")} />\n      <Block label=\"trilium.notesCount\" value={t(\"common.number\", { value: notesCount })} />\n      <Block label=\"trilium.dbSize\" value={t(\"common.bytes\", { value: databaseSizeBytes })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/trilium/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/trilium/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"trilium\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"trilium.version\")).toBeInTheDocument();\n    expect(screen.getByText(\"trilium.notesCount\")).toBeInTheDocument();\n    expect(screen.getByText(\"trilium.dbSize\")).toBeInTheDocument();\n  });\n\n  it(\"renders metrics when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { version: { app: \"1.0.0\" }, database: { activeNotes: 2 }, statistics: { databaseSizeBytes: 1024 } },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"trilium\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"trilium.version\", \"v1.0.0\");\n    expectBlockValue(container, \"trilium.notesCount\", 2);\n    expectBlockValue(container, \"trilium.dbSize\", 1024);\n  });\n});\n"
  },
  {
    "path": "src/widgets/trilium/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/etapi/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    metrics: {\n      endpoint: \"metrics?format=json\",\n      validate: [\"version\", \"database\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/trilium/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"trilium widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/truenas/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\nimport Pool from \"widgets/truenas/pool\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: alertData, error: alertError } = useWidgetAPI(widget, \"alerts\");\n  const { data: statusData, error: statusError } = useWidgetAPI(widget, \"status\");\n  const { data: poolsData, error: poolsError } = useWidgetAPI(widget, widget?.enablePools ? \"pools\" : \"\");\n  const { data: datasetData, error: datasetError } = useWidgetAPI(widget, widget?.enablePools ? \"dataset\" : \"\");\n\n  if (alertError || statusError || poolsError) {\n    const finalError = alertError ?? statusError ?? poolsError ?? datasetError;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!alertData || !statusData || (widget?.enablePools && (!poolsData || !datasetData))) {\n    return (\n      <Container service={service}>\n        <Block label=\"truenas.load\" />\n        <Block label=\"truenas.uptime\" />\n        <Block label=\"truenas.alerts\" />\n      </Container>\n    );\n  }\n\n  let pools = [];\n  const showPools =\n    Array.isArray(poolsData) && poolsData.length > 0 && Array.isArray(datasetData) && datasetData.length > 0;\n\n  if (showPools) {\n    pools = poolsData.map((pool) => {\n      const dataset = datasetData.find((d) => d.pool === pool.name && d.name === pool.name);\n      return {\n        id: pool.id,\n        name: pool.name,\n        healthy: pool.healthy,\n        allocated: dataset?.used.parsed ?? 0,\n        free: dataset?.available.parsed ?? 0,\n      };\n    });\n  }\n\n  return (\n    <>\n      <Container service={service}>\n        <Block label=\"truenas.load\" value={t(\"common.number\", { value: statusData.loadavg[0] })} />\n        <Block label=\"truenas.uptime\" value={t(\"common.duration\", { value: statusData.uptime_seconds })} />\n        <Block label=\"truenas.alerts\" value={t(\"common.number\", { value: alertData.pending })} />\n      </Container>\n      {showPools &&\n        pools\n          .sort((a, b) => a.name.localeCompare(b.name))\n          .map((pool) => (\n            <Pool key={pool.id} name={pool.name} healthy={pool.healthy} allocated={pool.allocated} free={pool.free} />\n          ))}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/widgets/truenas/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\n// Pool is rendered outside of the main Container; stub it to a simple marker.\nvi.mock(\"widgets/truenas/pool\", () => ({\n  default: ({ name, healthy, allocated, free }) => (\n    <div\n      data-testid=\"truenas-pool\"\n      data-name={name}\n      data-healthy={String(healthy)}\n      data-allocated={allocated}\n      data-free={free}\n    />\n  ),\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/truenas/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading (no pools)\", () => {\n    useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"truenas\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"truenas.load\")).toBeInTheDocument();\n    expect(screen.getByText(\"truenas.uptime\")).toBeInTheDocument();\n    expect(screen.getByText(\"truenas.alerts\")).toBeInTheDocument();\n    expect(screen.queryByTestId(\"truenas-pool\")).toBeNull();\n  });\n\n  it(\"renders values and pool list when enablePools is on and data is present\", () => {\n    useWidgetAPI.mockImplementation((widget, endpoint) => {\n      if (endpoint === \"alerts\") return { data: { pending: 7 }, error: undefined };\n      if (endpoint === \"status\") return { data: { loadavg: [1.23], uptime_seconds: 3600 }, error: undefined };\n      if (endpoint === \"pools\") return { data: [{ id: \"1\", name: \"tank\", healthy: true }], error: undefined };\n      if (endpoint === \"dataset\")\n        return {\n          data: [{ pool: \"tank\", name: \"tank\", used: { parsed: 10 }, available: { parsed: 20 } }],\n          error: undefined,\n        };\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"truenas\", enablePools: true } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"1.23\")).toBeInTheDocument();\n    expect(screen.getByText(\"3600\")).toBeInTheDocument(); // common.duration mocked\n    expect(screen.getByText(\"7\")).toBeInTheDocument();\n\n    const pool = screen.getByTestId(\"truenas-pool\");\n    expect(pool.getAttribute(\"data-name\")).toBe(\"tank\");\n    expect(pool.getAttribute(\"data-healthy\")).toBe(\"true\");\n    expect(pool.getAttribute(\"data-allocated\")).toBe(\"10\");\n    expect(pool.getAttribute(\"data-free\")).toBe(\"20\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/truenas/pool.jsx",
    "content": "import classNames from \"classnames\";\nimport { useTranslation } from \"next-i18next\";\n\nexport default function Pool({ name, free, allocated, healthy }) {\n  const { t } = useTranslation();\n  const usedPercent = Math.round((allocated / (free + allocated)) * 100);\n  const statusColor = healthy ? \"bg-green-500\" : \"bg-yellow-500\";\n\n  return (\n    <div className=\"flex flex-row text-theme-700 dark:text-theme-200 items-center text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1\">\n      <div\n        className=\"absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0\"\n        style={{\n          width: `${usedPercent}%`,\n        }}\n      />\n      <span className=\"ml-2 h-2 w-2 z-10\">\n        <span className={classNames(\"block w-2 h-2 rounded-sm\", statusColor)} />\n      </span>\n      <div className=\"text-xs z-10 self-center ml-2 relative h-4 grow mr-2\">\n        <div className=\"absolute w-full whitespace-nowrap text-ellipsis overflow-hidden text-left\">{name}</div>\n      </div>\n      <div className=\"self-center text-xs flex justify-end mr-1.5 pl-1 z-10 text-ellipsis overflow-hidden whitespace-nowrap\">\n        <span>\n          {`${t(\"common.bytes\", {\n            value: allocated,\n            maximumFractionDigits: 1,\n            binary: true,\n          })} / ${t(\"common.bytes\", {\n            value: free + allocated,\n            maximumFractionDigits: 1,\n            binary: true,\n          })}`}\n        </span>\n        <span className=\"pl-2\">({usedPercent}%)</span>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/widgets/truenas/pool.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { render, screen } from \"@testing-library/react\";\nimport { describe, expect, it } from \"vitest\";\n\nimport Pool from \"./pool\";\n\ndescribe(\"widgets/truenas/pool\", () => {\n  it(\"renders pool name, usage percent, and status color\", () => {\n    const { container } = render(<Pool name=\"tank\" free={50} allocated={50} healthy={false} />);\n    expect(screen.getByText(\"tank\")).toBeInTheDocument();\n\n    // 50 / 100 => 50%\n    expect(container.textContent).toContain(\"(50%)\");\n\n    // status color reflects healthy=false\n    expect(container.querySelector(\".bg-yellow-500\")).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "src/widgets/truenas/proxy.js",
    "content": "import WebSocket from \"ws\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall, sanitizeErrorURL } from \"utils/proxy/api-helpers\";\nimport credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\nimport validateWidgetData from \"utils/proxy/validate-widget-data\";\nimport widgets from \"widgets/widgets\";\n\nconst logger = createLogger(\"truenasProxyHandler\");\n\nfunction waitForEvent(ws, handler, { event = \"message\", parseJson = true } = {}) {\n  return new Promise((resolve, reject) => {\n    const timeout = setTimeout(() => {\n      cleanup();\n      reject(new Error(\"TrueNAS websocket wait timed out\"));\n    }, 10000);\n\n    const handleEvent = (payload) => {\n      try {\n        let parsed = payload;\n        if (parseJson) {\n          if (Buffer.isBuffer(payload)) {\n            parsed = JSON.parse(payload.toString());\n          } else if (typeof payload === \"string\") {\n            parsed = JSON.parse(payload);\n          }\n        }\n        const handlerResult = handler(parsed);\n        if (handlerResult !== undefined) {\n          cleanup();\n          if (handlerResult instanceof Error) {\n            reject(handlerResult);\n          } else {\n            resolve(handlerResult);\n          }\n        }\n      } catch (err) {\n        cleanup();\n        reject(err);\n      }\n    };\n\n    const handleError = (err) => {\n      cleanup();\n      logger.error(\"TrueNAS websocket error: %s\", err?.message ?? err);\n      reject(err);\n    };\n\n    const handleClose = () => {\n      cleanup();\n      logger.debug(\"TrueNAS websocket connection closed unexpectedly\");\n      reject(new Error(\"TrueNAS websocket closed the connection\"));\n    };\n\n    function cleanup() {\n      clearTimeout(timeout);\n      ws.off(event, handleEvent);\n      ws.off(\"error\", handleError);\n      ws.off(\"close\", handleClose);\n    }\n\n    ws.on(event, handleEvent);\n    ws.on(\"error\", handleError);\n    ws.on(\"close\", handleClose);\n  });\n}\n\nlet nextId = 1;\nasync function sendMethod(ws, method, params = []) {\n  const id = nextId++;\n  const payload = { jsonrpc: \"2.0\", id, method, params };\n  ws.send(JSON.stringify(payload));\n\n  return waitForEvent(ws, (message) => {\n    if (message?.id !== id) return undefined;\n    if (message?.error) {\n      return new Error(message.error?.message || JSON.stringify(message.error));\n    }\n    return message?.result ?? message;\n  });\n}\n\nasync function authenticate(ws, widget) {\n  if (widget?.key) {\n    try {\n      const apiKeyResult = await sendMethod(ws, \"auth.login_with_api_key\", [widget.key]);\n      if (apiKeyResult === true) return;\n      logger.warn(\"TrueNAS API key authentication failed, falling back to username/password when available.\");\n    } catch (err) {\n      logger.error(\"TrueNAS API key authentication failed: %s\", err?.message ?? err);\n    }\n  }\n\n  if (widget?.username && widget?.password) {\n    const loginResult = await sendMethod(ws, \"auth.login\", [widget.username, widget.password]);\n    if (loginResult === true) return;\n    logger.warn(\"TrueNAS username/password authentication failed.\");\n  }\n\n  throw new Error(\"TrueNAS authentication failed\");\n}\n\nexport default async function truenasProxyHandler(req, res, map) {\n  const { group, service, endpoint, index } = req.query;\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  if (!endpoint) {\n    return res.status(204).end();\n  }\n\n  const version = Number(widget.version ?? 1);\n  if (Number.isNaN(version) || version < 2) {\n    // Use legacy REST proxy for version 1\n    return credentialedProxyHandler(req, res, map);\n  }\n\n  const mappingEntry = Object.values(widgets[widget.type].mappings).find((mapping) => mapping.endpoint === endpoint);\n  const wsMethod = mappingEntry.wsMethod;\n\n  if (!wsMethod) {\n    logger.debug(\"Missing wsMethod mapping for TrueNAS endpoint %s\", endpoint);\n    return res.status(500).json({ error: \"Missing wsMethod mapping.\" });\n  }\n\n  try {\n    let data;\n    const wsUrl = new URL(formatApiCall(widgets[widget.type].wsAPI, { ...widget }));\n    const useSecure = wsUrl.protocol === \"https:\" || Boolean(widget.key); // API key requires secure connection\n    wsUrl.protocol = useSecure ? \"wss:\" : \"ws:\";\n    const ws = new WebSocket(wsUrl, { rejectUnauthorized: false });\n    await waitForEvent(ws, () => true, { event: \"open\", parseJson: false }); // wait for open\n    try {\n      await authenticate(ws, widget);\n      data = await sendMethod(ws, wsMethod);\n    } finally {\n      ws.close();\n    }\n\n    if (!validateWidgetData(widget, endpoint, data)) {\n      return res.status(500).json({ error: { message: \"Invalid data\", url: sanitizeErrorURL(widget.url), data } });\n    }\n\n    if (map) data = map(data);\n\n    return res.status(200).json(data);\n  } catch (err) {\n    if (err?.status) {\n      return res.status(err.status).json({ error: err.message });\n    }\n    logger.error(\"Websocket call for TrueNAS failed: %s\", err?.message ?? err);\n    return res.status(500).json({ error: err?.message ?? \"TrueNAS websocket call failed\" });\n  }\n}\n"
  },
  {
    "path": "src/widgets/truenas/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { getServiceWidget, validateWidgetData, logger } = vi.hoisted(() => ({\n  getServiceWidget: vi.fn(),\n  validateWidgetData: vi.fn(() => true),\n  logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn() },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/proxy/validate-widget-data\", () => ({\n  default: validateWidgetData,\n}));\nvi.mock(\"utils/proxy/handlers/credentialed\", () => ({\n  default: vi.fn(),\n}));\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    truenas: {\n      wsAPI: \"{url}/websocket\",\n      mappings: {\n        stats: { endpoint: \"stats\", wsMethod: \"system.info\" },\n      },\n    },\n  },\n}));\n\nvi.mock(\"ws\", () => {\n  class FakeWebSocket {\n    constructor(url) {\n      this.url = url;\n      this._handlers = new Map();\n    }\n    on(event, cb) {\n      const set = this._handlers.get(event) ?? new Set();\n      set.add(cb);\n      this._handlers.set(event, set);\n      if (event === \"open\") {\n        queueMicrotask(() => cb());\n      }\n    }\n    off(event, cb) {\n      const set = this._handlers.get(event);\n      if (set) set.delete(cb);\n    }\n    send(payload) {\n      const msg = JSON.parse(payload);\n      let result = true;\n      if (msg.method === \"system.info\") {\n        result = { ok: true };\n      }\n      queueMicrotask(() => {\n        const set = this._handlers.get(\"message\");\n        if (!set) return;\n        set.forEach((cb) => cb(JSON.stringify({ id: msg.id, result })));\n      });\n    }\n    close() {}\n  }\n\n  return { default: FakeWebSocket };\n});\n\nimport truenasProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/truenas/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    validateWidgetData.mockReturnValue(true);\n  });\n\n  it(\"uses websocket calls for v2+ and returns JSON result\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"truenas\",\n      url: \"http://tn\",\n      version: 2,\n      key: \"apikey\",\n    });\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"stats\", index: \"0\" } };\n    const res = createMockRes();\n\n    await truenasProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ ok: true });\n  });\n});\n"
  },
  {
    "path": "src/widgets/truenas/widget.js",
    "content": "import truenasProxyHandler from \"./proxy\";\n\nimport { asJson, jsonArrayFilter } from \"utils/proxy/api-helpers\";\n\nconst widget = {\n  api: \"{url}/api/v2.0/{endpoint}\",\n  wsAPI: \"{url}/api/current\",\n  proxyHandler: truenasProxyHandler,\n\n  mappings: {\n    alerts: {\n      endpoint: \"alert/list\",\n      wsMethod: \"alert.list\",\n      map: (data) => {\n        if (Array.isArray(data)) {\n          return { pending: data.filter((item) => item?.dismissed === false).length };\n        }\n        return { pending: jsonArrayFilter(data, (item) => item?.dismissed === false).length };\n      },\n    },\n    status: {\n      endpoint: \"system/info\",\n      wsMethod: \"system.info\",\n      validate: [\"loadavg\", \"uptime_seconds\"],\n    },\n    pools: {\n      endpoint: \"pool\",\n      wsMethod: \"pool.query\",\n      map: (data) => {\n        const list = Array.isArray(data) ? data : asJson(data);\n        return list.map((entry) => ({\n          id: entry.name,\n          name: entry.name,\n          healthy: entry.healthy,\n        }));\n      },\n    },\n    dataset: {\n      endpoint: \"pool/dataset\",\n      wsMethod: \"pool.dataset.query\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/truenas/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"truenas widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/tubearchivist/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: downloadsData, error: downloadsError } = useWidgetAPI(widget, \"downloads\");\n  const { data: videosData, error: videosError } = useWidgetAPI(widget, \"videos\");\n  const { data: channelsData, error: channelsError } = useWidgetAPI(widget, \"channels\");\n  const { data: playlistsData, error: playlistsError } = useWidgetAPI(widget, \"playlists\");\n\n  if (downloadsError || videosError || channelsError || playlistsError || (downloadsData && downloadsData.detail)) {\n    const finalError = downloadsError ?? videosError ?? channelsError ?? playlistsError ?? downloadsData.detail;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (!downloadsData || !videosData || !channelsData || !playlistsData) {\n    return (\n      <Container service={service}>\n        <Block label=\"tubearchivist.downloads\" />\n        <Block label=\"tubearchivist.videos\" />\n        <Block label=\"tubearchivist.channels\" />\n        <Block label=\"tubearchivist.playlists\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"tubearchivist.downloads\" value={t(\"common.number\", { value: downloadsData.pending ?? 0 })} />\n      <Block label=\"tubearchivist.videos\" value={t(\"common.number\", { value: videosData.doc_count ?? 0 })} />\n      <Block label=\"tubearchivist.channels\" value={t(\"common.number\", { value: channelsData.doc_count ?? 0 })} />\n      <Block label=\"tubearchivist.playlists\" value={t(\"common.number\", { value: playlistsData.doc_count ?? 0 })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/tubearchivist/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/tubearchivist/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"tubearchivist\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"tubearchivist.downloads\")).toBeInTheDocument();\n    expect(screen.getByText(\"tubearchivist.videos\")).toBeInTheDocument();\n    expect(screen.getByText(\"tubearchivist.channels\")).toBeInTheDocument();\n    expect(screen.getByText(\"tubearchivist.playlists\")).toBeInTheDocument();\n  });\n\n  it(\"renders counts when loaded\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"downloads\") return { data: { pending: 1 }, error: undefined };\n      if (endpoint === \"videos\") return { data: { doc_count: 2 }, error: undefined };\n      if (endpoint === \"channels\") return { data: { doc_count: 3 }, error: undefined };\n      if (endpoint === \"playlists\") return { data: { doc_count: 4 }, error: undefined };\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"tubearchivist\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"tubearchivist.downloads\", 1);\n    expectBlockValue(container, \"tubearchivist.videos\", 2);\n    expectBlockValue(container, \"tubearchivist.channels\", 3);\n    expectBlockValue(container, \"tubearchivist.playlists\", 4);\n  });\n});\n"
  },
  {
    "path": "src/widgets/tubearchivist/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    downloads: {\n      endpoint: \"stats/download\",\n    },\n    videos: {\n      endpoint: \"stats/video\",\n      validate: [\"doc_count\"],\n    },\n    channels: {\n      endpoint: \"stats/channel\",\n      validate: [\"doc_count\"],\n    },\n    playlists: {\n      endpoint: \"stats/playlist\",\n      validate: [\"doc_count\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/tubearchivist/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"tubearchivist widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/unifi/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: statsData, error: statsError } = useWidgetAPI(widget, \"stat/sites\");\n\n  if (statsError) {\n    return <Container service={service} error={statsError} />;\n  }\n\n  const defaultSite = widget.site\n    ? statsData?.data.find((s) => s.desc === widget.site)\n    : statsData?.data?.find((s) => s.name === \"default\");\n\n  if (!defaultSite) {\n    if (widget.site) {\n      return <Container service={service} error={{ message: `Site '${widget.site}' not found` }} />;\n    }\n\n    return (\n      <Container service={service}>\n        <Block label=\"unifi.uptime\" />\n        <Block label=\"unifi.wan\" />\n        <Block label=\"unifi.lan_users\" />\n        <Block label=\"unifi.wlan_users\" />\n      </Container>\n    );\n  }\n\n  const wan = defaultSite.health.find((h) => h.subsystem === \"wan\");\n  const lan = defaultSite.health.find((h) => h.subsystem === \"lan\");\n  const wlan = defaultSite.health.find((h) => h.subsystem === \"wlan\");\n  [wan, lan, wlan].forEach((s) => {\n    s.up = s.status === \"ok\"; // eslint-disable-line no-param-reassign\n    s.show = s.status !== \"unknown\"; // eslint-disable-line no-param-reassign\n  });\n\n  const uptime = wan[\"gw_system-stats\"]\n    ? `${t(\"common.number\", { value: wan[\"gw_system-stats\"].uptime / 86400, maximumFractionDigits: 1 })} ${t(\n        \"unifi.days\",\n      )}`\n    : null;\n\n  if (!(wan.show || lan.show || wlan.show || uptime)) {\n    return (\n      <Container service={service}>\n        <Block value={t(\"unifi.empty_data\")} />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      {uptime && <Block label=\"unifi.uptime\" value={uptime} />}\n      {wan.show && <Block label=\"unifi.wan\" value={wan.status === \"ok\" ? t(\"unifi.up\") : t(\"unifi.down\")} />}\n\n      {lan.show && <Block label=\"unifi.lan_users\" value={t(\"common.number\", { value: lan.num_user })} />}\n      {lan.show && !wlan.show && (\n        <Block label=\"unifi.lan_devices\" value={t(\"common.number\", { value: lan.num_adopted })} />\n      )}\n      {lan.show && !wlan.show && <Block label=\"unifi.lan\" value={lan.up ? t(\"unifi.up\") : t(\"unifi.down\")} />}\n\n      {wlan.show && <Block label=\"unifi.wlan_users\" value={t(\"common.number\", { value: wlan.num_user })} />}\n      {wlan.show && !lan.show && (\n        <Block label=\"unifi.wlan_devices\" value={t(\"common.number\", { value: wlan.num_adopted })} />\n      )}\n      {wlan.show && !lan.show && <Block label=\"unifi.wlan\" value={wlan.up ? t(\"unifi.up\") : t(\"unifi.down\")} />}\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/unifi/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue, findServiceBlockByLabel } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/unifi/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders when default site isn't available yet\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"unifi\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"unifi.uptime\")).toBeInTheDocument();\n    expect(screen.getByText(\"unifi.wan\")).toBeInTheDocument();\n    expect(screen.getByText(\"unifi.lan_users\")).toBeInTheDocument();\n    expect(screen.getByText(\"unifi.wlan_users\")).toBeInTheDocument();\n    // 4 blocks if all are rendered.\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n  });\n\n  it(\"renders a site-not-found error when widget.site doesn't match\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { data: [{ name: \"default\", desc: \"Default\", health: [] }] },\n      error: undefined,\n    });\n\n    renderWithProviders(<Component service={{ widget: { type: \"unifi\", site: \"Nope\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"Site 'Nope' not found\")).toBeInTheDocument();\n  });\n\n  it(\"renders uptime, wan and user counts when site data is present\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        data: [\n          {\n            name: \"default\",\n            desc: \"Default\",\n            health: [\n              { subsystem: \"wan\", status: \"ok\", num_user: 0, num_adopted: 0, \"gw_system-stats\": { uptime: 86400 } },\n              { subsystem: \"lan\", status: \"ok\", num_user: 2, num_adopted: 5 },\n              { subsystem: \"wlan\", status: \"ok\", num_user: 3, num_adopted: 6 },\n            ],\n          },\n        ],\n      },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"unifi\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    // uptime includes unifi.days suffix.\n    expect(findServiceBlockByLabel(container, \"unifi.uptime\")?.textContent).toContain(\"unifi.days\");\n    expectBlockValue(container, \"unifi.wan\", \"unifi.up\");\n    expectBlockValue(container, \"unifi.lan_users\", 2);\n    expectBlockValue(container, \"unifi.wlan_users\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/unifi/proxy.js",
    "content": "import cache from \"memory-cache\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\nimport { getPrivateWidgetOptions } from \"utils/config/widget-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { addCookieToJar, setCookieHeader } from \"utils/proxy/cookie-jar\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst udmpPrefix = \"/proxy/network\";\nconst proxyName = \"unifiProxyHandler\";\nconst prefixCacheKey = `${proxyName}__prefix`;\nconst logger = createLogger(proxyName);\n\nasync function getWidget(req) {\n  const { group, service, index } = req.query;\n\n  let widget = null;\n  if (group === \"unifi_console\" && service === \"unifi_console\") {\n    // info widget\n    const infowidgetIndex = req.query?.query ? JSON.parse(req.query.query).index : undefined;\n    widget = await getPrivateWidgetOptions(\"unifi_console\", infowidgetIndex);\n    if (!widget) {\n      logger.debug(\"Error retrieving settings for this Unifi widget\");\n      return null;\n    }\n    widget.type = \"unifi\";\n  } else {\n    if (!group || !service) {\n      logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n      return null;\n    }\n\n    widget = await getServiceWidget(group, service, index);\n\n    if (!widget) {\n      logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n      return null;\n    }\n  }\n\n  return widget;\n}\n\nasync function login(widget, csrfToken) {\n  const endpoint = widget.prefix === udmpPrefix ? \"auth/login\" : \"login\";\n  const api = widgets?.[widget.type]?.api?.replace(\"{prefix}\", \"\"); // no prefix for login url\n  const loginUrl = new URL(formatApiCall(api, { endpoint, ...widget }));\n  const loginBody = { username: widget.username, password: widget.password, remember: true, rememberMe: true };\n  const headers = { \"Content-Type\": \"application/json\" };\n  if (csrfToken) {\n    headers[\"X-CSRF-TOKEN\"] = csrfToken;\n  }\n  const [status, contentType, data, responseHeaders] = await httpProxy(loginUrl, {\n    method: \"POST\",\n    body: JSON.stringify(loginBody),\n    headers,\n  });\n  return [status, contentType, data, responseHeaders];\n}\n\nexport default async function unifiProxyHandler(req, res) {\n  const widget = await getWidget(req);\n  const { service } = req.query;\n  if (!widget) {\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const api = widgets?.[widget.type]?.api;\n  if (!api) {\n    return res.status(403).json({ error: \"Service does not support API calls\" });\n  }\n\n  let [status, contentType, data, responseHeaders] = [];\n  let prefix = cache.get(`${prefixCacheKey}.${service}`);\n  let csrfToken;\n  const headers = {};\n  if (widget.key) {\n    prefix = udmpPrefix;\n    headers[\"X-API-KEY\"] = widget.key;\n    headers[\"Accept\"] = \"application/json\";\n  } else if (prefix === null) {\n    // auto detect if we're talking to a UDM Pro or Network API device, and cache the result\n    // so that we don't make two requests each time data from Unifi is required\n    [status, contentType, data, responseHeaders] = await httpProxy(widget.url);\n    prefix = \"\";\n    if (responseHeaders?.[\"x-csrf-token\"]) {\n      // Unifi OS < 3.2.5 passes & requires csrf-token\n      prefix = udmpPrefix;\n      csrfToken = responseHeaders[\"x-csrf-token\"];\n    } else if (\n      responseHeaders?.[\"access-control-expose-headers\"] ||\n      responseHeaders?.[\"Access-Control-Expose-Headers\"]\n    ) {\n      // Unifi OS ≥ 3.2.5 doesnt pass csrf token but still uses different endpoint, same with Network API\n      prefix = udmpPrefix;\n    }\n  }\n  cache.put(`${prefixCacheKey}.${service}`, prefix);\n\n  widget.prefix = prefix;\n  const { endpoint } = req.query;\n  const url = new URL(formatApiCall(api, { endpoint, ...widget }));\n  const params = { method: \"GET\", headers };\n  setCookieHeader(url, params);\n\n  [status, contentType, data, responseHeaders] = await httpProxy(url, params);\n\n  if (status === 401 && !widget.key) {\n    logger.debug(\"Unifi isn't logged in or rejected the reqeust, attempting login.\");\n    if (responseHeaders?.[\"x-csrf-token\"]) {\n      csrfToken = responseHeaders[\"x-csrf-token\"];\n    }\n    [status, contentType, data, responseHeaders] = await login(widget, csrfToken);\n\n    if (status !== 200) {\n      logger.error(\"HTTP %d logging in to Unifi. Data: %s\", status, data);\n      return res.status(status).json({ error: { message: `HTTP Error ${status}`, url, data } });\n    }\n\n    const json = JSON.parse(data.toString());\n    if (!(json?.meta?.rc === \"ok\" || json?.login_time || json?.update_time)) {\n      logger.error(\"Error logging in to Unifi: Data: %s\", data);\n      return res.status(401).end(data);\n    }\n\n    addCookieToJar(url, responseHeaders);\n    setCookieHeader(url, params);\n\n    logger.debug(\"Retrying Unifi request after login.\");\n    [status, contentType, data, responseHeaders] = await httpProxy(url, params);\n  }\n\n  if (status !== 200) {\n    logger.error(\"HTTP %d getting data from Unifi endpoint %s. Data: %s\", status, url.href, data);\n    return res.status(status).json({ error: { message: `HTTP Error ${status}`, url, data } });\n  }\n\n  if (contentType) res.setHeader(\"Content-Type\", contentType);\n  return res.status(status).send(data);\n}\n"
  },
  {
    "path": "src/widgets/unifi/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, getPrivateWidgetOptions, cache, cookieJar, logger } = vi.hoisted(() => {\n  const store = new Map();\n  return {\n    httpProxy: vi.fn(),\n    getServiceWidget: vi.fn(),\n    getPrivateWidgetOptions: vi.fn(),\n    cache: {\n      get: vi.fn((k) => (store.has(k) ? store.get(k) : null)),\n      put: vi.fn((k, v) => store.set(k, v)),\n      del: vi.fn((k) => store.delete(k)),\n      _reset: () => store.clear(),\n    },\n    cookieJar: {\n      addCookieToJar: vi.fn(),\n      setCookieHeader: vi.fn(),\n    },\n    logger: { debug: vi.fn(), error: vi.fn() },\n  };\n});\n\nvi.mock(\"memory-cache\", () => ({\n  default: cache,\n  ...cache,\n}));\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\nvi.mock(\"utils/config/widget-helpers\", () => ({\n  getPrivateWidgetOptions,\n}));\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\nvi.mock(\"utils/proxy/cookie-jar\", () => cookieJar);\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    unifi: {\n      api: \"{url}{prefix}/api/{endpoint}\",\n    },\n  },\n}));\n\nimport unifiProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/unifi/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    cache._reset();\n  });\n\n  it(\"auto-detects prefix, logs in on 401, and retries the request\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"unifi\",\n      url: \"http://unifi\",\n      username: \"u\",\n      password: \"p\",\n    });\n\n    httpProxy\n      // autodetect call -> csrf header indicates udmp prefix\n      .mockResolvedValueOnce([200, \"text/html\", Buffer.from(\"\"), { \"x-csrf-token\": \"csrf\" }])\n      // initial api call -> unauthorized\n      .mockResolvedValueOnce([401, \"application/json\", Buffer.from(\"nope\"), { \"x-csrf-token\": \"csrf2\" }])\n      // login -> ok\n      .mockResolvedValueOnce([\n        200,\n        \"application/json\",\n        Buffer.from(JSON.stringify({ meta: { rc: \"ok\" } })),\n        { \"set-cookie\": [\"sid=1\"] },\n      ])\n      // retry api call -> ok\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"data\"), {}]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"self\", index: \"0\" } };\n    const res = createMockRes();\n\n    await unifiProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(4);\n    expect(httpProxy.mock.calls[1][0].toString()).toContain(\"/proxy/network/api/self\");\n    expect(cookieJar.addCookieToJar).toHaveBeenCalled();\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual(Buffer.from(\"data\"));\n  });\n});\n"
  },
  {
    "path": "src/widgets/unifi/widget.js",
    "content": "import unifiProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}{prefix}/api/{endpoint}\",\n  proxyHandler: unifiProxyHandler,\n\n  mappings: {\n    \"stat/sites\": {\n      endpoint: \"stat/sites\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/unifi/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"unifi widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/unmanic/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useEffect, useState } from \"react\";\n\nimport { formatProxyUrl } from \"utils/proxy/api-helpers\";\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  const { data: workersData, error: workersError } = useWidgetAPI(widget, \"workers\");\n\n  const [pendingData, setPendingData] = useState(null);\n\n  useEffect(() => {\n    async function fetchPending() {\n      const url = formatProxyUrl(widget, \"pending\");\n      const res = await fetch(url, { method: \"POST\" });\n      setPendingData(await res.json());\n    }\n    if (!pendingData) {\n      fetchPending();\n    }\n  }, [widget, pendingData]);\n\n  if (workersError) {\n    return <Container service={service} error={workersError} />;\n  }\n\n  if (!workersData || !pendingData) {\n    return (\n      <Container service={service}>\n        <Block label=\"unmanic.active_workers\" />\n        <Block label=\"unmanic.total_workers\" />\n        <Block label=\"unmanic.records_total\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"unmanic.active_workers\" value={workersData.active_workers} />\n      <Block label=\"unmanic.total_workers\" value={workersData.total_workers} />\n      <Block label=\"unmanic.records_total\" value={pendingData.recordsTotal} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/unmanic/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen, waitFor } from \"@testing-library/react\";\nimport { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/unmanic/component\", () => {\n  const originalFetch = globalThis.fetch;\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    globalThis.fetch = vi.fn(async () => ({ json: async () => ({ recordsTotal: 7 }) }));\n  });\n\n  afterEach(() => {\n    globalThis.fetch = originalFetch;\n  });\n\n  it(\"renders placeholders while loading pending data, then renders worker + pending stats\", async () => {\n    useWidgetAPI.mockReturnValue({ data: { active_workers: 1, total_workers: 2 }, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"unmanic\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"unmanic.active_workers\")).toBeInTheDocument();\n    expect(screen.getByText(\"unmanic.total_workers\")).toBeInTheDocument();\n    expect(screen.getByText(\"unmanic.records_total\")).toBeInTheDocument();\n\n    await waitFor(() => {\n      expectBlockValue(container, \"unmanic.active_workers\", 1);\n      expectBlockValue(container, \"unmanic.total_workers\", 2);\n      expectBlockValue(container, \"unmanic.records_total\", 7);\n    });\n  });\n});\n"
  },
  {
    "path": "src/widgets/unmanic/widget.js",
    "content": "import { asJson } from \"utils/proxy/api-helpers\";\nimport genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/unmanic/api/v2/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    workers: {\n      endpoint: \"workers/status\",\n      map: (data) => ({\n        total_workers: asJson(data).workers_status.length,\n        active_workers: asJson(data).workers_status.filter((worker) => !worker.idle).length,\n      }),\n    },\n    pending: {\n      method: \"POST\",\n      body: \"{}\",\n      endpoint: \"pending/tasks\",\n      validate: [\"recordsTotal\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/unmanic/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"unmanic widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/unraid/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst UNRAID_DEFAULT_FIELDS = [\"status\", \"cpu\", \"memoryPercent\", \"notifications\"];\nconst MAX_ALLOWED_FIELDS = 4;\n\nconst POOLS = [\"pool1\", \"pool2\", \"pool3\", \"pool4\"];\nconst POOL_FIELDS = [\n  { param: \"UsedSpace\", label: \"poolUsed\", valueKey: \"fsUsed\", valueType: \"common.bytes\" },\n  { param: \"FreeSpace\", label: \"poolFree\", valueKey: \"fsFree\", valueType: \"common.bytes\" },\n  { param: \"UsedPercent\", label: \"poolUsed\", valueKey: \"fsUsedPercent\", valueType: \"common.percent\" },\n];\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { data, error } = useWidgetAPI(widget);\n\n  if (error) {\n    return <Container service={service} error={error} />;\n  }\n\n  if (!widget.fields?.length) {\n    widget.fields = UNRAID_DEFAULT_FIELDS;\n  } else if (widget.fields.length > MAX_ALLOWED_FIELDS) {\n    widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);\n  }\n\n  if (!data) {\n    return (\n      <Container service={service}>\n        <Block label=\"unraid.status\" />\n        <Block label=\"unraid.memoryAvailable\" />\n        <Block label=\"unraid.memoryUsed\" />\n        <Block field=\"unraid.memoryPercent\" label=\"unraid.memoryUsed\" />\n        <Block label=\"unraid.cpu\" />\n        <Block label=\"unraid.notifications\" />\n        <Block field=\"unraid.arrayUsedSpace\" label=\"unraid.arrayUsed\" />\n        <Block field=\"unraid.arrayFree\" label=\"unraid.arrayFree\" />\n        <Block field=\"unraid.arrayUsedPercent\" label=\"unraid.arrayUsed\" />\n        {...POOLS.flatMap((pool) =>\n          POOL_FIELDS.map(({ param, label }) => (\n            <Block\n              key={`${pool}-${param}`}\n              field={`unraid.${pool}${param}`}\n              label={t(`unraid.${label}`, { pool: widget?.[pool] || pool })}\n            />\n          )),\n        )}\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"unraid.status\" value={t(`unraid.${data.arrayState}`)} />\n      <Block\n        label=\"unraid.memoryAvailable\"\n        value={t(\"common.bbytes\", { value: data.memoryAvailable })}\n        highlightValue={data.memoryAvailable}\n      />\n      <Block\n        label=\"unraid.memoryUsed\"\n        value={t(\"common.bbytes\", { value: data.memoryUsed })}\n        highlightValue={data.memoryUsed}\n      />\n      <Block\n        field=\"unraid.memoryPercent\"\n        label=\"unraid.memoryUsed\"\n        value={t(\"common.percent\", { value: data.memoryUsedPercent })}\n        highlightValue={data.memoryUsedPercent}\n      />\n      <Block\n        label=\"unraid.cpu\"\n        value={t(\"common.percent\", { value: data.cpuPercent })}\n        highlightValue={data.cpuPercent}\n      />\n      <Block label=\"unraid.notifications\" value={t(\"common.number\", { value: data.unreadNotifications })} />\n      <Block\n        field=\"unraid.arrayUsedSpace\"\n        label=\"unraid.arrayUsed\"\n        value={t(\"common.bytes\", { value: data.arrayUsed })}\n        highlightValue={data.arrayUsed}\n      />\n      <Block\n        label=\"unraid.arrayFree\"\n        value={t(\"common.bytes\", { value: data.arrayFree })}\n        highlightValue={data.arrayFree}\n      />\n      <Block\n        field=\"unraid.arrayUsedPercent\"\n        label=\"unraid.arrayUsed\"\n        value={t(\"common.percent\", { value: data.arrayUsedPercent })}\n        highlightValue={data.arrayUsedPercent}\n      />\n      {...POOLS.flatMap((pool) =>\n        POOL_FIELDS.map(({ param, label, valueKey, valueType }) => {\n          const poolValue = data.caches?.[widget?.[pool]]?.[valueKey] || \"-\";\n\n          return (\n            <Block\n              key={`${pool}-${param}`}\n              field={`unraid.${pool}${param}`}\n              label={t(`unraid.${label}`, { pool: widget?.[pool] || pool })}\n              value={t(valueType, { value: poolValue })}\n              highlightValue={poolValue}\n            />\n          );\n        }),\n      )}\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/unraid/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({\n  useWidgetAPI: vi.fn(),\n}));\n\nvi.mock(\"utils/proxy/use-widget-api\", () => ({\n  default: useWidgetAPI,\n}));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/unraid/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"defaults widget.fields and filters down to 4 visible blocks while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"unraid\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    // Component sets default fields\n    expect(service.widget.fields).toEqual([\"status\", \"cpu\", \"memoryPercent\", \"notifications\"]);\n\n    // Container filters the many placeholder Blocks down to the selected fields.\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"unraid.status\")).toBeInTheDocument();\n    expect(screen.getByText(\"unraid.cpu\")).toBeInTheDocument();\n    expect(screen.getByText(\"unraid.notifications\")).toBeInTheDocument();\n    expect(screen.getByText(\"unraid.memoryUsed\")).toBeInTheDocument();\n    expect(screen.queryByText(\"unraid.memoryAvailable\")).toBeNull();\n  });\n\n  it(\"renders values for the default fields\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        arrayState: \"started\",\n        cpuPercent: 12,\n        memoryAvailable: 100,\n        memoryUsed: 50,\n        memoryUsedPercent: 33,\n        unreadNotifications: 7,\n        arrayUsed: 1,\n        arrayFree: 2,\n        arrayUsedPercent: 3,\n        caches: {},\n      },\n      error: undefined,\n    });\n\n    const service = { widget: { type: \"unraid\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"unraid.started\")).toBeInTheDocument();\n    expect(screen.getByText(\"12\")).toBeInTheDocument();\n    expect(screen.getByText(\"33\")).toBeInTheDocument();\n    expect(screen.getByText(\"7\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/widgets/unraid/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { asJson } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\n\nconst logger = createLogger(\"unraidProxyHandler\");\n\nconst graphqlQuery = `\n{\n  array {\n    state\n    capacity {\n      kilobytes {\n        free\n        total\n        used\n      }\n    }\n    caches {\n      name\n      fsType\n      fsSize\n      fsFree\n      fsUsed\n    }\n  }\n  metrics {\n    memory {\n      active\n      available\n      percentTotal\n    }\n    cpu {\n      percentTotal\n    }\n  }\n  notifications {\n    overview {\n      unread {\n        total\n      }\n    }\n  }\n}\n`;\n\nfunction processUnraidResponse(data) {\n  const response = {};\n\n  try {\n    data = asJson(data)?.data;\n\n    response[\"memoryUsedPercent\"] = data?.metrics?.memory?.percentTotal ?? null;\n    response[\"memoryUsed\"] = data?.metrics?.memory?.active ?? null;\n    response[\"memoryAvailable\"] = data?.metrics?.memory?.available ?? null;\n    response[\"cpuPercent\"] = data?.metrics?.cpu?.percentTotal ?? null;\n    response[\"unreadNotifications\"] = data?.notifications?.overview?.unread?.total ?? null;\n    response[\"arrayState\"] = data?.array?.state ?? null;\n    response[\"arrayFree\"] = data?.array?.capacity?.kilobytes?.free * 1000 ?? null;\n    response[\"arrayUsed\"] = data?.array?.capacity?.kilobytes?.used * 1000 ?? null;\n    response[\"arrayUsedPercent\"] =\n      (data?.array?.capacity?.kilobytes?.used / data?.array?.capacity?.kilobytes?.total) * 100 ?? null;\n\n    response[\"caches\"] = {};\n    if (data?.array?.caches) {\n      data.array.caches.forEach((cache) => {\n        if (cache.fsType) {\n          response.caches[cache.name] = {\n            fsFree: cache.fsFree * 1000,\n            fsUsed: cache.fsUsed * 1000,\n            fsUsedPercent: (cache.fsUsed / cache.fsSize) * 100 ?? null,\n          };\n        }\n      });\n    }\n  } catch (error) {\n    return { error: error.message };\n  }\n\n  return response;\n}\n\nexport default async function unraidProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const url = new URL(widget.url + \"/graphql\");\n\n  const headers = {\n    \"Content-Type\": \"application/json\",\n    Accept: `application/json`,\n    \"X-API-Key\": `${widget.key}`,\n  };\n\n  const params = {\n    method: \"POST\",\n    headers,\n  };\n  params.body = JSON.stringify({\n    query: graphqlQuery,\n  });\n\n  const [status, , data] = await httpProxy(url, params);\n\n  if (status === 204 || status === 304) {\n    return res.status(status).end();\n  }\n\n  if (status !== 200) {\n    logger.error(\n      \"Error getting data from Unraid for service '%s' in group '%s': %d.  Data: %s\",\n      service,\n      group,\n      status,\n      data,\n    );\n    return res.status(status).send({ error: { message: \"Error calling Unraid API.\", data } });\n  }\n\n  const result = processUnraidResponse(data);\n  if (result.error) {\n    logger.error(\"Error processing Unraid data: %s\", result.error);\n    return res.status(500).json({ error: result.error });\n  }\n\n  res.setHeader(\"Content-Type\", \"application/json\");\n  return res.status(status).send(result);\n}\n"
  },
  {
    "path": "src/widgets/unraid/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  logger: {\n    debug: vi.fn(),\n    error: vi.fn(),\n  },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nimport unraidProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/unraid/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"calls the Unraid GraphQL endpoint and returns a flattened response\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://unraid\", key: \"k\" });\n\n    httpProxy.mockResolvedValueOnce([\n      200,\n      \"application/json\",\n      Buffer.from(\n        JSON.stringify({\n          data: {\n            metrics: { memory: { active: 10, available: 90, percentTotal: 10 }, cpu: { percentTotal: 5 } },\n            notifications: { overview: { unread: { total: 2 } } },\n            array: {\n              state: \"STARTED\",\n              capacity: { kilobytes: { free: 10, used: 20, total: 40 } },\n              caches: [{ name: \"cache\", fsType: \"btrfs\", fsSize: 100, fsFree: 25, fsUsed: 75 }],\n            },\n          },\n        }),\n      ),\n    ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await unraidProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(1);\n    expect(httpProxy.mock.calls[0][0].toString()).toBe(\"http://unraid/graphql\");\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual(\n      expect.objectContaining({\n        memoryUsedPercent: 10,\n        cpuPercent: 5,\n        unreadNotifications: 2,\n        arrayState: \"STARTED\",\n      }),\n    );\n    expect(res.body.caches.cache.fsUsedPercent).toBe(75);\n  });\n\n  it(\"returns 500 when the response cannot be processed\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://unraid\", key: \"k\" });\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"not-json\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await unraidProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual(expect.objectContaining({ error: expect.any(String) }));\n  });\n});\n"
  },
  {
    "path": "src/widgets/unraid/widget.js",
    "content": "import unraidProxyHandler from \"./proxy\";\n\nconst widget = {\n  proxyHandler: unraidProxyHandler,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/unraid/widget.test.js",
    "content": "import { describe, expect, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"unraid widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n    expect(widget.api).toBeUndefined();\n  });\n});\n"
  },
  {
    "path": "src/widgets/uptimekuma/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: statusData, error: statusError } = useWidgetAPI(widget, \"status_page\");\n  const { data: heartbeatData, error: heartbeatError } = useWidgetAPI(widget, \"heartbeat\");\n\n  if (statusError || heartbeatError) {\n    return <Container service={service} error={statusError ?? heartbeatError} />;\n  }\n\n  if (!statusData || !heartbeatData) {\n    return (\n      <Container service={service}>\n        <Block label=\"uptimekuma.up\" />\n        <Block label=\"uptimekuma.down\" />\n        <Block label=\"uptimekuma.uptime\" />\n        <Block label=\"uptimekuma.incidents\" />\n      </Container>\n    );\n  }\n\n  let sitesUp = 0;\n  let sitesDown = 0;\n  Object.values(heartbeatData.heartbeatList).forEach((siteList) => {\n    const lastHeartbeat = siteList[siteList.length - 1];\n    if (lastHeartbeat?.status === 1) {\n      sitesUp += 1;\n    } else {\n      sitesDown += 1;\n    }\n  });\n\n  // Adapted from https://github.com/bastienwirtz/homer/blob/b7cd8f9482e6836a96b354b11595b03b9c3d67cd/src/components/services/UptimeKuma.vue#L105\n  const uptimeList = Object.values(heartbeatData.uptimeList);\n  const percent = uptimeList.reduce((a, b) => a + b, 0) / uptimeList.length || 0;\n  const uptime = (percent * 100).toFixed(1);\n  const incidentTime = statusData.incident\n    ? Math.abs(new Date(statusData.incident?.createdDate) - new Date()) / 1000 / (60 * 60)\n    : null;\n\n  return (\n    <Container service={service}>\n      <Block label=\"uptimekuma.up\" value={t(\"common.number\", { value: sitesUp })} />\n      <Block label=\"uptimekuma.down\" value={t(\"common.number\", { value: sitesDown })} />\n      <Block label=\"uptimekuma.uptime\" value={t(\"common.percent\", { value: uptime })} highlightValue={Number(uptime)} />\n      {incidentTime && (\n        <Block\n          label=\"uptimekuma.incident\"\n          value={t(\"common.number\", { value: Math.round(incidentTime) }) + t(\"uptimekuma.m\")}\n        />\n      )}\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/uptimekuma/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/uptimekuma/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"uptimekuma\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"uptimekuma.up\")).toBeInTheDocument();\n    expect(screen.getByText(\"uptimekuma.down\")).toBeInTheDocument();\n    expect(screen.getByText(\"uptimekuma.uptime\")).toBeInTheDocument();\n    expect(screen.getByText(\"uptimekuma.incidents\")).toBeInTheDocument();\n  });\n\n  it(\"computes site up/down and uptime percent when loaded (no incident)\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"status_page\") return { data: { incident: null }, error: undefined };\n      if (endpoint === \"heartbeat\") {\n        return {\n          data: {\n            heartbeatList: {\n              a: [{ status: 1 }],\n              b: [{ status: 0 }],\n            },\n            uptimeList: { a: 0.5, b: 1 },\n          },\n          error: undefined,\n        };\n      }\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"uptimekuma\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"uptimekuma.up\", 1);\n    expectBlockValue(container, \"uptimekuma.down\", 1);\n    // avg = (0.5 + 1) / 2 = 0.75 => \"75.0\"\n    expectBlockValue(container, \"uptimekuma.uptime\", \"75.0\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/uptimekuma/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}/{slug}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    status_page: {\n      endpoint: \"status-page\",\n    },\n    heartbeat: {\n      endpoint: \"status-page/heartbeat\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/uptimekuma/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"uptimekuma widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/uptimerobot/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\nimport { useEffect, useState } from \"react\";\n\nimport { formatProxyUrl } from \"utils/proxy/api-helpers\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n  const { t } = useTranslation();\n\n  const [uptimerobotData, setUptimerobotData] = useState(null);\n\n  useEffect(() => {\n    async function fetchData() {\n      const url = formatProxyUrl(widget, \"getmonitors\");\n      const res = await fetch(url, { method: \"POST\" });\n      setUptimerobotData(await res.json());\n    }\n    if (!uptimerobotData) {\n      fetchData();\n    }\n  }, [widget, uptimerobotData]);\n\n  if (!uptimerobotData) {\n    return (\n      <Container service={service}>\n        <Block label=\"uptimerobot.status\" />\n        <Block label=\"uptimerobot.uptime\" />\n      </Container>\n    );\n  }\n\n  if (uptimerobotData.error) {\n    return <Container service={service} error={uptimerobotData.error} />;\n  }\n\n  // multiple monitors\n  if (uptimerobotData.pagination?.total > 1) {\n    const sitesUp = uptimerobotData.monitors.filter((m) => m.status === 2).length;\n\n    return (\n      <Container service={service}>\n        <Block label=\"uptimerobot.sitesUp\" value={sitesUp} />\n        <Block label=\"uptimerobot.sitesDown\" value={uptimerobotData.pagination.total - sitesUp} />\n      </Container>\n    );\n  }\n\n  // single monitor\n  const monitor = uptimerobotData.monitors[0];\n  const logs = Array.isArray(monitor.logs) ? monitor.logs : [];\n  const lastUpLog = logs.find((log) => log.type === 2);\n  const lastDownLog = logs.find((log) => log.type === 1);\n\n  let status;\n  let uptime = 0;\n\n  switch (monitor.status) {\n    case 0:\n      status = t(\"uptimerobot.paused\");\n      break;\n    case 1:\n      status = t(\"uptimerobot.notyetchecked\");\n      break;\n    case 2:\n      status = t(\"uptimerobot.up\");\n      uptime = t(\"common.duration\", { value: lastUpLog?.duration ?? 0 });\n      break;\n    case 8:\n      status = t(\"uptimerobot.seemsdown\");\n      break;\n    case 9:\n      status = t(\"uptimerobot.down\");\n      break;\n    default:\n      status = t(\"uptimerobot.unknown\");\n      break;\n  }\n\n  const lastDown = lastDownLog ? new Date(lastDownLog.datetime * 1000).toLocaleString() : \"\";\n  const downDuration = t(\"common.duration\", { value: lastDownLog?.duration ?? 0 });\n  const hideDown = !lastDownLog;\n\n  return (\n    <Container service={service}>\n      <Block label=\"uptimerobot.status\" value={status} />\n      <Block label=\"uptimerobot.uptime\" value={uptime} />\n      {!hideDown && <Block label=\"uptimerobot.lastDown\" value={lastDown} />}\n      {!hideDown && <Block label=\"uptimerobot.downDuration\" value={downDuration} />}\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/uptimerobot/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen, waitFor } from \"@testing-library/react\";\nimport { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/uptimerobot/component\", () => {\n  const originalFetch = globalThis.fetch;\n\n  beforeEach(() => {\n    vi.restoreAllMocks();\n  });\n\n  afterEach(() => {\n    globalThis.fetch = originalFetch;\n  });\n\n  it(\"renders placeholders initially and then renders multi-monitor counts\", async () => {\n    globalThis.fetch = vi.fn(async () => ({\n      json: async () => ({\n        pagination: { total: 3 },\n        monitors: [{ status: 2 }, { status: 9 }, { status: 2 }],\n      }),\n    }));\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"uptimerobot\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(screen.getByText(\"uptimerobot.status\")).toBeInTheDocument();\n    expect(screen.getByText(\"uptimerobot.uptime\")).toBeInTheDocument();\n\n    await waitFor(() => {\n      expectBlockValue(container, \"uptimerobot.sitesUp\", 2);\n      expectBlockValue(container, \"uptimerobot.sitesDown\", 1);\n    });\n  });\n});\n"
  },
  {
    "path": "src/widgets/uptimerobot/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/v2/{endpoint}?api_key={key}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    getmonitors: {\n      method: \"POST\",\n      endpoint: \"getMonitors\",\n      body: \"format=json&logs=1\",\n      headers: {\n        \"content-type\": \"application/x-www-form-urlencoded\",\n        \"cache-control\": \"no-cache\",\n      },\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/uptimerobot/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"uptimerobot widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/urbackup/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst Status = Object.freeze({\n  ok: Symbol(\"Ok\"),\n  errored: Symbol(\"Errored\"),\n  noRecent: Symbol(\"No Recent Backups\"),\n});\n\nfunction hasRecentBackups(client, maxDays) {\n  const days = maxDays || 3;\n  const diffTime = days * 24 * 60 * 60; // 7 days\n  const recentFile = client.lastbackup > Date.now() / 1000 - diffTime;\n  const recentImage =\n    client.image_not_supported || client.image_disabled || client.lastbackup_image > Date.now() / 1000 - diffTime;\n  return recentFile && recentImage;\n}\n\nfunction determineStatuses(urbackupData) {\n  let ok = 0;\n  let errored = 0;\n  let noRecent = 0;\n  let status;\n  urbackupData.clientStatuses.forEach((client) => {\n    status = Status.noRecent;\n    if (hasRecentBackups(client, urbackupData.maxDays)) {\n      status =\n        client.file_ok && (client.image_ok || client.image_not_supported || client.image_disabled)\n          ? Status.ok\n          : Status.errored;\n    }\n    switch (status) {\n      case Status.ok:\n        ok += 1;\n        break;\n      case Status.errored:\n        errored += 1;\n        break;\n      case Status.noRecent:\n        noRecent += 1;\n        break;\n      default:\n        break;\n    }\n  });\n\n  let totalUsage = false;\n\n  // calculate total disk space if provided\n  if (urbackupData.diskUsage) {\n    totalUsage = 0.0;\n    urbackupData.diskUsage.forEach((client) => {\n      totalUsage += client.used;\n    });\n  }\n\n  return { ok, errored, noRecent, totalUsage };\n}\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const showDiskUsage = widget.fields?.includes(\"totalUsed\");\n\n  const { data: urbackupData, error: urbackupError } = useWidgetAPI(widget, \"status\");\n\n  if (urbackupError) {\n    return <Container service={service} error={urbackupError} />;\n  }\n\n  if (!urbackupData) {\n    return (\n      <Container service={service}>\n        <Block label=\"urbackup.ok\" />\n        <Block label=\"urbackup.errored\" />\n        <Block label=\"urbackup.noRecent\" />\n        {showDiskUsage && <Block label=\"urbackup.totalUsed\" />}\n      </Container>\n    );\n  }\n\n  const statusData = determineStatuses(urbackupData, widget);\n\n  return (\n    <Container service={service}>\n      <Block label=\"urbackup.ok\" value={t(\"common.number\", { value: parseInt(statusData.ok, 10) })} />\n      <Block label=\"urbackup.errored\" value={t(\"common.number\", { value: parseInt(statusData.errored, 10) })} />\n      <Block label=\"urbackup.noRecent\" value={t(\"common.number\", { value: parseInt(statusData.noRecent, 10) })} />\n      {showDiskUsage && (\n        <Block\n          label=\"urbackup.totalUsed\"\n          value={t(\"common.bbytes\", { value: parseFloat(statusData.totalUsage, 10) })}\n        />\n      )}\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/urbackup/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/urbackup/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    vi.useFakeTimers();\n    vi.setSystemTime(new Date(\"2020-01-01T00:00:00Z\"));\n  });\n\n  afterEach(() => {\n    vi.useRealTimers();\n  });\n\n  it(\"renders placeholders while loading (optionally includes totalUsed)\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(\n      <Component service={{ widget: { type: \"urbackup\", fields: [\"ok\", \"errored\", \"noRecent\", \"totalUsed\"] } }} />,\n      {\n        settings: { hideErrors: false },\n      },\n    );\n\n    // Container filters children by widget.fields.\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"urbackup.ok\")).toBeInTheDocument();\n    expect(screen.getByText(\"urbackup.errored\")).toBeInTheDocument();\n    expect(screen.getByText(\"urbackup.noRecent\")).toBeInTheDocument();\n    expect(screen.getByText(\"urbackup.totalUsed\")).toBeInTheDocument();\n  });\n\n  it(\"renders ok/errored/noRecent and totalUsed when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: {\n        maxDays: 3,\n        clientStatuses: [\n          // ok\n          {\n            lastbackup: 1577836800,\n            lastbackup_image: 1577836800,\n            file_ok: true,\n            image_ok: true,\n            image_not_supported: false,\n            image_disabled: false,\n          },\n          // errored\n          {\n            lastbackup: 1577836800,\n            lastbackup_image: 1577836800,\n            file_ok: false,\n            image_ok: true,\n            image_not_supported: false,\n            image_disabled: false,\n          },\n          // no recent\n          {\n            lastbackup: 0,\n            lastbackup_image: 0,\n            file_ok: true,\n            image_ok: true,\n            image_not_supported: false,\n            image_disabled: false,\n          },\n        ],\n        diskUsage: [{ used: 1 }, { used: 2 }],\n      },\n      error: undefined,\n    });\n\n    const service = { widget: { type: \"urbackup\", fields: [\"ok\", \"errored\", \"noRecent\", \"totalUsed\"] } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expectBlockValue(container, \"urbackup.ok\", 1);\n    expectBlockValue(container, \"urbackup.errored\", 1);\n    expectBlockValue(container, \"urbackup.noRecent\", 1);\n    expectBlockValue(container, \"urbackup.totalUsed\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/urbackup/proxy.js",
    "content": "import { UrbackupServer } from \"urbackup-server-api\";\n\nimport getServiceWidget from \"utils/config/service-helpers\";\n\nexport default async function urbackupProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n  const serviceWidget = await getServiceWidget(group, service, index);\n\n  const server = new UrbackupServer({\n    url: serviceWidget.url,\n    username: serviceWidget.username,\n    password: serviceWidget.password,\n  });\n\n  await (async () => {\n    try {\n      const allClients = await server.getStatus({ includeRemoved: false });\n      let diskUsage = false;\n      if (serviceWidget.fields?.includes(\"totalUsed\")) {\n        diskUsage = await server.getUsage();\n      }\n      res.status(200).send({\n        clientStatuses: allClients,\n        diskUsage,\n        maxDays: serviceWidget.maxDays,\n      });\n    } catch (error) {\n      res.status(500).json({ error: \"Error communicating with UrBackup server\" });\n    }\n  })();\n}\n"
  },
  {
    "path": "src/widgets/urbackup/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { UrbackupServer, state, getServiceWidget } = vi.hoisted(() => {\n  const state = { instances: [] };\n\n  const UrbackupServer = vi.fn((opts) => {\n    const instance = {\n      opts,\n      getStatus: vi.fn(),\n      getUsage: vi.fn(),\n    };\n    state.instances.push(instance);\n    return instance;\n  });\n\n  return {\n    UrbackupServer,\n    state,\n    getServiceWidget: vi.fn(),\n  };\n});\n\nvi.mock(\"urbackup-server-api\", () => ({\n  UrbackupServer,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nimport urbackupProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/urbackup/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    state.instances.length = 0;\n  });\n\n  it(\"returns client statuses and maxDays without disk usage by default\", async () => {\n    getServiceWidget.mockResolvedValue({\n      url: \"http://ur\",\n      username: \"u\",\n      password: \"p\",\n      maxDays: 5,\n    });\n\n    UrbackupServer.mockImplementationOnce((opts) => {\n      const instance = {\n        opts,\n        getStatus: vi.fn().mockResolvedValue([{ id: 1 }]),\n        getUsage: vi.fn(),\n      };\n      state.instances.push(instance);\n      return instance;\n    });\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await urbackupProxyHandler(req, res);\n\n    expect(UrbackupServer).toHaveBeenCalledWith({ url: \"http://ur\", username: \"u\", password: \"p\" });\n    expect(state.instances[0].getUsage).not.toHaveBeenCalled();\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ clientStatuses: [{ id: 1 }], diskUsage: false, maxDays: 5 });\n  });\n\n  it(\"fetches disk usage when requested via fields\", async () => {\n    getServiceWidget.mockResolvedValue({\n      url: \"http://ur\",\n      username: \"u\",\n      password: \"p\",\n      maxDays: 1,\n      fields: [\"totalUsed\"],\n    });\n\n    UrbackupServer.mockImplementationOnce((opts) => {\n      const instance = {\n        opts,\n        getStatus: vi.fn().mockResolvedValue([{ id: 1 }]),\n        getUsage: vi.fn().mockResolvedValue({ totalUsed: 123 }),\n      };\n      state.instances.push(instance);\n      return instance;\n    });\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await urbackupProxyHandler(req, res);\n\n    expect(state.instances[0].getUsage).toHaveBeenCalled();\n    expect(res.statusCode).toBe(200);\n    expect(res.body.diskUsage).toEqual({ totalUsed: 123 });\n  });\n\n  it(\"returns 500 on server errors\", async () => {\n    getServiceWidget.mockResolvedValue({ url: \"http://ur\", username: \"u\", password: \"p\" });\n\n    UrbackupServer.mockImplementationOnce((opts) => {\n      const instance = {\n        opts,\n        getStatus: vi.fn().mockRejectedValue(new Error(\"nope\")),\n        getUsage: vi.fn(),\n      };\n      state.instances.push(instance);\n      return instance;\n    });\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await urbackupProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(500);\n    expect(res.body).toEqual({ error: \"Error communicating with UrBackup server\" });\n  });\n});\n"
  },
  {
    "path": "src/widgets/urbackup/widget.js",
    "content": "import urbackupProxyHandler from \"./proxy\";\n\nconst widget = {\n  proxyHandler: urbackupProxyHandler,\n  allowedEndpoints: /status/,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/urbackup/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"urbackup widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/vikunja/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const version = widget.version ?? 1;\n\n  const { data: projectsData, error: projectsError } = useWidgetAPI(widget, \"projects\");\n  const { data: tasksData, error: tasksError } = useWidgetAPI(widget, version === 2 ? \"tasks_v2\" : \"tasks\");\n\n  if (projectsError || tasksError) {\n    return <Container service={service} error={projectsError ?? tasksError} />;\n  } else if (projectsData?.message || tasksData?.message) {\n    return <Container service={service} error={projectsData?.message ?? tasksData?.message} />;\n  }\n\n  if (!projectsData || !tasksData) {\n    return (\n      <Container service={service}>\n        <Block label=\"vikunja.projects\" />\n        <Block label=\"vikunja.tasks7d\" />\n        <Block label=\"vikunja.tasksOverdue\" />\n        <Block label=\"vikunja.tasksInProgress\" />\n      </Container>\n    );\n  }\n\n  const projects = projectsData.filter((project) => project.id > 0); // saved filters have id < 0\n\n  const oneWeekFromNow = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);\n  const tasksWithDueDate = tasksData.filter((task) => !task.dueDateIsDefault);\n  const tasks7d = tasksWithDueDate.filter((task) => new Date(task.dueDate) <= oneWeekFromNow);\n  const tasksOverdue = tasksWithDueDate.filter((task) => new Date(task.dueDate) <= new Date(Date.now()));\n  const tasksInProgress = tasksData.filter((task) => task.inProgress);\n\n  return (\n    <>\n      <Container service={service}>\n        <Block label=\"vikunja.projects\" value={t(\"common.number\", { value: projects.length })} />\n        <Block label=\"vikunja.tasks7d\" value={t(\"common.number\", { value: tasks7d.length })} />\n        <Block label=\"vikunja.tasksOverdue\" value={t(\"common.number\", { value: tasksOverdue.length })} />\n        <Block label=\"vikunja.tasksInProgress\" value={t(\"common.number\", { value: tasksInProgress.length })} />\n      </Container>\n      {widget.enableTaskList &&\n        tasksData.slice(0, 5).map((task) => (\n          <div\n            key={task.id}\n            className=\"text-theme-700 dark:text-theme-200 relative h-5 rounded-md bg-theme-200/50 dark:bg-theme-900/20 m-1 px-1 flex\"\n          >\n            <div className=\"text-xs z-10 self-center ml-2 relative h-4 grow mr-2\">\n              <div className=\"absolute w-full h-4 whitespace-nowrap text-ellipsis overflow-hidden text-left\">\n                {task.title}\n              </div>\n            </div>\n            {!task.dueDateIsDefault && (\n              <div className=\"self-center text-xs flex justify-end mr-1.5 pl-1 z-10 text-ellipsis overflow-hidden whitespace-nowrap\">\n                {t(\"common.relativeDate\", {\n                  value: task.dueDate,\n                  formatParams: { value: { style: \"narrow\", numeric: \"auto\" } },\n                })}\n              </div>\n            )}\n          </div>\n        ))}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/widgets/vikunja/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/vikunja/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    vi.useFakeTimers();\n    vi.setSystemTime(new Date(\"2020-01-01T00:00:00Z\"));\n  });\n\n  afterEach(() => {\n    vi.useRealTimers();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"vikunja\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"vikunja.projects\")).toBeInTheDocument();\n    expect(screen.getByText(\"vikunja.tasks7d\")).toBeInTheDocument();\n    expect(screen.getByText(\"vikunja.tasksOverdue\")).toBeInTheDocument();\n    expect(screen.getByText(\"vikunja.tasksInProgress\")).toBeInTheDocument();\n  });\n\n  it(\"computes project/task stats when loaded\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"projects\") return { data: [{ id: 1 }, { id: -1 }], error: undefined };\n      if (endpoint === \"tasks\") {\n        return {\n          data: [\n            { dueDateIsDefault: false, dueDate: \"2020-01-02T00:00:00Z\", inProgress: true },\n            { dueDateIsDefault: false, dueDate: \"2019-12-31T00:00:00Z\", inProgress: false },\n            { dueDateIsDefault: true, dueDate: \"2099-01-01T00:00:00Z\", inProgress: false },\n          ],\n          error: undefined,\n        };\n      }\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"vikunja\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    // projects filters id > 0 => 1\n    expectBlockValue(container, \"vikunja.projects\", 1);\n    // tasks7d includes both non-default dueDate tasks (both <= one week)\n    expectBlockValue(container, \"vikunja.tasks7d\", 2);\n    // overdue includes dueDate <= now => 1 (2019-12-31)\n    expectBlockValue(container, \"vikunja.tasksOverdue\", 1);\n    // inProgress => 1\n    expectBlockValue(container, \"vikunja.tasksInProgress\", 1);\n  });\n});\n"
  },
  {
    "path": "src/widgets/vikunja/widget.js",
    "content": "import { asJson } from \"utils/proxy/api-helpers\";\nimport credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst map = (data) =>\n  asJson(data).map((task) => ({\n    id: task.id,\n    title: task.title,\n    priority: task.priority,\n    dueDate: task.due_date,\n    dueDateIsDefault: task.due_date === \"0001-01-01T00:00:00Z\",\n    inProgress: task.percent_done > 0 && task.percent_done < 1,\n  }));\n\nconst widget = {\n  api: `{url}/api/v1/{endpoint}`,\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    projects: {\n      endpoint: \"projects\",\n    },\n    tasks: {\n      endpoint: \"tasks/all?filter=done%3Dfalse&sort_by=due_date\",\n      map: map,\n    },\n    tasks_v2: {\n      endpoint: \"tasks?filter=done%3Dfalse&sort_by=due_date\",\n      map: map,\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/vikunja/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"vikunja widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/wallos/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst MAX_ALLOWED_FIELDS = 4;\n\nconst todayDate = new Date();\nfunction toApiMonthYear(offset = 0) {\n  // API expects 1-indexed months, wrap around if needed\n  const m = todayDate.getMonth() + 1 + offset;\n  return {\n    month: ((m + 11) % 12) + 1,\n    year: todayDate.getFullYear() + Math.floor((m - 1) / 12),\n  };\n}\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  if (!widget.fields) {\n    widget.fields = [\"activeSubscriptions\", \"nextRenewingSubscription\", \"thisMonthlyCost\", \"nextMonthlyCost\"];\n  } else if (widget.fields?.length > MAX_ALLOWED_FIELDS) {\n    widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);\n  }\n\n  const subscriptionsEndPoint =\n    widget.fields.includes(\"activeSubscriptions\") || widget.fields.includes(\"nextRenewingSubscription\")\n      ? \"get_subscriptions\"\n      : \"\";\n  const { data: subscriptionsData, error: subscriptionsError } = useWidgetAPI(widget, subscriptionsEndPoint, {\n    state: 0,\n    sort: \"next_payment\",\n  });\n  const subscriptionsThisMonthlyEndpoint = widget.fields.includes(\"thisMonthlyCost\") ? \"get_monthly_cost\" : \"\";\n  const { data: subscriptionsThisMonthlyCostData, error: subscriptionsThisMonthlyCostError } = useWidgetAPI(\n    widget,\n    subscriptionsThisMonthlyEndpoint,\n    toApiMonthYear(), // this month\n  );\n  const subscriptionsNextMonthlyEndpoint = widget.fields.includes(\"nextMonthlyCost\") ? \"get_monthly_cost\" : \"\";\n  const { data: subscriptionsNextMonthlyCostData, error: subscriptionsNextMonthlyCostError } = useWidgetAPI(\n    widget,\n    subscriptionsNextMonthlyEndpoint,\n    toApiMonthYear(1), // next month\n  );\n  const subscriptionsPreviousMonthlyEndpoint = widget.fields.includes(\"previousMonthlyCost\") ? \"get_monthly_cost\" : \"\";\n  const { data: subscriptionsPreviousMonthlyCostData, error: subscriptionsPreviousMonthlyCostError } = useWidgetAPI(\n    widget,\n    subscriptionsPreviousMonthlyEndpoint,\n    toApiMonthYear(-1), // previous month\n  );\n\n  if (\n    subscriptionsError ||\n    subscriptionsThisMonthlyCostError ||\n    subscriptionsNextMonthlyCostError ||\n    subscriptionsPreviousMonthlyCostError\n  ) {\n    const finalError =\n      subscriptionsError ??\n      subscriptionsThisMonthlyCostError ??\n      subscriptionsNextMonthlyCostError ??\n      subscriptionsPreviousMonthlyCostError;\n    return <Container service={service} error={finalError} />;\n  }\n\n  if (\n    (!subscriptionsData &&\n      (widget.fields.includes(\"activeSubscriptions\") || widget.fields.includes(\"nextRenewingSubscription\"))) ||\n    (!subscriptionsThisMonthlyCostData && widget.fields.includes(\"thisMonthlyCost\")) ||\n    (!subscriptionsNextMonthlyCostData && widget.fields.includes(\"nextMonthlyCost\")) ||\n    (!subscriptionsPreviousMonthlyCostData && widget.fields.includes(\"previousMonthlyCost\"))\n  ) {\n    return (\n      <Container service={service}>\n        <Block label=\"wallos.activeSubscriptions\" />\n        <Block label=\"wallos.nextRenewingSubscription\" />\n        <Block label=\"wallos.previousMonthlyCost\" />\n        <Block label=\"wallos.thisMonthlyCost\" />\n        <Block label=\"wallos.nextMonthlyCost\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block\n        label=\"wallos.activeSubscriptions\"\n        value={t(\"common.number\", { value: subscriptionsData?.subscriptions?.length })}\n      />\n      <Block label=\"wallos.nextRenewingSubscription\" value={subscriptionsData?.subscriptions[0]?.name} />\n      <Block label=\"wallos.previousMonthlyCost\" value={subscriptionsPreviousMonthlyCostData?.localized_monthly_cost} />\n      <Block label=\"wallos.thisMonthlyCost\" value={subscriptionsThisMonthlyCostData?.localized_monthly_cost} />\n      <Block label=\"wallos.nextMonthlyCost\" value={subscriptionsNextMonthlyCostData?.localized_monthly_cost} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/wallos/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/wallos/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"defaults fields to 4 and filters loading placeholders accordingly\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"wallos\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\n      \"activeSubscriptions\",\n      \"nextRenewingSubscription\",\n      \"thisMonthlyCost\",\n      \"nextMonthlyCost\",\n    ]);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"wallos.activeSubscriptions\")).toBeInTheDocument();\n    expect(screen.getByText(\"wallos.nextRenewingSubscription\")).toBeInTheDocument();\n    expect(screen.getByText(\"wallos.thisMonthlyCost\")).toBeInTheDocument();\n    expect(screen.getByText(\"wallos.nextMonthlyCost\")).toBeInTheDocument();\n    expect(screen.queryByText(\"wallos.previousMonthlyCost\")).toBeNull();\n  });\n\n  it(\"renders subscription and monthly cost values when loaded\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"get_subscriptions\") return { data: { subscriptions: [{ name: \"Sub\" }] }, error: undefined };\n      if (endpoint === \"get_monthly_cost\") return { data: { localized_monthly_cost: \"$10\" }, error: undefined };\n      return { data: undefined, error: undefined };\n    });\n\n    const service = {\n      widget: {\n        type: \"wallos\",\n        fields: [\"activeSubscriptions\", \"nextRenewingSubscription\", \"thisMonthlyCost\", \"nextMonthlyCost\"],\n      },\n    };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expectBlockValue(container, \"wallos.activeSubscriptions\", 1);\n    expectBlockValue(container, \"wallos.nextRenewingSubscription\", \"Sub\");\n    expectBlockValue(container, \"wallos.thisMonthlyCost\", \"$10\");\n    expectBlockValue(container, \"wallos.nextMonthlyCost\", \"$10\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/wallos/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}?api_key={key}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    get_monthly_cost: {\n      endpoint: \"subscriptions/get_monthly_cost.php\",\n      validate: [\"localized_monthly_cost\", \"currency_symbol\"],\n      params: [\"month\", \"year\"],\n    },\n    get_subscriptions: {\n      endpoint: \"subscriptions/get_subscriptions.php\",\n      validate: [\"subscriptions\"],\n      params: [\"state\", \"sort\"],\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/wallos/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"wallos widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/watchtower/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: watchData, error: watchError } = useWidgetAPI(widget, \"watchtower\");\n\n  if (watchError) {\n    return <Container service={service} error={watchError} />;\n  }\n\n  if (!watchData) {\n    return (\n      <Container service={service}>\n        <Block label=\"watchtower.containers_scanned\" />\n        <Block label=\"watchtower.containers_updated\" />\n        <Block label=\"watchtower.containers_failed\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block\n        label=\"watchtower.containers_scanned\"\n        value={t(\"common.number\", { value: watchData.watchtower_containers_scanned })}\n      />\n      <Block\n        label=\"watchtower.containers_updated\"\n        value={t(\"common.number\", { value: watchData.watchtower_containers_updated })}\n      />\n      <Block\n        label=\"watchtower.containers_failed\"\n        value={t(\"common.number\", { value: watchData.watchtower_containers_failed })}\n      />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/watchtower/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/watchtower/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"watchtower\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"watchtower.containers_scanned\")).toBeInTheDocument();\n    expect(screen.getByText(\"watchtower.containers_updated\")).toBeInTheDocument();\n    expect(screen.getByText(\"watchtower.containers_failed\")).toBeInTheDocument();\n  });\n\n  it(\"renders metrics when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { watchtower_containers_scanned: 1, watchtower_containers_updated: 2, watchtower_containers_failed: 3 },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"watchtower\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"watchtower.containers_scanned\", 1);\n    expectBlockValue(container, \"watchtower.containers_updated\", 2);\n    expectBlockValue(container, \"watchtower.containers_failed\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/watchtower/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst proxyName = \"watchtowerProxyHandler\";\nconst logger = createLogger(proxyName);\n\nexport default async function watchtowerProxyHandler(req, res) {\n  const { group, service, endpoint, index } = req.query;\n\n  if (!group || !service) {\n    logger.debug(\"Invalid or missing service '%s' or group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n\n  if (!widget) {\n    logger.debug(\"Invalid or missing widget for service '%s' in group '%s'\", service, group);\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));\n\n  const [status, contentType, data] = await httpProxy(url, {\n    method: \"GET\",\n    headers: {\n      Authorization: `Bearer ${widget.key}`,\n    },\n  });\n\n  if (status !== 200 || !data) {\n    logger.error(\"Error getting data from WatchTower: %d.  Data: %s\", status, data);\n    return res.status(status).json({ error: { message: `HTTP Error ${status}`, url, data } });\n  }\n\n  const cleanData = data\n    .toString()\n    .split(\"\\n\")\n    .filter((s) => s.startsWith(\"watchtower\"));\n  const jsonRes = {};\n\n  cleanData\n    .map((e) => e.split(\" \"))\n    .forEach((strArray) => {\n      const [key, value] = strArray;\n      jsonRes[key] = value;\n    });\n\n  if (contentType) res.setHeader(\"Content-Type\", contentType);\n  return res.status(status).send(jsonRes);\n}\n"
  },
  {
    "path": "src/widgets/watchtower/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  logger: {\n    debug: vi.fn(),\n    error: vi.fn(),\n  },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    watchtower: {\n      api: \"{url}/{endpoint}\",\n    },\n  },\n}));\n\nimport watchtowerProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/watchtower/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"parses watchtower metrics and returns a key/value object\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"watchtower\", url: \"http://watch\", key: \"k\" });\n    httpProxy.mockResolvedValueOnce([\n      200,\n      \"text/plain\",\n      Buffer.from(\"watchtower_running 1\\nfoo 2\\nwatchtower_status 3\\n\"),\n    ]);\n\n    const req = { query: { group: \"g\", service: \"svc\", endpoint: \"metrics\", index: \"0\" } };\n    const res = createMockRes();\n\n    await watchtowerProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(1);\n    expect(httpProxy.mock.calls[0][0].toString()).toBe(\"http://watch/metrics\");\n    expect(httpProxy.mock.calls[0][1]).toEqual({\n      method: \"GET\",\n      headers: { Authorization: \"Bearer k\" },\n    });\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual({ watchtower_running: \"1\", watchtower_status: \"3\" });\n    expect(res.setHeader).toHaveBeenCalledWith(\"Content-Type\", \"text/plain\");\n  });\n});\n"
  },
  {
    "path": "src/widgets/watchtower/widget.js",
    "content": "import watchtowerProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/{endpoint}\",\n  proxyHandler: watchtowerProxyHandler,\n\n  mappings: {\n    watchtower: {\n      endpoint: \"v1/metrics\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/watchtower/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"watchtower widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/wgeasy/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  const endpoint = widget.version === 2 ? \"clientv2\" : \"client\";\n\n  const { data: infoData, error: infoError } = useWidgetAPI(widget, endpoint);\n\n  if (!widget.fields) {\n    widget.fields = [\"connected\", \"enabled\", \"total\"];\n  }\n\n  if (infoError || infoData?.statusCode > 400) {\n    return <Container service={service} error={infoError ?? { message: infoData.statusMessage, data: infoData }} />;\n  }\n\n  if (!infoData) {\n    return (\n      <Container service={service}>\n        <Block label=\"wgeasy.connected\" />\n        <Block label=\"wgeasy.enabled\" />\n        <Block label=\"wgeasy.disabled\" />\n        <Block label=\"wgeasy.total\" />\n      </Container>\n    );\n  }\n\n  const enabled = infoData.filter((item) => item.enabled).length;\n  const disabled = infoData.length - enabled;\n  const connectionThreshold = (widget.threshold ?? 2) * 60 * 1000;\n  const currentTime = new Date();\n  const connected = infoData.filter(\n    (item) => currentTime - new Date(item.latestHandshakeAt) < connectionThreshold,\n  ).length;\n\n  return (\n    <Container service={service}>\n      <Block label=\"wgeasy.connected\" value={connected} />\n      <Block label=\"wgeasy.enabled\" value={enabled} />\n      <Block label=\"wgeasy.disabled\" value={disabled} />\n      <Block label=\"wgeasy.total\" value={infoData.length} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/wgeasy/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/wgeasy/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    vi.useFakeTimers();\n    vi.setSystemTime(new Date(\"2020-01-01T00:00:00Z\"));\n  });\n\n  afterEach(() => {\n    vi.useRealTimers();\n  });\n\n  it(\"sets default fields and renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"wgeasy\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"connected\", \"enabled\", \"total\"]);\n    // Container filters by widget.fields; \"disabled\" is not included by default.\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"wgeasy.connected\")).toBeInTheDocument();\n    expect(screen.getByText(\"wgeasy.enabled\")).toBeInTheDocument();\n    expect(screen.queryByText(\"wgeasy.disabled\")).toBeNull();\n    expect(screen.getByText(\"wgeasy.total\")).toBeInTheDocument();\n  });\n\n  it(\"computes enabled/disabled/connected counts when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: [\n        { enabled: true, latestHandshakeAt: \"2020-01-01T00:00:00Z\" },\n        { enabled: true, latestHandshakeAt: \"2019-12-31T23:00:00Z\" },\n        { enabled: false, latestHandshakeAt: \"2019-12-30T00:00:00Z\" },\n      ],\n      error: undefined,\n    });\n\n    const service = { widget: { type: \"wgeasy\", threshold: 2 } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    // enabled=2, disabled=1; connected uses threshold minutes (2min) so only the first handshake counts.\n    expectBlockValue(container, \"wgeasy.enabled\", 2);\n    expectBlockValue(container, \"wgeasy.connected\", 1);\n    expectBlockValue(container, \"wgeasy.total\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/wgeasy/widget.js",
    "content": "import credentialedProxyHandler from \"utils/proxy/handlers/credentialed\";\n\nconst widget = {\n  api: \"{url}/api/{endpoint}\",\n  proxyHandler: credentialedProxyHandler,\n\n  mappings: {\n    client: {\n      endpoint: \"wireguard/client\",\n    },\n    clientv2: {\n      endpoint: \"client\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/wgeasy/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"wgeasy widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/whatsupdocker/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { widget } = service;\n\n  const { data: containersData, error: containersError } = useWidgetAPI(widget, \"containers\");\n\n  if (containersError) {\n    return <Container service={service} error={containersError} />;\n  }\n\n  if (!containersData) {\n    return (\n      <Container service={service}>\n        <Block label=\"whatsupdocker.monitoring\" />\n        <Block label=\"whatsupdocker.updates\" />\n      </Container>\n    );\n  }\n\n  const totalCount = containersData.length;\n  const updatesAvailable = containersData.filter((container) => container.updateAvailable).length;\n\n  return (\n    <Container service={service}>\n      <Block label=\"whatsupdocker.monitoring\" value={totalCount} />\n      <Block label=\"whatsupdocker.updates\" value={updatesAvailable} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/whatsupdocker/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/whatsupdocker/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"whatsupdocker\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(2);\n    expect(screen.getByText(\"whatsupdocker.monitoring\")).toBeInTheDocument();\n    expect(screen.getByText(\"whatsupdocker.updates\")).toBeInTheDocument();\n  });\n\n  it(\"renders monitoring and updates counts when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: [{ updateAvailable: true }, { updateAvailable: false }, { updateAvailable: true }],\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"whatsupdocker\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"whatsupdocker.monitoring\", 3);\n    expectBlockValue(container, \"whatsupdocker.updates\", 2);\n  });\n});\n"
  },
  {
    "path": "src/widgets/whatsupdocker/widget.js",
    "content": "import genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/{endpoint}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    containers: {\n      endpoint: \"api/containers\",\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/whatsupdocker/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"whatsupdocker widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/widgets.js",
    "content": "import adguard from \"./adguard/widget\";\nimport apcups from \"./apcups/widget\";\nimport arcane from \"./arcane/widget\";\nimport argocd from \"./argocd/widget\";\nimport atsumeru from \"./atsumeru/widget\";\nimport audiobookshelf from \"./audiobookshelf/widget\";\nimport authentik from \"./authentik/widget\";\nimport autobrr from \"./autobrr/widget\";\nimport azuredevops from \"./azuredevops/widget\";\nimport backrest from \"./backrest/widget\";\nimport bazarr from \"./bazarr/widget\";\nimport beszel from \"./beszel/widget\";\nimport booklore from \"./booklore/widget\";\nimport caddy from \"./caddy/widget\";\nimport calendar from \"./calendar/widget\";\nimport calibreweb from \"./calibreweb/widget\";\nimport changedetectionio from \"./changedetectionio/widget\";\nimport channelsdvrserver from \"./channelsdvrserver/widget\";\nimport checkmk from \"./checkmk/widget\";\nimport cloudflared from \"./cloudflared/widget\";\nimport coinmarketcap from \"./coinmarketcap/widget\";\nimport crowdsec from \"./crowdsec/widget\";\nimport customapi from \"./customapi/widget\";\nimport deluge from \"./deluge/widget\";\nimport develancacheui from \"./develancacheui/widget\";\nimport diskstation from \"./diskstation/widget\";\nimport dispatcharr from \"./dispatcharr/widget\";\nimport dockhand from \"./dockhand/widget\";\nimport downloadstation from \"./downloadstation/widget\";\nimport emby from \"./emby/widget\";\nimport esphome from \"./esphome/widget\";\nimport evcc from \"./evcc/widget\";\nimport filebrowser from \"./filebrowser/widget\";\nimport fileflows from \"./fileflows/widget\";\nimport firefly from \"./firefly/widget\";\nimport flood from \"./flood/widget\";\nimport freshrss from \"./freshrss/widget\";\nimport frigate from \"./frigate/widget\";\nimport fritzbox from \"./fritzbox/widget\";\nimport gamedig from \"./gamedig/widget\";\nimport gatus from \"./gatus/widget\";\nimport ghostfolio from \"./ghostfolio/widget\";\nimport gitea from \"./gitea/widget\";\nimport gitlab from \"./gitlab/widget\";\nimport glances from \"./glances/widget\";\nimport gluetun from \"./gluetun/widget\";\nimport gotify from \"./gotify/widget\";\nimport grafana from \"./grafana/widget\";\nimport hdhomerun from \"./hdhomerun/widget\";\nimport headscale from \"./headscale/widget\";\nimport healthchecks from \"./healthchecks/widget\";\nimport homeassistant from \"./homeassistant/widget\";\nimport homebox from \"./homebox/widget\";\nimport homebridge from \"./homebridge/widget\";\nimport immich from \"./immich/widget\";\nimport jackett from \"./jackett/widget\";\nimport jdownloader from \"./jdownloader/widget\";\nimport jellyfin from \"./jellyfin/widget\";\nimport jellystat from \"./jellystat/widget\";\nimport karakeep from \"./karakeep/widget\";\nimport kavita from \"./kavita/widget\";\nimport komga from \"./komga/widget\";\nimport komodo from \"./komodo/widget\";\nimport kopia from \"./kopia/widget\";\nimport lidarr from \"./lidarr/widget\";\nimport linkwarden from \"./linkwarden/widget\";\nimport lubelogger from \"./lubelogger/widget\";\nimport mailcow from \"./mailcow/widget\";\nimport mastodon from \"./mastodon/widget\";\nimport mealie from \"./mealie/widget\";\nimport medusa from \"./medusa/widget\";\nimport mikrotik from \"./mikrotik/widget\";\nimport minecraft from \"./minecraft/widget\";\nimport miniflux from \"./miniflux/widget\";\nimport mjpeg from \"./mjpeg/widget\";\nimport moonraker from \"./moonraker/widget\";\nimport mylar from \"./mylar/widget\";\nimport myspeed from \"./myspeed/widget\";\nimport navidrome from \"./navidrome/widget\";\nimport netalertx from \"./netalertx/widget\";\nimport netdata from \"./netdata/widget\";\nimport nextcloud from \"./nextcloud/widget\";\nimport nextdns from \"./nextdns/widget\";\nimport npm from \"./npm/widget\";\nimport nzbget from \"./nzbget/widget\";\nimport octoprint from \"./octoprint/widget\";\nimport omada from \"./omada/widget\";\nimport ombi from \"./ombi/widget\";\nimport opendtu from \"./opendtu/widget\";\nimport openmediavault from \"./openmediavault/widget\";\nimport openwrt from \"./openwrt/widget\";\nimport opnsense from \"./opnsense/widget\";\nimport pangolin from \"./pangolin/widget\";\nimport paperlessngx from \"./paperlessngx/widget\";\nimport peanut from \"./peanut/widget\";\nimport pfsense from \"./pfsense/widget\";\nimport photoprism from \"./photoprism/widget\";\nimport pihole from \"./pihole/widget\";\nimport plantit from \"./plantit/widget\";\nimport plex from \"./plex/widget\";\nimport portainer from \"./portainer/widget\";\nimport prometheus from \"./prometheus/widget\";\nimport prometheusmetric from \"./prometheusmetric/widget\";\nimport prowlarr from \"./prowlarr/widget\";\nimport proxmox from \"./proxmox/widget\";\nimport proxmoxbackupserver from \"./proxmoxbackupserver/widget\";\nimport pterodactyl from \"./pterodactyl/widget\";\nimport pyload from \"./pyload/widget\";\nimport qbittorrent from \"./qbittorrent/widget\";\nimport qnap from \"./qnap/widget\";\nimport radarr from \"./radarr/widget\";\nimport readarr from \"./readarr/widget\";\nimport romm from \"./romm/widget\";\nimport rutorrent from \"./rutorrent/widget\";\nimport sabnzbd from \"./sabnzbd/widget\";\nimport scrutiny from \"./scrutiny/widget\";\nimport seerr from \"./seerr/widget\";\nimport slskd from \"./slskd/widget\";\nimport sonarr from \"./sonarr/widget\";\nimport sparkyfitness from \"./sparkyfitness/widget\";\nimport speedtest from \"./speedtest/widget\";\nimport spoolman from \"./spoolman/widget\";\nimport stash from \"./stash/widget\";\nimport stocks from \"./stocks/widget\";\nimport strelaysrv from \"./strelaysrv/widget\";\nimport suwayomi from \"./suwayomi/widget\";\nimport swagdashboard from \"./swagdashboard/widget\";\nimport tailscale from \"./tailscale/widget\";\nimport tandoor from \"./tandoor/widget\";\nimport tautulli from \"./tautulli/widget\";\nimport tdarr from \"./tdarr/widget\";\nimport technitium from \"./technitium/widget\";\nimport tracearr from \"./tracearr/widget\";\nimport traefik from \"./traefik/widget\";\nimport transmission from \"./transmission/widget\";\nimport trilium from \"./trilium/widget\";\nimport truenas from \"./truenas/widget\";\nimport tubearchivist from \"./tubearchivist/widget\";\nimport unifi from \"./unifi/widget\";\nimport unmanic from \"./unmanic/widget\";\nimport unraid from \"./unraid/widget\";\nimport uptimekuma from \"./uptimekuma/widget\";\nimport uptimerobot from \"./uptimerobot/widget\";\nimport urbackup from \"./urbackup/widget\";\nimport vikunja from \"./vikunja/widget\";\nimport wallos from \"./wallos/widget\";\nimport watchtower from \"./watchtower/widget\";\nimport wgeasy from \"./wgeasy/widget\";\nimport whatsupdocker from \"./whatsupdocker/widget\";\nimport xteve from \"./xteve/widget\";\nimport yourspotify from \"./yourspotify/widget\";\nimport zabbix from \"./zabbix/widget\";\n\nconst widgets = {\n  adguard,\n  apcups,\n  arcane,\n  argocd,\n  atsumeru,\n  audiobookshelf,\n  authentik,\n  autobrr,\n  azuredevops,\n  backrest,\n  bazarr,\n  booklore,\n  beszel,\n  caddy,\n  calibreweb,\n  changedetectionio,\n  channelsdvrserver,\n  checkmk,\n  cloudflared,\n  coinmarketcap,\n  crowdsec,\n  customapi,\n  deluge,\n  develancacheui,\n  diskstation,\n  dispatcharr,\n  dockhand,\n  downloadstation,\n  emby,\n  esphome,\n  evcc,\n  filebrowser,\n  fileflows,\n  firefly,\n  flood,\n  freshrss,\n  frigate,\n  fritzbox,\n  gamedig,\n  gatus,\n  ghostfolio,\n  gitea,\n  gitlab,\n  glances,\n  gluetun,\n  gotify,\n  grafana,\n  hdhomerun,\n  headscale,\n  hoarder: karakeep,\n  karakeep,\n  homeassistant,\n  homebox,\n  homebridge,\n  healthchecks,\n  ical: calendar,\n  immich,\n  jackett,\n  jdownloader,\n  jellyfin,\n  jellyseerr: seerr,\n  jellystat,\n  kavita,\n  komga,\n  komodo,\n  kopia,\n  lidarr,\n  linkwarden,\n  lubelogger,\n  mailcow,\n  mastodon,\n  mealie,\n  medusa,\n  minecraft,\n  miniflux,\n  mikrotik,\n  mjpeg,\n  moonraker,\n  mylar,\n  myspeed,\n  navidrome,\n  netalertx,\n  netdata,\n  nextcloud,\n  nextdns,\n  npm,\n  nzbget,\n  octoprint,\n  omada,\n  ombi,\n  opendtu,\n  opnsense,\n  overseerr: seerr,\n  openmediavault,\n  openwrt,\n  paperlessngx,\n  pangolin,\n  peanut,\n  pfsense,\n  photoprism,\n  proxmoxbackupserver,\n  pialert: netalertx,\n  pihole,\n  plantit,\n  plex,\n  portainer,\n  prometheus,\n  prometheusmetric,\n  prowlarr,\n  proxmox,\n  pterodactyl,\n  pyload,\n  qbittorrent,\n  qnap,\n  radarr,\n  readarr,\n  romm,\n  rutorrent,\n  sabnzbd,\n  scrutiny,\n  seerr,\n  slskd,\n  sonarr,\n  sparkyfitness,\n  speedtest,\n  spoolman,\n  stash,\n  stocks,\n  strelaysrv,\n  swagdashboard,\n  suwayomi,\n  tailscale,\n  tandoor,\n  tautulli,\n  technitium,\n  tdarr,\n  tracearr,\n  traefik,\n  transmission,\n  trilium,\n  tubearchivist,\n  truenas,\n  unifi,\n  unifi_console: unifi,\n  unmanic,\n  unraid,\n  uptimekuma,\n  uptimerobot,\n  urbackup,\n  vikunja,\n  wallos,\n  watchtower,\n  wgeasy,\n  whatsupdocker,\n  xteve,\n  yourspotify,\n  zabbix,\n};\n\nexport default widgets;\n"
  },
  {
    "path": "src/widgets/xteve/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n\n  const { widget } = service;\n\n  const { data: xteveData, error: xteveError } = useWidgetAPI(widget);\n\n  if (xteveError) {\n    return <Container service={service} error={xteveError} />;\n  }\n\n  if (!xteveData) {\n    return (\n      <Container service={service}>\n        <Block label=\"xteve.streams_all\" />\n        <Block label=\"xteve.streams_active \" />\n        <Block label=\"xteve.streams_xepg\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"xteve.streams_all\" value={t(\"common.number\", { value: xteveData[\"streams.all\"] ?? 0 })} />\n      <Block label=\"xteve.streams_active\" value={t(\"common.number\", { value: xteveData[\"streams.active\"] ?? 0 })} />\n      <Block label=\"xteve.streams_xepg\" value={t(\"common.number\", { value: xteveData[\"streams.xepg\"] ?? 0 })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/xteve/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/xteve/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders while loading\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"xteve\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"xteve.streams_all\")).toBeInTheDocument();\n    expect(screen.getByText(\"xteve.streams_active\")).toBeInTheDocument();\n    expect(screen.getByText(\"xteve.streams_xepg\")).toBeInTheDocument();\n  });\n\n  it(\"renders counts when loaded\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: { \"streams.all\": 10, \"streams.active\": 2, \"streams.xepg\": 3 },\n      error: undefined,\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"xteve\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"xteve.streams_all\", 10);\n    expectBlockValue(container, \"xteve.streams_active\", 2);\n    expectBlockValue(container, \"xteve.streams_xepg\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/xteve/proxy.js",
    "content": "import getServiceWidget from \"utils/config/service-helpers\";\nimport createLogger from \"utils/logger\";\nimport { formatApiCall } from \"utils/proxy/api-helpers\";\nimport { httpProxy } from \"utils/proxy/http\";\nimport widgets from \"widgets/widgets\";\n\nconst logger = createLogger(\"xteveProxyHandler\");\n\nexport default async function xteveProxyHandler(req, res) {\n  const { group, service, index } = req.query;\n\n  if (!group || !service) {\n    return res.status(400).json({ error: \"Invalid proxy service type\" });\n  }\n\n  const widget = await getServiceWidget(group, service, index);\n  const api = widgets?.[widget.type]?.api;\n  if (!api) {\n    return res.status(403).json({ error: \"Service does not support API calls\" });\n  }\n\n  const url = formatApiCall(api, { endpoint: \"api/\", ...widget });\n  const method = \"POST\";\n  const payload = { cmd: \"status\" };\n\n  if (widget.username && widget.password) {\n    const [status, contentType, data] = await httpProxy(url, {\n      method,\n      body: JSON.stringify({\n        cmd: \"login\",\n        username: widget.username,\n        password: widget.password,\n      }),\n    });\n\n    if (status !== 200) {\n      logger.debug(\"Error logging into xteve\", status, url);\n      return res.status(status).json({ error: { message: `HTTP Error ${status} logging into xteve`, url, data } });\n    }\n\n    const json = JSON.parse(data.toString());\n\n    if (json?.status !== true) {\n      return res.status(401).json({ error: { message: \"Authentication failed\", url, data } });\n    }\n\n    payload.token = json.token;\n  }\n\n  const [status, contentType, data] = await httpProxy(url, {\n    method,\n    body: JSON.stringify(payload),\n  });\n\n  if (status !== 200) {\n    logger.debug(\"Error %d calling xteve endpoint %s\", status, url);\n    return res.status(status).json({ error: { message: `HTTP Error ${status}`, url, data } });\n  }\n\n  if (contentType) res.setHeader(\"Content-Type\", contentType);\n  return res.status(status).send(data);\n}\n"
  },
  {
    "path": "src/widgets/xteve/proxy.test.js",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport createMockRes from \"test-utils/create-mock-res\";\n\nconst { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({\n  httpProxy: vi.fn(),\n  getServiceWidget: vi.fn(),\n  logger: {\n    debug: vi.fn(),\n  },\n}));\n\nvi.mock(\"utils/logger\", () => ({\n  default: () => logger,\n}));\n\nvi.mock(\"utils/config/service-helpers\", () => ({\n  default: getServiceWidget,\n}));\n\nvi.mock(\"utils/proxy/http\", () => ({\n  httpProxy,\n}));\n\nvi.mock(\"widgets/widgets\", () => ({\n  default: {\n    xteve: {\n      api: \"{url}/{endpoint}\",\n    },\n  },\n}));\n\nimport xteveProxyHandler from \"./proxy\";\n\ndescribe(\"widgets/xteve/proxy\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"logs in when credentials are provided and includes token in subsequent status request\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"xteve\",\n      url: \"http://xteve\",\n      username: \"u\",\n      password: \"p\",\n    });\n\n    httpProxy\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ status: true, token: \"tok\" }))])\n      .mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"status-data\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await xteveProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(2);\n    expect(httpProxy.mock.calls[0][0]).toBe(\"http://xteve/api/\");\n    expect(JSON.parse(httpProxy.mock.calls[0][1].body)).toEqual({\n      cmd: \"login\",\n      username: \"u\",\n      password: \"p\",\n    });\n    expect(JSON.parse(httpProxy.mock.calls[1][1].body)).toEqual({ cmd: \"status\", token: \"tok\" });\n    expect(res.statusCode).toBe(200);\n    expect(res.body).toEqual(Buffer.from(\"status-data\"));\n  });\n\n  it(\"returns 401 when authentication fails\", async () => {\n    getServiceWidget.mockResolvedValue({\n      type: \"xteve\",\n      url: \"http://xteve\",\n      username: \"u\",\n      password: \"p\",\n    });\n\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", Buffer.from(JSON.stringify({ status: false }))]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await xteveProxyHandler(req, res);\n\n    expect(res.statusCode).toBe(401);\n    expect(res.body.error.message).toBe(\"Authentication failed\");\n  });\n\n  it(\"skips login when credentials are not provided\", async () => {\n    getServiceWidget.mockResolvedValue({ type: \"xteve\", url: \"http://xteve\" });\n    httpProxy.mockResolvedValueOnce([200, \"application/json\", Buffer.from(\"status-data\")]);\n\n    const req = { query: { group: \"g\", service: \"svc\", index: \"0\" } };\n    const res = createMockRes();\n\n    await xteveProxyHandler(req, res);\n\n    expect(httpProxy).toHaveBeenCalledTimes(1);\n    expect(JSON.parse(httpProxy.mock.calls[0][1].body)).toEqual({ cmd: \"status\" });\n    expect(res.statusCode).toBe(200);\n  });\n});\n"
  },
  {
    "path": "src/widgets/xteve/widget.js",
    "content": "import xteveProxyHandler from \"./proxy\";\n\nconst widget = {\n  api: \"{url}/{endpoint}\",\n  proxyHandler: xteveProxyHandler,\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/xteve/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"xteve widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/yourspotify/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\nimport { useMemo } from \"react\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nfunction getStartDate(interval) {\n  const d = new Date();\n  switch (interval) {\n    case \"day\":\n      d.setDate(d.getDate() - 1);\n      break;\n    case \"week\":\n      d.setDate(d.getDate() - 7);\n      break;\n    case \"month\":\n      d.setMonth(d.getMonth() - 1);\n      break;\n    case \"year\":\n      d.setFullYear(d.getFullYear() - 1);\n      break;\n  }\n  return d.toISOString();\n}\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const interval = widget?.interval || \"week\";\n\n  const date = useMemo(() => {\n    return interval === \"all\" ? \"2006-04-23T00:00:00.000Z\" : getStartDate(interval);\n  }, [interval]);\n\n  const params = {\n    timeSplit: \"all\",\n    start: date,\n  };\n\n  const { data: songsListened, error: songsError } = useWidgetAPI(widget, \"songs\", params);\n  const { data: timeListened, error: timeError } = useWidgetAPI(widget, \"time\", params);\n  const { data: artistsListened, error: artistsError } = useWidgetAPI(widget, \"artists\", params);\n\n  if (songsError || timeError || artistsError) {\n    return <Container service={service} error={songsError ?? timeError ?? artistsError} />;\n  }\n\n  if (isNaN(songsListened) || isNaN(timeListened) || isNaN(artistsListened)) {\n    return (\n      <Container service={service}>\n        <Block label=\"yourspotify.songs\" />\n        <Block label=\"yourspotify.time\" />\n        <Block label=\"yourspotify.artists\" />\n      </Container>\n    );\n  }\n\n  return (\n    <Container service={service}>\n      <Block label=\"yourspotify.songs\" value={t(\"common.number\", { value: songsListened })} />\n\n      <Block\n        label=\"yourspotify.time\"\n        value={t(\n          timeListened > 0 ? \"common.duration\" : \"common.number\", // Display 0 if duration is 0\n          {\n            value: timeListened / 1000,\n          },\n        )}\n      />\n      <Block label=\"yourspotify.artists\" value={t(\"common.number\", { value: artistsListened })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/yourspotify/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/yourspotify/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"renders placeholders when any metric is NaN\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"songs\") return { data: NaN, error: undefined };\n      if (endpoint === \"time\") return { data: 0, error: undefined };\n      if (endpoint === \"artists\") return { data: 0, error: undefined };\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"yourspotify\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(3);\n    expect(screen.getByText(\"yourspotify.songs\")).toBeInTheDocument();\n    expect(screen.getByText(\"yourspotify.time\")).toBeInTheDocument();\n    expect(screen.getByText(\"yourspotify.artists\")).toBeInTheDocument();\n  });\n\n  it(\"renders songs, time and artists when loaded\", () => {\n    useWidgetAPI.mockImplementation((_widget, endpoint) => {\n      if (endpoint === \"songs\") return { data: 1, error: undefined };\n      if (endpoint === \"time\") return { data: 2000, error: undefined };\n      if (endpoint === \"artists\") return { data: 3, error: undefined };\n      return { data: undefined, error: undefined };\n    });\n\n    const { container } = renderWithProviders(<Component service={{ widget: { type: \"yourspotify\" } }} />, {\n      settings: { hideErrors: false },\n    });\n\n    expectBlockValue(container, \"yourspotify.songs\", 1);\n    expectBlockValue(container, \"yourspotify.time\", 2);\n    expectBlockValue(container, \"yourspotify.artists\", 3);\n  });\n});\n"
  },
  {
    "path": "src/widgets/yourspotify/widget.js",
    "content": "import { asJson } from \"utils/proxy/api-helpers\";\nimport genericProxyHandler from \"utils/proxy/handlers/generic\";\n\nconst widget = {\n  api: \"{url}/spotify/{endpoint}?token={key}\",\n  proxyHandler: genericProxyHandler,\n\n  mappings: {\n    songs: {\n      endpoint: \"songs_per\",\n      params: [\"start\", \"timeSplit\"],\n      map: (data) => asJson(data)[0]?.count || 0,\n    },\n    time: {\n      endpoint: \"time_per\",\n      params: [\"start\", \"timeSplit\"],\n      map: (data) => asJson(data)[0]?.count || 0,\n    },\n    artists: {\n      endpoint: \"different_artists_per\",\n      params: [\"start\", \"timeSplit\"],\n      map: (data) => asJson(data)[0]?.artists?.length || 0,\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/yourspotify/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"yourspotify widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "src/widgets/zabbix/component.jsx",
    "content": "import Block from \"components/services/widget/block\";\nimport Container from \"components/services/widget/container\";\nimport { useTranslation } from \"next-i18next\";\n\nimport useWidgetAPI from \"utils/proxy/use-widget-api\";\n\nconst PriorityUnclassified = \"0\";\nconst PriorityInformation = \"1\";\nconst PriorityWarning = \"2\";\nconst PriorityAverage = \"3\";\nconst PriorityHigh = \"4\";\nconst PriorityDisaster = \"5\";\n\nexport default function Component({ service }) {\n  const { t } = useTranslation();\n  const { widget } = service;\n\n  const { data: zabbixData, error: zabbixError } = useWidgetAPI(widget, \"trigger\");\n\n  if (zabbixError) {\n    return <Container service={service} error={zabbixError} />;\n  }\n\n  if (!widget.fields) {\n    widget.fields = [\"warning\", \"average\", \"high\", \"disaster\"];\n  } else if (widget.fields?.length > 4) {\n    widget.fields = widget.fields.slice(0, 4);\n  }\n\n  if (!zabbixData) {\n    return (\n      <Container service={service}>\n        <Block label=\"zabbix.unclassified\" />\n        <Block label=\"zabbix.information\" />\n        <Block label=\"zabbix.warning\" />\n        <Block label=\"zabbix.average\" />\n        <Block label=\"zabbix.high\" />\n        <Block label=\"zabbix.disaster\" />\n      </Container>\n    );\n  }\n\n  const unclassified = zabbixData.filter((item) => item.priority === PriorityUnclassified).length;\n  const information = zabbixData.filter((item) => item.priority === PriorityInformation).length;\n  const warning = zabbixData.filter((item) => item.priority === PriorityWarning).length;\n  const average = zabbixData.filter((item) => item.priority === PriorityAverage).length;\n  const high = zabbixData.filter((item) => item.priority === PriorityHigh).length;\n  const disaster = zabbixData.filter((item) => item.priority === PriorityDisaster).length;\n\n  return (\n    <Container service={service}>\n      <Block label=\"zabbix.unclassified\" value={t(\"common.number\", { value: unclassified })} />\n      <Block label=\"zabbix.information\" value={t(\"common.number\", { value: information })} />\n      <Block label=\"zabbix.warning\" value={t(\"common.number\", { value: warning })} />\n      <Block label=\"zabbix.average\" value={t(\"common.number\", { value: average })} />\n      <Block label=\"zabbix.high\" value={t(\"common.number\", { value: high })} />\n      <Block label=\"zabbix.disaster\" value={t(\"common.number\", { value: disaster })} />\n    </Container>\n  );\n}\n"
  },
  {
    "path": "src/widgets/zabbix/component.test.jsx",
    "content": "// @vitest-environment jsdom\n\nimport { screen } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { renderWithProviders } from \"test-utils/render-with-providers\";\nimport { expectBlockValue } from \"test-utils/widget-assertions\";\n\nconst { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));\nvi.mock(\"utils/proxy/use-widget-api\", () => ({ default: useWidgetAPI }));\n\nimport Component from \"./component\";\n\ndescribe(\"widgets/zabbix/component\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"defaults fields to 4 and filters placeholders accordingly\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });\n\n    const service = { widget: { type: \"zabbix\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    expect(service.widget.fields).toEqual([\"warning\", \"average\", \"high\", \"disaster\"]);\n    expect(container.querySelectorAll(\".service-block\")).toHaveLength(4);\n    expect(screen.getByText(\"zabbix.warning\")).toBeInTheDocument();\n    expect(screen.getByText(\"zabbix.average\")).toBeInTheDocument();\n    expect(screen.getByText(\"zabbix.high\")).toBeInTheDocument();\n    expect(screen.getByText(\"zabbix.disaster\")).toBeInTheDocument();\n    expect(screen.queryByText(\"zabbix.unclassified\")).toBeNull();\n    expect(screen.queryByText(\"zabbix.information\")).toBeNull();\n  });\n\n  it(\"renders error UI when endpoint errors\", () => {\n    useWidgetAPI.mockReturnValue({ data: undefined, error: { message: \"nope\" } });\n\n    renderWithProviders(<Component service={{ widget: { type: \"zabbix\" } }} />, { settings: { hideErrors: false } });\n\n    expect(screen.getAllByText(/widget\\.api_error/i).length).toBeGreaterThan(0);\n    expect(screen.getByText(\"nope\")).toBeInTheDocument();\n  });\n\n  it(\"computes and renders priority counts for selected fields\", () => {\n    useWidgetAPI.mockReturnValue({\n      data: [{ priority: \"2\" }, { priority: \"3\" }, { priority: \"3\" }, { priority: \"5\" }],\n      error: undefined,\n    });\n\n    const service = { widget: { type: \"zabbix\" } };\n    const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });\n\n    // Default fields: warning/average/high/disaster\n    expectBlockValue(container, \"zabbix.warning\", 1);\n    expectBlockValue(container, \"zabbix.average\", 2);\n    expectBlockValue(container, \"zabbix.high\", 0);\n    expectBlockValue(container, \"zabbix.disaster\", 1);\n  });\n});\n"
  },
  {
    "path": "src/widgets/zabbix/widget.js",
    "content": "import jsonrpcProxyHandler from \"utils/proxy/handlers/jsonrpc\";\n\nconst widget = {\n  api: \"{url}/api_jsonrpc.php\",\n  proxyHandler: jsonrpcProxyHandler,\n\n  mappings: {\n    trigger: {\n      endpoint: \"trigger.get\",\n      params: {\n        output: [\"triggerid\", \"description\", \"priority\"],\n        filter: {\n          value: 1,\n        },\n        sortfield: \"priority\",\n        sortorder: \"DESC\",\n        monitored: \"true\",\n      },\n    },\n  },\n};\n\nexport default widget;\n"
  },
  {
    "path": "src/widgets/zabbix/widget.test.js",
    "content": "import { describe, it } from \"vitest\";\n\nimport { expectWidgetConfigShape } from \"test-utils/widget-config\";\n\nimport widget from \"./widget\";\n\ndescribe(\"zabbix widget config\", () => {\n  it(\"exports a valid widget config\", () => {\n    expectWidgetConfigShape(widget);\n  });\n});\n"
  },
  {
    "path": "tailwind.config.js",
    "content": "/** @type {import('tailwindcss').Config} */\nconst tailwindForms = require(\"@tailwindcss/forms\");\nconst tailwindScrollbars = require(\"tailwind-scrollbar\");\n\nmodule.exports = {\n  darkMode: \"class\",\n  content: [\n    \"./src/pages/**/*.{js,ts,jsx,tsx}\",\n    \"./src/components/**/*.{js,ts,jsx,tsx}\",\n    \"./src/widgets/**/*.{js,ts,jsx,tsx}\",\n  ],\n  variants: {\n    extend: {\n      display: [\"group-hover\"],\n    },\n  },\n  theme: {\n    extend: {\n      colors: {\n        theme: {\n          50: \"rgb(var(--color-50) / <alpha-value>)\",\n          100: \"rgb(var(--color-100) / <alpha-value>)\",\n          200: \"rgb(var(--color-200) / <alpha-value>)\",\n          300: \"rgb(var(--color-300) / <alpha-value>)\",\n          400: \"rgb(var(--color-400) / <alpha-value>)\",\n          500: \"rgb(var(--color-500) / <alpha-value>)\",\n          600: \"rgb(var(--color-600) / <alpha-value>)\",\n          700: \"rgb(var(--color-700) / <alpha-value>)\",\n          800: \"rgb(var(--color-800) / <alpha-value>)\",\n          900: \"rgb(var(--color-900) / <alpha-value>)\",\n        },\n      },\n    },\n  },\n  plugins: [tailwindForms, tailwindScrollbars],\n};\n"
  },
  {
    "path": "vitest.config.mjs",
    "content": "import { fileURLToPath, URL } from \"node:url\";\n\nimport { defineConfig } from \"vitest/config\";\n\nexport default defineConfig({\n  // Next.js handles JSX via SWC; Vitest uses Vite/esbuild, so enable the modern JSX runtime\n  // to avoid requiring `import React from \"react\"` in every JSX file.\n  esbuild: {\n    jsx: \"automatic\",\n  },\n  resolve: {\n    alias: {\n      components: fileURLToPath(new URL(\"./src/components\", import.meta.url)),\n      pages: fileURLToPath(new URL(\"./src/pages\", import.meta.url)),\n      styles: fileURLToPath(new URL(\"./src/styles\", import.meta.url)),\n      \"test-utils\": fileURLToPath(new URL(\"./src/test-utils\", import.meta.url)),\n      utils: fileURLToPath(new URL(\"./src/utils\", import.meta.url)),\n      widgets: fileURLToPath(new URL(\"./src/widgets\", import.meta.url)),\n    },\n  },\n  test: {\n    environment: \"node\",\n    // Use worker threads instead of forked processes to reduce overhead and avoid noisy per-process Node warnings.\n    pool: \"threads\",\n    setupFiles: [\"./vitest.setup.js\"],\n    include: [\"src/**/*.test.{js,jsx}\", \"src/**/*.spec.{js,jsx}\"],\n    coverage: {\n      provider: \"v8\",\n      all: true,\n      reporter: [\"text\", \"lcov\", \"json-summary\"],\n      include: [\"src/**/*.{js,jsx,ts,tsx}\"],\n      exclude: [\n        // Ignore build artifacts / generated reports\n        \".next/**\",\n        \"coverage/**\",\n        // Exclude tests and test harness code from coverage totals.\n        \"src/**/*.test.{js,jsx,ts,tsx}\",\n        \"src/**/*.spec.{js,jsx,ts,tsx}\",\n        \"src/**/__tests__/**\",\n        \"src/test-utils/**\",\n        \"src/widgets/widgets.js\",\n        \"src/widgets/components.js\",\n        \"src/skeleton/custom.js\",\n        \"next-i18next.config.js\",\n        \"next.config.js\",\n        \"postcss.config.js\",\n        \"tailwind.config.js\",\n        \"eslint.config.mjs\",\n        \"vitest.config.mjs\",\n        \".prettierrc.js\",\n      ],\n    },\n  },\n});\n"
  },
  {
    "path": "vitest.setup.js",
    "content": "import \"@testing-library/jest-dom/vitest\";\n\nimport { cleanup } from \"@testing-library/react\";\nimport { afterEach, vi } from \"vitest\";\n\nafterEach(() => {\n  // Node-environment tests shouldn't require jsdom; guard cleanup accordingly.\n  if (typeof document !== \"undefined\") cleanup();\n});\n\n// implement a couple of common formatters mocked in next-i18next\nvi.mock(\"next-i18next\", () => ({\n  // Keep app/page components importable in unit tests.\n  appWithTranslation: (Component) => Component,\n  useTranslation: () => ({\n    i18n: { language: \"en\" },\n    t: (key, opts) => {\n      if (key === \"common.number\") return String(opts?.value ?? \"\");\n      if (key === \"common.percent\") return String(opts?.value ?? \"\");\n      if (key === \"common.bytes\") return String(opts?.value ?? \"\");\n      if (key === \"common.bbytes\") return String(opts?.value ?? \"\");\n      if (key === \"common.byterate\") return String(opts?.value ?? \"\");\n      if (key === \"common.bibyterate\") return String(opts?.value ?? \"\");\n      if (key === \"common.bitrate\") return String(opts?.value ?? \"\");\n      if (key === \"common.duration\") return String(opts?.value ?? \"\");\n      if (key === \"common.ms\") return String(opts?.value ?? \"\");\n      if (key === \"common.date\") return String(opts?.value ?? \"\");\n      if (key === \"common.relativeDate\") return String(opts?.value ?? \"\");\n      return key;\n    },\n  }),\n}));\n"
  }
]