[
  {
    "path": ".dockerignore",
    "content": "assets/*\ndockerfile\n*.md\n.git\nscreenshot.png\nnode_modules\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\n# These are supported funding model platforms\n\ngithub: [bastienwirtz]\nbuy_me_a_coffee: bastien\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Logs & errors**\nPlease include any usefull information:\n- Errors in your browser console (`ctrl+shift+i` or `F12`)\n- If applicable, your docker container logs.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Configuration**\nIf applicable, copy related homer yaml configuration here.\n```yml\n\n```\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "## Description\n\nPlease include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.\n\nFixes # (issue)\n\n## Type of change\n\n- [ ] Bug fix (non-breaking change which fixes an issue)\n- [ ] New feature (non-breaking change which adds functionality)\n- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)\n- [ ] Refactoring\n\n## Checklist:\n\n- [ ] I've read & comply with the [contributing guidelines](https://github.com/bastienwirtz/homer/blob/main/CONTRIBUTING.md)\n- [ ] I have tested my code for new features & regressions on both mobile & desktop devices, using the latest version of major browsers.\n- [ ] I have made corresponding changes to the documentation (`README.md`).\n- [ ] I've checked my modifications for any breaking changes, especially in the `config.yml` file\n"
  },
  {
    "path": ".github/release.yml",
    "content": "changelog:\n  exclude:\n    authors:\n      - dependabot\n  categories:\n    - title: Main changes\n      labels:\n        - \"*\""
  },
  {
    "path": ".github/workflows/dockerhub.yml",
    "content": "# Build & publish docker images\nname: Dockerhub\n\non:\n  push:\n    tags: [v*]\n\njobs:\n  dockerhub:\n    runs-on: ubuntu-latest\n    timeout-minutes: 20\n    steps:\n      -\n        name: Checkout\n        uses: actions/checkout@v4\n      -\n        name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n      -\n        name: Set up Docker Buildx\n        id: buildx\n        uses: docker/setup-buildx-action@v3\n      -\n        name: Login to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n      -\n        name: Login to GHCR\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ github.token }}\n      -\n        name: Build and push\n        uses: docker/build-push-action@v6\n        with:\n          push: true\n          tags: |\n            b4bz/homer:latest\n            b4bz/homer:${{ github.ref_name }}\n            ghcr.io/${{ github.repository }}:latest\n            ghcr.io/${{ github.repository }}:${{ github.ref_name }}\n          platforms: linux/amd64,linux/arm/v7,linux/arm/v6,linux/arm64\n          build-args: |\n            VERSION_TAG=${{ github.ref_name }}\n"
  },
  {
    "path": ".github/workflows/integration.yml",
    "content": "# This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node\n# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions\n\nname: Integration\n\non:\n  push:\n    branches: [ main ]\n  pull_request:\n    branches: [ main ]\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    timeout-minutes: 20\n    steps:\n    - \n      name: Checkout\n      uses: actions/checkout@v4\n    - \n      name: pnpm setup\n      uses: pnpm/action-setup@v4\n    - \n      name: Node.js setup\n      uses: actions/setup-node@v4\n      with:\n        node-version: 22\n        cache: 'pnpm'\n    - \n      name: install dependencies\n      run: pnpm install --frozen-lockfile\n    - \n      name: Check code style & potentential issues\n      run: pnpm lint\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "# Publish pre-build release\nname: Create Github release\n\non:\n  push:\n    tags: [v*]\n\njobs:\n  build:\n    name: Upload Release Asset\n    runs-on: ubuntu-latest\n    timeout-minutes: 20\n    steps:\n      - \n        name: Checkout\n        uses: actions/checkout@v4\n      - \n        name: pnpm setup\n        uses: pnpm/action-setup@v4\n      - \n        name: Node.js setup\n        uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'pnpm'\n      - \n        name: Build project\n        run: |\n          pnpm install --frozen-lockfile\n          pnpm build\n      - \n        name: Create artifact\n        working-directory: \"dist\"\n        run: zip -r ../homer.zip ./*\n      - \n        name: Create Release\n        id: create_release\n        uses: softprops/action-gh-release@v2\n        with:\n          token: ${{ secrets.GITHUB_TOKEN }}\n          generate_release_notes: true\n          files: |\n            homer.zip"
  },
  {
    "path": ".gitignore",
    "content": ".DS_Store\nnode_modules\n/dist\n\n# local env files\n.env.local\n.env.*.local\n\n# Log files\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Editor directories and files\n.idea\n.vscode\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n\n# App configuration\nconfig.yml\n\n.drone.yml\n\n# Specific Agent file\nCLAUDE.md\nGEMINI.md\n"
  },
  {
    "path": ".jsconfig.json",
    "content": "{\n    \"compilerOptions\": {\n      \"paths\": {\n        \"@/*\": [\"./src/*\"]\n      }\n    },\n    \"exclude\": [\"node_modules\", \"dist\"]\n  }"
  },
  {
    "path": ".schema/config-schema.json",
    "content": "{\n  \"$id\": \"https://raw.githubusercontent.com/bastienwirtz/homer/main/.schema/config-schema.json\",\n  \"$schema\": \"http://json-schema.org/draft-07/schema\",\n  \"description\": \"https://github.com/bastienwirtz/homer/blob/main/docs/configuration.md\",\n  \"examples\": [],\n  \"title\": \"Homer Dashboard configuration\",\n  \"type\": \"object\",\n  \"definitions\": {\n    \"Colors\": {\n      \"type\": \"object\",\n      \"additionalProperties\": false,\n      \"properties\": {\n        \"light\": {\n          \"$ref\": \"#/definitions/ColorSet\"\n        },\n        \"dark\": {\n          \"$ref\": \"#/definitions/ColorSet\"\n        }\n      },\n      \"title\": \"Colors\"\n    },\n    \"ColorSet\": {\n      \"type\": \"object\",\n      \"additionalProperties\": false,\n      \"properties\": {\n        \"highlight-primary\": {\n          \"type\": \"string\"\n        },\n        \"highlight-secondary\": {\n          \"type\": \"string\"\n        },\n        \"highlight-hover\": {\n          \"type\": \"string\"\n        },\n        \"background\": {\n          \"type\": \"string\"\n        },\n        \"card-background\": {\n          \"type\": \"string\"\n        },\n        \"text\": {\n          \"type\": \"string\"\n        },\n        \"text-header\": {\n          \"type\": \"string\"\n        },\n        \"text-title\": {\n          \"type\": \"string\"\n        },\n        \"text-subtitle\": {\n          \"type\": \"string\"\n        },\n        \"card-shadow\": {\n          \"type\": \"string\"\n        },\n        \"link\": {\n          \"type\": \"string\"\n        },\n        \"link-hover\": {\n          \"type\": \"string\"\n        },\n        \"background-image\": {\n          \"type\": \"string\"\n        }\n      }\n    },\n    \"Defaults\": {\n      \"type\": \"object\",\n      \"additionalProperties\": false,\n      \"properties\": {\n        \"layout\": {\n          \"enum\": [\n            \"columns\",\n            \"list\"\n          ],\n          \"description\": \"Layout of the dashboard, either 'columns' or 'list'\"\n        },\n        \"colorTheme\": {\n          \"enum\": [\n            \"auto\",\n            \"light\",\n            \"dark\"\n          ],\n          \"description\": \"One of 'auto', 'light', or 'dark'\"\n        }\n      },\n      \"title\": \"Defaults\"\n    },\n    \"Hotkey\": {\n      \"type\": \"object\",\n      \"additionalProperties\": false,\n      \"properties\": {\n        \"search\": {\n          \"type\": \"string\",\n          \"description\": \"hotkey for search, e.g. Shift\"\n        }\n      },\n      \"required\": [\n        \"search\"\n      ]\n    },\n    \"Link\": {\n      \"type\": \"object\",\n      \"additionalProperties\": false,\n      \"properties\": {\n        \"name\": {\n          \"type\": \"string\",\n          \"description\": \"Name as seen in the navbar\"\n        },\n        \"icon\": {\n          \"type\": \"string\",\n          \"description\": \"Fontawesome icon\"\n        },\n        \"url\": {\n          \"type\": \"string\",\n          \"description\": \"Url of the link. When #filename is used, it is a link to another homer page, while 'filename' is the name of the config file\"\n        },\n        \"target\": {\n          \"type\": \"string\",\n          \"description\": \"html tag target attribute like _blank for a new page\"\n        }\n      },\n      \"required\": [\n        \"url\"\n      ],\n      \"title\": \"Link\"\n    },\n    \"Message\": {\n      \"type\": \"object\",\n      \"additionalProperties\": false,\n      \"properties\": {\n        \"url\": {\n          \"type\": \"string\",\n          \"format\": \"uri\"\n        },\n        \"mapping\": {\n          \"$ref\": \"#/definitions/Mapping\",\n          \"description\": \"Mapping for the content loaded from the URL\"\n        },\n        \"refreshInterval\": {\n          \"type\": \"integer\",\n          \"description\": \"The refresh interval in milliseconds for reloading the message url\"\n        },\n        \"style\": {\n          \"type\": \"string\",\n          \"description\": \"See https://bulma.io/documentation/components/message/#colors for styling options\"\n        },\n        \"title\": {\n          \"type\": \"string\",\n          \"description\": \"Title of the message box\"\n        },\n        \"icon\": {\n          \"type\": \"string\",\n          \"description\": \"Fontawesome icon for the message box\"\n        },\n        \"content\": {\n          \"type\": \"string\",\n          \"description\": \"HTML content for the message box\"\n        }\n      },\n      \"title\": \"Messagebox\"\n    },\n    \"Mapping\": {\n      \"type\": \"object\",\n      \"additionalProperties\": true,\n      \"title\": \"Mapping\"\n    },\n    \"Proxy\": {\n      \"type\": \"object\",\n      \"additionalProperties\": false,\n      \"properties\": {\n        \"useCredentials\": {\n          \"type\": \"boolean\",\n          \"description\": \"# send cookies & authorization headers when fetching service specific data. Set to `true` if you use an authentication proxy. Can be overrided on service level. \"\n        },\n        \"headers\": {\n          \"$ref\": \"#/definitions/Headers\",\n          \"description\": \"send custom headers when fetching service specific data. Can also be set on a service level.\"\n        }\n      },\n      \"title\": \"Proxy\"\n    },\n    \"Headers\": {\n      \"type\": \"object\",\n      \"additionalProperties\": true,\n      \"title\": \"Headers\"\n    },\n    \"Service\": {\n      \"type\": \"object\",\n      \"additionalProperties\": false,\n      \"properties\": {\n        \"name\": {\n          \"type\": \"string\",\n          \"description\": \"Service name\"\n        },\n        \"icon\": {\n          \"type\": \"string\",\n          \"description\": \"Fontawesome icon for the service\"\n        },\n        \"logo\": {\n          \"type\": \"string\",\n          \"description\": \"A path to an image can also be provided. Note that icon take precedence if both icon and logo are set.\"\n        },\n        \"class\": {\n          \"type\": \"string\",\n          \"description\": \"Optional css class to add on the service group. Example 'highlight-purple'\"\n        },\n        \"items\": {\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Item\"\n          }\n        }\n      },\n      \"required\": [\n        \"items\"\n      ],\n      \"title\": \"Service\"\n    },\n    \"Item\": {\n      \"type\": \"object\",\n      \"additionalProperties\": true,\n      \"properties\": {\n        \"name\": {\n          \"type\": \"string\"\n        },\n        \"logo\": {\n          \"type\": \"string\",\n          \"description\": \"Path to a logo. Alternatively a fa icon can be provided\"\n        },\n        \"icon\": {\n          \"type\": \"string\",\n          \"description\": \"Fontawesome icon for the item, alternative for logo\"\n        },\n        \"subtitle\": {\n          \"type\": \"string\"\n        },\n        \"tag\": {\n          \"type\": \"string\",\n          \"description\": \"Show tag\"\n        },\n        \"keywords\": {\n          \"type\": \"string\",\n          \"description\": \"Optional keyword used for searching purpose\"\n        },\n        \"url\": {\n          \"type\": \"string\",\n          \"description\": \"Url of this item\"\n        },\n        \"target\": {\n          \"type\": \"string\",\n          \"description\": \"html tag target attribute like _blank for a new page\"\n        },\n        \"tagstyle\": {\n          \"type\": \"string\",\n          \"description\": \"Styleclass for the tag\"\n        },\n        \"type\": {\n          \"type\": \"string\",\n          \"description\": \"Optional, loads a specific component that provides extra features. MUST MATCH a file name (without file extension) available in `src/components/services`\"\n        }\n      },\n      \"title\": \"Item\"\n    }\n  },\n  \"properties\": {\n    \"externalConfig\": {\n      \"type\": \"string\",\n      \"description\": \"Use external configuration file. Using this will ignore remaining config in this file externalConfig: https://example.com/server-luci/config.yaml\"\n    },\n    \"title\": {\n      \"type\": \"string\",\n      \"description\": \"Title of the dashboard\"\n    },\n    \"subtitle\": {\n      \"type\": \"string\",\n      \"description\": \"Subtitle of the dashboard\"\n    },\n    \"documentTitle\": {\n      \"type\": \"string\",\n      \"description\": \"Title of the document. When not filled, title (and subtitle will be used)\"\n    },\n    \"logo\": {\n      \"type\": \"string\",\n      \"description\": \"Path to logo image\"\n    },\n    \"icon\": {\n      \"type\": \"string\",\n      \"description\": \"Dashboard icon\"\n    },\n    \"header\": {\n      \"type\": \"boolean\",\n      \"description\": \"Show header, default is true\"\n    },\n    \"hotkey\": {\n      \"$ref\": \"#/definitions/Hotkey\",\n      \"description\": \"Define hotkeys, for example for search\"\n    },\n    \"footer\": {\n      \"anyOf\": [\n        {\n          \"type\": \"boolean\"\n        },\n        {\n          \"type\": \"string\"\n        }\n      ],\n      \"description\": \"footer Line content. HTML is supported. Set false if you want to hide it.\"\n    },\n    \"columns\": {\n      \"type\": \"string\",\n      \"description\": \"'auto' or number (must be a factor of 12: 1, 2, 3, 4, 6, 12)\",\n      \"format\": \"integer\"\n    },\n    \"connectivityCheck\": {\n      \"type\": \"boolean\",\n      \"description\": \"# whether you want to display a message when the apps are not accessible anymore (VPN disconnected for example). You should set it to true when using an authentication proxy, it also reloads the page when a redirection is detected when checking connectivity.\"\n    },\n    \"proxy\": {\n      \"$ref\": \"#/definitions/Proxy\",\n      \"description\": \"Optional: Proxy / hosting option\"\n    },\n    \"defaults\": {\n      \"$ref\": \"#/definitions/Defaults\"\n    },\n    \"theme\": {\n      \"type\": \"string\",\n      \"description\": \"'default' or one of the themes available in 'src/assets/themes'\"\n    },\n    \"stylesheet\": {\n      \"type\": \"array\",\n      \"items\": {\n        \"type\": \"string\"\n      },\n      \"description\": \"Will load custom CSS files. Especially useful for custom icon sets. Entries are paths to the stylesheets\"\n    },\n    \"colors\": {\n      \"$ref\": \"#/definitions/Colors\"\n    },\n    \"message\": {\n      \"$ref\": \"#/definitions/Message\",\n      \"description\": \"Messagebox\"\n    },\n    \"links\": {\n      \"description\": \"Links in the navigation bar\",\n      \"type\": \"array\",\n      \"items\": {\n        \"$ref\": \"#/definitions/Link\"\n      }\n    },\n    \"services\": {\n      \"description\": \"Services\",\n      \"type\": \"array\",\n      \"items\": {\n        \"$ref\": \"#/definitions/Service\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# AGENTS Instructions\n\nThis file provides guidance to AI Agents when working with code in this repository.\n\n## Development Commands\n\n```bash\npnpm install      # Install dependencies (PNPM enforced via packageManager)\npnpm dev          # Start development server on http://localhost:3000\npnpm mock         # Start mock API server for testing service integrations\npnpm build        # Build for production\npnpm preview      # Preview production build\npnpm lint         # Run ESLint with auto-fix\n```\n\n## Architecture Overview\n\nHomer is a static Vue.js 3 PWA dashboard that loads configuration from YAML files. The architecture is service-oriented with dynamic component loading.\n\n### Core Application Structure\n\n- **Entry Point**: `src/main.js` mounts the Vue app\n- **Root Component**: `src/App.vue` handles layout, configuration loading, and routing\n- **Configuration System**: YAML-based with runtime merging of defaults (`src/assets/defaults.yml`) and user config (`/assets/config.yml`)\n- **Service Components**: 53 specialized integrations in `src/components/services/` that extend a Generic component pattern\n\n### Service Integration Pattern\n\nAll service components follow this architecture:\n\n- Extend `Generic.vue` using Vue slots (`<template #indicator>`, `<template #content>`, `<template #icon>`)\n- Use the `service.js` mixin (`src/mixins/service.js`) for common API functionality\n- Use a custom `fetch` method provided by the service mixin to seamlessly support proxy configuration, custom headers, and credentials.\n\n### Configuration & Routing\n\n- **Multi-page Support**: Hash-based routing without Vue Router\n- **Dynamic Config Loading**: External URLs supported via `config.remote_config`\n- **Theme System**: CSS layers architecture with three built-in themes in `src/assets/themes/`\n- **Asset Management**: Static files served from `/assets/` with runtime configuration merging\n\n### Build System Details\n\n- **Vite 7**: Modern build tool with Vue plugin\n- **PWA**: Auto-updating service worker via `vite-plugin-pwa`\n- **SCSS**: Bulma framework with modular component styling\n- **Docker**: Multi-stage build (Node.js → Alpine + Lighttpd)\n\n### Mock Data Creation Pattern\n\nWhen creating mock data for service components testing:\n\n**Structure**: `dummy-data/[component-name]/[api-path]/[endpoint]`\n\n**Steps**:\n\n1. **Analyze component**: Read the Vue component file to identify API calls (look for `this.fetch()` calls)\n2. **Check existing mock**: If mock directory exists, read existing files to check for missing fields\n3. **Create/update structure**: `mkdir -p dummy-data/[lowercase-component-name]/` and mirror API endpoint paths\n4. **Create/update JSON files**: Write realistic mock responses matching the expected data structure\n5. **Verify fields**: Ensure all fields used in the component's computed properties and templates are included\n6. **Update existing mocks**: If mock files exist but are missing fields, add the missing fields without removing existing data\n\n**Key Points**:\n\n- Component directory name should be lowercase version of component name (e.g., `AdGuardHome.vue` → `adguardhome/`)\n- Directory structure mirrors API endpoints exactly\n- Files contain JSON responses (no file extension needed)\n- Mock server serves from `dummy-data/` via `pnpm mock` command\n- Each component gets isolated directory to prevent API path conflicts\n- When updating existing mocks, preserve existing data and only add missing fields required by the component\n- Always read existing mock files first to understand current structure before making changes\n\n**Example**: For `AdGuardHome.vue`:\n- API calls: `/control/status`, `/control/stats`\n- Mock files: `dummy-data/adguardhome/control/status`, `dummy-data/adguardhome/control/stats`\n\n### Development Notes\n\n- Use `pnpm mock` to test service integrations with dummy data\n- Configuration changes require restart in development mode\n- New service components should follow the Generic component slot pattern\n- Themes use CSS custom properties for dynamic color switching\n- The app has no backend dependencies and generates static files only\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "\n# 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\nbastien.wirtz@gmail.com.\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## 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\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Introduction\n\n### Welcome!\n\nFirst off, thank you for considering contributing to Homer!\n\n### Project philosophy\n\nHomer is meant to be a light and very simple dashboard that keeps all your useful utilities at hand. The few features implemented in Homer focus on\nUX and usability. If you are looking for a full featured dashboard, there are tons of great stuff out there like https://gethomepage.dev/, https://heimdall.site/, https://github.com/rmountjoy92/DashMachine or https://organizr.app/.\n\n- Configuration is stored in a simple config file, avoiding the need for a backend/database while making it possible to use versioning or [config template](https://docs.ansible.com/ansible/latest/user_guide/playbooks_templating.html).\n- Only modern browsers are supported, feel free to use any JS features without any polyfill as soon as the latest version of the major browsers supports them.\n\n\n# Ground Rules\n\n### Code of conduct and guidelines\n\nFirst of all, we expect everyone (contributors and maintainers alike) to respect the [Code of conduct](https://github.com/bastienwirtz/homer/blob/main/CODE_OF_CONDUCT.md). It is not a recommendation, it is mandatory.\n\nFor all contributions, please respect the following guidelines:\n\n* Each pull request should implement ONE feature or bugfix. If you want to add or fix more than one thing, submit more than one pull request.\n* Do not commit changes to files that are irrelevant to your feature or bugfix (e.g. `.gitignore`).\n* Do not add unnecessary dependencies.\n* Be aware that the pull request review process is not immediate, and is generally proportional to the size of the pull request.\n\n# Getting started\n\n### Discuss about ideas\n\nIf you want to add a feature, it's often best to talk about it before starting to work on it and submitting a pull request. It's not mandatory at all, but\nfeel free to open an issue to present your idea.\n\n### Working with AI Agents\n\nThis repository include an [`AGENTS.md`](https://github.com/bastienwirtz/homer/blob/main/AGENTS.md) instruction file for agents. It use an [open format](https://agents.md/), which most agent should natively use for context. However, for specific agent like Claude Code or Gemini, you will have to specifically ask it to read the file or create symlink:  \n\n```sh\nln -s AGENTS.md CLAUDE.md\nln -s AGENTS.md GEMINI.md\n```\n\n### How to submit a contribution\n\nThe general process to submit a contribution is as follow:\n1. Take a look at the [development guideline](https://github.com/bastienwirtz/homer/blob/main/docs/development.md).\n2. Create your own fork of the code\n3. Do the changes in your fork\n4. Make sure to fill the [pull request description](https://github.com/bastienwirtz/homer/blob/main/.github/PULL_REQUEST_TEMPLATE.md) properly.\n\n### Happy coding :metal:\n"
  },
  {
    "path": "Dockerfile",
    "content": "# build stage\nFROM --platform=$BUILDPLATFORM node:22-alpine3.21 AS build-stage\n\nENV PNPM_HOME=\"/pnpm\"\nENV PATH=\"$PNPM_HOME:$PATH\"\n\nRUN corepack enable && corepack use pnpm@10\n\nWORKDIR /app\n\nCOPY package.json pnpm-lock.yaml ./\nRUN pnpm install --frozen-lockfile\n\nCOPY . .\nRUN pnpm build\n\n# production stage\nFROM alpine:3.21\n\nARG VERSION_TAG=latest\n\nLABEL \\\n    org.label-schema.schema-version=\"1.0\" \\\n    org.label-schema.version=\"$VERSION_TAG\" \\\n    org.opencontainers.image.title=\"Homer Image\" \\\n    org.opencontainers.image.description=\"A dead simple static Home-Page for your server to keep your services on hand, from a simple yaml configuration file.\" \\\n    org.opencontainers.image.ref.name=\"b4bz/homer:${VERSION_TAG}\" \\\n    org.opencontainers.image.version=\"$VERSION_TAG\" \\\n    org.opencontainers.image.licenses=\"Apache-2.0 license\" \\\n    org.opencontainers.image.source=\"https://github.com/bastienwirtz/homer\" \\\n    org.opencontainers.image.url=\"https://hub.docker.com/r/b4bz/homer\"\n\nENV GID=1000 \\\n    UID=1000 \\\n    PORT=8080 \\\n    SUBFOLDER=\"/_\" \\\n    INIT_ASSETS=1 \\\n    IPV6_DISABLE=0\n\nRUN addgroup -S lighttpd -g ${GID} && adduser -D -S -u ${UID} lighttpd lighttpd && \\\n    apk add -U --no-cache tzdata lighttpd\n\nWORKDIR /www\n\nCOPY lighttpd.conf /lighttpd.conf\nCOPY lighttpd-ipv6.sh /etc/lighttpd/ipv6.sh\nCOPY entrypoint.sh /entrypoint.sh\nCOPY --from=build-stage --chown=${UID}:${GID} /app/dist /www/\nCOPY --from=build-stage --chown=${UID}:${GID} /app/dist/assets /www/default-assets\n\nUSER ${UID}:${GID}\n\nHEALTHCHECK --start-period=10s --start-interval=1s --interval=30s --timeout=5s --retries=3 \\\n    CMD wget --no-verbose -Y off --tries=1 --spider http://127.0.0.1:${PORT}/ || exit 1\n\nEXPOSE ${PORT}\n\nENTRYPOINT [\"/bin/sh\", \"/entrypoint.sh\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright 2018 Bastien Wirtz\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "<h1 align=\"center\">\n <img\n  width=\"180\"\n  alt=\"Homer's donut\"\n  src=\"https://raw.githubusercontent.com//bastienwirtz/homer/main/public/logo.png\">\n    <br/>\n    Homer\n</h1>\n\n<h4 align=\"center\">\n A dead simple static <strong>HOM</strong>epage for your serv<strong>ER</strong> to keep your services on hand, from a simple <code>yaml</code> configuration file.\n</h4>\n<p align=\"center\">\n  <a href=\"https://www.buymeacoffee.com/bastien\" target=\"_blank\"><img src=\"https://cdn.buymeacoffee.com/buttons/default-yellow.png\" alt=\"Buy Me A Coffee\" height=\"41\" width=\"174\"></a>\n<p>\n<p align=\"center\">\n <a href=\"https://opensource.org/licenses/Apache-2.0\"><img\n  alt=\"License: Apache 2\"\n  src=\"https://img.shields.io/badge/License-Apache%202.0-blue.svg\"></a>\n  <a href=\"https://github.com/bastienwirtz/homer/releases/latest/download/homer.zip\"><img\n  alt=\"Download homer static build\"\n  src=\"https://img.shields.io/badge/Download-homer.zip-orange\"></a>\n <a href=\"https://twitter.com/acdlite/status/974390255393505280\"><img\n  alt=\"speed-blazing\"\n  src=\"https://img.shields.io/badge/speed-blazing%20%F0%9F%94%A5-red\"></a>\n <a href=\"https://github.com/awesome-selfhosted/awesome-selfhosted\"><img\n  alt=\"Awesome\"\n  src=\"https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg\"></a>\n</p>\n\n<p align=\"center\">\n <strong>\n  <a href=\"https://homer-demo.netlify.app\">Demo</a>\n  •\n  <a href=\"https://hub.docker.com/r/b4bz/homer\">Docker Hub</a>\n  •\n  <a href=\"#get-started\">Get started</a>\n </strong>\n</p>\n\n## Highlights\n\n- ⚡️ Lightweight & Fast\n- 🥱 Low / No maintenance\n- 📄 Simple [yaml](http://yaml.org/) file configuration\n- ➕ Installable (pwa)\n- 🧠 Smart cards\n- 🔍️ Fuzzy search\n- 📂 Multi pages & item grouping\n- 🎨 Theme customization\n- ⌨️ keyboard shortcuts:\n  - <kbd>/</kbd> Start searching.\n  - <kbd>Escape</kbd> Stop searching.\n  - <kbd>Enter</kbd> Open the first matching result (respects the bookmark's `_target` property).\n  - <kbd>Alt</kbd> (or <kbd>Option</kbd>) + <kbd>Enter</kbd> Open the first matching result in a new tab.\n\n## Table of Contents\n\n- [Getting started](#get-started)\n- [Kubernetes Installation](docs/kubernetes.md)\n- [Configuration](docs/configuration.md)\n- [Theming](docs/theming.md)\n- [Smart cards](docs/customservices.md)\n- [Tips & tricks](docs/tips-and-tricks.md)\n- [Development](docs/development.md)\n- [Troubleshooting](docs/troubleshooting.md)\n\n## Get started\n\nHomer is a full static html/js dashboard, based on a simple yaml configuration file. See [documentation](docs/configuration.md) for information about the configuration (`assets/config.yml`) options.\n\nIt's meant to be served by an HTTP server, **it will not work if you open the index.html directly over file:// protocol**.\n\n### Using docker\n\nThe configuration directory is bind mounted to make your dashboard easy to maintain.\n\n**Start the container with `docker run`**\n\n```sh\n# Make sure your local config directory exists\ndocker run -d \\\n  --name homer \\\n  -p 8080:8080 \\\n  --mount type=bind,source=\"/path/to/config/dir\",target=/www/assets \\\n  --restart=unless-stopped \\\n  b4bz/homer:latest\n```\n\n> [!NOTE]  \n> The container will run using a user uid and gid 1000 by default, add `--user <your-UID>:<your-GID>` to the docker command to adjust it if necessary. Make sure this match the permissions of your assets directory.\n\n**or `docker-compose`**\n\n```yaml\nservices:\n  homer:\n    image: b4bz/homer\n    container_name: homer\n    volumes:\n      - /path/to/config/dir:/www/assets # Make sure your local config directory exists\n    ports:\n      - 8080:8080\n    user: 1000:1000 # default\n    environment:\n      - INIT_ASSETS=1 # default, requires the config directory to be writable for the container user (see user option)\n    restart: unless-stopped\n```\n\n**Environment variables:**\n\n- **`INIT_ASSETS`** (default: `1`)\nInstall example configuration file & assets (favicons, ...) to help you get started.\n\n- **`SUBFOLDER`** (default: `null`)\nIf you would like to host Homer in a subfolder, (ex: *<http://my-domain/homer>*), set this to the subfolder path (ex `/homer`).\n\n- **`PORT`** (default: `8080`)\nIf you would like to change internal port of Homer from default `8080` to your port choice.\n\n- **`IPV6_DISABLE`** (default: 0)\nSet to `1` to disable listening on IPv6.\n\n### Using the release tarball (prebuilt, ready to use)\n\nDownload and extract the latest release (`homer.zip`) from the [release page](https://github.com/bastienwirtz/homer/releases), rename the `assets/config.yml.dist` file to `assets/config.yml`, and put it behind a web server.\n\n```sh\nwget https://github.com/bastienwirtz/homer/releases/latest/download/homer.zip\nunzip homer.zip -d homer\ncd homer\ncp assets/config.yml.dist assets/config.yml\npnpx http-server # or python -m http.server 8010 or any web server.\n```\n\n### Build manually\n\n```sh\npnpm install\npnpm build\n```\n\nThen your dashboard is ready to use in the `/dist` directory.\n"
  },
  {
    "path": "docs/configuration.md",
    "content": "# Configuration\n\nHomer relies on a single [yaml](http://yaml.org/) configuration file, located in the `/assets` directory.  \n`.dist` sample configuration files are available to help you get started. Alternatively, the example below can be\ncopied into the config file.\n\n> [!NOTE]  \n> On docker installations, the sample configuration is automatically installed when no configuration is found **if**\n> the configuration directory is writable to the docker user. If no configuration has been installed, check your\n> container logs and your mounted configuration directory ownership & permissions  \n\n```yaml\n---\n# Homepage configuration\n# See https://fontawesome.com/search for icons options\n\n# Optional: Use external configuration file.\n# Using this will ignore remaining config in this file\n# externalConfig: https://example.com/server-luci/config.yaml\n\ntitle: \"App dashboard\"\nsubtitle: \"Homer\"\n# documentTitle: \"Welcome\" # Customize the browser tab text\nlogo: \"assets/logo.png\"\n# Alternatively a fa icon can be provided:\n# icon: \"fas fa-skull-crossbones\"\n\nheader: true # Set to false to hide the header\n# Optional: Different hotkey for search, defaults to \"/\"\n# hotkey:\n#   search: \"Shift\"\nfooter: '<p>Created with <span class=\"has-text-danger\">❤️</span> with <a href=\"https://bulma.io/\">bulma</a>, <a href=\"https://vuejs.org/\">vuejs</a> & <a href=\"https://fontawesome.com/\">font awesome</a> // Fork me on <a href=\"https://github.com/bastienwirtz/homer\"><i class=\"fab fa-github-alt\"></i></a></p>' # set false if you want to hide it.\n\ncolumns: \"3\" # \"auto\" or number (must be a factor of 12: 1, 2, 3, 4, 6, 12)\nconnectivityCheck: true # whether you want to display a message when the apps are not accessible anymore (VPN disconnected for example).\n                        # You should set it to true when using an authentication proxy, it also reloads the page when a redirection is detected when checking connectivity.\n\n# Optional: Proxy / hosting option\nproxy:\n  useCredentials: false # send cookies & authorization headers when fetching service specific data. Set to `true` if you use an authentication proxy. Can be overrided on service level. \n  headers: # send custom headers when fetching service specific data. Can also be set on a service level.\n    Test: \"Example\"\n    Test1: \"Example1\"\n\n\n# Set the default layout and color scheme\ndefaults:\n  layout: columns # Either 'columns', or 'list'\n  colorTheme: auto # One of 'auto', 'light', or 'dark'\n\n# Optional theming\ntheme: default # 'default' or one of the themes available in 'src/assets/themes'.\n\n# Optional custom stylesheet\n# Will load custom CSS files. Especially useful for custom icon sets.\n# stylesheet:\n#   - \"assets/custom.css\"\n\n# Here is the exhaustive list of customization parameters\n# However all value are optional and will fallback to default if not set.\n# if you want to change only some of the colors, feel free to remove all unused key.\ncolors:\n  light:\n    highlight-primary: \"#3367d6\"\n    highlight-secondary: \"#4285f4\"\n    highlight-hover: \"#5a95f5\"\n    background: \"#f5f5f5\"\n    card-background: \"#ffffff\"\n    text: \"#363636\"\n    text-header: \"#424242\"\n    text-title: \"#303030\"\n    text-subtitle: \"#424242\"\n    card-shadow: rgba(0, 0, 0, 0.1)\n    link: \"#3273dc\"\n    link-hover: \"#363636\"\n    background-image: \"/assets/your/light/bg.png\" # prefix with your sub subpath if any (ex: /homer/assets/...)\n  dark:\n    highlight-primary: \"#3367d6\"\n    highlight-secondary: \"#4285f4\"\n    highlight-hover: \"#5a95f5\"\n    background: \"#131313\"\n    card-background: \"#2b2b2b\"\n    text: \"#eaeaea\"\n    text-header: \"#ffffff\"\n    text-title: \"#fafafa\"\n    text-subtitle: \"#f5f5f5\"\n    card-shadow: rgba(0, 0, 0, 0.4)\n    link: \"#3273dc\"\n    link-hover: \"#ffdd57\"\n    background-image: \"/assets/your/dark/bg.png\" # prefix with your sub subpath if any (ex: /homer/assets/...)\n\n# Optional message\nmessage:\n  # url: \"https://<my-api-endpoint>\" # Can fetch information from an endpoint to override value below.\n  # mapping: # allows to map fields from the remote format to the one expected by Homer\n  #   title: 'id' # use value from field 'id' as title\n  #   content: 'value' # value from field 'value' as content\n  # refreshInterval: 10000 # Optional: time interval to refresh message\n  #\n  # Real example using chucknorris.io for showing Chuck Norris facts as messages:\n  # url: https://api.chucknorris.io/jokes/random\n  # mapping:\n  #   title: 'id'\n  #   content: 'value'\n  # refreshInterval: 10000\n  style: \"is-warning\"\n  title: \"Optional message!\"\n  icon: \"fa fa-exclamation-triangle\"\n  # The content also accepts HTML content, so you can add divs, images or whatever you want to make match your needs.\n  content: \"Lorem ipsum dolor sit amet, consectetur adipiscing elit.\"\n\n# Optional navbar\n# links: [] # Allows for navbar (dark mode, layout, and search) without any links\nlinks:\n  - name: \"Link 1\"\n    icon: \"fab fa-github\"\n    url: \"https://github.com/bastienwirtz/homer\"\n    target: \"_blank\" # optional html tag target attribute\n  - name: \"link 2\"\n    icon: \"fas fa-book\"\n    url: \"https://github.com/bastienwirtz/homer\"\n  # this will link to a second homer page that will load config from page2.yml and keep default config values as in config.yml file\n  # see url field and assets/page.yml used in this example:\n  - name: \"Second Page\"\n    icon: \"fas fa-file-alt\"\n    url: \"#page2\"\n\n# Services\n# First level array represents a group.\n# Leave only a \"items\" key if not using group (group name, icon & tagstyle are optional, section separation will not be displayed).\nservices:\n  - name: \"Application\"\n    icon: \"fas fa-code-branch\"\n    # A path to an image can also be provided. Note that icon take precedence if both icon and logo are set.\n    # logo: \"path/to/logo\"\n    # class: \"highlight-purple\" # Optional css class to add on the service group. \n    items:\n      - name: \"Awesome app\"\n        logo: \"assets/tools/sample.png\"\n        # Alternatively a fa icon can be provided:\n        # icon: \"fab fa-jenkins\"\n        subtitle: \"Bookmark example\"\n        tag: \"app\"\n        keywords: \"self hosted reddit\" # optional keyword used for searching purpose\n        url: \"https://www.reddit.com/r/selfhosted/\"\n        target: \"_blank\" # optional html tag target attribute\n      - name: \"Another one\"\n        logo: \"assets/tools/sample2.png\"\n        subtitle: \"Another application\"\n        tag: \"app\"\n        # Optional tagstyle\n        tagstyle: \"is-success\"\n        url: \"#\"\n  - name: \"Other group\"\n    icon: \"fas fa-heartbeat\"\n    items:\n      - name: \"Pi-hole\"\n        logo: \"assets/tools/sample.png\"\n        # subtitle: \"Network-wide Ad Blocking\" # optional, if no subtitle is defined, PiHole statistics will be shown\n        tag: \"other\"\n        url: \"http://192.168.0.151/admin\"\n        type: \"PiHole\" # optional, loads a specific component that provides extra features. MUST MATCH a file name (without file extension) available in `src/components/services`\n        target: \"_blank\" # optional html a tag target attribute\n        # class: \"green\" # optional custom CSS class for card, useful with custom stylesheet\n        # background: red # optional color for card to set color directly without custom stylesheet\n```\n\nView **[smart cards](customservices.md)** for details about all available cards (like `PiHole`) and how to configure them.\n\nIf you choose to fetch message information from an endpoint, the output format should be as follows (or you can [custom map fields as shown in tips-and-tricks](./tips-and-tricks.md#mapping-fields)):\n\n```json\n{\n  \"style\": null,\n  \"title\": \"Lorem ipsum 42\",\n  \"content\": \"LA LA LA Lorem ipsum dolor sit amet, .....\"\n}\n```\n\n`null` value or missing keys will be ignored and value from the `config.yml` will be used if available.\nEmpty values (either in `config.yml` or the endpoint data) will hide the element (ex: set `\"title\": \"\"` to hide the title bar).\n\n## Connectivity checks\n\nAs a webapp (PWA) the dashboard can still be displayed when your homer server is offline.\nThe connectivity checker periodically sends a HEAD request bypassing the PWA cache to the dashbord page to make sure it's still reachable.\n\nIt can be useful when you access your dashboard through a VPN or ssh tunnel for example, to know if your conection is up. It also helps when using an authentication proxy, it will reload the page if the authentication expires (when a redirect is send in response to the HEAD request).\n\n## Style Options\n\nHomer uses [bulma CSS](https://bulma.io/), which provides a [modifiers syntax](https://bulma.io/documentation/start/syntax/). You'll notice in the config there is a `tagstyle` option. It can be set to any of the bulma modifiers. You'll probably want to use one of these 4 main colors:\n\n- `is-info` (blue)\n- `is-success` (green)\n- `is-warning` (yellow)\n- `is-danger` (red)\n\nYou can read the [bulma modifiers page](https://bulma.io/documentation/start/syntax/) for other options regarding size, style, or state.\n\n## Theming & customization\n\nSee `colors` settings in the configuration example above.\nFavicon and application icon (pwa) are located in the `assets/icons` directory and can be replaced by any image you want (just keep the same name & size).\nThe `/assets/manifest.json` can also be edited to change the app (pwa) name, description and other settings.\n\n### Community theme\n\n- [Catppuccin theme](https://github.com/mrpbennett/catppucin-homer) by [@mrpbenett](https://github.com/mrpbennett)\n- [DietPi theme](https://codeberg.org/Cs137/homer-theme-dietpi) by [@Cs137](https://codeberg.org/Cs137)\n- [Dracula theme](https://draculatheme.com/homer) by [@Tuetenk0pp](https://github.com/Tuetenk0pp)\n- [Homer Theme v2](https://github.com/walkxcode/homer-theme) by [@walkxcode](https://github.com/walkxcode)\n\n## PWA Icons\n\nSee icons documentation [here](https://github.com/bastienwirtz/homer/blob/main/public/assets/icons/README.md).\n"
  },
  {
    "path": "docs/customservices.md",
    "content": "# Smart cards\n\nSmart cards provide specific integrations for external services. They display additional information and extra features beyond basic service card. Smart cards are enabled by adding a `type` key to the service item in your YAML configuration.\n\nEach service integration has different requirements and may need additional configuration parameters (see card list below).\n\n> [!WARNING]  \n> Your `config.yml` file is exposed at `/assets/config.yml` via HTTP. Any sensitive information (like API keys)\n> in this file is visible to anyone who can access your Homer instance. Only include API keys if your Homer\n> instance is protected by authentication or access controls **or use a proxy like [`CORSair`](https://github.com/bastienwirtz/corsair)\n>  to inject your credentials safely**, using environment variable on the server side. \n\nAvailable services are located in `src/components/`:\n\n- [Common options](#common-options)\n- [AdGuard Home](#adguard-home)\n- [CopyToClipboard](#copy-to-clipboard)\n- [Docuseal](#docuseal)\n- [Docker Socket Proxy](#docker-socket-proxy)\n- [Emby / Jellyfin](#emby--jellyfin)\n- [FreshRSS](#freshrss)\n- [Gatus](#gatus)\n- [Gitea / Forgejo](#gitea--forgejo)\n- [Glances](#glances)\n- [Gotify](#gotify)\n- [Healthchecks](#healthchecks)\n- [Home Assistant](#home-assistant)\n- [Immich](#immich)\n- [Jellystat](#jellystat)\n- [Lidarr, Prowlarr, Sonarr, Readarr and Radarr](#lidarr-prowlarr-sonarr-readarr-and-radarr)\n- [Linkding](#linkding)\n- [Matrix](#matrix)\n- [Mealie](#mealie)\n- [Medusa](#medusa)\n- [Miniflux](#miniflux)\n- [Nextcloud](#nextcloud)\n- [OctoPrint / Moonraker](#octoprintmoonraker)\n- [Olivetin](#olivetin)\n- [OpenHAB](#openhab)\n- [OpenWeatherMap](#openweathermap)\n- [Paperless-NGX](#paperlessng)\n- [PeaNUT](#peanut)\n- [PiAlert](#pialert)\n- [PiHole](#pihole)\n- [Ping](#ping)\n- [Plex](#plex)\n- [Portainer](#portainer)\n- [Prometheus](#prometheus)\n- [Proxmox](#proxmox)\n- [qBittorrent](#qbittorrent)\n- [rTorrent](#rtorrent)\n- [SABnzbd](#sabnzbd)\n- [Scrutiny](#scrutiny)\n- [Speedtest Tracker](#speedtesttracker)\n- [Tautulli](#tautulli)\n- [Tdarr](#tdarr)\n- [Traefik](#traefik)\n- [Transmission](#transmission)\n- [TrueNas Scale](#truenas-scale)\n- [Uptime Kuma](#uptime-kuma)\n- [Vaultwarden](#vaultwarden)\n- [Wallabag](#wallabag)\n- [What's Up Docker](#whats-up-docker)\n\n> [!IMPORTANT]  \n> Smart cards that interact with external services are subject to CORS restrictions, therefore require one of the following:\n>\n> - All services hosted on the **same domain** as Homer (mydomain.tld/pihole, mydomain.tld/proxmox) to avoid cross-domain request entirely.\n> - All services configured to **accept cross-site requests** by sending the necessary CORS headers (either directly in service configuration or via proxy).\n> - **Use a proxy** to add the necessary CORS headers (lot of options, some of them described [here](https://enable-cors.org/server.html). Also check [`CORSair`](https://github.com/bastienwirtz/corsair), a light and simple solution)\n>\n> If you experience any issues, see the [troubleshooting](troubleshooting.md#my-service-card-doesnt-work-nothing-appears-or-offline-status-is-displayed-pi-hole-sonarr-ping-) page.\n\n## Common options\n\n```yaml\n- name: \"My Service\"\n  type: \"<type>\"\n  logo: \"assets/tools/sample.png\" # Optional\n  url: https://my-service.url # Optional: Card link and API base url unless 'endpoint' is provided (see below) \n  endpoint: https://my-service-api.url # Optional: alternative base URL used to fetch service data when necessary.\n  useCredentials: false # Optional: Override global proxy.useCredentials configuration.\n  headers: # Optional: Override global proxy.headers configuration.\n```\n\nIf a subtitle is provided, (using the `subtitle` configuration key), **it will override (hide)** any custom information displayed on the subtitle line by the custom integration.\n\n## AdGuard Home\n\nDisplays AdGuard Home protection status and blocked query statistics.\n\n```yaml\n- name: \"AdGuard Home\"\n  type: \"AdGuardHome\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n```\n\n> **Note**: If AdGuard Home’s web user is password-protected, you must pass Authorization HTTP header along with all requests. It can be done using a proxy or adding the following to the item configuration:\n>\n> ```yaml  \n> headers:  \n>   Authorization: \"Basic <base64-encoded for username:password>\"\n> ```\n\n## Copy to Clipboard\n\nDisplays a service card with a copy button that copies the specified text to your clipboard when clicked.\n\n```yaml\n- name: \"Copy me!\"\n  type: \"CopyToClipboard\"\n  logo: \"assets/tools/sample.png\"\n  subtitle: \"Click the copy icon to copy text\"\n  clipboard: \"this text will be copied to your clipboard\"\n  url: \"https://optional-link.com\" # optional: opens when clicking the card (not the copy button)\n```\n\n## Docker Socket Proxy\n\nDisplays counts of running, stopped, and error containers from Docker Socket Proxy.\n\n```yaml\n- name: \"Docker\"\n  type: \"DockerSocketProxy\"\n  logo: \"assets/tools/sample.png\"\n  endpoint: \"https://my-service-api.url:port\"\n```\n\n## Docuseal\n\nDisplays the Docuseal version.\n\n```yaml\n- name: Docuseal\n  type: Docuseal\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n```\n\n## Emby / Jellyfin\n\nDisplays stats from your Emby or Jellyfin server.\nThe `libraryType` configuration let you choose which stats to show.\n\n```yaml\n- name: \"Emby\"\n  type: \"Emby\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  apikey: \"<---insert-api-key-here--->\"\n  libraryType: \"music\" # Choose which stats to show. Can be one of: music, series or movies.\n```\n\n## FreshRSS\n\nDisplays unread article count and total subscriptions from your FreshRSS server.\n\n```yaml\n- name: \"FreshRSS\"\n  type: \"FreshRSS\"\n  url: https://my-service.url\n  updateInterval: 5000 # (Optional) Interval (in ms) for updating the stats\n  username: \"<---your-username--->\"\n  password: \"<---your-password--->\"\n```\n\n## Gatus\n\nThe Gatus service displays information about the configured services from the defined Gatus server.\nTwo lines are needed in the config.yml :\n\n```yaml\n  type: \"Gatus\"\n  url: \"http://192.168.0.151/gatus\"\n\n```\n\nOptionally, the results can be filtered to only include jobs in the defined groups:\n```yaml\n  groups: [Services, External]\n```\n\nThe status can be checked regularly by defining an update Interval in ms:\n```yaml\n  updateInterval: 5000\n```\n\nThe average times can be hidden (saves their calculation also) by setting the following:\n```yaml\n  hideaverages: true\n```\n\n## Gitea / Forgejo\n\nDisplays a Gitea / Forgejo version.\n\n```yaml\n- name: Forgejo\n  type: Gitea\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n```\n\n## Glances\n\nDisplays system metrics (CPU, memory, swap, load) from a Glances server.\n\n```yaml\n- name: \"System Metrics\"\n  type: \"Glances\"\n  icon: \"fa-solid fa-heart-pulse\"\n  url: https://my-service.url\n  stats: [cpu, mem] # Options: load, cpu, mem, swap\n```\n\nIf you don't already have a glances server up and running, here is a sample Docker compose file to get you started:\n\n```yml\n---\nservices:\n  glances:\n    image: nicolargo/glances:latest\n    container_name: glances\n    environment:\n      - TZ=Europe/Rome\n      - GLANCES_OPT=-w\n    ports:\n      - 61208:61208\n    restart: unless-stopped\n```\n\n## Gotify\n\nDisplays the number of outstanding messages and system health status.\n\n```yaml\n- name: \"Gotify\"\n  type: \"Gotify\"\n  url: https://my-service.url\n  apikey: \"<---insert-client-token-here--->\"\n```\n\n**API Token**: Use a **client token** (not an app token).\n\n## Healthchecks\n\nDisplays status counts (up/down/grace) from your Healthchecks monitoring service.\n\n```yaml\n- name: \"Healthchecks\"\n  type: \"Healthchecks\"\n  url: https://my-service.url\n  apikey: \"<---insert-api-key-here--->\"\n```\n\n**API Key**: Found in Healthchecks web interface under **Settings > API Access > API key (read-only)**.\n\n## Home Assistant\n\nDisplays Home Assistant instance status, version, location, and entity count.\n\n```yaml\n- name: \"Home Assistant\"\n  type: \"HomeAssistant\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  apikey: \"<---insert-long-lived-access-token-here--->\"\n  items: [] # optional: \"name\", \"version\", \"entities\"\n  separator: \" \" # optional\n```\n\n**API Token**: Create a long-lived access token in Home Assistant:\n1. Go to **Profile > Security > Long-lived access tokens**\n2. Click **Create Token**\n\n**CORS Configuration**: Edit Home Assistant `configuration.yml` and add Homer's IP:\n```yaml\nhttp:\n  cors_allowed_origins:\n    - \"http://homer.local:8080\"\n    - \"https://your-homer-domain.com\"\n```\n\n## Immich\n\nDisplays user count, photo/video counts, and storage usage from your Immich server.\n\n```yaml\n- name: \"Immich\"\n  type: \"Immich\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  apikey: \"<---insert-api-key-here--->\"\n```\n\n**Requirements**: Immich server version `1.118.0` or later\n**API Key**: Create an API key in Immich web interface under **Administration > API Keys**\n\n## Jellystat\n\nDisplay the number of concurrent streams on your jellyfin server.\n\n```yaml\n- name: \"Jellystat\"\n  type: \"Jellystat\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  apikey: \"<---insert-api-key-here--->\"\n```\n\n**API Key**: You can create an API key in the dashboard of you jellystat server: settings/API Keys -> Add Key\n\n\n## Lidarr, Prowlarr, Sonarr, Readarr and Radarr\n\nDisplays Activity (blue), Missing (purple) Warning (orange) or Error (red) notifications bubbles from the Lidarr, Readarr, Radarr or Sonarr application.\nTwo lines are needed in the `config.yml`:\n\n```yaml\n- name: \"Lidarr\"\n  type: \"Lidarr\" # \"Lidarr\" \"Prowlarr\", \"Radarr\" or \"Sonarr\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  checkInterval: 5000 # (Optional) Interval (in ms) for updating the status\n  apikey: \"<---insert-api-key-here--->\"\n```\n\nThe url must be the root url of Lidarr, Prowlarr, Readarr, Radarr or Sonarr application.\n\n**API Key**:  The Lidarr, Prowlarr, Readarr, Radarr or Sonarr API key can be found in `Settings` > `General`. It is needed to access the API.\n\n> [!IMPORTANT]  \n> **Radarr API V3 support**: If you are using an older version of Radarr or Sonarr which don't support the new V3 api endpoints, add the following line to your service config `\"legacyApi: true\"`\n\n## Linkding\n\nThis integration makes it possible to query Linkding and list multiple results from Linkding.\nLinkding has to be configured with CORS enabled. Linkding does not support that, but a reverse proxy in front can fix that.\nThis integration supports at max 15 results from Linkding, but you can add it multiple times to you dashboard with different queries to retrieve what you need.\n\n```yaml\n- name: \"Linkding\"\n  type: \"Linkding\"\n  url: https://my-service.url\n  token: \"<---insert-api-key-here--->\"\n  limit: 10 # Maximum number of items returned by Linkding, minimal 1 and max 15\n  query: \"#ToDo #Homer\" # query to do on Linkding. Use #tagname to search for tags\n```\n\n## Matrix\n\nDisplays a Matrix version, and shows if the server is online.\n\n```yaml\n- name: \"Matrix - Server\"\n  type: \"Matrix\"\n  logo: \"assets/tools/sample.png\"\n  url: \"http://matrix.example.com\"\n```\n\n## Mealie\n\nDisplays the number of recipes Mealie is keeping organized or the planned meal for today if one is planned.\n\n```yaml\n- name: \"Mealie\"\n  type: \"Mealie\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  apikey: \"<---insert-api-key-here--->\"\n```\n\n**API Key**: You will have to set an API key in the field `apikey` which can be created in your Mealie installation.\nThe API page can be found: Click on hamburger menu -> Click on your profile -> Click on \"Manage your API Tokens\"\n\n## Medusa\n\nDisplays News (grey), Warning (orange) or Error (red) notifications bubbles from the Medusa application.\n\n```yaml\n- name: \"Medusa\"\n  type: \"Medusa\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  apikey: \"<---insert-api-key-here--->\"\n```\n\nThe url must be the root url of Medusa application.\n\n**API Key**: The Medusa API key can be found in General configuration > Interface. It is needed to access Medusa API.\n\n## Miniflux\n\nDisplays the number of unread articles from your Miniflux RSS reader.\n\n```yaml\n- name: \"Miniflux\"\n  type: \"Miniflux\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  apikey: \"<---insert-api-key-here--->\"\n  style: \"status\" # Either \"status\" or \"counter\"\n  checkInterval: 60000 # Optional: Interval (in ms) for updating the unread count\n```\n\n**API Key**: Generate an API key in Miniflux web interface under **Settings > API Keys > Create a new API key**\n\n## Nextcloud\n\nDisplays Nextcloud version and shows if Nextcloud is online, offline, or in [maintenance\nmode](https://docs.nextcloud.com/server/stable/admin_manual/maintenance/upgrade.html#maintenance-mode).\n\n```yaml\n- name: Nextcloud\n  type: Nextcloud\n  logo: assets/tools/sample.png\n  url: https://my-service.url\n```\n\n## OctoPrint/Moonraker\n\nThe OctoPrint/Moonraker service only needs an `apikey` & `endpoint` and optionally a `display` or `url` option. `url` can be used when you click on the service it will launch the `url`\nMoonraker's API mimmicks a few of OctoPrint's endpoints which makes these services compatible. See <https://moonraker.readthedocs.io/en/latest/web_api/#octoprint-api-emulation> for details.\n\n```yaml\n- name: \"Octoprint\"\n  type: \"OctoPrint\"\n  logo: assets/tools/sample.png\n  endpoint: \"https://my-service-api.url:port\"\n  apikey: \"<---insert-api-key-here--->\"\n  display: \"text\" # 'text' or 'bar'. Default to `text`.\n  \n```\n\n## Olivetin\n\nDisplays a Olivetin version.\n\n```yaml\n- name: Olivetin\n  type: Olivetin\n  logo: assets/tools/sample.png\n  url: https://my-service.url\n```\n\n## OpenHAB\n\nDisplays OpenHAB system status, things count, and items count.\n\n```yaml\n- name: \"OpenHAB\"\n  type: \"OpenHAB\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  apikey: \"<---insert-api-key-here--->\"\n  things: true # query things API for counts\n  items: true  # query items API for counts\n```\n\n**API Token**: Create an API token following the [official OpenHAB documentation](https://www.openhab.org/docs/configuration/apitokens.html)\n\n**CORS Configuration**: Edit `services/runtime.cfg` and add:\n\n```ini\norg.openhab.cors:enable=true\n```\n\n## OpenWeatherMap\n\nUsing the OpenWeatherMap service you can display weather information about a given location.\nThe following configuration is available for the OpenWeatherMap service:\n\n```yaml\n- name: \"Weather\"\n  type: \"OpenWeather\"\n  apikey: \"<---insert-api-key-here--->\" # Request one from https://openweathermap.org/api.\n  location: \"Amsterdam\" # your location.\n  locationId: \"2759794\" # Optional: Specify OpenWeatherMap city ID for better accuracy\n  units: \"metric\" # units to display temperature. Can be one of: metric, imperial, kelvin. Defaults to kelvin.\n  background: \"square\" # choose which type of background you want behind the image. Can be one of: square, circle, none. Defaults to none.\n  \n```\n\n**Remarks:**\nIf for some reason your city can't be found by entering the name in the `location` property, you could also try to configure the OWM city ID in the `locationId` property. To retrieve your specific City ID, go to the [OWM website](https://openweathermap.org), search for your city and retrieve the ID from the URL (for example, the City ID of Amsterdam is 2759794).\n\n## Paperless-NGX\n\nDisplays total number of documents stored.\n\n```yaml\n- name: \"Paperless\"\n  type: \"PaperlessNG\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  apikey: \"<---insert-api-key-here--->\"\n```\n\n**API Key**: API key can be generated in Settings > Administration > Auth Tokens\n\n## PeaNUT\n\nDisplays current status and UPS load of the UPS device.\n\n```yaml\n- name: \"PeaNUT\"\n  type: PeaNUT\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  # device: \"ups\" # The ID of the device\n```\n\n## PiAlert\n\nDisplays stats from your PiAlert server.\n\n```yaml\n- name: \"PiAlert\"\n  type: \"PiAlert\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  updateInterval: 5000 # (Optional) Interval (in ms) for updating the stats\n```\n\n## PiHole\n\nDisplays info about your local PiHole instance right on your Homer dashboard.\n\n```yaml\n- name: \"Pi-hole\"\n  type: \"PiHole\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  # endpoint: \"https://my-service-api.url\" # optional, For v6 API, this is the base URL used to fetch Pi-hole data overwriting the url\n  apikey: \"<---insert-api-key-here--->\" # optional, needed if web interface is password protected\n  apiVersion: 5 # optional, defaults to 5. Use 6 if your PiHole instance uses API v6\n  checkInterval: 3000 # optional, defaults to 300000. interval in ms to check Pi-hole status\n```\n\n**API Key**: Required only if Pi-hole web interface is password protected. Go to **Settings > API/Web Interface > Show API token**\n\n**API Versions**:\n\n- **v5** (default): Uses legacy API endpoints\n- **v6**: Uses modern API with session management - set `apiVersion: 6`\n\n## Ping\n\nChecks if the target link is available and displays the round trip time (RTT) of the request.\nBy default the HEAD method is used but it can be configured to use GET using the optional `method` property.\nOptionally, use `successCodes` to define which HTTP response status codes should be considered as available status.\n\n```yaml\n- name: \"Awesome app\"\n  type: Ping\n  logo: \"assets/tools/sample.png\"\n  url: \"https://www.wikipedia.org/\"\n  # method: \"head\"\n  # successCodes: [200, 418] # Optional, default to all 2xx HTTP response status codes\n  # timeout: 500 # Timeout in ms before ping is aborted. Default 2000\n  # subtitle: \"Bookmark example\" # By default, request round trip time is displayed when subtitle is not set\n  # updateInterval: 5000 # (Optional) Interval (in ms) for updating ping status\n```\n\n## Plex\n\nDisplays active streams, total movies, and total TV series from your Plex server.\n\n```yaml\n- name: \"Plex\"\n  type: \"Plex\"\n  logo: \"assets/tools/sample.png\"\n  url: \"https://my-service.url/web\"\n  endpoint: \"https://my-service.url\"\n  token: \"<---insert-plex-token-here--->\"\n```\n\n**Plex Token**: See [How to find your Plex token](https://www.plexopedia.com/plex-media-server/general/plex-token/)\n\n## Portainer\n\nDisplays container counts (running/dead/misc), version, and online status from your Portainer instance.\n\n```yaml\n- name: \"Portainer\"\n  type: \"Portainer\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  apikey: \"<---insert-api-key-here--->\"\n  environments: # optional: specific environments to check\n    - \"raspberry\"\n    - \"local\"\n```\n\n**Requirements**: Portainer version 1.11 or later\n\n**API Key**: Generate an access token in Portainer UI. See [Creating an Access Token](https://docs.portainer.io/api/access#creating-an-access-token)\n\n## Prometheus\n\n```yaml\n- name: \"Prometheus\"\n  type: \"Prometheus\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n```\n\n## Proxmox\n\nDisplays status information of a Proxmox node (VMs running and disk, memory and cpu used). \n\n```yaml\n- name: \"Proxmox - Node\"\n  type: \"Proxmox\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  node: \"your-node-name\"\n  warning_value: 50\n  danger_value: 80\n  api_token: \"PVEAPIToken=root@pam!your-api-token-name=your-api-token-key\"\n  # values below this line are optional (default value are false/empty):\n  hide_decimals: true # removes decimals from stats values.\n  hide: [] # hides information. Possible values are \"vms\", \"vms_total\", \"lxcs\", \"lxcs_total\", \"disk\", \"mem\" and \"cpu\".\n  small_font_on_small_screens: true # uses small font on small screens (like mobile)\n  small_font_on_desktop: true # uses small font on desktops (just in case you're showing much info)\n```\n\n**API Key**: You can set it up in Proxmox under Permissions > API Tokens. You also need to know the realm the user of the API Token is assigned to (by default pam).\n\nThe API Token (or the user assigned to that token if not separated permissions is checked) are this:\n\n| Path                | Permission | Comments                                                          |\n|---------------------|------------|-------------------------------------------------------------------|\n| /nodes/\\<your-node> | Sys.Audit  |                                                                   |\n| /vms/\\<id-vm>       | VM.Audit   | You need to have this permission on any VM you want to be counted |\n\nIt is highly recommended that you create and API Token with only these permissions on a read-only mode.\n\n## qBittorrent\n\nDisplays the global upload and download rates, as well as the number of torrents\nlisted. The service communicates with the qBittorrent API interface which needs\nto be accessible from the browser. Please consult\n[the instructions](https://github.com/qbittorrent/qBittorrent/pull/12579)\nfor setting up qBittorrent.\n\n```yaml\n- name: \"qBittorrent\"\n  type: \"qBittorrent\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url # Your rTorrent web UI, f.e. ruTorrent or Flood.\n  rateInterval: 2000 # Interval for updating the download and upload rates.\n  torrentInterval: 5000 # Interval for updating the torrent count.\n```\n\n## rTorrent\n\nDisplays the global upload and download rates, as well as the number of torrents\nlisted in rTorrent. The service communicates with the rTorrent XML-RPC interface which needs\nto be accessible from the browser. Please consult\n[the instructions](https://github.com/rakshasa/rtorrent-doc/blob/master/RPC-Setup-XMLRPC.md)\nfor setting up rTorrent.\n\n```yaml\n- name: \"rTorrent\"\n  type: \"Rtorrent\"\n  logo: \"assets/tools/sample.png\"\n  url: \"https://my-service.url\" # Your rTorrent web UI, f.e. ruTorrent or Flood.\n  xmlrpc: \"https://my-service.url:port\" # Reverse proxy for rTorrent's XML-RPC.\n  rateInterval: 5000 # Interval for updating the download and upload rates.\n  torrentInterval: 60000 # Interval for updating the torrent count.\n  username: \"username\" # Username for logging into rTorrent (if applicable).\n  password: \"password\" # Password for logging into rTorrent (if applicable).\n```\n\n## SABnzbd\n\nDisplays the number of currently active downloads on your SABnzbd instance. \n\n```yaml\n- name: \"SABnzbd\"\n  type: \"SABnzbd\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  apikey: \"<---insert-api-key-here--->\"\n  downloadInterval: 5000 # (Optional) Interval (in ms) for updating the download count\n```\n\n**API Key**: An API key is required, and can be obtained from the \"Config\" > \"General\" section of the SABnzbd config in the web UI.\n\n## Scrutiny\n\nDisplays info about the total number of disk passed and failed S.M.A.R.T and scrutiny checks\n\n```yaml\n- name: \"Scrutiny\"\n  type: \"Scrutiny\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  updateInterval: 5000 # (Optional) Interval (in ms) for updating the status\n```\n\n## SpeedtestTracker\n\nDisplays the download and upload speeds in Mbit/s and the ping in ms.\n\n```yaml\n- name: \"Speedtest Tracker\"\n  type: \"SpeedtestTracker\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n```\n\n## Tautulli\n\nDisplays the number of currently active streams on you Plex instance.\n\n```yaml\n- name: \"Tautulli\"\n  type: \"Tautulli\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  checkInterval: 5000 # (Optional) Interval (in ms) for updating the status  \n  apikey: \"<---insert-api-key-here--->\"\n```\n\n**API Key**: An API key is required, and can be obtained from the \"Web Interface\" section of settings on the Tautulli web UI.\n\nBecause the service type and link don't necessarily have to match, you could\neven make the service type Tautulli on your Plex card and provide a separate\nendpoint pointing to Tautulli!\n\n```yaml\n- name: \"Plex\"\n  type: \"Tautulli\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-plex.url/web # Plex\n  endpoint: https://my-tautulli.url # Tautulli\n  apikey: \"<---insert-api-key-here--->\"\n```\n\n## Tdarr\n\nDisplays the number of currently queued items for transcoding on your Tdarr instance as well as the number of errored items.\n\n```yaml\n- name: \"Tdarr\"\n  type: \"Tdarr\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  checkInterval: 5000 # (Optional) Interval (in ms) for updating the queue & error counts\n```\n\n## Traefik\n\nDisplays Traefik.\n\n```yaml\n- name: \"Traefik\"\n  type: \"Traefik\"\n  logo: \"assets/tools/sample.png\"\n  url: \"http://traefik.example.com\"\n  # basic_auth: \"admin:password\"  # (Optional) Send Authorization header. \n```\n\n**Authentication**: If BasicAuth is set, credentials will be encoded in Base64 and sent as an Authorization header (`Basic <encoded_value>`). The value must be formatted as \"admin:password\".\n\n## Transmission\n\nDisplays the global upload and download rates, as well as the number of active torrents from your Transmission daemon. \nThe service communicates with the Transmission RPC interface which needs to be accessible from the browser.\n\n```yaml\n- name: \"Transmission\"\n  logo: \"assets/tools/sample.png\"\n  url: \"http://192.168.1.2:9091\" # Your Transmission web interface URL\n  type: \"Transmission\"\n  auth: \"username:password\" # Optional: HTTP Basic Auth\n  interval: 5000 # Optional: Interval for refreshing data (ms)\n  target: \"_blank\" # Optional: HTML a tag target attribute\n```\n\nThe service automatically handles Transmission's session management and CSRF protection.\n\n## Truenas Scale\n\nDisplays TrueNAS version.\n\n```yaml\n- name: \"Truenas\"\n  type: \"TruenasScale\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  api_token: \"<---insert-api-key-here--->\"\n```\n\n## Uptime Kuma\n\nDisplays overall status, uptime percentage, and incident information from your Uptime Kuma status page.\n\n```yaml\n- name: \"Uptime Kuma\"\n  type: \"UptimeKuma\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  slug: \"default\" # status page slug, defaults to \"default\"\n```\n\n**Requirements**: Uptime Kuma version `1.13.1` or later (for [multiple status pages support](https://github.com/louislam/uptime-kuma/releases/tag/1.13.1))\n\n## Vaultwarden\n\nDisplays Vaultwarden version and status.\n\n```yaml\n- name: \"Vaultwarden - Server\"\n  type: \"Vaultwarden\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n```\n\n## Wallabag\n\nDisplays Wallabag version.\n\n```yaml\n- name: Wallabag\n  type: Wallabag\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n```\n\n## What's up Docker\n\nDisplay info about the number of container running and the number for which an update is available on your Homer dashboard.\n\n```yaml\n- name: \"What's Up Docker\"\n  type: \"WUD\"\n  logo: \"assets/tools/sample.png\"\n  url: https://my-service.url\n  subtitle: \"Docker image update notifier\"\n```\n"
  },
  {
    "path": "docs/development.md",
    "content": "# Development\n\nIf you want to contribute to Homer, please read the [contributing guidelines](https://github.com/bastienwirtz/homer/blob/main/CONTRIBUTING.md) first. \n\n```sh\npnpm install\npnpm dev\n```\n\n## Custom services\n\nCustom services are small VueJs component (see `src/components/services/`) that add little features to a classic, \"static\", dashboard item. It should be very simple.\nA dashboard can contain a lot of items, so performance is very important. \n\nThe [`Generic`](https://github.com/bastienwirtz/homer/blob/main/src/components/services/Generic.vue) service provides a typical card layout which\nyou can extend to add specific features. Unless you want a completely different design, extended the generic service is the recommended way. It gives you 3 [slots](https://vuejs.org/v2/guide/components-slots.html#Named-Slots) to extend: `icon`, `content` and `indicator`. \nEach one is **optional**, and will display the usual information if omitted.\n\nEach service must implement the `item` [property](https://vuejs.org/v2/guide/components-props.html) and bind it the Generic component if used.\n\n### Skeleton\n\n```Vue\n<template>\n  <Generic :item=\"item\">\n    <template #icon>\n      <!-- left area containing the icon -->\n    </template>\n    <template #content>\n      <!-- main area containing the title, subtitle, ... -->\n    </template>\n    <template #indicator>\n      <!-- top right area, empty by default -->\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport Generic from \"./Generic.vue\";\n\nexport default {\n  name: \"MyNewService\",\n  props: {\n    item: Object,\n  },\n  components: {\n    Generic,\n  }\n};\n</script>\n```\n\n## Themes\n\nThemes are meant to be simple customization (written in [scss](https://sass-lang.com/documentation/syntax)).\nTo add a new theme, just add a file in the theme directory, and put all style in the `body #app.theme-<name>` scope. Then import it in the main style file.\n\n```scss\n// `src/assets/themes/my-awesome-theme.scss`\nbody #app.theme-my-awesome-theme. { ... }\n```\n\n```scss\n// `src/assets/app.scss`\n// Themes import\n@import \"./themes/sui.scss\";\n...\n@import \"./themes/my-awesome-theme.scss\";\n```\n"
  },
  {
    "path": "docs/kubernetes.md",
    "content": "# Kubernetes Installation\n\nWe have different solution to install Homer on Kubernetes Cluster, each solution responds to a specific need. \n\n## Table of Contents\n\n- [Helm Chart](#helm-chart)\n- [Controller With CRDs](#controller-crds)\n- [Controller With Ingress Annotations](#controller-annotations)\n- [Operator](#Operator)\n\n## Helm Chart\n\nTo deploy Homer in Kubernetes\n\nThanks to [@djjudas21](https://github.com/djjudas21) [charts](https://github.com/djjudas21/charts/tree/main/charts/homer):\n\n### Installation\n\n```sh\nhelm repo add djjudas21 https://djjudas21.github.io/charts/\nhelm repo update djjudas21\n\n# install with all defaults\nhelm install homer djjudas21/homer\n\n# install with customisations\nwget https://raw.githubusercontent.com/djjudas21/charts/main/charts/homer/values.yaml\n# edit values.yaml\nhelm install homer djjudas21/homer -f values.yaml\n```\n\n## Controller CRDs\n\nTo deploy Homer in Kubernetes with [Custom Resources Definition](https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/) to dynamic declaration for Homer Service\n\nThanks to [@jplanckeel](https://github.com/jplanckeel) [homer-k8s](https://github.com/bananaops/homer-k8s/tree/main/):\n\n### Installation\n\n```sh\nhelm repo add bananaops https://bananaops.github.io/homer-k8s/\nhelm repo update bananaops\n\n# install with all defaults\nhelm install homer bananaops/homer-k8s\n\n# install with customisations\nwget https://raw.githubusercontent.com/bananaops/homer-k8s/main/helm/homer-k8s/values.yaml\n# edit values.yaml\nhelm install homer bananaops/homer-k8s -f values.yaml\n```\n\n### Usage\n\n- [usage](https://github.com/bananaops/homer-k8s/tree/main/?tab=readme-ov-file#crds-homerservices)\n\n## Controller Annotations\n\nTo deploy Homer in Kubernetes with controller to check ingress annotation and modify homer configuration \n\nThanks to [@paulfantom](https://github.com/paulfantom) [homer-reloader](https://github.com/paulfantom/homer-reloader/tree/main/):\n\n\n## Operator\n\nTo deploy many Homer in Kubernetes with [Custom Resources Definition](https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/)\n\nThanks to [@rajsinghtech](https://github.com/rajsinghtech) [homer-operator](https://github.com/rajsinghtech/homer-operator/tree/main/):\n\n\n### Installation\n\n```sh\n# install with customisations\nwget https://raw.githubusercontent.com/rajsinghtech/homer-operator/main/deploy/operator.yaml\n# Apply operator file\nkubectl apply -f operator.yaml\n```\n\n### Usage\n\n- [usage](https://github.com/rajsinghtech/homer-operator?tab=readme-ov-file#usage)\n"
  },
  {
    "path": "docs/theming.md",
    "content": "# Theming\n\n## Change theme\n\nThe default theme can be changed using the yaml configuration file\n\n```yaml\ntheme: default # 'default', 'walkxcode', or 'neon' see files in 'src/assets/themes'.\n```\n\n## Colors and background customization\n\nDefault colors and background can be customized for each theme variant (light and dark), using either the yaml config file, or the css variables (see \"Additional stylesheets\" below).\n\n### Available options\n\n| yaml | css | description |\n| --------------------- | ----------------------- | --- |\n| `highlight-primary`   | `--highlight-primary`   | header background, group title icons       |\n| `highlight-secondary` | `--highlight-secondary` | navbar background, default tag color |\n| `highlight-hover`     | `--highlight-hover`     | navbar links hover, search input background |\n| `background`          | `--background`          | page background color |\n| `card-background`     | `--card-background`     | service card background color |\n| `text`                | `--text`                | main text color |\n| `text-header`         | `--text-header`         | header text color |\n| `text-title`          | `--text-title`          | service card title color |\n| `text-subtitle`       | `--text-subtitle`       | service card subtitle color  |\n| `card-shadow`         | `--card-shadow`         | Service card `box-shadow` |\n| `link`                | `--link`                | Links color (footer & message), service card icon color  |\n| `link-hover`          | `--link-hover`          | Links hover color (footer & message), service card icon hover color |\n| `background-image`    | `--background-image`    | page background image url (when used in css, set `url(<image-url>)` instead of just the url. see example below)|\n\n\nYAML example\n\n```yml\ncolors:\n  light:\n    highlight-primary: \"#3367d6\"\n    background-image: \"assets/your/light/bg.webp\"\n    ...\n  dark:\n    highlight-primary: \"#3367d6\"\n    background-image: \"assets/your/dark/bg.webp\"\n    ...\n```\n\nCSS example\n\n```css\n.light {\n    --highlight-primary: #3367d6;\n    --background-image: url(\"assets/your/light/bg.webp\");\n    ...\n}\n\n.dark {\n    --highlight-primary: #3367d6;\n    --background-image: url(\"assets/your/dark/bg.webp\");\n    ...\n}\n```\n\n## Additional stylesheets\n\nOne or more additional stylesheets can be loaded to add or override style from the current theme. Use the 'stylesheet' option in the yaml configuration file to load your own CSS file.\n\n```yml\nstylesheet:\n   - \"assets/custom.css\"\n```\n\n### Customization example\n\n#### Max width modification\n\n```css\nbody #main-section .container {\n    max-width: 2000px; // adjust to your needs (eg: calc(100% - 100px), none, ...)\n}\n```\n\n#### Background gradient\n\n```css\n#app {\n    height: 100%;\n    background: linear-gradient(90deg, #5c2483, #0095db);\n}\n```\n"
  },
  {
    "path": "docs/tips-and-tricks.md",
    "content": "# Tips & Tricks\n\nHere is a collection of neat tips and tricks that Homer users have come up with!\n\n## Dashboard icons\n\nGreat source to find service icons\n\n- <https://selfh.st/icons/>\n- <https://github.com/homarr-labs/dashboard-icons>\n\n## Use Homer as a custom \"new tab\" page\n\n#### `by @vosdev`\n\nThese extensions for [Firefox](https://addons.mozilla.org/firefox/addon/custom-new-tab-page) and [Chrome & Friends](https://chrome.google.com/webstore/detail/new-tab-changer/occbjkhimchkolibngmcefpjlbknggfh) allow you to have your homer dashboard in your new tab page, while leaving focus on the address bar meaning you can still type right away if you want to search or go to a page that is not on your homer dash.\n\nThe Firefox extension loads Homer in an iframe on your new tab page, meaning you have to add `target: '_top'` to each of your items.\n\n```yaml\n- name: \"Reddit\"\n  logo: \"assets/daily/reddit.png\"\n  url: \"https://reddit.com\"\n  target: '_top'\n\n- name: \"YouTube\"\n  logo: \"assets/daily/youtube.png\"\n  url: \"https://youtube.com\"\n  target: '_top'\n```\n\n## YAML Anchors\n\n#### `by @JamiePhonic`\n\nSince Homer is configured using YAML, it supports all of YAML's helpful features, such as anchoring!\n\nFor example, you can define tags and tag styles for each \"item\" in a service.\nUsing Anchoring, you can define all your tags and their styles once like this: (for example)\n\n```yaml\n# Some pre-defined tag styles. reference these using <<: *{NAME} inside an item definition; For Example, <<: *Apps\ntags: \n  Favourite: &Favourite\n    - tag: \"Favourite\"\n      tagstyle: \"is-medium is-primary\"\n  CI: &CI\n    - tag: \"CI\"\n      tagstyle: \"is-medium is-success\"\n  Apps: &Apps\n    - tag: \"App\"\n      tagstyle: \"is-medium is-info\"      \n```\n\nand then simply reference these pre-defined (anchored) tags in each item like so:\n\n```yaml\n- name: \"VS Code\"\n  logo: \"/assets/vscode.png\"\n  subtitle: \"Develop Code Anywhere, On Anything!\"\n  <<: *Apps # Reference to the predefined \"App\" Tag\n  url: \"https://vscode.example.com/\"\n  target: \"_blank\" # optional html tag target attribute\n````\n\nThen when Homer reads your config, it will substitute your anchors automatically, the above example is equal to:\n\n```yaml\n- name: \"VS Code\"\n  logo: \"/assets/vscode.png\"\n  subtitle: \"Develop Code Anywhere, On Anything!\"\n  tag: \"App\"\n  tagstyle: \"is-medium is-info\"\n  url: \"https://vscode.example.com/\"\n  target: \"_blank\" # optional html tag target attribute\n```\n\nThe end result is that if you want to update the name or style of any particular tag, just update it once, in the tags section!\nGreat if you have a lot of services or a lot of tags!  \n\n## YAML auto complete with a YAML schema \n\nA lot of editor support auto completion, see <https://www.schemastore.org/json/>   \nThe homer schema is available here: <https://raw.githubusercontent.com/bastienwirtz/homer/main/.schema/config-schema.json>\n\nFor example with IntelliJ you can define:\n\n```yaml\n# $schema: https://raw.githubusercontent.com/bastienwirtz/homer/main/.schema/config-schema.json\n```\nWith VSCode you can define it like this:\n```yaml\n# yaml-language-server: $schema=https://raw.githubusercontent.com/bastienwirtz/homer/main/.schema/config-schema.json\n```\n\n## Remotely edit your config with Code Server\n\n#### `by @JamiePhonic`\n\nHomer doesn't yet provide a way to edit your configuration from inside Homer itself, but that doesn't mean it can't be done!\n\nYou can setup and use [Code-Server](https://github.com/cdr/code-server) to edit your `config.yml` file from anywhere!\n\nIf you're running Homer in docker, you can setup a Code-Server container and pass your homer config directory into it.\nSimply pass your homer config directory as an extra -v parameter to your code-server container:\n\n```sh\n-v '/your/local/homer/config-dir/':'/config/homer':'rw'\n```\n\nThis will map your homer config directory (For example, /docker/appdata/homer/) into code-server's `/config/` directory, in a sub folder called `homer`\n\nAs a bonus, Code-Server puts the \"current folder\" as a parameter in the URL bar, so you could add a `links:` entry in Homer that points to your code-server instance with the directory pre-filled for essentially 1 click editing!\n\nFor example:\n\n```yml\nlinks:\n  - name: Edit config\n    icon: fas fa-cog\n    url: https://vscode.example.net/?folder=/config/homer\n    target: \"_blank\" # optional html tag target attribute\n```\n\nwhere the path after `?folder=` is the path to the folder where you mounted your homer config INSIDE the Code-Server container.\n\n### Example Code-Server docker create command\n\n```sh\ndocker create \\\n  --name=code-server \\\n  -e PUID=1000 \\\n  -e PGID=1000 \\\n  -e TZ=Europe/London \\\n  -e PASSWORD={YOUR_PASSWORD} `#optional` \\\n  -e SUDO_PASSWORD={YOUR SUDO_PASSWORD} `#optional` \\\n  -p 8443:8443 \\\n  -v /path/to/appdata/config:/config \\\n  -v /your/local/homer/config-dir/:/config/homer \\\n  --restart unless-stopped \\\n  linuxserver/code-server\n```\n\n## Get the news headlines in Homer\n\n### Mapping Fields\n\nMost times, the url you're getting headlines from follows a different schema than the one expected by Homer.\n\nFor example, if you would like to show jokes from ChuckNorris.io, you'll find that the url <https://api.chucknorris.io/jokes/random> is giving you info like this:\n\n```json\n{\n  \"categories\": [],\n  \"created_at\": \"2020-01-05 13:42:22.089095\",\n  \"icon_url\": \"https://assets.chucknorris.host/img/avatar/chuck-norris.png\",\n  \"id\": \"MR2-BnMBR667xSpQBIleUg\",\n  \"updated_at\": \"2020-01-05 13:42:22.089095\",\n  \"url\": \"https://api.chucknorris.io/jokes/MR2-BnMBR667xSpQBIleUg\",\n  \"value\": \"Chuck Norris can quitely sneak up on himself\"\n}\n```\n\nbut... you need that info to be transformed to something like this:\n\n```json\n{\n  \"title\": \"MR2-BnMBR667xSpQBIleUg\",\n  \"content\": \"Chuck Norris can quitely sneak up on himself\"\n}\n```\n\nNow, you can do that using the `mapping` field in your `message` configuration. This example would be something like this:\n\n```yml\nmessage:\n  url: https://api.chucknorris.io/jokes/random\n  mapping:\n    title: 'id'\n    content: 'value'\n```\n\nAs you would see, using the ID as a title doesn't seem nice, that's why when a field is empty it would keep the default values, like this:\n\n```yml\nmessage:\n  url: https://api.chucknorris.io/jokes/random\n  mapping:\n    content: 'value'\n  title: \"Chuck Norris Facts!\"\n```\n\nand even an error message in case the `url` didn't respond or threw an error:\n\n```yml\nmessage:\n  url: https://api.chucknorris.io/jokes/random\n  mapping:\n    content: 'value'\n  title: \"Chuck Norris Facts!\"\n  content: \"Message could not be loaded\"\n```\n\n#### `by @JamiePhonic`\n\nHomer allows you to set a \"message\" that will appear at the top of the page, however, you can also supply a `url:`.\n\nIf the URL you specified returns a JSON object that defines a `title` and `content` item, homer will replace these values from your `config.yml` with the ones in the returned object.\n\nSo, using [Node-Red](https://nodered.org/docs/getting-started/) and a quick flow, you can process an RSS feed to replace the message with a news item!\n\nTo get started, simply import [this flow](https://flows.nodered.org/flow/4b6406c9a684c26ace0430dd1826e95d) into your Node-Red instance and change the RSS feed in the \"Get News RSS Feed\" node to one of your choosing!\n\nSo far, the flow has been tested with BBC News and Sky News, however it should be easy to modify the flow to work with other RSS feeds if they don't work out of the box!\n\n## Write HTML into the dashboard\n\n### Show latest camera feed\n\n#### `by @matheusvellone`\n\nThe `message.content` config entry accepts HTML code, so you can add images.\nIf you use Frigate, or have any `latest.jpg` URL for your camera, you can add it to your dashboard. You can also style the `div`/`img` tags to look nicer on your dashboard.\n\n```yml\nmessage:\n  title: Cameras\n  content: >\n    <div>\n      <a href=\"http://frigate.local:5000/cameras/garage\">\n        <img src=\"http://frigate.local:5000/api/garage/latest.jpg?h=220\"/>\n      </a>\n      <a href=\"http://frigate.local:5000/cameras/backyard\">\n        <img src=\"http://frigate.local:5000/api/backyard/latest.jpg?h=220\"/>\n      </a>\n    </div>\n```\n\nWhen using Frigate you can even add a live feed to your dashboard, like this:\n\n```yml\nmessage:\n  title: Cameras\n  content: >\n    <img src=\"http://frigate.local:5000/api/piscina\"/>\n```"
  },
  {
    "path": "docs/troubleshooting.md",
    "content": "# Troubleshooting\n\n## My docker container refuse to start / is stuck at restarting\n\nYou might be facing a permission issue. First of all, check your container logs (adjust the container name if necessary): \n\n```sh\n$ docker logs homer\n[...]\nAssets directory not writable. Check assets directory permissions & docker user or skip default assets install by setting the INIT_ASSETS env var to 0\n```\n\nIn this case you need to make sure your mounted assets directory have the same GID / UID the container user have (default 1000:1000), and that the read and write permission is granted for the user or the group.\n\nYou can either:\n\n- Update your assets directory permissions (ex: `chown -R 1000:1000 /your/assets/folder/`, `chmod -R u+rw /your/assets/folder/`)\n- Change the docker user by using the `--user` arguments with docker cli or `user: 1000:1000` with docker compose.\n\n> [!NOTE]\n>\n> - **Do not** use env var to set the GID / UID of the user running container. Use the Docker `user` option.\n> - **Do not** use 0:0 as a user value, it would be a security risk, and it's not guaranty to work.\n\nCheck this [thread](https://github.com/bastienwirtz/homer/issues/459) for more information about debugging\npermission issues.\n\n## My service card doesn't work, nothing appears or offline status is displayed (pi-hole, sonarr, ping, ...)\n\nYou might be facing a [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) (Cross Origin Request Sharing) issue.\nIt happens when the targeted service is hosted on a different domain or port.\nWeb browsers will not allow to fetch information from a different site without explicit permissions (the targeted service\nmust include a special `Access-Control-Allow-Origin: *` HTTP headers).\nIf this happens your web console (`ctrl+shift+i` or `F12`) will be filled with this kind of errors:\n\n```text\nAccess to fetch at 'https://<target-service>' from origin 'https://<homer>' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.\n```\n\nTo resolve this, you can either:\n\n- Host all your target service under the same domain & port.\n- Modify the target server configuration so that the response of the server included following header- `Access-Control-Allow-Origin: *` (<https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests>). It might be an option in the targeted service, otherwise depending on how the service is hosted, the proxy or web server can seamlessly add it.\n- **Use a proxy** to add the necessary CORS headers (lot of options, some of them described [here](https://enable-cors.org/server.html). Also check [`CORSair`](https://github.com/bastienwirtz/corsair), a light and simple solution)\n\n## I am using an authentication proxy and homer says I am offline\n\nThis should be a configuration issue.\n\n- Make sure the option `connectivityCheck` is set to `true` in configuration.\n- Check your proxy configuration, the expected behavior is to redirect user using a 302 to the login page when user is not authenticated.\n\n## I put my API key into the OpenWeather service and it still isn't working\n\nIf you have just made an OpenWeatherMap account and/or a newly-made API key, there is a high chance that you need to wait for it to be activated (often a few hours). If after waiting it still doesn't work, make sure to check the location you have provided since it may be an invalid location.\n\nFor some basic debugging steps, you can:\n\n- Check with a large city such as Amsterdam as the specified location within your configuration.\n- Make sure your web browser is running the latest version of the homer configuration after updating the location (Ctrl + Shift + R).\n- Check for errors within the browser console (Ctrl + Shift + I) relating to api.openweathermap.org\n"
  },
  {
    "path": "dummy-data/README.md",
    "content": "# Dummy data\n\nThis directory content makes possible to test custom services cards or create a demo without actually running the service.\nThe principle is simple: save a sample output of the API used in the service in a static file in this directory. The path must be identical as the service endpoint to be used seamlessly.\n\n## Start the mock server to expose dummy data\n\n```sh\npnpm mock\n```\n\n## How to add a new services sample\n\n- create a directory for your service, and any sub-folder existing in the service api path.\n- save the api output in a file named after the service endpoint.\n\nExample:\n\n```sh\nmkdir pihole\ncurl http://my-pihole.me/admin/api.php -o pihole/api.php # /admin is omitted because for PiHole, the implementation expect it to be in the base url (`url` or `endpoint` property)\n```\n"
  },
  {
    "path": "dummy-data/_headers",
    "content": "/*\n  Content-Type: application/json\n"
  },
  {
    "path": "dummy-data/adguardhome/control/stats",
    "content": "{\n  \"time_units\": \"hours\",\n  \"num_dns_queries\": 28947,\n  \"num_blocked_filtering\": 12489,\n  \"num_replaced_safebrowsing\": 0,\n  \"num_replaced_safesearch\": 0,\n  \"num_replaced_parental\": 0,\n  \"avg_processing_time\": 0.34,\n  \"top_queried_domains\": [\n    {\n      \"name\": \"example.com\",\n      \"count\": 1289\n    },\n    {\n      \"name\": \"api.github.com\",\n      \"count\": 892\n    }\n  ],\n  \"top_clients\": [\n    {\n      \"name\": \"192.168.1.100\",\n      \"count\": 8945\n    },\n    {\n      \"name\": \"192.168.1.101\",\n      \"count\": 6234\n    }\n  ],\n  \"top_blocked_domains\": [\n    {\n      \"name\": \"ads.google.com\",\n      \"count\": 1245\n    },\n    {\n      \"name\": \"tracker.example.com\",\n      \"count\": 987\n    }\n  ],\n  \"dns_queries\": [\n    12450, 13200, 14100, 13800, 12900, 11200, 10800, 9600, 8200, 7800,\n    9200, 10500, 12100, 13600, 14800, 15200, 14900, 13700, 12800, 11900,\n    11200, 10800, 10200, 9800\n  ],\n  \"blocked_filtering\": [\n    5200, 5800, 6100, 5900, 5400, 4800, 4600, 4200, 3600, 3400,\n    4000, 4500, 5200, 5800, 6300, 6500, 6300, 5800, 5400, 5100,\n    4800, 4600, 4300, 4200\n  ],\n  \"replaced_safebrowsing\": [],\n  \"replaced_parental\": []\n}"
  },
  {
    "path": "dummy-data/adguardhome/control/status",
    "content": "{\n  \"protection_enabled\": true,\n  \"version\": \"v0.107.48\",\n  \"language\": \"en\",\n  \"dns_address\": \"127.0.0.1:53\",\n  \"dns_port\": 53,\n  \"protection_disabled_duration\": null,\n  \"http_port\": 80,\n  \"https_port\": 443,\n  \"querylog_enabled\": true,\n  \"querylog_size\": 5000,\n  \"querylog_size_memory\": 1000,\n  \"querylog_interval\": 2160,\n  \"dhcp_available\": true,\n  \"running\": true\n}"
  },
  {
    "path": "dummy-data/dockersocketproxy/containers/json",
    "content": "[\n  {\n    \"Id\": \"8dfafdbc3a40\",\n    \"Names\": [\"/boring_feynman\"],\n    \"Image\": \"nginx:latest\",\n    \"ImageID\": \"sha256:f6d0b4767a6c\",\n    \"Command\": \"/docker-entrypoint.sh nginx -g 'daemon off;'\",\n    \"Created\": 1640995200,\n    \"Ports\": [\n      {\n        \"IP\": \"0.0.0.0\",\n        \"PrivatePort\": 80,\n        \"PublicPort\": 8080,\n        \"Type\": \"tcp\"\n      }\n    ],\n    \"Labels\": {\n      \"maintainer\": \"NGINX Docker Maintainers <docker-maint@nginx.com>\"\n    },\n    \"State\": \"running\",\n    \"Status\": \"Up 2 hours\",\n    \"HostConfig\": {\n      \"NetworkMode\": \"default\"\n    },\n    \"NetworkSettings\": {\n      \"Networks\": {\n        \"bridge\": {\n          \"IPAMConfig\": null,\n          \"Links\": null,\n          \"Aliases\": null,\n          \"NetworkID\": \"f2de39df4171\",\n          \"EndpointID\": \"2cdc4edb1ded\",\n          \"Gateway\": \"172.17.0.1\",\n          \"IPAddress\": \"172.17.0.2\",\n          \"IPPrefixLen\": 16,\n          \"IPv6Gateway\": \"\",\n          \"GlobalIPv6Address\": \"\",\n          \"GlobalIPv6PrefixLen\": 0,\n          \"MacAddress\": \"02:42:ac:11:00:02\"\n        }\n      }\n    },\n    \"Mounts\": []\n  },\n  {\n    \"Id\": \"9e87a2b84b8e\",\n    \"Names\": [\"/web-app\"],\n    \"Image\": \"node:16-alpine\",\n    \"ImageID\": \"sha256:c85b8f829d1f\",\n    \"Command\": \"npm start\",\n    \"Created\": 1640991600,\n    \"Ports\": [\n      {\n        \"IP\": \"0.0.0.0\", \n        \"PrivatePort\": 3000,\n        \"PublicPort\": 3000,\n        \"Type\": \"tcp\"\n      }\n    ],\n    \"Labels\": {},\n    \"State\": \"running\",\n    \"Status\": \"Up 3 hours\",\n    \"HostConfig\": {\n      \"NetworkMode\": \"default\"\n    },\n    \"NetworkSettings\": {\n      \"Networks\": {\n        \"bridge\": {\n          \"IPAMConfig\": null,\n          \"Links\": null,\n          \"Aliases\": null,\n          \"NetworkID\": \"f2de39df4171\",\n          \"EndpointID\": \"3edc5fdb2efe\",\n          \"Gateway\": \"172.17.0.1\",\n          \"IPAddress\": \"172.17.0.3\",\n          \"IPPrefixLen\": 16,\n          \"IPv6Gateway\": \"\",\n          \"GlobalIPv6Address\": \"\",\n          \"GlobalIPv6PrefixLen\": 0,\n          \"MacAddress\": \"02:42:ac:11:00:03\"\n        }\n      }\n    },\n    \"Mounts\": [\n      {\n        \"Type\": \"bind\",\n        \"Source\": \"/home/user/app\",\n        \"Destination\": \"/app\",\n        \"Mode\": \"\",\n        \"RW\": true,\n        \"Propagation\": \"rprivate\"\n      }\n    ]\n  },\n  {\n    \"Id\": \"7b9a3c6d2e1f\",\n    \"Names\": [\"/database\"],\n    \"Image\": \"postgres:13\",\n    \"ImageID\": \"sha256:b4ed8d5b4f3a\",\n    \"Command\": \"docker-entrypoint.sh postgres\",\n    \"Created\": 1640988000,\n    \"Ports\": [\n      {\n        \"IP\": \"127.0.0.1\",\n        \"PrivatePort\": 5432,\n        \"PublicPort\": 5432,\n        \"Type\": \"tcp\"\n      }\n    ],\n    \"Labels\": {},\n    \"State\": \"dead\",\n    \"Status\": \"Up 4 hours\",\n    \"HostConfig\": {\n      \"NetworkMode\": \"default\"\n    },\n    \"NetworkSettings\": {\n      \"Networks\": {\n        \"bridge\": {\n          \"IPAMConfig\": null,\n          \"Links\": null,\n          \"Aliases\": null,\n          \"NetworkID\": \"f2de39df4171\",\n          \"EndpointID\": \"4fdc6gdb3gfg\",\n          \"Gateway\": \"172.17.0.1\",\n          \"IPAddress\": \"172.17.0.4\",\n          \"IPPrefixLen\": 16,\n          \"IPv6Gateway\": \"\",\n          \"GlobalIPv6Address\": \"\",\n          \"GlobalIPv6PrefixLen\": 0,\n          \"MacAddress\": \"02:42:ac:11:00:04\"\n        }\n      }\n    },\n    \"Mounts\": [\n      {\n        \"Type\": \"volume\",\n        \"Name\": \"postgres_data\",\n        \"Source\": \"/var/lib/docker/volumes/postgres_data/_data\",\n        \"Destination\": \"/var/lib/postgresql/data\",\n        \"Driver\": \"local\",\n        \"Mode\": \"rw\",\n        \"RW\": true,\n        \"Propagation\": \"\"\n      }\n    ]\n  },\n  {\n    \"Id\": \"5c8d1f4e9a2b\",\n    \"Names\": [\"/old-service\"],\n    \"Image\": \"ubuntu:20.04\",\n    \"ImageID\": \"sha256:f643c72bc252\",\n    \"Command\": \"/bin/bash\",\n    \"Created\": 1640984400,\n    \"Ports\": [],\n    \"Labels\": {},\n    \"State\": \"exited\",\n    \"Status\": \"Exited (0) 2 hours ago\",\n    \"HostConfig\": {\n      \"NetworkMode\": \"default\"\n    },\n    \"NetworkSettings\": {\n      \"Networks\": {\n        \"bridge\": {\n          \"IPAMConfig\": null,\n          \"Links\": null,\n          \"Aliases\": null,\n          \"NetworkID\": \"\",\n          \"EndpointID\": \"\",\n          \"Gateway\": \"\",\n          \"IPAddress\": \"\",\n          \"IPPrefixLen\": 0,\n          \"IPv6Gateway\": \"\",\n          \"GlobalIPv6Address\": \"\",\n          \"GlobalIPv6PrefixLen\": 0,\n          \"MacAddress\": \"\"\n        }\n      }\n    },\n    \"Mounts\": []\n  },\n  {\n    \"Id\": \"1a2b3c4d5e6f\",\n    \"Names\": [\"/backup-job\"],\n    \"Image\": \"alpine:latest\",\n    \"ImageID\": \"sha256:c059bfaa849c\",\n    \"Command\": \"sh -c 'sleep 3600'\",\n    \"Created\": 1640980800,\n    \"Ports\": [],\n    \"Labels\": {},\n    \"State\": \"exited\",\n    \"Status\": \"Exited (0) 30 minutes ago\",\n    \"HostConfig\": {\n      \"NetworkMode\": \"default\"\n    },\n    \"NetworkSettings\": {\n      \"Networks\": {\n        \"bridge\": {\n          \"IPAMConfig\": null,\n          \"Links\": null,\n          \"Aliases\": null,\n          \"NetworkID\": \"\",\n          \"EndpointID\": \"\",\n          \"Gateway\": \"\",\n          \"IPAddress\": \"\",\n          \"IPPrefixLen\": 0,\n          \"IPv6Gateway\": \"\",\n          \"GlobalIPv6Address\": \"\",\n          \"GlobalIPv6PrefixLen\": 0,\n          \"MacAddress\": \"\"\n        }\n      }\n    },\n    \"Mounts\": []\n  }\n]"
  },
  {
    "path": "dummy-data/docuseal/version",
    "content": "1.8.3a\n"
  },
  {
    "path": "dummy-data/emby/System/info/public",
    "content": "{\n  \"LocalAddress\": \"192.168.1.100:8096\",\n  \"ServerName\": \"Homer-Emby-Server\",\n  \"Version\": \"4.8.8.0\",\n  \"ProductName\": \"Emby Server\",\n  \"OperatingSystem\": \"Linux\",\n  \"Id\": \"a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6\",\n  \"StartupWizardCompleted\": true,\n  \"SupportsLibraryMonitor\": true,\n  \"WebSocketPortNumber\": 8096,\n  \"CompletedInstallations\": [],\n  \"CanSelfRestart\": true,\n  \"CanSelfUpdate\": true,\n  \"CanLaunchWebBrowser\": false,\n  \"WanAddress\": \"192.168.1.100:8096\",\n  \"HasUpdateAvailable\": false,\n  \"SupportsAutoRunAtStartup\": false,\n  \"TranscodingTempPath\": \"/var/lib/emby/transcoding-temp\",\n  \"CachePath\": \"/var/lib/emby/cache\",\n  \"LogPath\": \"/var/log/emby\",\n  \"InternalMetadataPath\": \"/var/lib/emby/metadata\",\n  \"ItemsByNamePath\": \"/var/lib/emby/metadata/People\",\n  \"ProgramDataPath\": \"/var/lib/emby\"\n}"
  },
  {
    "path": "dummy-data/emby/items/counts",
    "content": "{\n  \"MovieCount\": 1247,\n  \"SeriesCount\": 89,\n  \"EpisodeCount\": 2156,\n  \"ArtistCount\": 234,\n  \"AlbumCount\": 567,\n  \"SongCount\": 8923,\n  \"MusicVideoCount\": 42,\n  \"BoxSetCount\": 23,\n  \"BookCount\": 156,\n  \"ItemCount\": 13437\n}"
  },
  {
    "path": "dummy-data/freshrss/api/greader.php/accounts/ClientLogin",
    "content": "SID=username/c7fef7ce380efb8c79a0df25686a3387\nLSID=null\nAuth=username/c7fef7ce380efb8c79a0df25686a3387\n"
  },
  {
    "path": "dummy-data/freshrss/api/greader.php/reader/api/0/subscription/list",
    "content": "{\"subscriptions\":[{\"id\":\"feed/3\",\"title\":\"Test News\",\"categories\":[{\"id\":\"user/-/label/News\",\"label\":\"News\"}],\"url\":\"https://www.reddit.com/r/testnews.rss\",\"htmlUrl\":\"https://www.reddit.com/r/testnews\",\"iconUrl\":\"http://192.168.10.165/f.php?b4a11f09\"},{\"id\":\"feed/17\",\"title\":\"Announcements Latest Topics\",\"categories\":[{\"id\":\"user/-/label/Technology\",\"label\":\"Technology\"}],\"url\":\"https://forums.testsite.net/forums/forum/7-announcements.xml\",\"htmlUrl\":\"https://forums.testsite.net/forum/7-announcements/\",\"iconUrl\":\"http://192.168.10.165/f.php?70b5fd98\"},{\"id\":\"feed/16\",\"title\":\"Blog\",\"categories\":[{\"id\":\"user/-/label/Technology\",\"label\":\"Technology\"}],\"url\":\"https://www.firewall.com/blog/rss.xml\",\"htmlUrl\":\"https://www.firewall.com/blog\",\"iconUrl\":\"http://192.168.20.165/f.php?0107d102\"},{\"id\":\"feed/2\",\"title\":\"techsite\",\"categories\":[{\"id\":\"user/-/label/Technology\",\"label\":\"Technology\"}],\"url\":\"https://techsite.com/rss\",\"htmlUrl\":\"https://techsite.com/\",\"iconUrl\":\"http://192.168.10.100/f.php?69ad225d\"},{\"id\":\"feed/5\",\"title\":\"Hackaday\",\"categories\":[{\"id\":\"user/-/label/Technology\",\"label\":\"Technology\"}],\"url\":\"https://hackaday.com/feed/\",\"htmlUrl\":\"https://hackaday.com/\",\"iconUrl\":\"http://192.168.10.15/f.php?7be93110\"},{\"id\":\"feed/7\",\"title\":\"Self-Hosted Alternatives to Popular Services\",\"categories\":[{\"id\":\"user/-/label/Technology\",\"label\":\"Technology\"}],\"url\":\"https://www.reddit.com/r/selfhosted.rss\",\"htmlUrl\":\"https://www.reddit.com/r/selfhosted\",\"iconUrl\":\"http://192.168.10.100/f.php?87bdf454\"},{\"id\":\"feed/15\",\"title\":\"UniFi Access Point／Switch／LTE | Releases | Ubiquiti Community\",\"categories\":[{\"id\":\"user/-/label/Technology\",\"label\":\"Technology\"}],\"url\":\"https://community.ui.com/rss/releases/UniFi-Access-Point-Switch-LTE/9fc3b2fa-9e73-449a-924f-470e79884470\",\"htmlUrl\":\"https://community.ui.com/\",\"iconUrl\":\"http://192.168.10.100/f.php?a8b7368a\"},{\"id\":\"feed/14\",\"title\":\"UniFi Network Application | Releases | Ubiquiti Community\",\"categories\":[{\"id\":\"user/-/label/Technology\",\"label\":\"Technology\"}],\"url\":\"https://community.ui.com/rss/releases/UniFi-Network-Application/e6712595-81bb-4829-8e42-9e2630fabcfe\",\"htmlUrl\":\"https://community.ui.com/\",\"iconUrl\":\"http://192.168.10.100/f.php?87f0ed63\"},{\"id\":\"feed/1\",\"title\":\"FreshRSS releases\",\"categories\":[{\"id\":\"user/-/label/Uncategorized\",\"label\":\"Uncategorized\"}],\"url\":\"https://github.com/FreshRSS/FreshRSS/releases.atom\",\"htmlUrl\":\"https://github.com/FreshRSS/FreshRSS/\",\"iconUrl\":\"http://192.168.10.100/f.php?261334e9\"},{\"id\":\"feed/10\",\"title\":\"Hooked on Wood\",\"categories\":[{\"id\":\"user/-/label/Videos\",\"label\":\"Videos\"}],\"url\":\"https://www.youtube.com/feeds/videos.xml?channel_id=UCuvjeMfKGqSoYc32Xk5MLfQ\",\"htmlUrl\":\"https://www.youtube.com/channel/UCuvjeMfKGqSoYc32Xk5MLfQ\",\"iconUrl\":\"http://192.168.10.100/f.php?e6aff645\"},{\"id\":\"feed/11\",\"title\":\"Jeff Geerling\",\"categories\":[{\"id\":\"user/-/label/Videos\",\"label\":\"Videos\"}],\"url\":\"https://www.youtube.com/feeds/videos.xml?channel_id=UCR-DXc1voovS8nhAvccRZhg\",\"htmlUrl\":\"https://www.youtube.com/channel/UCR-DXc1voovS8nhAvccRZhg\",\"iconUrl\":\"http://192.168.10.100/f.php?5b542c1f\"},{\"id\":\"feed/9\",\"title\":\"Lawrence Systems\",\"categories\":[{\"id\":\"user/-/label/Videos\",\"label\":\"Videos\"}],\"url\":\"https://www.youtube.com/feeds/videos.xml?channel_id=UCHkYOD-3fZbuGhwsADBd9ZQ\",\"htmlUrl\":\"https://www.youtube.com/channel/UCHkYOD-3fZbuGhwsADBd9ZQ\",\"iconUrl\":\"http://192.168.10.100/f.php?3ab68f8e\"},{\"id\":\"feed/13\",\"title\":\"Thunderf00t\",\"categories\":[{\"id\":\"user/-/label/Videos\",\"label\":\"Videos\"}],\"url\":\"https://www.youtube.com/feeds/videos.xml?channel_id=UCmb8hO2ilV9vRa8cilis88A\",\"htmlUrl\":\"https://www.youtube.com/channel/UCmb8hO2ilV9vRa8cilis88A\",\"iconUrl\":\"http://192.168.10.100/f.php?d87007ad\"}]}\n"
  },
  {
    "path": "dummy-data/freshrss/api/greader.php/reader/api/0/unread-count",
    "content": "{\"max\":1070,\"unreadcounts\":[{\"id\":\"feed/3\",\"count\":234,\"newestItemTimestampUsec\":\"1690915225367106\"},{\"id\":\"user/-/label/News\",\"count\":234,\"newestItemTimestampUsec\":\"1690915225367106\"},{\"id\":\"feed/17\",\"count\":48,\"newestItemTimestampUsec\":\"1689390054724200\"},{\"id\":\"feed/16\",\"count\":17,\"newestItemTimestampUsec\":\"1689796853964422\"},{\"id\":\"feed/2\",\"count\":219,\"newestItemTimestampUsec\":\"1690916425408239\"},{\"id\":\"feed/5\",\"count\":199,\"newestItemTimestampUsec\":\"1690915225367107\"},{\"id\":\"feed/7\",\"count\":211,\"newestItemTimestampUsec\":\"1690916425408242\"},{\"id\":\"feed/15\",\"count\":22,\"newestItemTimestampUsec\":\"1689663652972458\"},{\"id\":\"feed/14\",\"count\":21,\"newestItemTimestampUsec\":\"1688368807781577\"},{\"id\":\"user/-/label/Technology\",\"count\":737,\"newestItemTimestampUsec\":\"1690916425408242\"},{\"id\":\"feed/1\",\"count\":10,\"newestItemTimestampUsec\":\"1687016967198678\"},{\"id\":\"user/-/label/Uncategorized\",\"count\":10,\"newestItemTimestampUsec\":\"1687016967198678\"},{\"id\":\"feed/10\",\"count\":15,\"newestItemTimestampUsec\":\"1688654406671211\"},{\"id\":\"feed/11\",\"count\":25,\"newestItemTimestampUsec\":\"1690902033356786\"},{\"id\":\"feed/9\",\"count\":34,\"newestItemTimestampUsec\":\"1690736426018726\"},{\"id\":\"feed/13\",\"count\":15,\"newestItemTimestampUsec\":\"1690837226605329\"},{\"id\":\"user/-/label/Videos\",\"count\":89,\"newestItemTimestampUsec\":\"1690902033356786\"},{\"id\":\"user/-/state/com.google/reading-list\",\"count\":1070,\"newestItemTimestampUsec\":\"1690916425408242\"}]}\n"
  },
  {
    "path": "dummy-data/gatus/api/v1/endpoints/statuses",
    "content": "[\n  {\n    \"name\": \"Gateway\",\n    \"group\": \"Services\",\n    \"key\": \"services_gateway\",\n    \"results\": [\n      {\n        \"status\": 200,\n        \"hostname\": \"gateway.example.com\",\n        \"duration\": 10000000,\n        \"conditionResults\": [\n          {\n            \"condition\": \"[STATUS] == 200\",\n            \"success\": true\n          },\n          {\n            \"condition\": \"[RESPONSE_TIME] < 500\",\n            \"success\": true\n          }\n        ],\n        \"success\": true,\n        \"timestamp\": \"2025-05-26T07:35:41.784208588Z\"\n      },\n      {\n        \"status\": 200,\n        \"hostname\": \"gateway.example.com\",\n        \"duration\": 10000000,\n        \"conditionResults\": [\n          {\n            \"condition\": \"[STATUS] == 200\",\n            \"success\": true\n          },\n          {\n            \"condition\": \"[RESPONSE_TIME] < 500\",\n            \"success\": true\n          }\n        ],\n        \"success\": true,\n        \"timestamp\": \"2025-05-26T07:40:41.804489793Z\"\n      },\n      {\n        \"status\": 200,\n        \"hostname\": \"gateway.example.com\",\n        \"duration\": 10000000,\n        \"conditionResults\": [\n          {\n            \"condition\": \"[STATUS] == 200\",\n            \"success\": true\n          },\n          {\n            \"condition\": \"[RESPONSE_TIME] < 500\",\n            \"success\": true\n          }\n        ],\n        \"success\": true,\n        \"timestamp\": \"2025-05-26T07:45:41.837925713Z\"\n      },\n      {\n        \"status\": 200,\n        \"hostname\": \"gateway.example.com\",\n        \"duration\": 10000000,\n        \"conditionResults\": [\n          {\n            \"condition\": \"[STATUS] == 200\",\n            \"success\": true\n          },\n          {\n            \"condition\": \"[RESPONSE_TIME] < 500\",\n            \"success\": true\n          }\n        ],\n        \"success\": true,\n        \"timestamp\": \"2025-05-26T07:50:41.848391366Z\"\n      }\n    ]\n  },\n  {\n    \"name\": \"Website\",\n    \"group\": \"External\",\n    \"key\": \"external_website\",\n    \"results\": [\n      {\n        \"status\": 200,\n        \"hostname\": \"www.example.com\",\n        \"duration\": 10000000,\n        \"conditionResults\": [\n          {\n            \"condition\": \"[STATUS] == 200\",\n            \"success\": true\n          },\n          {\n            \"condition\": \"[RESPONSE_TIME] < 500\",\n            \"success\": false\n          }\n        ],\n        \"success\": false,\n        \"timestamp\": \"2025-05-26T07:35:41.784208588Z\"\n      },\n      {\n        \"status\": 200,\n        \"hostname\": \"gateway.example.com\",\n        \"duration\": 10000000,\n        \"conditionResults\": [\n          {\n            \"condition\": \"[STATUS] == 200\",\n            \"success\": false\n          },\n          {\n            \"condition\": \"[RESPONSE_TIME] < 500\",\n            \"success\": true\n          }\n        ],\n        \"success\": false,\n        \"timestamp\": \"2025-05-26T07:40:41.804489793Z\"\n      },\n      {\n        \"status\": 200,\n        \"hostname\": \"gateway.example.com\",\n        \"duration\": 10000000,\n        \"conditionResults\": [\n          {\n            \"condition\": \"[STATUS] == 200\",\n            \"success\": true\n          },\n          {\n            \"condition\": \"[RESPONSE_TIME] < 500\",\n            \"success\": true\n          }\n        ],\n        \"success\": true,\n        \"timestamp\": \"2025-05-26T07:45:41.837925713Z\"\n      },\n      {\n        \"status\": 200,\n        \"hostname\": \"gateway.example.com\",\n        \"duration\": 10000000,\n        \"conditionResults\": [\n          {\n            \"condition\": \"[STATUS] == 200\",\n            \"success\": true\n          },\n          {\n            \"condition\": \"[RESPONSE_TIME] < 500\",\n            \"success\": true\n          }\n        ],\n        \"success\": true,\n        \"timestamp\": \"2025-05-26T07:50:41.848391366Z\"\n      }\n    ]\n  },\n  {\n    \"name\": \"DNS\",\n    \"group\": \"Services\",\n    \"key\": \"services_dns\",\n    \"results\": [\n      {\n        \"status\": 200,\n        \"hostname\": \"ns1.example\",\n        \"duration\": 20000000,\n        \"conditionResults\": [\n          {\n            \"condition\": \"[STATUS] == 200\",\n            \"success\": false\n          }\n        ],\n        \"success\": false,\n        \"timestamp\": \"2025-05-26T07:35:41.784208588Z\"\n      },\n      {\n        \"status\": 200,\n        \"hostname\": \"ns1.example.com\",\n        \"duration\": 20000000,\n        \"conditionResults\": [\n          {\n            \"condition\": \"[STATUS] == 200\",\n            \"success\": false\n          }\n        ],\n        \"success\": false,\n        \"timestamp\": \"2025-05-26T07:40:41.804489793Z\"\n      },\n      {\n        \"status\": 200,\n        \"hostname\": \"ns1.example.com\",\n        \"duration\": 20000000,\n        \"conditionResults\": [\n          {\n            \"condition\": \"[STATUS] == 200\",\n            \"success\": false\n          }\n        ],\n        \"success\": false,\n        \"timestamp\": \"2025-05-26T07:45:41.837925713Z\"\n      },\n      {\n        \"status\": 200,\n        \"hostname\": \"ns1.example.com\",\n        \"duration\": 20000000,\n        \"conditionResults\": [\n          {\n            \"condition\": \"[STATUS] == 200\",\n            \"success\": false\n          }\n        ],\n        \"success\": false,\n        \"timestamp\": \"2025-05-26T07:50:41.848391366Z\"\n      }\n    ]\n  }\n]\n"
  },
  {
    "path": "dummy-data/gitea/swagger.v1.json",
    "content": "{\n  \"info\": {\n    \"description\": \"This documentation describes the Forgejo API.\",\n    \"title\": \"Forgejo API\",\n    \"license\": {\n      \"name\": \"MIT\",\n      \"url\": \"http://opensource.org/licenses/MIT\"\n    },\n    \"version\": \"8.0.3+gitea-1.22.0\"\n  }\n}\n"
  },
  {
    "path": "dummy-data/glances/api/4/quicklook",
    "content": "{\"cpu_name\":\"AMD Ryzen 7 3700X 8-Core Processor\",\"cpu_hz_current\":2662987562.5,\"cpu_hz\":3600000000.0,\"cpu\":3.7,\"percpu\":[{\"key\":\"cpu_number\",\"cpu_number\":0,\"total\":5.1,\"user\":3.3,\"system\":0.6,\"idle\":94.9,\"nice\":0.0,\"iowait\":0.0,\"irq\":0.8,\"softirq\":0.3,\"steal\":0.0,\"guest\":0.0,\"guest_nice\":0.0,\"dpc\":null,\"interrupt\":null},{\"key\":\"cpu_number\",\"cpu_number\":1,\"total\":2.6,\"user\":2.0,\"system\":0.5,\"idle\":97.4,\"nice\":0.0,\"iowait\":0.0,\"irq\":0.1,\"softirq\":0.1,\"steal\":0.0,\"guest\":0.0,\"guest_nice\":0.0,\"dpc\":null,\"interrupt\":null},{\"key\":\"cpu_number\",\"cpu_number\":2,\"total\":1.6,\"user\":1.1,\"system\":0.3,\"idle\":98.4,\"nice\":0.0,\"iowait\":0.1,\"irq\":0.1,\"softirq\":0.0,\"steal\":0.0,\"guest\":0.0,\"guest_nice\":0.0,\"dpc\":null,\"interrupt\":null},{\"key\":\"cpu_number\",\"cpu_number\":3,\"total\":7.4,\"user\":6.2,\"system\":0.9,\"idle\":92.6,\"nice\":0.0,\"iowait\":0.1,\"irq\":0.1,\"softirq\":0.0,\"steal\":0.0,\"guest\":0.0,\"guest_nice\":0.0,\"dpc\":null,\"interrupt\":null},{\"key\":\"cpu_number\",\"cpu_number\":4,\"total\":1.3,\"user\":0.9,\"system\":0.4,\"idle\":98.7,\"nice\":0.0,\"iowait\":0.0,\"irq\":0.1,\"softirq\":0.0,\"steal\":0.0,\"guest\":0.0,\"guest_nice\":0.0,\"dpc\":null,\"interrupt\":null},{\"key\":\"cpu_number\",\"cpu_number\":5,\"total\":10.4,\"user\":8.9,\"system\":1.2,\"idle\":89.6,\"nice\":0.0,\"iowait\":0.1,\"irq\":0.1,\"softirq\":0.0,\"steal\":0.0,\"guest\":0.0,\"guest_nice\":0.0,\"dpc\":null,\"interrupt\":null},{\"key\":\"cpu_number\",\"cpu_number\":6,\"total\":3.1,\"user\":2.3,\"system\":0.6,\"idle\":96.9,\"nice\":0.0,\"iowait\":0.1,\"irq\":0.1,\"softirq\":0.0,\"steal\":0.0,\"guest\":0.0,\"guest_nice\":0.0,\"dpc\":null,\"interrupt\":null},{\"key\":\"cpu_number\",\"cpu_number\":7,\"total\":2.1,\"user\":1.3,\"system\":0.6,\"idle\":97.9,\"nice\":0.0,\"iowait\":0.1,\"irq\":0.1,\"softirq\":0.0,\"steal\":0.0,\"guest\":0.0,\"guest_nice\":0.0,\"dpc\":null,\"interrupt\":null},{\"key\":\"cpu_number\",\"cpu_number\":8,\"total\":5.8,\"user\":4.9,\"system\":0.7,\"idle\":94.2,\"nice\":0.0,\"iowait\":0.1,\"irq\":0.1,\"softirq\":0.0,\"steal\":0.0,\"guest\":0.0,\"guest_nice\":0.0,\"dpc\":null,\"interrupt\":null},{\"key\":\"cpu_number\",\"cpu_number\":9,\"total\":2.0,\"user\":1.3,\"system\":0.4,\"idle\":98.0,\"nice\":0.0,\"iowait\":0.2,\"irq\":0.1,\"softirq\":0.0,\"steal\":0.0,\"guest\":0.0,\"guest_nice\":0.0,\"dpc\":null,\"interrupt\":null},{\"key\":\"cpu_number\",\"cpu_number\":10,\"total\":1.5,\"user\":0.9,\"system\":0.3,\"idle\":98.5,\"nice\":0.0,\"iowait\":0.2,\"irq\":0.1,\"softirq\":0.0,\"steal\":0.0,\"guest\":0.0,\"guest_nice\":0.0,\"dpc\":null,\"interrupt\":null},{\"key\":\"cpu_number\",\"cpu_number\":11,\"total\":5.3,\"user\":4.6,\"system\":0.6,\"idle\":94.7,\"nice\":0.0,\"iowait\":0.1,\"irq\":0.1,\"softirq\":0.0,\"steal\":0.0,\"guest\":0.0,\"guest_nice\":0.0,\"dpc\":null,\"interrupt\":null},{\"key\":\"cpu_number\",\"cpu_number\":12,\"total\":1.5,\"user\":1.0,\"system\":0.4,\"idle\":98.5,\"nice\":0.0,\"iowait\":0.0,\"irq\":0.1,\"softirq\":0.0,\"steal\":0.0,\"guest\":0.0,\"guest_nice\":0.0,\"dpc\":null,\"interrupt\":null},{\"key\":\"cpu_number\",\"cpu_number\":13,\"total\":5.7,\"user\":4.9,\"system\":0.7,\"idle\":94.3,\"nice\":0.0,\"iowait\":0.0,\"irq\":0.1,\"softirq\":0.0,\"steal\":0.0,\"guest\":0.0,\"guest_nice\":0.0,\"dpc\":null,\"interrupt\":null},{\"key\":\"cpu_number\",\"cpu_number\":14,\"total\":2.9,\"user\":2.0,\"system\":0.8,\"idle\":97.1,\"nice\":0.0,\"iowait\":0.0,\"irq\":0.1,\"softirq\":0.0,\"steal\":0.0,\"guest\":0.0,\"guest_nice\":0.0,\"dpc\":null,\"interrupt\":null},{\"key\":\"cpu_number\",\"cpu_number\":15,\"total\":1.7,\"user\":1.1,\"system\":0.5,\"idle\":98.3,\"nice\":0.0,\"iowait\":0.0,\"irq\":0.1,\"softirq\":0.0,\"steal\":0.0,\"guest\":0.0,\"guest_nice\":0.0,\"dpc\":null,\"interrupt\":null}],\"mem\":59.6,\"swap\":0.0,\"cpu_log_core\":16,\"cpu_phys_core\":8,\"load\":5.7}"
  },
  {
    "path": "dummy-data/gotify/health",
    "content": "{\n  \"health\": \"green\",\n  \"database\": \"green\"\n}"
  },
  {
    "path": "dummy-data/gotify/message",
    "content": "{\n  \"messages\": [\n    {\n      \"id\": 1,\n      \"appid\": 1,\n      \"message\": \"System backup completed successfully\",\n      \"title\": \"Backup Service\",\n      \"priority\": 2,\n      \"date\": \"2024-01-15T10:30:00Z\"\n    },\n    {\n      \"id\": 2,\n      \"appid\": 2,\n      \"message\": \"Database optimization finished\",\n      \"title\": \"Database Manager\",\n      \"priority\": 1,\n      \"date\": \"2024-01-15T09:15:00Z\"\n    },\n    {\n      \"id\": 3,\n      \"appid\": 1,\n      \"message\": \"Server restart scheduled for maintenance\",\n      \"title\": \"System Admin\",\n      \"priority\": 5,\n      \"date\": \"2024-01-15T08:45:00Z\"\n    },\n    {\n      \"id\": 4,\n      \"appid\": 3,\n      \"message\": \"New user registration: john.doe@example.com\",\n      \"title\": \"User Management\",\n      \"priority\": 1,\n      \"date\": \"2024-01-15T07:20:00Z\"\n    },\n    {\n      \"id\": 5,\n      \"appid\": 2,\n      \"message\": \"Weekly report generated and sent\",\n      \"title\": \"Report Generator\",\n      \"priority\": 2,\n      \"date\": \"2024-01-14T18:00:00Z\"\n    },\n    {\n      \"id\": 6,\n      \"appid\": 4,\n      \"message\": \"Security scan completed - no threats detected\",\n      \"title\": \"Security Monitor\",\n      \"priority\": 2,\n      \"date\": \"2024-01-14T16:30:00Z\"\n    },\n    {\n      \"id\": 7,\n      \"appid\": 1,\n      \"message\": \"Disk usage is at 85% on /var partition\",\n      \"title\": \"System Monitor\",\n      \"priority\": 4,\n      \"date\": \"2024-01-14T14:15:00Z\"\n    }\n  ],\n  \"paging\": {\n    \"size\": 7,\n    \"since\": 0,\n    \"limit\": 100\n  }\n}"
  },
  {
    "path": "dummy-data/healthchecks/api/v1/checks",
    "content": "{\n  \"checks\": [\n    {\n      \"name\": \"Database Backup\",\n      \"tags\": \"backup database\",\n      \"desc\": \"Daily database backup job\",\n      \"grace\": 3600,\n      \"n_pings\": 127,\n      \"status\": \"up\",\n      \"last_ping\": \"2024-01-15T10:30:00+00:00\",\n      \"next_ping\": \"2024-01-16T10:30:00+00:00\",\n      \"manual_resume\": false,\n      \"methods\": \"\",\n      \"unique_key\": \"a1b2c3d4-e5f6-7890-abcd-ef1234567890\"\n    },\n    {\n      \"name\": \"Web Server Monitoring\",\n      \"tags\": \"web server nginx\",\n      \"desc\": \"Monitor web server health\",\n      \"grace\": 300,\n      \"n_pings\": 2847,\n      \"status\": \"up\",\n      \"last_ping\": \"2024-01-15T10:25:00+00:00\",\n      \"next_ping\": \"2024-01-15T10:30:00+00:00\",\n      \"manual_resume\": false,\n      \"methods\": \"\",\n      \"unique_key\": \"b2c3d4e5-f6g7-8901-bcde-f23456789012\"\n    },\n    {\n      \"name\": \"SSL Certificate Check\",\n      \"tags\": \"ssl certificate\",\n      \"desc\": \"Weekly SSL certificate expiry check\",\n      \"grace\": 86400,\n      \"n_pings\": 52,\n      \"status\": \"up\",\n      \"last_ping\": \"2024-01-14T12:00:00+00:00\",\n      \"next_ping\": \"2024-01-21T12:00:00+00:00\",\n      \"manual_resume\": false,\n      \"methods\": \"\",\n      \"unique_key\": \"c3d4e5f6-g7h8-9012-cdef-345678901234\"\n    },\n    {\n      \"name\": \"Log Cleanup Service\",\n      \"tags\": \"cleanup logs maintenance\",\n      \"desc\": \"Weekly log file cleanup\",\n      \"grace\": 7200,\n      \"n_pings\": 15,\n      \"status\": \"grace\",\n      \"last_ping\": \"2024-01-13T02:00:00+00:00\",\n      \"next_ping\": \"2024-01-20T02:00:00+00:00\",\n      \"manual_resume\": false,\n      \"methods\": \"\",\n      \"unique_key\": \"d4e5f6g7-h8i9-0123-defa-456789012345\"\n    },\n    {\n      \"name\": \"Email Service\",\n      \"tags\": \"email smtp\",\n      \"desc\": \"Email service availability check\",\n      \"grace\": 600,\n      \"n_pings\": 0,\n      \"status\": \"down\",\n      \"last_ping\": \"2024-01-12T08:15:00+00:00\",\n      \"next_ping\": \"2024-01-15T08:15:00+00:00\",\n      \"manual_resume\": false,\n      \"methods\": \"\",\n      \"unique_key\": \"e5f6g7h8-i9j0-1234-efab-567890123456\"\n    },\n    {\n      \"name\": \"API Health Check\",\n      \"tags\": \"api health\",\n      \"desc\": \"External API endpoint health monitoring\",\n      \"grace\": 180,\n      \"n_pings\": 1440,\n      \"status\": \"up\",\n      \"last_ping\": \"2024-01-15T10:28:00+00:00\",\n      \"next_ping\": \"2024-01-15T10:30:00+00:00\",\n      \"manual_resume\": false,\n      \"methods\": \"\",\n      \"unique_key\": \"f6g7h8i9-j0k1-2345-fabc-678901234567\"\n    },\n    {\n      \"name\": \"Backup Verification\",\n      \"tags\": \"backup verify\",\n      \"desc\": \"Verify backup integrity\",\n      \"grace\": 1800,\n      \"n_pings\": 45,\n      \"status\": \"grace\",\n      \"last_ping\": \"2024-01-14T22:30:00+00:00\",\n      \"next_ping\": \"2024-01-15T22:30:00+00:00\",\n      \"manual_resume\": false,\n      \"methods\": \"\",\n      \"unique_key\": \"g7h8i9j0-k1l2-3456-gbcd-789012345678\"\n    }\n  ]\n}"
  },
  {
    "path": "dummy-data/homeassistant/api/config",
    "content": "{\n  \"location_name\": \"Home\",\n  \"latitude\": 40.7128,\n  \"longitude\": -74.0060,\n  \"elevation\": 10,\n  \"unit_system\": {\n    \"length\": \"km\",\n    \"mass\": \"kg\",\n    \"pressure\": \"Pa\",\n    \"temperature\": \"°C\",\n    \"volume\": \"L\"\n  },\n  \"time_zone\": \"America/New_York\",\n  \"components\": [\n    \"automation\",\n    \"climate\",\n    \"device_tracker\",\n    \"frontend\",\n    \"history\",\n    \"light\",\n    \"logger\",\n    \"media_player\",\n    \"recorder\",\n    \"script\",\n    \"sensor\",\n    \"switch\",\n    \"system_health\",\n    \"weather\"\n  ],\n  \"config_dir\": \"/config\",\n  \"allowlist_external_dirs\": [\n    \"/config\",\n    \"/share\"\n  ],\n  \"allowlist_external_urls\": [],\n  \"version\": \"2024.1.5\",\n  \"config_source\": \"storage\",\n  \"recovery_mode\": false,\n  \"state\": \"RUNNING\",\n  \"external_url\": null,\n  \"internal_url\": null,\n  \"currency\": \"USD\",\n  \"country\": \"US\",\n  \"language\": \"en\"\n}"
  },
  {
    "path": "dummy-data/homeassistant/api/states",
    "content": "[\n  {\n    \"entity_id\": \"sensor.living_room_temperature\",\n    \"state\": \"22.5\",\n    \"attributes\": {\n      \"unit_of_measurement\": \"°C\",\n      \"device_class\": \"temperature\",\n      \"friendly_name\": \"Living Room Temperature\"\n    },\n    \"last_changed\": \"2024-01-15T10:30:00+00:00\",\n    \"last_updated\": \"2024-01-15T10:30:00+00:00\",\n    \"context\": {\n      \"id\": \"01HMV123456789\",\n      \"parent_id\": null,\n      \"user_id\": null\n    }\n  },\n  {\n    \"entity_id\": \"light.bedroom_ceiling\",\n    \"state\": \"on\",\n    \"attributes\": {\n      \"brightness\": 180,\n      \"color_mode\": \"brightness\",\n      \"supported_color_modes\": [\"brightness\"],\n      \"friendly_name\": \"Bedroom Ceiling Light\"\n    },\n    \"last_changed\": \"2024-01-15T09:15:00+00:00\",\n    \"last_updated\": \"2024-01-15T09:15:00+00:00\",\n    \"context\": {\n      \"id\": \"01HMV234567890\",\n      \"parent_id\": null,\n      \"user_id\": \"user123\"\n    }\n  },\n  {\n    \"entity_id\": \"switch.coffee_maker\",\n    \"state\": \"off\",\n    \"attributes\": {\n      \"friendly_name\": \"Coffee Maker\"\n    },\n    \"last_changed\": \"2024-01-14T22:00:00+00:00\",\n    \"last_updated\": \"2024-01-14T22:00:00+00:00\",\n    \"context\": {\n      \"id\": \"01HMV345678901\",\n      \"parent_id\": null,\n      \"user_id\": null\n    }\n  },\n  {\n    \"entity_id\": \"climate.living_room\",\n    \"state\": \"heat\",\n    \"attributes\": {\n      \"temperature\": 21.0,\n      \"current_temperature\": 20.5,\n      \"hvac_modes\": [\"off\", \"heat\", \"cool\", \"auto\"],\n      \"min_temp\": 7.0,\n      \"max_temp\": 35.0,\n      \"target_temp_step\": 0.5,\n      \"friendly_name\": \"Living Room Thermostat\"\n    },\n    \"last_changed\": \"2024-01-15T08:00:00+00:00\",\n    \"last_updated\": \"2024-01-15T10:25:00+00:00\",\n    \"context\": {\n      \"id\": \"01HMV456789012\",\n      \"parent_id\": null,\n      \"user_id\": \"user123\"\n    }\n  },\n  {\n    \"entity_id\": \"sensor.front_door\",\n    \"state\": \"closed\",\n    \"attributes\": {\n      \"device_class\": \"door\",\n      \"friendly_name\": \"Front Door\"\n    },\n    \"last_changed\": \"2024-01-15T07:30:00+00:00\",\n    \"last_updated\": \"2024-01-15T07:30:00+00:00\",\n    \"context\": {\n      \"id\": \"01HMV567890123\",\n      \"parent_id\": null,\n      \"user_id\": null\n    }\n  },\n  {\n    \"entity_id\": \"media_player.living_room_tv\",\n    \"state\": \"playing\",\n    \"attributes\": {\n      \"volume_level\": 0.4,\n      \"is_volume_muted\": false,\n      \"media_content_type\": \"tvshow\",\n      \"media_title\": \"The Office\",\n      \"app_name\": \"Netflix\",\n      \"friendly_name\": \"Living Room TV\"\n    },\n    \"last_changed\": \"2024-01-15T10:00:00+00:00\",\n    \"last_updated\": \"2024-01-15T10:20:00+00:00\",\n    \"context\": {\n      \"id\": \"01HMV678901234\",\n      \"parent_id\": null,\n      \"user_id\": \"user123\"\n    }\n  },\n  {\n    \"entity_id\": \"automation.morning_routine\",\n    \"state\": \"on\",\n    \"attributes\": {\n      \"last_triggered\": \"2024-01-15T07:00:00+00:00\",\n      \"mode\": \"single\",\n      \"current\": 0,\n      \"friendly_name\": \"Morning Routine\"\n    },\n    \"last_changed\": \"2024-01-14T07:00:00+00:00\",\n    \"last_updated\": \"2024-01-15T07:00:00+00:00\",\n    \"context\": {\n      \"id\": \"01HMV789012345\",\n      \"parent_id\": null,\n      \"user_id\": null\n    }\n  },\n  {\n    \"entity_id\": \"weather.home\",\n    \"state\": \"partly-cloudy\",\n    \"attributes\": {\n      \"temperature\": 18.0,\n      \"humidity\": 65,\n      \"pressure\": 1013.2,\n      \"wind_speed\": 12.5,\n      \"wind_bearing\": 225,\n      \"visibility\": 16.0,\n      \"forecast\": [],\n      \"friendly_name\": \"Home Weather\"\n    },\n    \"last_changed\": \"2024-01-15T10:00:00+00:00\",\n    \"last_updated\": \"2024-01-15T10:30:00+00:00\",\n    \"context\": {\n      \"id\": \"01HMV890123456\",\n      \"parent_id\": null,\n      \"user_id\": null\n    }\n  }\n]"
  },
  {
    "path": "dummy-data/homeassistant/api_root",
    "content": "{\n  \"message\": \"API running.\"\n}"
  },
  {
    "path": "dummy-data/immich/api/server/statistics",
    "content": "{\n  \"photos\": 12847,\n  \"videos\": 1523,\n  \"usage\": 248576851456,\n  \"usageByUser\": [\n    {\n      \"userId\": \"user-1234-5678-9abc-def0\",\n      \"userName\": \"john.doe\",\n      \"photos\": 8945,\n      \"videos\": 892,\n      \"usage\": 156789012345\n    },\n    {\n      \"userId\": \"user-2345-6789-abcd-ef01\",\n      \"userName\": \"jane.smith\",\n      \"photos\": 2134,\n      \"videos\": 423,\n      \"usage\": 67891234567\n    },\n    {\n      \"userId\": \"user-3456-789a-bcde-f012\",\n      \"userName\": \"family.shared\",\n      \"photos\": 1768,\n      \"videos\": 208,\n      \"usage\": 23896604544\n    }\n  ],\n  \"usageRaw\": 248576851456,\n  \"photosGrowth\": {\n    \"date\": \"2024-01-15\",\n    \"value\": 42\n  },\n  \"videosGrowth\": {\n    \"date\": \"2024-01-15\", \n    \"value\": 7\n  },\n  \"usageGrowth\": {\n    \"date\": \"2024-01-15\",\n    \"value\": 2147483648\n  }\n}"
  },
  {
    "path": "dummy-data/immich/api/server-info/stats",
    "content": "{\"photos\":25754,\"videos\":1774,\"usage\":123142197784,\"usageByUser\":[{\"userId\":\"28eff0e8-60b3-4b4c-9236-bd48559b1400\",\"userFirstName\":\"John\",\"userLastName\":\"Doe\",\"photos\":14153,\"videos\":501,\"usage\":54391364678},{\"userId\":\"f3051051-9794-3470-b253-d1d7c1d46ef3\",\"userFirstName\":\"John\",\"userLastName\":\"Doe\",\"photos\":0,\"videos\":0,\"usage\":0},{\"userId\":\"940bd5e2-bf3d-33e6-86b6-b9d60a1d1e06\",\"userFirstName\":\"Jane\",\"userLastName\":\"Doe\",\"photos\":11601,\"videos\":1273,\"usage\":68750833106}]}\n"
  },
  {
    "path": "dummy-data/jellystat/proxy/getSessions",
    "content": "[\n  {\n    \"PlayState\": {\n      \"CanSeek\": true,\n      \"IsPaused\": false,\n      \"IsMuted\": false,\n      \"RepeatMode\": \"RepeatNone\",\n      \"ShuffleMode\": \"Sorted\",\n      \"VolumeLevel\": 85,\n      \"AudioStreamIndex\": 1,\n      \"SubtitleStreamIndex\": -1,\n      \"MediaSourceId\": \"12345abcdef\",\n      \"PlayMethod\": \"DirectPlay\",\n      \"PlaySessionId\": \"session-1-abc123\",\n      \"PlaylistItemId\": \"playlist-item-1\",\n      \"PositionTicks\": 18000000000\n    },\n    \"AdditionalUsers\": [],\n    \"Capabilities\": {\n      \"PlayableMediaTypes\": [\"Audio\", \"Video\"],\n      \"SupportedCommands\": [\"Play\", \"Pause\", \"Stop\", \"Seek\"]\n    },\n    \"RemoteEndPoint\": \"192.168.1.100\",\n    \"PlayableMediaTypes\": [\"Audio\", \"Video\"],\n    \"Id\": \"session-1-abc123\",\n    \"UserId\": \"user123abc\",\n    \"UserName\": \"john_doe\",\n    \"Client\": \"Jellyfin Web\",\n    \"LastActivityDate\": \"2024-01-15T10:30:00.0000000Z\",\n    \"LastPlaybackCheckIn\": \"2024-01-15T10:30:00.0000000Z\",\n    \"DeviceName\": \"Chrome on Desktop\",\n    \"DeviceId\": \"device-desktop-chrome\",\n    \"ApplicationVersion\": \"10.8.13\",\n    \"IsActive\": true,\n    \"SupportsMediaControl\": true,\n    \"SupportsRemoteControl\": true,\n    \"NowPlayingItem\": {\n      \"Name\": \"The Office - S03E01 - Gay Witch Hunt\",\n      \"OriginalTitle\": \"Gay Witch Hunt\",\n      \"Id\": \"episode123abc\",\n      \"Etag\": \"etag123\",\n      \"SourceType\": \"Library\",\n      \"PlaylistItemId\": \"playlist-item-1\",\n      \"DateCreated\": \"2024-01-10T00:00:00.0000000Z\",\n      \"DateLastMediaAdded\": \"2024-01-10T00:00:00.0000000Z\",\n      \"ExtraType\": null,\n      \"AirsBeforeSeasonNumber\": null,\n      \"AirsAfterSeasonNumber\": null,\n      \"AirsBeforeEpisodeNumber\": null,\n      \"CanDelete\": false,\n      \"CanDownload\": false,\n      \"HasSubtitles\": true,\n      \"Container\": \"mkv\",\n      \"SortName\": \"office s03e01 gay witch hunt\",\n      \"ForcedSortName\": null,\n      \"Video3DFormat\": null,\n      \"PremiereDate\": \"2006-09-21T00:00:00.0000000Z\",\n      \"ExternalUrls\": [],\n      \"MediaSources\": [],\n      \"CriticRating\": null,\n      \"ProductionLocations\": [],\n      \"Path\": \"/media/tv/The Office/Season 03/S03E01.mkv\",\n      \"EnableMediaSourceDisplay\": true,\n      \"OfficialRating\": \"TV-14\",\n      \"CustomRating\": null,\n      \"ChannelId\": null,\n      \"ChannelName\": null,\n      \"Overview\": \"Michael's kiss with Oscar at the Dundies leads to sensitivity training for the office.\",\n      \"Taglines\": [],\n      \"Genres\": [\"Comedy\"],\n      \"CommunityRating\": 8.1,\n      \"CumulativeRunTimeTicks\": 13050000000,\n      \"RunTimeTicks\": 13050000000,\n      \"PlayAccess\": \"Full\",\n      \"AspectRatio\": \"16:9\",\n      \"ProductionYear\": 2006,\n      \"IsPlaceHolder\": false,\n      \"Number\": null,\n      \"ChannelNumber\": null,\n      \"IndexNumber\": 1,\n      \"IndexNumberEnd\": null,\n      \"ParentIndexNumber\": 3,\n      \"RemoteTrailers\": [],\n      \"ProviderIds\": {},\n      \"IsHD\": true,\n      \"IsFolder\": false,\n      \"ParentId\": \"season3abc\",\n      \"Type\": \"Episode\",\n      \"People\": [],\n      \"Studios\": [],\n      \"GenreItems\": [],\n      \"ParentLogoItemId\": null,\n      \"ParentBackdropItemId\": \"series123\",\n      \"ParentBackdropImageTags\": [\"backdrop1\"],\n      \"LocalTrailerCount\": 0,\n      \"UserData\": {\n        \"Rating\": null,\n        \"PlayedPercentage\": 75.5,\n        \"UnplayedItemCount\": null,\n        \"PlaybackPositionTicks\": 18000000000,\n        \"PlayCount\": 1,\n        \"IsFavorite\": false,\n        \"Likes\": null,\n        \"LastPlayedDate\": \"2024-01-15T10:30:00.0000000Z\",\n        \"Played\": false,\n        \"Key\": \"episode123abc\"\n      },\n      \"RecursiveItemCount\": 0,\n      \"ChildCount\": 0,\n      \"SeriesName\": \"The Office\",\n      \"SeriesId\": \"series123\",\n      \"SeasonId\": \"season3abc\",\n      \"SpecialFeatureCount\": 0,\n      \"DisplayPreferencesId\": \"episode123abc\",\n      \"Status\": null,\n      \"AirTime\": null,\n      \"AirDays\": [],\n      \"Tags\": [],\n      \"PrimaryImageAspectRatio\": 1.777777777777778,\n      \"Artists\": [],\n      \"ArtistItems\": [],\n      \"Album\": null,\n      \"CollectionType\": null,\n      \"DisplayOrder\": null,\n      \"AlbumId\": null,\n      \"AlbumPrimaryImageTag\": null,\n      \"SeriesPrimaryImageTag\": \"series-primary\",\n      \"AlbumArtist\": null,\n      \"AlbumArtists\": [],\n      \"SeasonName\": \"Season 3\",\n      \"MediaStreams\": [],\n      \"VideoType\": \"VideoFile\",\n      \"PartCount\": 1,\n      \"MediaSourceCount\": 1,\n      \"ImageTags\": {\n        \"Primary\": \"episode-primary\"\n      },\n      \"BackdropImageTags\": [],\n      \"ScreenshotImageTags\": [],\n      \"ParentLogoImageTag\": null,\n      \"ParentArtItemId\": null,\n      \"ParentArtImageTag\": null,\n      \"SeriesThumbImageTag\": null,\n      \"ImageBlurHashes\": {},\n      \"SeriesStudio\": \"NBC\",\n      \"ParentThumbItemId\": null,\n      \"ParentThumbImageTag\": null,\n      \"ParentPrimaryImageItemId\": \"series123\",\n      \"ParentPrimaryImageTag\": \"series-primary\",\n      \"Chapters\": [],\n      \"LocationType\": \"FileSystem\",\n      \"IsoType\": null,\n      \"MediaType\": \"Video\",\n      \"EndDate\": null,\n      \"LockedFields\": [],\n      \"TrailerCount\": 0,\n      \"MovieCount\": 0,\n      \"SeriesCount\": 0,\n      \"ProgramCount\": 0,\n      \"EpisodeCount\": 0,\n      \"SongCount\": 0,\n      \"AlbumCount\": 0,\n      \"ArtistCount\": 0,\n      \"MusicVideoCount\": 0,\n      \"LockData\": false,\n      \"Width\": 1920,\n      \"Height\": 1080,\n      \"CameraMake\": null,\n      \"CameraModel\": null,\n      \"Software\": null,\n      \"ExposureTime\": null,\n      \"FocalLength\": null,\n      \"ImageOrientation\": null,\n      \"Aperture\": null,\n      \"ShutterSpeed\": null,\n      \"Latitude\": null,\n      \"Longitude\": null,\n      \"Altitude\": null,\n      \"IsoSpeedRating\": null,\n      \"SeriesTimerId\": null,\n      \"ProgramId\": null,\n      \"ChannelPrimaryImageTag\": null,\n      \"StartDate\": null,\n      \"CompletionPercentage\": null,\n      \"IsRepeat\": null,\n      \"EpisodeTitle\": \"Gay Witch Hunt\",\n      \"ChannelType\": null,\n      \"Audio\": null,\n      \"IsMovie\": false,\n      \"IsSports\": false,\n      \"IsNews\": false,\n      \"IsKids\": false,\n      \"IsPremiere\": false,\n      \"TimerId\": null,\n      \"NormalizationGain\": null,\n      \"CurrentProgram\": null\n    },\n    \"FullNowPlayingItem\": {},\n    \"NowViewingItem\": null,\n    \"DeviceType\": \"Desktop\",\n    \"NowPlayingQueue\": [],\n    \"NowPlayingQueueFullItems\": [],\n    \"HasCustomDeviceName\": false,\n    \"PlaylistItemId\": \"playlist-item-1\",\n    \"ServerId\": \"jellyfin-server-123\",\n    \"UserPrimaryImageTag\": null,\n    \"SupportedCommands\": []\n  },\n  {\n    \"PlayState\": {\n      \"CanSeek\": true,\n      \"IsPaused\": true,\n      \"IsMuted\": false,\n      \"RepeatMode\": \"RepeatNone\",\n      \"ShuffleMode\": \"Sorted\",\n      \"VolumeLevel\": 65,\n      \"AudioStreamIndex\": 1,\n      \"SubtitleStreamIndex\": 2,\n      \"MediaSourceId\": \"67890defghi\",\n      \"PlayMethod\": \"DirectPlay\",\n      \"PlaySessionId\": \"session-2-def456\",\n      \"PlaylistItemId\": \"playlist-item-2\",\n      \"PositionTicks\": 45000000000\n    },\n    \"AdditionalUsers\": [],\n    \"Capabilities\": {\n      \"PlayableMediaTypes\": [\"Audio\", \"Video\"],\n      \"SupportedCommands\": [\"Play\", \"Pause\", \"Stop\", \"Seek\"]\n    },\n    \"RemoteEndPoint\": \"192.168.1.101\",\n    \"PlayableMediaTypes\": [\"Audio\", \"Video\"],\n    \"Id\": \"session-2-def456\",\n    \"UserId\": \"user456def\",\n    \"UserName\": \"jane_smith\",\n    \"Client\": \"Jellyfin Android\",\n    \"LastActivityDate\": \"2024-01-15T10:25:00.0000000Z\",\n    \"LastPlaybackCheckIn\": \"2024-01-15T10:25:00.0000000Z\",\n    \"DeviceName\": \"Samsung Galaxy S21\",\n    \"DeviceId\": \"device-android-samsung\",\n    \"ApplicationVersion\": \"2.6.2\",\n    \"IsActive\": true,\n    \"SupportsMediaControl\": true,\n    \"SupportsRemoteControl\": true,\n    \"NowPlayingItem\": {\n      \"Name\": \"Inception\",\n      \"OriginalTitle\": \"Inception\",\n      \"Id\": \"movie456def\",\n      \"Etag\": \"etag456\",\n      \"SourceType\": \"Library\",\n      \"PlaylistItemId\": \"playlist-item-2\",\n      \"DateCreated\": \"2024-01-05T00:00:00.0000000Z\",\n      \"DateLastMediaAdded\": \"2024-01-05T00:00:00.0000000Z\",\n      \"Container\": \"mkv\",\n      \"SortName\": \"inception\",\n      \"PremiereDate\": \"2010-07-16T00:00:00.0000000Z\",\n      \"Path\": \"/media/movies/Inception (2010)/Inception.mkv\",\n      \"EnableMediaSourceDisplay\": true,\n      \"OfficialRating\": \"PG-13\",\n      \"Overview\": \"A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a C.E.O.\",\n      \"Taglines\": [\"Your mind is the scene of the crime\"],\n      \"Genres\": [\"Action\", \"Sci-Fi\", \"Thriller\"],\n      \"CommunityRating\": 8.8,\n      \"CumulativeRunTimeTicks\": 88800000000,\n      \"RunTimeTicks\": 88800000000,\n      \"PlayAccess\": \"Full\",\n      \"AspectRatio\": \"2.40:1\",\n      \"ProductionYear\": 2010,\n      \"IsPlaceHolder\": false,\n      \"IsHD\": true,\n      \"IsFolder\": false,\n      \"Type\": \"Movie\",\n      \"LocalTrailerCount\": 0,\n      \"UserData\": {\n        \"PlayedPercentage\": 50.6,\n        \"PlaybackPositionTicks\": 45000000000,\n        \"PlayCount\": 0,\n        \"IsFavorite\": true,\n        \"LastPlayedDate\": \"2024-01-15T10:25:00.0000000Z\",\n        \"Played\": false,\n        \"Key\": \"movie456def\"\n      },\n      \"PrimaryImageAspectRatio\": 0.6666666666666666,\n      \"VideoType\": \"VideoFile\",\n      \"PartCount\": 1,\n      \"MediaSourceCount\": 1,\n      \"ImageTags\": {\n        \"Primary\": \"movie-primary\"\n      },\n      \"BackdropImageTags\": [\"backdrop1\", \"backdrop2\"],\n      \"LocationType\": \"FileSystem\",\n      \"MediaType\": \"Video\",\n      \"Width\": 1920,\n      \"Height\": 800,\n      \"IsMovie\": true,\n      \"IsSports\": false,\n      \"IsNews\": false,\n      \"IsKids\": false,\n      \"IsPremiere\": false\n    },\n    \"FullNowPlayingItem\": {},\n    \"NowViewingItem\": null,\n    \"DeviceType\": \"Phone\",\n    \"NowPlayingQueue\": [],\n    \"NowPlayingQueueFullItems\": [],\n    \"HasCustomDeviceName\": false,\n    \"PlaylistItemId\": \"playlist-item-2\",\n    \"ServerId\": \"jellyfin-server-123\",\n    \"UserPrimaryImageTag\": null,\n    \"SupportedCommands\": []\n  },\n  {\n    \"PlayState\": null,\n    \"AdditionalUsers\": [],\n    \"Capabilities\": {\n      \"PlayableMediaTypes\": [\"Audio\", \"Video\"],\n      \"SupportedCommands\": [\"Play\", \"Pause\", \"Stop\"]\n    },\n    \"RemoteEndPoint\": \"192.168.1.102\",\n    \"PlayableMediaTypes\": [\"Audio\", \"Video\"],\n    \"Id\": \"session-3-ghi789\",\n    \"UserId\": \"user789ghi\",\n    \"UserName\": \"family_user\",\n    \"Client\": \"Jellyfin for Roku\",\n    \"LastActivityDate\": \"2024-01-15T10:20:00.0000000Z\",\n    \"LastPlaybackCheckIn\": \"2024-01-15T10:20:00.0000000Z\",\n    \"DeviceName\": \"Roku Ultra\",\n    \"DeviceId\": \"device-roku-ultra\",\n    \"ApplicationVersion\": \"1.6.8\",\n    \"IsActive\": true,\n    \"SupportsMediaControl\": true,\n    \"SupportsRemoteControl\": true,\n    \"FullNowPlayingItem\": {},\n    \"NowViewingItem\": null,\n    \"DeviceType\": \"Tv\",\n    \"NowPlayingQueue\": [],\n    \"NowPlayingQueueFullItems\": [],\n    \"HasCustomDeviceName\": false,\n    \"ServerId\": \"jellyfin-server-123\",\n    \"UserPrimaryImageTag\": null,\n    \"SupportedCommands\": []\n  }\n]"
  },
  {
    "path": "dummy-data/lidarr/api/v1/health",
    "content": "[\n  {\n    \"source\": \"IndexerStatusCheck\",\n    \"type\": \"warning\",\n    \"message\": \"Indexer MusicBrainzDB is unavailable due to recent indexer errors: Service temporarily unavailable\",\n    \"wikiUrl\": \"https://wiki.servarr.com/lidarr/health#indexers-are-unavailable-due-to-recent-failures\"\n  },\n  {\n    \"source\": \"ImportMechanismCheck\",\n    \"type\": \"ok\", \n    \"message\": \"No issues with import mechanism checks\"\n  },\n  {\n    \"source\": \"DownloadClientStatusCheck\",\n    \"type\": \"ok\",\n    \"message\": \"All download clients are available\"\n  },\n  {\n    \"source\": \"RootFolderCheck\",\n    \"type\": \"error\",\n    \"message\": \"Missing root folder: /music\",\n    \"wikiUrl\": \"https://wiki.servarr.com/lidarr/health#missing-root-folder\"\n  },\n  {\n    \"source\": \"UpdateCheck\",\n    \"type\": \"ok\",\n    \"message\": \"Update available: 1.3.6.3557 -> 2.0.7.3849\"\n  },\n  {\n    \"source\": \"MetadataProviderCheck\",\n    \"type\": \"warning\",\n    \"message\": \"Metadata provider Last.fm API key is invalid or expired\",\n    \"wikiUrl\": \"https://wiki.servarr.com/lidarr/health#metadata-provider-issues\"\n  }\n]"
  },
  {
    "path": "dummy-data/lidarr/api/v1/queue/status",
    "content": "{\n  \"totalCount\": 4,\n  \"count\": 4,\n  \"unknownCount\": 0,\n  \"errors\": false,\n  \"warnings\": false\n}"
  },
  {
    "path": "dummy-data/lidarr/api/v1/wanted/missing",
    "content": "{\n  \"page\": 1,\n  \"pageSize\": 20,\n  \"sortKey\": \"releaseDate\",\n  \"sortDirection\": \"descending\",\n  \"totalRecords\": 7,\n  \"records\": [\n    {\n      \"artistId\": 1,\n      \"albumId\": 12345,\n      \"foreignAlbumId\": \"mbid-123-456-789\",\n      \"title\": \"Dark Side of the Moon\",\n      \"disambiguation\": \"\",\n      \"overview\": \"The eighth studio album by Pink Floyd, released in 1973.\",\n      \"artistName\": \"Pink Floyd\",\n      \"foreignArtistId\": \"mbid-artist-123\",\n      \"monitored\": true,\n      \"anyReleaseOk\": true,\n      \"profileId\": 1,\n      \"duration\": 2580000,\n      \"albumType\": \"Album\",\n      \"secondaryTypes\": [],\n      \"mediumCount\": 1,\n      \"releaseDate\": \"1973-03-01T00:00:00Z\",\n      \"releases\": [\n        {\n          \"id\": 67890,\n          \"albumId\": 12345,\n          \"foreignReleaseId\": \"mbid-release-123\",\n          \"title\": \"Dark Side of the Moon\",\n          \"status\": \"Official\",\n          \"duration\": 2580000,\n          \"trackCount\": 10,\n          \"mediumCount\": 1,\n          \"disambiguation\": \"\",\n          \"country\": [\"US\"],\n          \"label\": [\"Harvest\", \"Capitol\"],\n          \"monitored\": true\n        }\n      ],\n      \"genres\": [\"Progressive Rock\", \"Psychedelic Rock\"],\n      \"media\": [\n        {\n          \"mediumNumber\": 1,\n          \"mediumName\": \"\",\n          \"mediumFormat\": \"CD\"\n        }\n      ],\n      \"artist\": {\n        \"artistName\": \"Pink Floyd\",\n        \"foreignArtistId\": \"mbid-artist-123\",\n        \"nameSlug\": \"pink-floyd\",\n        \"overview\": \"English rock band formed in London in 1965.\",\n        \"disambiguation\": \"\",\n        \"links\": [],\n        \"images\": [],\n        \"path\": \"/music/Pink Floyd\",\n        \"qualityProfileId\": 1,\n        \"metadataProfileId\": 1,\n        \"monitored\": true,\n        \"monitorNewItems\": \"all\",\n        \"genres\": [\"Progressive Rock\", \"Psychedelic Rock\", \"Art Rock\"],\n        \"cleanName\": \"pinkfloyd\",\n        \"sortName\": \"Pink Floyd\",\n        \"tags\": [],\n        \"added\": \"2024-01-01T00:00:00Z\",\n        \"ratings\": {\n          \"votes\": 54321,\n          \"value\": 9.2\n        },\n        \"statistics\": {\n          \"albumCount\": 15,\n          \"trackFileCount\": 142,\n          \"trackCount\": 149,\n          \"totalTrackCount\": 149,\n          \"sizeOnDisk\": 7516192768,\n          \"percentOfTracks\": 95.3\n        },\n        \"id\": 1\n      },\n      \"images\": [],\n      \"links\": [],\n      \"statistics\": {\n        \"trackFileCount\": 0,\n        \"trackCount\": 10,\n        \"totalTrackCount\": 10,\n        \"sizeOnDisk\": 0,\n        \"percentOfTracks\": 0.0\n      },\n      \"grabbed\": false,\n      \"id\": 12345\n    },\n    {\n      \"artistId\": 2,\n      \"albumId\": 23456,\n      \"foreignAlbumId\": \"mbid-234-567-890\",\n      \"title\": \"OK Computer\",\n      \"disambiguation\": \"\",\n      \"overview\": \"The third studio album by Radiohead, released in 1997.\",\n      \"artistName\": \"Radiohead\",\n      \"foreignArtistId\": \"mbid-artist-234\",\n      \"monitored\": true,\n      \"anyReleaseOk\": true,\n      \"profileId\": 1,\n      \"duration\": 3230000,\n      \"albumType\": \"Album\",\n      \"secondaryTypes\": [],\n      \"mediumCount\": 1,\n      \"releaseDate\": \"1997-06-16T00:00:00Z\",\n      \"releases\": [\n        {\n          \"id\": 78901,\n          \"albumId\": 23456,\n          \"foreignReleaseId\": \"mbid-release-234\",\n          \"title\": \"OK Computer\",\n          \"status\": \"Official\",\n          \"duration\": 3230000,\n          \"trackCount\": 12,\n          \"mediumCount\": 1,\n          \"disambiguation\": \"\",\n          \"country\": [\"GB\"],\n          \"label\": [\"Parlophone\", \"Capitol\"],\n          \"monitored\": true\n        }\n      ],\n      \"genres\": [\"Alternative Rock\", \"Art Rock\"],\n      \"media\": [\n        {\n          \"mediumNumber\": 1,\n          \"mediumName\": \"\",\n          \"mediumFormat\": \"CD\"\n        }\n      ],\n      \"artist\": {\n        \"artistName\": \"Radiohead\",\n        \"foreignArtistId\": \"mbid-artist-234\",\n        \"nameSlug\": \"radiohead\",\n        \"overview\": \"English rock band formed in Abingdon, Oxfordshire, in 1985.\",\n        \"disambiguation\": \"\",\n        \"links\": [],\n        \"images\": [],\n        \"path\": \"/music/Radiohead\",\n        \"qualityProfileId\": 1,\n        \"metadataProfileId\": 1,\n        \"monitored\": true,\n        \"monitorNewItems\": \"all\",\n        \"genres\": [\"Alternative Rock\", \"Art Rock\", \"Electronic\"],\n        \"cleanName\": \"radiohead\",\n        \"sortName\": \"Radiohead\",\n        \"tags\": [],\n        \"added\": \"2024-01-01T00:00:00Z\",\n        \"ratings\": {\n          \"votes\": 45678,\n          \"value\": 8.9\n        },\n        \"statistics\": {\n          \"albumCount\": 9,\n          \"trackFileCount\": 89,\n          \"trackCount\": 95,\n          \"totalTrackCount\": 95,\n          \"sizeOnDisk\": 4831838208,\n          \"percentOfTracks\": 93.7\n        },\n        \"id\": 2\n      },\n      \"images\": [],\n      \"links\": [],\n      \"statistics\": {\n        \"trackFileCount\": 0,\n        \"trackCount\": 12,\n        \"totalTrackCount\": 12,\n        \"sizeOnDisk\": 0,\n        \"percentOfTracks\": 0.0\n      },\n      \"grabbed\": false,\n      \"id\": 23456\n    }\n  ]\n}"
  },
  {
    "path": "dummy-data/linkding/api/bookmarks",
    "content": "{\n  \"count\": 12,\n  \"next\": null,\n  \"previous\": null,\n  \"results\": [\n    {\n      \"id\": 1,\n      \"url\": \"https://github.com/bastienwirtz/homer\",\n      \"title\": \"Homer - A very simple static homepage for your server\",\n      \"description\": \"A dead simple static HOMepage for your servER to keep your services on hand, from a simple yaml configuration file.\",\n      \"notes\": \"\",\n      \"website_title\": \"GitHub\",\n      \"website_description\": \"GitHub is where over 100 million developers shape the future of software, together.\",\n      \"web_archive_snapshot_url\": \"\",\n      \"favicon_url\": \"https://github.githubassets.com/favicons/favicon.svg\",\n      \"preview_image_url\": \"\",\n      \"is_archived\": false,\n      \"unread\": false,\n      \"shared\": false,\n      \"tag_names\": [\"selfhosted\", \"dashboard\", \"yaml\"],\n      \"date_added\": \"2024-01-15T10:30:00.123456Z\",\n      \"date_modified\": \"2024-01-15T10:30:00.123456Z\"\n    },\n    {\n      \"id\": 2,\n      \"url\": \"https://docs.docker.com/\",\n      \"title\": \"Docker Documentation\",\n      \"description\": \"Official Docker documentation with guides, references, and tutorials for containerization.\",\n      \"notes\": \"Essential for container management\",\n      \"website_title\": \"Docker Docs\",\n      \"website_description\": \"Docker helps developers build, share, run, and verify applications anywhere.\",\n      \"web_archive_snapshot_url\": \"\",\n      \"favicon_url\": \"https://docs.docker.com/favicons/docs@2x.ico\",\n      \"preview_image_url\": \"\",\n      \"is_archived\": false,\n      \"unread\": false,\n      \"shared\": false,\n      \"tag_names\": [\"docker\", \"containers\", \"documentation\"],\n      \"date_added\": \"2024-01-14T15:20:00.123456Z\",\n      \"date_modified\": \"2024-01-14T15:20:00.123456Z\"\n    },\n    {\n      \"id\": 3,\n      \"url\": \"https://nginx.org/en/docs/\",\n      \"title\": \"nginx documentation\",\n      \"description\": \"Official nginx web server documentation covering installation, configuration, and modules.\",\n      \"notes\": \"\",\n      \"website_title\": \"nginx\",\n      \"website_description\": \"nginx [engine x] is an HTTP and reverse proxy server, a mail proxy server, and a generic TCP/UDP proxy server.\",\n      \"web_archive_snapshot_url\": \"\",\n      \"favicon_url\": \"https://nginx.org/favicon.ico\",\n      \"preview_image_url\": \"\",\n      \"is_archived\": false,\n      \"unread\": false,\n      \"shared\": false,\n      \"tag_names\": [\"nginx\", \"webserver\", \"proxy\"],\n      \"date_added\": \"2024-01-13T09:45:00.123456Z\",\n      \"date_modified\": \"2024-01-13T09:45:00.123456Z\"\n    }\n  ]\n}"
  },
  {
    "path": "dummy-data/matrix/_matrix/federation/v1/version",
    "content": "{\n  \"server\": {\n    \"name\": \"Synapse\",\n    \"version\": \"1.99.0\"\n  }\n}"
  },
  {
    "path": "dummy-data/mealie/api/admin/about/statistics",
    "content": "{\n  \"totalRecipes\": 247,\n  \"totalUsers\": 5,\n  \"totalGroups\": 2,\n  \"totalCategories\": 18,\n  \"totalTags\": 42,\n  \"totalTools\": 15,\n  \"totalMealPlans\": 156,\n  \"totalShoppingLists\": 28,\n  \"totalComments\": 89,\n  \"lastUpdated\": \"2024-01-15T10:30:00Z\",\n  \"version\": \"1.0.0\",\n  \"demoStatus\": false,\n  \"allowSignup\": true,\n  \"defaultGroup\": \"Home\",\n  \"buildVersion\": \"v1.0.0-1234567\",\n  \"apiVersion\": \"v1\"\n}"
  },
  {
    "path": "dummy-data/mealie/api/groups/mealplans/today",
    "content": "[\n  {\n    \"id\": \"meal-123-abc\",\n    \"date\": \"2024-01-15\",\n    \"entryType\": \"dinner\",\n    \"title\": \"Dinner\",\n    \"text\": \"\",\n    \"recipe\": {\n      \"id\": \"recipe-456-def\",\n      \"name\": \"Chicken Tikka Masala\",\n      \"slug\": \"chicken-tikka-masala\",\n      \"image\": \"recipe-456-def.webp\",\n      \"description\": \"Creamy tomato-based curry with tender chicken pieces and aromatic spices\",\n      \"recipeCategory\": [\n        {\n          \"id\": \"cat-1\",\n          \"name\": \"Indian\",\n          \"slug\": \"indian\"\n        },\n        {\n          \"id\": \"cat-2\", \n          \"name\": \"Main Course\",\n          \"slug\": \"main-course\"\n        }\n      ],\n      \"tags\": [\n        {\n          \"id\": \"tag-1\",\n          \"name\": \"Curry\",\n          \"slug\": \"curry\"\n        },\n        {\n          \"id\": \"tag-2\",\n          \"name\": \"Chicken\", \n          \"slug\": \"chicken\"\n        },\n        {\n          \"id\": \"tag-3\",\n          \"name\": \"Spicy\",\n          \"slug\": \"spicy\"\n        }\n      ],\n      \"rating\": 4.5,\n      \"totalTime\": \"45 minutes\",\n      \"prepTime\": \"15 minutes\",\n      \"cookTime\": \"30 minutes\",\n      \"performTime\": null,\n      \"servings\": 4,\n      \"dateAdded\": \"2024-01-10T00:00:00Z\",\n      \"dateUpdated\": \"2024-01-14T12:30:00Z\",\n      \"createdBy\": {\n        \"id\": \"user-789\",\n        \"username\": \"chef_sarah\",\n        \"fullName\": \"Sarah Mitchell\"\n      },\n      \"updateBy\": {\n        \"id\": \"user-789\",\n        \"username\": \"chef_sarah\", \n        \"fullName\": \"Sarah Mitchell\"\n      }\n    },\n    \"groupId\": \"group-default-123\"\n  }\n]"
  },
  {
    "path": "dummy-data/medusa/api/v2/config",
    "content": "{\n  \"system\": {\n    \"news\": {\n      \"unread\": 3,\n      \"latest\": [\n        {\n          \"title\": \"Medusa v1.0.19 Released\",\n          \"date\": \"2024-01-14\",\n          \"content\": \"Bug fixes and performance improvements\",\n          \"read\": false\n        },\n        {\n          \"title\": \"New indexer support added\",\n          \"date\": \"2024-01-12\", \n          \"content\": \"Support for additional torrent indexers\",\n          \"read\": false\n        },\n        {\n          \"title\": \"Database maintenance completed\",\n          \"date\": \"2024-01-10\",\n          \"content\": \"Weekly database optimization finished\",\n          \"read\": false\n        }\n      ]\n    },\n    \"version\": {\n      \"version\": \"1.0.19\",\n      \"branch\": \"master\",\n      \"commit\": \"abc123def456\",\n      \"dbVersion\": 44,\n      \"pythonVersion\": \"3.11.7\"\n    },\n    \"os\": {\n      \"platform\": \"Linux\",\n      \"release\": \"6.5.0-15-generic\",\n      \"version\": \"#15~22.04.1-Ubuntu\"\n    },\n    \"memory\": {\n      \"used\": 512.5,\n      \"total\": 8192.0,\n      \"percent\": 6.3\n    }\n  },\n  \"main\": {\n    \"logs\": {\n      \"numWarnings\": 2,\n      \"numErrors\": 1,\n      \"loggingLevels\": [\n        \"DEBUG\",\n        \"INFO\", \n        \"WARNING\",\n        \"ERROR\"\n      ],\n      \"currentLevel\": \"INFO\"\n    },\n    \"general\": {\n      \"webHost\": \"0.0.0.0\",\n      \"webPort\": 8081,\n      \"webRoot\": \"\",\n      \"launchBrowser\": false,\n      \"versionNotify\": true,\n      \"autoUpdate\": false,\n      \"logDir\": \"/app/Logs\",\n      \"dataDir\": \"/app/Data\",\n      \"configVersion\": 12\n    },\n    \"showDefaults\": {\n      \"status\": \"Skipped/Wanted/Snatched/Downloaded\",\n      \"statusAfter\": \"Downloaded\",\n      \"season_folders\": true,\n      \"anime\": false,\n      \"scene\": false,\n      \"archive_firstmatch\": false,\n      \"quality_default\": \"Standard Definition\",\n      \"subtitles\": false,\n      \"flatten_folders\": false,\n      \"indexer_default\": \"tvdb\",\n      \"indexer_timeout\": 20,\n      \"skip_removed_files\": false\n    }\n  },\n  \"search\": {\n    \"general\": {\n      \"randomize_providers\": false,\n      \"download_propers\": true,\n      \"check_propers_interval\": \"daily\",\n      \"propers_search_days\": 2,\n      \"backlog_days\": 7,\n      \"cache_trimming\": false,\n      \"max_cache_age\": 30\n    },\n    \"nzb\": {\n      \"nzbs\": false,\n      \"nzbs_uid\": \"\",\n      \"nzbs_hash\": \"\"\n    },\n    \"torrent\": {\n      \"torrent_method\": \"blackhole\",\n      \"torrent_path\": \"\",\n      \"torrent_seed_time\": 0,\n      \"torrent_paused\": false,\n      \"torrent_high_bandwidth\": false,\n      \"torrent_label\": \"\",\n      \"torrent_label_anime\": \"\",\n      \"torrent_verify_cert\": false\n    }\n  }\n}"
  },
  {
    "path": "dummy-data/miniflux/v1/entries",
    "content": "{\n  \"total\": 42,\n  \"entries\": [\n    {\n      \"id\": 888,\n      \"user_id\": 1,\n      \"feed_id\": 42,\n      \"title\": \"Example Unread Entry\",\n      \"url\": \"http://example.org/article.html\",\n      \"comments_url\": \"\",\n      \"author\": \"John Doe\",\n      \"content\": \"<p>This is an unread RSS entry</p>\",\n      \"hash\": \"29f99e4074cdacca1766f47697d03c66070ef6a14770a1fd5a867483c207a1bb\",\n      \"published_at\": \"2025-11-11T16:15:19Z\",\n      \"created_at\": \"2025-11-11T16:15:19Z\",\n      \"status\": \"unread\",\n      \"share_code\": \"\",\n      \"starred\": false,\n      \"reading_time\": 5,\n      \"enclosures\": null,\n      \"feed\": {\n        \"id\": 42,\n        \"user_id\": 1,\n        \"title\": \"Tech Blog\",\n        \"site_url\": \"http://example.org\",\n        \"feed_url\": \"http://example.org/feed.atom\",\n        \"checked_at\": \"2025-11-11T21:06:03.133839Z\",\n        \"category\": {\n          \"id\": 22,\n          \"user_id\": 1,\n          \"title\": \"Technology\"\n        }\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": "dummy-data/nextcloud/status.php",
    "content": "{\n  \"installed\": true,\n  \"maintenance\": false,\n  \"needsDbUpgrade\": false,\n  \"version\": \"28.0.2.1\",\n  \"versionstring\": \"28.0.2\",\n  \"edition\": \"\",\n  \"productname\": \"Nextcloud\",\n  \"extendedSupport\": false\n}"
  },
  {
    "path": "dummy-data/octoprint/api/job",
    "content": "{\n\t\"job\": {\n\t\t\"averagePrintTime\": 669.3131185749999,\n\t\t\"estimatedPrintTime\": 314.87566979223726,\n\t\t\"filament\": {\n\t\t\t\"tool0\": {\n\t\t\t\t\"length\": 134.81171000000032,\n\t\t\t\t\"volume\": 0.0\n\t\t\t}\n\t\t},\n\t\t\"file\": {\n\t\t\t\"date\": 1665547748,\n\t\t\t\"display\": \"CE3PRO_3mmX3mm Brass insert V2.gcode\",\n\t\t\t\"name\": \"CE3PRO_3mmX3mm Brass insert V2.gcode\",\n\t\t\t\"origin\": \"local\",\n\t\t\t\"path\": \"MISC/CE3PRO_3mmX3mm Brass insert V2.gcode\",\n\t\t\t\"size\": 129581\n\t\t},\n\t\t\"lastPrintTime\": 669.3131185749999,\n\t\t\"user\": \"friendlyngeeks\"\n\t},\n\t\"progress\": {\n\t\t\"completion\": 27.456185706237797,\n\t\t\"filepos\": 35578,\n\t\t\"printTime\": 460,\n\t\t\"printTimeLeft\": 4612,\n\t\t\"printTimeLeftOrigin\": \"linear\"\n\t},\n\t\"state\": \"Printing\"\n}"
  },
  {
    "path": "dummy-data/octoprint/api/printer",
    "content": "{\n    \"temperature\": {\n        \"bed\": {\n            \"actual\": 20.52,\n            \"offset\": 0,\n            \"target\": 0.0\n        },\n        \"tool0\": {\n            \"actual\": 20.44,\n            \"offset\": 0,\n            \"target\": 0.0\n        }\n    },\n    \"state\": {\n        \"text\": \"Operational\",\n        \"flags\": {\n            \"operational\": true,\n            \"paused\": false,\n            \"printing\": false,\n            \"cancelling\": false,\n            \"pausing\": false,\n            \"error\": false,\n            \"ready\": true,\n            \"closedOrError\": false\n        }\n    }\n}"
  },
  {
    "path": "dummy-data/octoprint/api/status_printer_offline-error.json",
    "content": "{\n\t\"error\": \"SerialException: device reports readiness to read but returned no data (device disconnected or multiple access on port?)\",\n\t\"job\": {\n\t\t\"averagePrintTime\": null,\n\t\t\"estimatedPrintTime\": null,\n\t\t\"filament\": null,\n\t\t\"file\": {\n\t\t\t\"date\": null,\n\t\t\t\"display\": null,\n\t\t\t\"name\": null,\n\t\t\t\"origin\": null,\n\t\t\t\"path\": null,\n\t\t\t\"size\": null\n\t\t},\n\t\t\"lastPrintTime\": null,\n\t\t\"user\": null\n\t},\n\t\"progress\": {\n\t\t\"completion\": null,\n\t\t\"filepos\": null,\n\t\t\"printTime\": null,\n\t\t\"printTimeLeft\": null,\n\t\t\"printTimeLeftOrigin\": null\n\t},\n\t\"state\": \"Offline after error\"\n}"
  },
  {
    "path": "dummy-data/octoprint/api/status_printer_offline.json",
    "content": "{\n\t\"job\": {\n\t\t\"estimatedPrintTime\": null,\n\t\t\"filament\": {\n\t\t\t\"length\": null,\n\t\t\t\"volume\": null\n\t\t},\n\t\t\"file\": {\n\t\t\t\"date\": null,\n\t\t\t\"name\": null,\n\t\t\t\"origin\": null,\n\t\t\t\"path\": null,\n\t\t\t\"size\": null\n\t\t},\n\t\t\"lastPrintTime\": null,\n\t\t\"user\": null\n\t},\n\t\"progress\": {\n\t\t\"completion\": null,\n\t\t\"filepos\": null,\n\t\t\"printTime\": null,\n\t\t\"printTimeLeft\": null,\n\t\t\"printTimeOrigin\": null\n\t},\n\t\"state\": \"Offline\"\n}"
  },
  {
    "path": "dummy-data/octoprint/api/status_printer_operational.json",
    "content": "{\n\t\"job\": {\n\t\t\"estimatedPrintTime\": null,\n\t\t\"filament\": {\n\t\t\t\"length\": null,\n\t\t\t\"volume\": null\n\t\t},\n\t\t\"file\": {\n\t\t\t\"date\": null,\n\t\t\t\"name\": null,\n\t\t\t\"origin\": null,\n\t\t\t\"path\": null,\n\t\t\t\"size\": null\n\t\t},\n\t\t\"lastPrintTime\": null,\n\t\t\"user\": null\n\t},\n\t\"progress\": {\n\t\t\"completion\": null,\n\t\t\"filepos\": null,\n\t\t\"printTime\": null,\n\t\t\"printTimeLeft\": null,\n\t\t\"printTimeOrigin\": null\n\t},\n\t\"state\": \"Operational\"\n}"
  },
  {
    "path": "dummy-data/octoprint/api/status_printer_printing.json",
    "content": "{\n\t\"job\": {\n\t\t\"averagePrintTime\": 669.3131185749999,\n\t\t\"estimatedPrintTime\": 314.87566979223726,\n\t\t\"filament\": {\n\t\t\t\"tool0\": {\n\t\t\t\t\"length\": 134.81171000000032,\n\t\t\t\t\"volume\": 0.0\n\t\t\t}\n\t\t},\n\t\t\"file\": {\n\t\t\t\"date\": 1665547748,\n\t\t\t\"display\": \"CE3PRO_3mmX3mm Brass insert V2.gcode\",\n\t\t\t\"name\": \"CE3PRO_3mmX3mm Brass insert V2.gcode\",\n\t\t\t\"origin\": \"local\",\n\t\t\t\"path\": \"MISC/CE3PRO_3mmX3mm Brass insert V2.gcode\",\n\t\t\t\"size\": 129581\n\t\t},\n\t\t\"lastPrintTime\": 669.3131185749999,\n\t\t\"user\": \"friendlyngeeks\"\n\t},\n\t\"progress\": {\n\t\t\"completion\": 0.1551153332664511,\n\t\t\"filepos\": 201,\n\t\t\"printTime\": 0,\n\t\t\"printTimeLeft\": 668,\n\t\t\"printTimeLeftOrigin\": \"average\"\n\t},\n\t\"state\": \"Printing\"\n}"
  },
  {
    "path": "dummy-data/octoprint/api/status_printer_printing_1of2.json",
    "content": "{\n\t\"job\": {\n\t\t\"averagePrintTime\": 669.3131185749999,\n\t\t\"estimatedPrintTime\": 314.87566979223726,\n\t\t\"filament\": {\n\t\t\t\"tool0\": {\n\t\t\t\t\"length\": 134.81171000000032,\n\t\t\t\t\"volume\": 0.0\n\t\t\t}\n\t\t},\n\t\t\"file\": {\n\t\t\t\"date\": 1665547748,\n\t\t\t\"display\": \"CE3PRO_3mmX3mm Brass insert V2.gcode\",\n\t\t\t\"name\": \"CE3PRO_3mmX3mm Brass insert V2.gcode\",\n\t\t\t\"origin\": \"local\",\n\t\t\t\"path\": \"MISC/CE3PRO_3mmX3mm Brass insert V2.gcode\",\n\t\t\t\"size\": 129581\n\t\t},\n\t\t\"lastPrintTime\": 669.3131185749999,\n\t\t\"user\": \"friendlyngeeks\"\n\t},\n\t\"progress\": {\n\t\t\"completion\": 0.1551153332664511,\n\t\t\"filepos\": 201,\n\t\t\"printTime\": 0,\n\t\t\"printTimeLeft\": 668,\n\t\t\"printTimeLeftOrigin\": \"average\"\n\t},\n\t\"state\": \"Printing\"\n}"
  },
  {
    "path": "dummy-data/octoprint/api/status_printer_printing_2of2.json",
    "content": "{\n\t\"job\": {\n\t\t\"averagePrintTime\": 669.3131185749999,\n\t\t\"estimatedPrintTime\": 314.87566979223726,\n\t\t\"filament\": {\n\t\t\t\"tool0\": {\n\t\t\t\t\"length\": 134.81171000000032,\n\t\t\t\t\"volume\": 0.0\n\t\t\t}\n\t\t},\n\t\t\"file\": {\n\t\t\t\"date\": 1665547748,\n\t\t\t\"display\": \"CE3PRO_3mmX3mm Brass insert V2.gcode\",\n\t\t\t\"name\": \"CE3PRO_3mmX3mm Brass insert V2.gcode\",\n\t\t\t\"origin\": \"local\",\n\t\t\t\"path\": \"MISC/CE3PRO_3mmX3mm Brass insert V2.gcode\",\n\t\t\t\"size\": 129581\n\t\t},\n\t\t\"lastPrintTime\": 669.3131185749999,\n\t\t\"user\": \"friendlyngeeks\"\n\t},\n\t\"progress\": {\n\t\t\"completion\": 27.456185706237797,\n\t\t\"filepos\": 35578,\n\t\t\"printTime\": 476,\n\t\t\"printTimeLeft\": 1612,\n\t\t\"printTimeLeftOrigin\": \"linear\"\n\t},\n\t\"state\": \"Printing\"\n}"
  },
  {
    "path": "dummy-data/octoprint/api/status_printing_completion.json",
    "content": "{\n\t\"job\": {\n\t\t\"averagePrintTime\": 698.814525153,\n\t\t\"estimatedPrintTime\": 314.87566979223726,\n\t\t\"filament\": {\n\t\t\t\"tool0\": {\n\t\t\t\t\"length\": 134.81171000000032,\n\t\t\t\t\"volume\": 0.0\n\t\t\t}\n\t\t},\n\t\t\"file\": {\n\t\t\t\"date\": 1665547748,\n\t\t\t\"display\": \"CE3PRO_3mmX3mm Brass insert V2.gcode\",\n\t\t\t\"name\": \"CE3PRO_3mmX3mm Brass insert V2.gcode\",\n\t\t\t\"origin\": \"local\",\n\t\t\t\"path\": \"MISC/CE3PRO_3mmX3mm Brass insert V2.gcode\",\n\t\t\t\"size\": 129581\n\t\t},\n\t\t\"lastPrintTime\": 728.315931731,\n\t\t\"user\": \"friendlyngeeks\"\n\t},\n\t\"progress\": {\n\t\t\"completion\": 100.0,\n\t\t\"filepos\": 129581,\n\t\t\"printTime\": 728,\n\t\t\"printTimeLeft\": 0,\n\t\t\"printTimeLeftOrigin\": null\n\t},\n\t\"state\": \"Operational\"\n}"
  },
  {
    "path": "dummy-data/olivetin/webUiSettings.json",
    "content": "{\n    \"Rest\": \"./api/\",\n    \"ShowFooter\": true,\n    \"ShowNavigation\": true,\n    \"ShowNewVersions\": true,\n    \"AvailableVersion\": \"none\",\n    \"CurrentVersion\": \"2024.11.24\",\n    \"PageTitle\": \"OliveTin\",\n    \"SectionNavigationStyle\": \"sidebar\",\n    \"DefaultIconForBack\": \"&laquo;\",\n    \"SshFoundKey\": \"not found at /home/user/.ssh/id_rsa\",\n    \"SshFoundConfig\": \"not found at /home/user/.ssh/config\",\n    \"EnableCustomJs\": false,\n    \"AuthLoginUrl\": \"\",\n    \"AuthLocalLogin\": false,\n    \"AuthOAuth2Providers\": null,\n    \"AdditionalLinks\": null\n}\n"
  },
  {
    "path": "dummy-data/openHAB/rest/systeminfo",
    "content": "{\r\n    \"systemInfo\": {\r\n        \"configFolder\": \"/etc/openhab\",\r\n        \"userdataFolder\": \"/var/lib/openhab\",\r\n        \"logFolder\": \"/var/log/openhab\",\r\n        \"javaVersion\": \"17.0.9\",\r\n        \"javaVendor\": \"Azul Systems, Inc.\",\r\n        \"javaVendorVersion\": \"Zulu17.46+19-CA\",\r\n        \"osName\": \"Linux\",\r\n        \"osVersion\": \"6.5.11-4-pve\",\r\n        \"osArchitecture\": \"amd64\",\r\n        \"availableProcessors\": 2,\r\n        \"freeMemory\": 75885968,\r\n        \"totalMemory\": 494927872,\r\n        \"uptime\": 2150186,\r\n        \"startLevel\": 100\r\n    }\r\n}"
  },
  {
    "path": "dummy-data/openweather/weather",
    "content": "{\n  \"coord\": {\n      \"lon\": 3.0586,\n      \"lat\": 50.633\n  },\n  \"weather\": [\n      {\n          \"id\": 800,\n          \"main\": \"Clear\",\n          \"description\": \"clear sky\",\n          \"icon\": \"01d\"\n      }\n  ],\n  \"base\": \"stations\",\n  \"main\": {\n      \"temp\": 287.38,\n      \"feels_like\": 286.76,\n      \"temp_min\": 286.38,\n      \"temp_max\": 287.71,\n      \"pressure\": 1019,\n      \"humidity\": 73\n  },\n  \"visibility\": 10000,\n  \"wind\": {\n      \"speed\": 3.09,\n      \"deg\": 30\n  },\n  \"clouds\": {\n      \"all\": 0\n  },\n  \"dt\": 1718867378,\n  \"sys\": {\n      \"type\": 2,\n      \"id\": 2011132,\n      \"country\": \"FR\",\n      \"sunrise\": 1718854500,\n      \"sunset\": 1718913826\n  },\n  \"timezone\": 7200,\n  \"id\": 2998324,\n  \"name\": \"Lille\",\n  \"cod\": 200\n}"
  },
  {
    "path": "dummy-data/paperlessng/api/documents",
    "content": "{\n  \"count\": 1847,\n  \"next\": \"http://paperless.local/api/documents/?page=2\",\n  \"previous\": null,\n  \"all\": [1, 2, 3, 4, 5],\n  \"results\": [\n    {\n      \"id\": 1847,\n      \"correspondent\": 15,\n      \"document_type\": 12,\n      \"storage_path\": null,\n      \"title\": \"Bank Statement - January 2024\",\n      \"content\": \"Monthly bank statement with account summary and transaction details\",\n      \"tags\": [8, 15, 23],\n      \"created\": \"2024-01-15T10:30:00Z\",\n      \"created_date\": \"2024-01-15\",\n      \"modified\": \"2024-01-15T10:30:00Z\",\n      \"added\": \"2024-01-15T10:30:00Z\",\n      \"archive_serial_number\": \"ASN2024001847\",\n      \"original_file_name\": \"bank_statement_202401.pdf\",\n      \"archived_file_name\": \"0001847.pdf\"\n    },\n    {\n      \"id\": 1846,\n      \"correspondent\": 23,\n      \"document_type\": 5,\n      \"storage_path\": null,\n      \"title\": \"Utility Bill - Electric Company\",\n      \"content\": \"Monthly electricity bill for December 2023\",\n      \"tags\": [12, 18],\n      \"created\": \"2024-01-14T16:45:00Z\",\n      \"created_date\": \"2024-01-14\",\n      \"modified\": \"2024-01-14T16:45:00Z\",\n      \"added\": \"2024-01-14T16:45:00Z\",\n      \"archive_serial_number\": \"ASN2024001846\",\n      \"original_file_name\": \"electric_bill_202312.pdf\",\n      \"archived_file_name\": \"0001846.pdf\"\n    },\n    {\n      \"id\": 1845,\n      \"correspondent\": 7,\n      \"document_type\": 18,\n      \"storage_path\": null,\n      \"title\": \"Insurance Policy Renewal Notice\",\n      \"content\": \"Annual home insurance policy renewal documentation\",\n      \"tags\": [5, 11, 19],\n      \"created\": \"2024-01-13T14:20:00Z\",\n      \"created_date\": \"2024-01-13\",\n      \"modified\": \"2024-01-13T14:20:00Z\",\n      \"added\": \"2024-01-13T14:20:00Z\",\n      \"archive_serial_number\": \"ASN2024001845\",\n      \"original_file_name\": \"insurance_renewal_2024.pdf\",\n      \"archived_file_name\": \"0001845.pdf\"\n    },\n    {\n      \"id\": 1844,\n      \"correspondent\": 31,\n      \"document_type\": 9,\n      \"storage_path\": null,\n      \"title\": \"Tax Document - W2 Form 2023\",\n      \"content\": \"Annual W2 tax form from employer\",\n      \"tags\": [2, 14, 25],\n      \"created\": \"2024-01-12T09:15:00Z\",\n      \"created_date\": \"2024-01-12\",\n      \"modified\": \"2024-01-12T09:15:00Z\",\n      \"added\": \"2024-01-12T09:15:00Z\",\n      \"archive_serial_number\": \"ASN2024001844\",\n      \"original_file_name\": \"w2_form_2023.pdf\",\n      \"archived_file_name\": \"0001844.pdf\"\n    },\n    {\n      \"id\": 1843,\n      \"correspondent\": 42,\n      \"document_type\": 21,\n      \"storage_path\": null,\n      \"title\": \"Medical Records - Annual Checkup\",\n      \"content\": \"Annual physical examination results and health summary\",\n      \"tags\": [6, 17, 28],\n      \"created\": \"2024-01-11T11:30:00Z\",\n      \"created_date\": \"2024-01-11\",\n      \"modified\": \"2024-01-11T11:30:00Z\",\n      \"added\": \"2024-01-11T11:30:00Z\",\n      \"archive_serial_number\": \"ASN2024001843\",\n      \"original_file_name\": \"medical_checkup_2024.pdf\",\n      \"archived_file_name\": \"0001843.pdf\"\n    }\n  ]\n}"
  },
  {
    "path": "dummy-data/peanut/api/v1/devices/ups",
    "content": "{\n  \"battery.charge\": 100,\n  \"battery.voltage\": 13,\n  \"battery.voltage.high\": 13,\n  \"battery.voltage.low\": 10,\n  \"battery.voltage.nominal\": 12,\n  \"device.type\": \"ups\",\n  \"driver.flag.noscanlangid\": \"enabled\",\n  \"driver.flag.novendor\": \"enabled\",\n  \"driver.name\": \"nutdrv_qx\",\n  \"driver.parameter.langid_fix\": 1000,\n  \"driver.parameter.pollfreq\": 30,\n  \"driver.parameter.pollinterval\": 2,\n  \"driver.parameter.vendorid\": 1,\n  \"driver.version\": \"2.8.0\",\n  \"input.frequency\": 50,\n  \"input.frequency.nominal\": \"0\",\n  \"input.voltage\": 228,\n  \"input.voltage.fault\": \"0.0\",\n  \"input.voltage.nominal\": \"0\",\n  \"output.voltage\": 228,\n  \"ups.beeper.status\": \"disabled\",\n  \"ups.delay.shutdown\": 60,\n  \"ups.delay.start\": \"0\",\n  \"ups.load\": 50,\n  \"ups.productid\": \"0000\",\n  \"ups.status\": \"OL\",\n  \"ups.temperature\": 30,\n  \"ups.type\": \"offline / line interactive\"\n}"
  },
  {
    "path": "dummy-data/pialert/php/server/devices.php",
    "content": "[89,82,0,15,0,0]"
  },
  {
    "path": "dummy-data/pihole/api.php",
    "content": "{\n\t\"domains_being_blocked\": 152588,\n\t\"dns_queries_today\": 0,\n\t\"ads_blocked_today\": 0,\n\t\"percent_blocked\": 42,\n\t\"unique_domains\": 0,\n\t\"queries_forwarded\": 0,\n\t\"queries_cached\": 0,\n\t\"clients_ever_seen\": 0,\n\t\"unique_clients\": 0,\n\t\"dns_queries_all_types\": 0,\n\t\"reply_UNKNOWN\": 0,\n\t\"reply_NODATA\": 0,\n\t\"reply_NXDOMAIN\": 0,\n\t\"reply_CNAME\": 0,\n\t\"reply_IP\": 0,\n\t\"reply_DOMAIN\": 0,\n\t\"reply_RRNAME\": 0,\n\t\"reply_SERVFAIL\": 0,\n\t\"reply_REFUSED\": 0,\n\t\"reply_NOTIMP\": 0,\n\t\"reply_OTHER\": 0,\n\t\"reply_DNSSEC\": 0,\n\t\"reply_NONE\": 0,\n\t\"reply_BLOB\": 0,\n\t\"dns_queries_all_replies\": 0,\n\t\"privacy_level\": 0,\n\t\"status\": \"enabled\",\n\t\"gravity_last_updated\": {\n\t\t\"file_exists\": true,\n\t\t\"absolute\": 1665486627,\n\t\t\"relative\": {\n\t\t\t\"days\": 0,\n\t\t\t\"hours\": 0,\n\t\t\t\"minutes\": 22\n\t\t}\n\t}\n}"
  },
  {
    "path": "dummy-data/plex/library/sections",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<MediaContainer size=\"3\" allowSync=\"1\" art=\"/:/resources/show-fanart.jpg\" identifier=\"com.plexapp.plugins.library\" librarySectionID=\"0\" mediaTagPrefix=\"/system/bundle/media/flags/\" mediaTagVersion=\"1705317600\" thumb=\"/:/resources/show.png\" title1=\"Plex Library\" viewGroup=\"secondary\" viewMode=\"458752\">\n  <Directory allowSync=\"1\" art=\"/:/resources/movie-fanart.jpg\" composite=\"/library/sections/1/composite/1705317600\" filters=\"1\" refreshing=\"0\" thumb=\"/:/resources/movie.png\" key=\"1\" type=\"movie\" title=\"Movies\" agent=\"com.plexapp.agents.themoviedb\" scanner=\"Plex Movie\" language=\"en-US\" uuid=\"abcd1234-5678-90ab-cdef-123456789012\" updatedAt=\"1705317600\" createdAt=\"1704067200\" scannedAt=\"1705317000\" content=\"1\" directory=\"1\" contentChangedAt=\"1705316400\" hidden=\"0\">\n    <Location id=\"1\" path=\"/media/movies\" />\n  </Directory>\n  <Directory allowSync=\"1\" art=\"/:/resources/show-fanart.jpg\" composite=\"/library/sections/2/composite/1705317600\" filters=\"1\" refreshing=\"0\" thumb=\"/:/resources/show.png\" key=\"2\" type=\"show\" title=\"TV Shows\" agent=\"com.plexapp.agents.thetvdb\" scanner=\"Plex TV Series\" language=\"en-US\" uuid=\"efgh5678-90ab-cdef-1234-567890abcdef\" updatedAt=\"1705317600\" createdAt=\"1704067200\" scannedAt=\"1705317000\" content=\"1\" directory=\"1\" contentChangedAt=\"1705316400\" hidden=\"0\">\n    <Location id=\"2\" path=\"/media/tv\" />\n  </Directory>\n  <Directory allowSync=\"1\" art=\"/:/resources/artist-fanart.jpg\" composite=\"/library/sections/3/composite/1705317600\" filters=\"1\" refreshing=\"0\" thumb=\"/:/resources/artist.png\" key=\"3\" type=\"artist\" title=\"Music\" agent=\"com.plexapp.agents.lastfm\" scanner=\"Plex Music\" language=\"en-US\" uuid=\"ijkl9012-3456-789a-bcde-f0123456789a\" updatedAt=\"1705317600\" createdAt=\"1704067200\" scannedAt=\"1705317000\" content=\"1\" directory=\"1\" contentChangedAt=\"1705316400\" hidden=\"0\">\n    <Location id=\"3\" path=\"/media/music\" />\n  </Directory>\n</MediaContainer>"
  },
  {
    "path": "dummy-data/plex/library/sections1all",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<MediaContainer size=\"247\" allowSync=\"1\" art=\"/:/resources/movie-fanart.jpg\" identifier=\"com.plexapp.plugins.library\" librarySectionID=\"1\" librarySectionTitle=\"Movies\" librarySectionUUID=\"abcd1234-5678-90ab-cdef-123456789012\" mediaTagPrefix=\"/system/bundle/media/flags/\" mediaTagVersion=\"1705317600\" mixedParents=\"0\" nocache=\"1\" offset=\"0\" thumb=\"/:/resources/movie.png\" title1=\"Movies\" title2=\"All Movies\" totalSize=\"247\" viewGroup=\"movie\" viewMode=\"458752\">\n  <Video ratingKey=\"1001\" key=\"/library/metadata/1001\" guid=\"plex://movie/5d9c086fe98e47001e0d5001\" type=\"movie\" title=\"Inception\" titleSort=\"Inception\" contentRating=\"PG-13\" summary=\"A thief who steals corporate secrets through dream-sharing technology.\" rating=\"8.8\" audienceRating=\"9.1\" year=\"2010\" tagline=\"Your mind is the scene of the crime\" thumb=\"/library/metadata/1001/thumb/1705317600\" art=\"/library/metadata/1001/art/1705317600\" duration=\"8880000\" originallyAvailableAt=\"2010-07-16\" addedAt=\"1705144800\" updatedAt=\"1705317600\">\n    <Media id=\"2001\" duration=\"8880000\" bitrate=\"12000\" width=\"1920\" height=\"1080\" aspectRatio=\"1.78\" audioChannels=\"6\" audioCodec=\"dts\" videoCodec=\"h264\" videoResolution=\"1080\" container=\"mkv\" videoFrameRate=\"24p\" audioProfile=\"dts\" videoProfile=\"high\" />\n  </Video>\n  <!-- 246 more movies would be here, showing just one for brevity -->\n</MediaContainer>"
  },
  {
    "path": "dummy-data/plex/library/sections2all",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<MediaContainer size=\"89\" allowSync=\"1\" art=\"/:/resources/show-fanart.jpg\" identifier=\"com.plexapp.plugins.library\" librarySectionID=\"2\" librarySectionTitle=\"TV Shows\" librarySectionUUID=\"efgh5678-90ab-cdef-1234-567890abcdef\" mediaTagPrefix=\"/system/bundle/media/flags/\" mediaTagVersion=\"1705317600\" mixedParents=\"0\" nocache=\"1\" offset=\"0\" thumb=\"/:/resources/show.png\" title1=\"TV Shows\" title2=\"All Shows\" totalSize=\"89\" viewGroup=\"show\" viewMode=\"458752\">\n  <Directory ratingKey=\"2001\" key=\"/library/metadata/2001\" guid=\"plex://show/5d9c081fe98e47001e0c2001\" type=\"show\" title=\"Breaking Bad\" titleSort=\"Breaking Bad\" contentRating=\"TV-MA\" summary=\"A high school chemistry teacher diagnosed with inoperable lung cancer turns to manufacturing and selling methamphetamine in order to secure his family's future.\" rating=\"9.5\" year=\"2008\" thumb=\"/library/metadata/2001/thumb/1705317600\" art=\"/library/metadata/2001/art/1705317600\" banner=\"/library/metadata/2001/banner/1705317600\" duration=\"2820000\" originallyAvailableAt=\"2008-01-20\" leafCount=\"62\" viewedLeafCount=\"62\" childCount=\"5\" addedAt=\"1705144800\" updatedAt=\"1705317600\">\n    <Genre tag=\"Crime\" />\n    <Genre tag=\"Drama\" />\n    <Genre tag=\"Thriller\" />\n  </Directory>\n  <Directory ratingKey=\"2002\" key=\"/library/metadata/2002\" guid=\"plex://show/5d9c081fe98e47001e0c2002\" type=\"show\" title=\"Friends\" titleSort=\"Friends\" contentRating=\"TV-14\" summary=\"Follows the personal and professional lives of six twenty to thirty-something-year-old friends living in Manhattan.\" rating=\"8.9\" year=\"1994\" thumb=\"/library/metadata/2002/thumb/1705317600\" art=\"/library/metadata/2002/art/1705317600\" banner=\"/library/metadata/2002/banner/1705317600\" duration=\"1320000\" originallyAvailableAt=\"1994-09-22\" leafCount=\"236\" viewedLeafCount=\"236\" childCount=\"10\" addedAt=\"1705144800\" updatedAt=\"1705317600\">\n    <Genre tag=\"Comedy\" />\n    <Genre tag=\"Romance\" />\n  </Directory>\n  <!-- 87 more shows would be here, showing just two for brevity -->\n</MediaContainer>"
  },
  {
    "path": "dummy-data/plex/status/sessions",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<MediaContainer size=\"2\" allowCameraUpload=\"1\" allowChannelAccess=\"1\" allowMediaDeletion=\"1\" allowSharing=\"1\" allowSync=\"1\" backgroundProcessing=\"1\" certificateVersion=\"2\" companionProxy=\"1\" countryCode=\"US\" diagnostics=\"logs,databases,streaminglogs\" eventStream=\"1\" friendlyName=\"Homer-Plex-Server\" hubSearch=\"1\" itemClusters=\"1\" livenessTimeout=\"0\" machineIdentifier=\"abc123def456ghi789jkl012\" mediaProviders=\"1\" multiuser=\"1\" myPlex=\"1\" myPlexMappingState=\"mapped\" myPlexSigninState=\"ok\" myPlexSubscription=\"1\" myPlexUsername=\"homer@example.com\" offlineTranscode=\"1\" ownerFeatures=\"adaptive_bitrate,collections,content_filter,dvr,hardware_transcoding,home,loudness_analysis,music_videos,pass,photo_autotags,premium_music_metadata,session_bandwidth_restrictions,sync,trailers,webhooks\" photoAutoTag=\"1\" platform=\"Linux\" platformVersion=\"6.5.0-15-generic\" pluginHost=\"1\" pushNotifications=\"1\" readOnlyLibraries=\"1\" requestParametersInCookie=\"1\" streamingBrainABRVersion=\"3\" streamingBrainVersion=\"2\" sync=\"1\" transcoderActiveVideoSessions=\"0\" transcoderAudio=\"1\" transcoderLyrics=\"1\" transcoderPhoto=\"1\" transcoderSubtitles=\"1\" transcoderVideo=\"1\" transcoderVideoBitrates=\"64,96,208,320,720,1500,2000,3000,4000,8000,10000,12000,20000\" transcoderVideoQualities=\"0,1,2,3,4,5,6,7,8,9,10,11,12\" transcoderVideoResolutions=\"128,128,160,240,320,480,768,720,720,1080,1080,1080,1080\" updatedAt=\"1705317600\" updater=\"1\" version=\"1.40.1.8227-c0dd5a73e\" voiceSearch=\"1\">\n  <Video sessionKey=\"1\" key=\"/library/metadata/12345\" parentKey=\"/library/metadata/12300\" grandparentKey=\"/library/metadata/12000\" guid=\"plex://episode/5d9c086fe98e47001e0d5c3f\" parentGuid=\"plex://season/602e67d31d3358002d2fb2bd\" grandparentGuid=\"plex://show/5d9c081fe98e47001e0c8382\" type=\"episode\" title=\"The One Where Monica Gets a Roommate\" titleSort=\"One Where Monica Gets a Roommate, The\" grandparentTitle=\"Friends\" parentTitle=\"Season 1\" contentRating=\"TV-14\" summary=\"Monica and the gang introduce Rachel to the real world after she leaves her fiancé at the altar.\" index=\"1\" parentIndex=\"1\" lastViewedAt=\"1705315800\" year=\"1994\" thumb=\"/library/metadata/12345/thumb/1705317600\" art=\"/library/metadata/12000/art/1705317600\" parentThumb=\"/library/metadata/12300/thumb/1705317600\" grandparentThumb=\"/library/metadata/12000/thumb/1705317600\" grandparentArt=\"/library/metadata/12000/art/1705317600\" duration=\"1380000\" originallyAvailableAt=\"1994-09-22\" addedAt=\"1705230400\" updatedAt=\"1705317600\" chapterSource=\"media\" primaryExtraKey=\"/library/metadata/12346\" ratingKey=\"12345\" viewOffset=\"420000\" skipCount=\"1\">\n    <Media id=\"67890\" duration=\"1380000\" bitrate=\"8137\" width=\"1920\" height=\"1080\" aspectRatio=\"1.78\" audioChannels=\"2\" audioCodec=\"aac\" videoCodec=\"h264\" videoResolution=\"1080\" container=\"mkv\" videoFrameRate=\"24p\" audioProfile=\"lc\" videoProfile=\"high\">\n      <Part accessible=\"1\" exists=\"1\" id=\"98765\" key=\"/library/parts/98765/1705317600/file.mkv\" duration=\"1380000\" file=\"/media/tv/Friends/Season 01/Friends - S01E01 - The One Where Monica Gets a Roommate.mkv\" size=\"1398101419\" audioProfile=\"lc\" container=\"mkv\" indexes=\"sd\" videoProfile=\"high\" />\n    </Media>\n    <User id=\"1\" thumb=\"https://plex.tv/users/abc123def456/avatar?c=1705317600\" title=\"John Doe\" />\n    <Player address=\"192.168.1.100\" device=\"Chrome\" machineIdentifier=\"browser-chrome-192-168-1-100\" model=\"hosted\" platform=\"Chrome\" platformVersion=\"120\" product=\"Plex Web\" profile=\"Web\" remotePublicAddress=\"203.0.113.1\" state=\"playing\" title=\"Chrome (John's Desktop)\" userID=\"1\" vendor=\"Google\" version=\"4.126.1\" />\n    <Session id=\"session123abc\" bandwidth=\"8000\" location=\"lan\" />\n  </Video>\n  <Video sessionKey=\"2\" key=\"/library/metadata/54321\" guid=\"plex://movie/5d9c086fe98e47001e0d5c4a\" type=\"movie\" title=\"The Matrix\" titleSort=\"Matrix, The\" contentRating=\"R\" summary=\"A computer hacker learns from mysterious rebels about the true nature of his reality and his role in the war against its controllers.\" rating=\"8.7\" year=\"1999\" tagline=\"The fight for the future begins\" thumb=\"/library/metadata/54321/thumb/1705317600\" art=\"/library/metadata/54321/art/1705317600\" duration=\"8160000\" originallyAvailableAt=\"1999-03-31\" addedAt=\"1705144800\" updatedAt=\"1705317600\" chapterSource=\"media\" ratingKey=\"54321\" viewOffset=\"2100000\">\n    <Media id=\"11223\" duration=\"8160000\" bitrate=\"15463\" width=\"1920\" height=\"816\" aspectRatio=\"2.35\" audioChannels=\"6\" audioCodec=\"dts\" videoCodec=\"h264\" videoResolution=\"1080\" container=\"mkv\" videoFrameRate=\"24p\" audioProfile=\"dts\" videoProfile=\"high\">\n      <Part accessible=\"1\" exists=\"1\" id=\"33445\" key=\"/library/parts/33445/1705317600/file.mkv\" duration=\"8160000\" file=\"/media/movies/The Matrix (1999)/The Matrix (1999).mkv\" size=\"15758698701\" audioProfile=\"dts\" container=\"mkv\" indexes=\"sd\" videoProfile=\"high\" />\n    </Media>\n    <User id=\"2\" thumb=\"https://plex.tv/users/def456ghi789/avatar?c=1705317600\" title=\"Jane Smith\" />\n    <Player address=\"192.168.1.101\" device=\"Android\" machineIdentifier=\"android-phone-samsung\" model=\"SM-G991B\" platform=\"Android\" platformVersion=\"14\" product=\"Plex for Android\" profile=\"Mobile\" remotePublicAddress=\"203.0.113.2\" state=\"paused\" title=\"Samsung Galaxy S21\" userID=\"2\" vendor=\"Samsung\" version=\"9.8.1\" />\n    <Session id=\"session456def\" bandwidth=\"4000\" location=\"lan\" />\n  </Video>\n</MediaContainer>"
  },
  {
    "path": "dummy-data/portainer/api/endpoints",
    "content": "[\n  {\n    \"Id\": 1,\n    \"Name\": \"local\",\n    \"Type\": 1,\n    \"URL\": \"unix:///var/run/docker.sock\",\n    \"GroupId\": 1,\n    \"PublicURL\": \"\",\n    \"Status\": 1,\n    \"UserAccessPolicies\": {},\n    \"TeamAccessPolicies\": {},\n    \"Extensions\": [],\n    \"TagIds\": [],\n    \"AssociatedEndpoints\": [],\n    \"Snapshots\": [\n      {\n        \"Time\": 1705317600,\n        \"DockerVersion\": \"24.0.7\",\n        \"Swarm\": false,\n        \"TotalCPU\": 8,\n        \"TotalMemory\": 16777216000,\n        \"RunningContainerCount\": 12,\n        \"StoppedContainerCount\": 5,\n        \"HealthyContainerCount\": 10,\n        \"UnhealthyContainerCount\": 0,\n        \"VolumeCount\": 25,\n        \"ImageCount\": 47,\n        \"ServiceCount\": 0,\n        \"StackCount\": 0,\n        \"DockerRootDir\": \"/var/lib/docker\"\n      }\n    ],\n    \"Kubernetes\": {\n      \"Snapshots\": []\n    },\n    \"Agent\": {\n      \"NodeName\": \"\",\n      \"ChecklnInterval\": 5,\n      \"Version\": \"\"\n    },\n    \"Edge\": {\n      \"AsyncMode\": false,\n      \"PingInterval\": 60,\n      \"CommandInterval\": 5,\n      \"SnapshotInterval\": 5\n    }\n  },\n  {\n    \"Id\": 2, \n    \"Name\": \"production\",\n    \"Type\": 2,\n    \"URL\": \"tcp://prod-docker:2376\",\n    \"GroupId\": 1,\n    \"PublicURL\": \"https://prod-docker.example.com\",\n    \"Status\": 1,\n    \"UserAccessPolicies\": {},\n    \"TeamAccessPolicies\": {},\n    \"Extensions\": [],\n    \"TagIds\": [],\n    \"AssociatedEndpoints\": [],\n    \"Snapshots\": [\n      {\n        \"Time\": 1705317600,\n        \"DockerVersion\": \"24.0.7\",\n        \"Swarm\": false,\n        \"TotalCPU\": 16,\n        \"TotalMemory\": 33554432000,\n        \"RunningContainerCount\": 25,\n        \"StoppedContainerCount\": 3,\n        \"HealthyContainerCount\": 23,\n        \"UnhealthyContainerCount\": 2,\n        \"VolumeCount\": 40,\n        \"ImageCount\": 75,\n        \"ServiceCount\": 0,\n        \"StackCount\": 0,\n        \"DockerRootDir\": \"/var/lib/docker\"\n      }\n    ],\n    \"Kubernetes\": {\n      \"Snapshots\": []\n    },\n    \"Agent\": {\n      \"NodeName\": \"\",\n      \"ChecklnInterval\": 5,\n      \"Version\": \"\"\n    },\n    \"Edge\": {\n      \"AsyncMode\": false,\n      \"PingInterval\": 60,\n      \"CommandInterval\": 5,\n      \"SnapshotInterval\": 5\n    }\n  }\n]"
  },
  {
    "path": "dummy-data/portainer/api/endpoints.backup",
    "content": "[\n  {\n    \"Id\": 1,\n    \"Name\": \"local\",\n    \"Type\": 1,\n    \"URL\": \"unix:///var/run/docker.sock\",\n    \"GroupId\": 1,\n    \"PublicURL\": \"\",\n    \"Status\": 1,\n    \"UserAccessPolicies\": {},\n    \"TeamAccessPolicies\": {},\n    \"Extensions\": [],\n    \"TagIds\": [],\n    \"AssociatedEndpoints\": [],\n    \"Snapshots\": [\n      {\n        \"Time\": 1705317600,\n        \"DockerVersion\": \"24.0.7\",\n        \"Swarm\": false,\n        \"TotalCPU\": 8,\n        \"TotalMemory\": 16777216000,\n        \"RunningContainerCount\": 12,\n        \"StoppedContainerCount\": 5,\n        \"HealthyContainerCount\": 10,\n        \"UnhealthyContainerCount\": 0,\n        \"VolumeCount\": 25,\n        \"ImageCount\": 47,\n        \"ServiceCount\": 0,\n        \"StackCount\": 0,\n        \"DockerRootDir\": \"/var/lib/docker\"\n      }\n    ],\n    \"Kubernetes\": {\n      \"Snapshots\": []\n    },\n    \"Agent\": {\n      \"NodeName\": \"\",\n      \"ChecklnInterval\": 5,\n      \"Version\": \"\"\n    },\n    \"Edge\": {\n      \"AsyncMode\": false,\n      \"PingInterval\": 60,\n      \"CommandInterval\": 5,\n      \"SnapshotInterval\": 5\n    }\n  },\n  {\n    \"Id\": 2, \n    \"Name\": \"production\",\n    \"Type\": 2,\n    \"URL\": \"tcp://prod-docker:2376\",\n    \"GroupId\": 1,\n    \"PublicURL\": \"https://prod-docker.example.com\",\n    \"Status\": 1,\n    \"UserAccessPolicies\": {},\n    \"TeamAccessPolicies\": {},\n    \"Extensions\": [],\n    \"TagIds\": [],\n    \"AssociatedEndpoints\": [],\n    \"Snapshots\": [\n      {\n        \"Time\": 1705317600,\n        \"DockerVersion\": \"24.0.7\",\n        \"Swarm\": false,\n        \"TotalCPU\": 16,\n        \"TotalMemory\": 33554432000,\n        \"RunningContainerCount\": 25,\n        \"StoppedContainerCount\": 3,\n        \"HealthyContainerCount\": 23,\n        \"UnhealthyContainerCount\": 2,\n        \"VolumeCount\": 40,\n        \"ImageCount\": 75,\n        \"ServiceCount\": 0,\n        \"StackCount\": 0,\n        \"DockerRootDir\": \"/var/lib/docker\"\n      }\n    ],\n    \"Kubernetes\": {\n      \"Snapshots\": []\n    },\n    \"Agent\": {\n      \"NodeName\": \"\",\n      \"ChecklnInterval\": 5,\n      \"Version\": \"\"\n    },\n    \"Edge\": {\n      \"AsyncMode\": false,\n      \"PingInterval\": 60,\n      \"CommandInterval\": 5,\n      \"SnapshotInterval\": 5\n    }\n  }\n]"
  },
  {
    "path": "dummy-data/portainer/api/status",
    "content": "{\n  \"Version\": \"2.19.4\",\n  \"APIVersion\": \"2.19.4\",\n  \"DatabaseVersion\": \"34\",\n  \"Build\": {\n    \"BuildNumber\": \"1234567890\",\n    \"ImageTag\": \"2.19.4-alpine\",\n    \"NodejsVersion\": \"v18.17.1\",\n    \"YarnVersion\": \"1.22.19\",\n    \"WebpackVersion\": \"5.88.2\",\n    \"GoVersion\": \"go1.21.5\"\n  },\n  \"InstanceID\": \"portainer-instance-abc123def456\",\n  \"Edition\": \"CE\",\n  \"DemoEnvironment\": {\n    \"Enabled\": false,\n    \"URL\": \"\"\n  },\n  \"AnalyticsEnabled\": false,\n  \"AuthenticationMethod\": 1,\n  \"Users\": 3,\n  \"ValidLicense\": false,\n  \"LicenseInfo\": {\n    \"Company\": \"\",\n    \"CreatedAt\": 0,\n    \"ExpiresAt\": 0,\n    \"LicenseKey\": \"\",\n    \"ProductEdition\": \"\",\n    \"Seats\": 0,\n    \"Valid\": false\n  },\n  \"RequiredPasswordLength\": 12,\n  \"UserSessionTimeout\": \"8h\",\n  \"Features\": {\n    \"EdgeDeviceUntrustedMode\": false\n  },\n  \"EdgeAgentCheckinIntervalSeconds\": 5\n}"
  },
  {
    "path": "dummy-data/prometheus/api/v1/alerts",
    "content": "{\n  \"status\": \"success\",\n  \"data\": {\n    \"alerts\": [\n      {\n        \"labels\": {\n          \"alertname\": \"HighCPUUsage\",\n          \"instance\": \"localhost:9090\",\n          \"job\": \"prometheus\",\n          \"severity\": \"warning\"\n        },\n        \"annotations\": {\n          \"description\": \"CPU usage is above 80% for more than 5 minutes\",\n          \"summary\": \"High CPU usage detected\"\n        },\n        \"state\": \"firing\",\n        \"activeAt\": \"2024-01-15T10:30:00.000Z\",\n        \"value\": \"85.2\"\n      },\n      {\n        \"labels\": {\n          \"alertname\": \"HighMemoryUsage\", \n          \"instance\": \"web-server-01:9090\",\n          \"job\": \"node-exporter\",\n          \"severity\": \"critical\"\n        },\n        \"annotations\": {\n          \"description\": \"Memory usage is above 90% for more than 10 minutes\",\n          \"summary\": \"Critical memory usage detected\"\n        },\n        \"state\": \"firing\",\n        \"activeAt\": \"2024-01-15T10:25:00.000Z\",\n        \"value\": \"92.8\"\n      },\n      {\n        \"labels\": {\n          \"alertname\": \"DiskSpaceLow\",\n          \"instance\": \"db-server-01:9090\", \n          \"job\": \"node-exporter\",\n          \"severity\": \"warning\",\n          \"device\": \"/dev/sda1\"\n        },\n        \"annotations\": {\n          \"description\": \"Disk space is below 20% on {{ $labels.device }}\",\n          \"summary\": \"Low disk space warning\"\n        },\n        \"state\": \"pending\",\n        \"activeAt\": \"2024-01-15T11:00:00.000Z\",\n        \"value\": \"18.5\"\n      },\n      {\n        \"labels\": {\n          \"alertname\": \"ServiceDown\",\n          \"instance\": \"api-server-02:8080\",\n          \"job\": \"api-health-check\",\n          \"severity\": \"critical\",\n          \"service\": \"user-api\"\n        },\n        \"annotations\": {\n          \"description\": \"Service {{ $labels.service }} is not responding\",\n          \"summary\": \"Service is down\"\n        },\n        \"state\": \"pending\",\n        \"activeAt\": \"2024-01-15T11:10:00.000Z\",\n        \"value\": \"0\"\n      },\n      {\n        \"labels\": {\n          \"alertname\": \"DatabaseConnections\",\n          \"instance\": \"db-server-01:5432\",\n          \"job\": \"postgres-exporter\", \n          \"severity\": \"info\"\n        },\n        \"annotations\": {\n          \"description\": \"Database connection count is normal\",\n          \"summary\": \"Database connections stable\"\n        },\n        \"state\": \"inactive\",\n        \"activeAt\": \"2024-01-15T09:00:00.000Z\",\n        \"value\": \"45\"\n      },\n      {\n        \"labels\": {\n          \"alertname\": \"HTTPResponseTime\",\n          \"instance\": \"web-server-02:80\",\n          \"job\": \"blackbox-exporter\",\n          \"severity\": \"info\"\n        },\n        \"annotations\": {\n          \"description\": \"HTTP response time is within acceptable limits\",\n          \"summary\": \"Response time normal\"\n        },\n        \"state\": \"inactive\",\n        \"activeAt\": \"2024-01-15T08:30:00.000Z\",\n        \"value\": \"150\"\n      }\n    ]\n  }\n}"
  },
  {
    "path": "dummy-data/prowlarr/api/v1/health",
    "content": "[\n  {\n    \"source\": \"IndexerStatusCheck\",\n    \"type\": \"warning\",\n    \"message\": \"Indexer 1337x has been disabled due to recent failures: Connection timeout after 30 seconds\",\n    \"wikiUrl\": \"https://wiki.servarr.com/prowlarr/health#indexers-are-unavailable-due-to-recent-failures\"\n  },\n  {\n    \"source\": \"IndexerRSSCheck\",\n    \"type\": \"ok\",\n    \"message\": \"All indexer RSS feeds are functioning normally\"\n  },\n  {\n    \"source\": \"ApplicationStatusCheck\", \n    \"type\": \"warning\",\n    \"message\": \"Application Sonarr sync failed: Unable to connect to Sonarr at http://sonarr:8989\",\n    \"wikiUrl\": \"https://wiki.servarr.com/prowlarr/health#applications-are-unavailable-due-to-recent-failures\"\n  },\n  {\n    \"source\": \"UpdateCheck\",\n    \"type\": \"ok\",\n    \"message\": \"Update available: 1.11.4.4173 -> 1.12.2.4211\"\n  },\n  {\n    \"source\": \"IndexerSearchCheck\",\n    \"type\": \"error\",\n    \"message\": \"Indexer TorrentLeech returned invalid search results: Malformed JSON response\",\n    \"wikiUrl\": \"https://wiki.servarr.com/prowlarr/health#indexer-search-failures\"\n  },\n  {\n    \"source\": \"ProxyCheck\",\n    \"type\": \"ok\",\n    \"message\": \"No proxy configuration issues detected\"\n  },\n  {\n    \"source\": \"IndexerLongTermStatusCheck\",\n    \"type\": \"warning\",\n    \"message\": \"Indexer RARBG has been failing for more than 6 hours: HTTP 403 Forbidden\",\n    \"wikiUrl\": \"https://wiki.servarr.com/prowlarr/health#indexers-are-unavailable-due-to-recent-failures\"\n  }\n]"
  },
  {
    "path": "dummy-data/proxmox/api2/json/nodes/node1/lxc",
    "content": "{\n\t\"data\": [{\n\t\t\"disk\": 0,\n\t\t\"mem\": 983848043,\n\t\t\"cpus\": 2,\n\t\t\"pid\": 1218,\n\t\t\"maxdisk\": 107374182400,\n\t\t\"netin\": 43863882954,\n\t\t\"diskread\": 0,\n\t\t\"diskwrite\": 0,\n\t\t\"name\": \"HAOS\",\n\t\t\"netout\": 10426448652,\n\t\t\"cpu\": 0.00879886290177172,\n\t\t\"uptime\": 3390069,\n\t\t\"status\": \"running\",\n\t\t\"maxmem\": 3221225472,\n\t\t\"vmid\": 100\n\t}, {\n\t\t\"cpu\": 0.00219971572544293,\n\t\t\"name\": \"debian1\",\n\t\t\"netout\": 919020028,\n\t\t\"vmid\": 101,\n\t\t\"maxmem\": 4294967296,\n\t\t\"uptime\": 3390064,\n\t\t\"status\": \"running\",\n\t\t\"maxdisk\": 107374182400,\n\t\t\"pid\": 1295,\n\t\t\"cpus\": 2,\n\t\t\"disk\": 0,\n\t\t\"mem\": 2755160795,\n\t\t\"diskread\": 0,\n\t\t\"diskwrite\": 0,\n\t\t\"netin\": 5105600872\n\t}]\n}"
  },
  {
    "path": "dummy-data/proxmox/api2/json/nodes/node1/qemu",
    "content": "{\n\t\"data\": [{\n\t\t\"disk\": 0,\n\t\t\"mem\": 983848043,\n\t\t\"cpus\": 2,\n\t\t\"pid\": 1218,\n\t\t\"maxdisk\": 107374182400,\n\t\t\"netin\": 43863882954,\n\t\t\"diskread\": 0,\n\t\t\"diskwrite\": 0,\n\t\t\"name\": \"HAOS\",\n\t\t\"netout\": 10426448652,\n\t\t\"cpu\": 0.00879886290177172,\n\t\t\"uptime\": 3390069,\n\t\t\"status\": \"running\",\n\t\t\"maxmem\": 3221225472,\n\t\t\"vmid\": 100\n\t}, {\n\t\t\"cpu\": 0.00219971572544293,\n\t\t\"name\": \"debian1\",\n\t\t\"netout\": 919020028,\n\t\t\"vmid\": 101,\n\t\t\"maxmem\": 4294967296,\n\t\t\"uptime\": 3390064,\n\t\t\"status\": \"running\",\n\t\t\"maxdisk\": 107374182400,\n\t\t\"pid\": 1295,\n\t\t\"cpus\": 2,\n\t\t\"disk\": 0,\n\t\t\"mem\": 2755160795,\n\t\t\"diskread\": 0,\n\t\t\"diskwrite\": 0,\n\t\t\"netin\": 5105600872\n\t}]\n}"
  },
  {
    "path": "dummy-data/proxmox/api2/json/nodes/node1/status",
    "content": "{\n    \"data\": {\n        \"swap\": {\n            \"free\": 8589930496,\n            \"total\": 8589930496,\n            \"used\": 0\n        },\n        \"cpuinfo\": {\n            \"model\": \"Intel(R) Core(TM) i7-4790 CPU @3.60GHz\",\n            \"hvm\": \"1\",\n            \"user_hz\": 100,\n            \"sockets\": 1,\n            \"cpus\": 8,\n            \"flags\": \"fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc cpuid aperfmperf pni pclmulqdq dtes64 monitor ds_cpl vmx smx est tm2 ssse3 sdbg fma cx16 xtpr pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt aes xsave avx f16c rdrand lahf_lm abm cpuid_fault epb invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm xsaveopt dtherm ida arat pln pts\",\n            \"cores\": 4,\n            \"mhz\": \"4000.000\"\n        },\n        \"idle\": 0,\n        \"memory\": {\n            \"used\": 6283382784,\n            \"total\": 12419133440,\n            \"free\": 6135750656\n        },\n        \"rootfs\": {\n            \"avail\": 22670036992,\n            \"free\": 24176627712,\n            \"total\": 29148368896,\n            \"used\": 4971741184\n        },\n        \"uptime\": 3390081,\n        \"ksm\": {\n            \"shared\": 1079975936\n        },\n        \"cpu\": 0.00440286186020914,\n        \"kversion\": \"Linux 5.15.30-2-pve #1 SMP PVE 5.15.30-3 (Fri, 22 Apr 2022 18: 08: 27+0200)\",\n        \"loadavg\": [\n            \"0.00\",\n            \"0.01\",\n            \"0.04\"\n        ],\n        \"pveversion\": \"pve-manager/7.2-3/c743d6c1\",\n        \"wait\": 0.00330214639515685\n    }\n}"
  },
  {
    "path": "dummy-data/qBittorrent/api/v2/torrents/info",
    "content": "[\n  {\n    \"added_on\": 1666985518,\n    \"amount_left\": 0,\n    \"auto_tmm\": false,\n    \"availability\": -1,\n    \"category\": \"\",\n    \"completed\": 1474873344,\n    \"completion_on\": 1666985584,\n    \"content_path\": \"/downloads/ubuntu-22.04.1-live-server-amd64.iso\",\n    \"dl_limit\": -1,\n    \"dlspeed\": 0,\n    \"download_path\": \"\",\n    \"downloaded\": 1513976240,\n    \"downloaded_session\": 0,\n    \"eta\": 8640000,\n    \"f_l_piece_prio\": false,\n    \"force_start\": false,\n    \"hash\": \"cf3ea75e2ebbd30e0da6e6e215e2226bf35f2e33\",\n    \"infohash_v1\": \"cf3ea75e2ebbd30e0da6e6e215e2226bf35f2e33\",\n    \"infohash_v2\": \"\",\n    \"last_activity\": 1666985588,\n    \"magnet_uri\": \"magnet:?xt=urn:btih:cf3ea75e2ebbd30e0da6e6e215e2226bf35f2e33&dn=ubuntu-22.04.1-live-server-amd64.iso&tr=https%3a%2f%2ftorrent.ubuntu.com%2fannounce&tr=https%3a%2f%2fipv6.torrent.ubuntu.com%2fannounce\",\n    \"max_ratio\": 0,\n    \"max_seeding_time\": -1,\n    \"name\": \"ubuntu-22.04.1-live-server-amd64.iso\",\n    \"num_complete\": 0,\n    \"num_incomplete\": 583,\n    \"num_leechs\": 0,\n    \"num_seeds\": 0,\n    \"priority\": 0,\n    \"progress\": 1,\n    \"ratio\": 1.7163413343924075e-05,\n    \"ratio_limit\": -2,\n    \"save_path\": \"/downloads/\",\n    \"seeding_time\": 4,\n    \"seeding_time_limit\": -2,\n    \"seen_complete\": 1666985584,\n    \"seq_dl\": false,\n    \"size\": 1474873344,\n    \"state\": \"pausedUP\",\n    \"super_seeding\": false,\n    \"tags\": \"\",\n    \"time_active\": 69,\n    \"total_size\": 1474873344,\n    \"tracker\": \"\",\n    \"trackers_count\": 2,\n    \"up_limit\": -1,\n    \"uploaded\": 25985,\n    \"uploaded_session\": 0,\n    \"upspeed\": 0\n  }\n]"
  },
  {
    "path": "dummy-data/qBittorrent/api/v2/transfer/info",
    "content": "{\n\t\"connection_status\": \"connected\",\n\t\"dht_nodes\": 318,\n\t\"dl_info_data\": 23481469329,\n\t\"dl_info_speed\": 1234567,\n\t\"dl_rate_limit\": 40960000,\n\t\"up_info_data\": 1788370216,\n\t\"up_info_speed\": 765432,\n\t\"up_rate_limit\": 10547200\n}"
  },
  {
    "path": "dummy-data/radarr/api/v3/health",
    "content": "[\n  {\n    \"source\": \"IndexerStatusCheck\",\n    \"type\": \"warning\",\n    \"message\": \"Indexer TorrentDay is unavailable due to recent indexer errors: HTTP 503 Service Unavailable\",\n    \"wikiUrl\": \"https://wiki.servarr.com/radarr/health#indexers-are-unavailable-due-to-recent-failures\"\n  },\n  {\n    \"source\": \"ImportMechanismCheck\",\n    \"type\": \"ok\",\n    \"message\": \"No issues with import mechanism checks\"\n  },\n  {\n    \"source\": \"DownloadClientStatusCheck\",\n    \"type\": \"ok\",\n    \"message\": \"All download clients are available\"\n  },\n  {\n    \"source\": \"RootFolderCheck\",\n    \"type\": \"warning\",\n    \"message\": \"Missing root folder: /movies\",\n    \"wikiUrl\": \"https://wiki.servarr.com/radarr/health#missing-root-folder\"\n  },\n  {\n    \"source\": \"UpdateCheck\",\n    \"type\": \"ok\",\n    \"message\": \"Update available: 4.7.5.7809 -> 5.0.3.8127\"\n  },\n  {\n    \"source\": \"DiskSpaceCheck\",\n    \"type\": \"error\",\n    \"message\": \"Disk space is critically low on /movies: 2.1 GB remaining\",\n    \"wikiUrl\": \"https://wiki.servarr.com/radarr/health#disk-space\"\n  }\n]"
  },
  {
    "path": "dummy-data/radarr/api/v3/queue",
    "content": "{\n  \"page\": 1,\n  \"pageSize\": 20,\n  \"sortKey\": \"progress\",\n  \"sortDirection\": \"descending\",\n  \"totalRecords\": 2,\n  \"records\": []\n}"
  },
  {
    "path": "dummy-data/radarr/api/v3/queuedetails",
    "content": "[\n  {\n    \"movieId\": 1,\n    \"title\": \"Inception (2010)\",\n    \"trackedDownloadStatus\": \"ok\",\n    \"trackedDownloadState\": \"importPending\",\n    \"id\": 1\n  },\n  {\n    \"movieId\": 2,\n    \"title\": \"The Matrix (1999)\",\n    \"trackedDownloadStatus\": \"warning\",\n    \"trackedDownloadState\": \"downloading\",\n    \"id\": 2\n  }\n]"
  },
  {
    "path": "dummy-data/radarr/api/v3/wanted/missing",
    "content": "{\n  \"page\": 1,\n  \"pageSize\": 20,\n  \"sortKey\": \"digitalRelease\",\n  \"sortDirection\": \"descending\",\n  \"totalRecords\": 5,\n  \"records\": [\n    {\n      \"title\": \"Dune: Part Two\",\n      \"originalTitle\": \"Dune: Part Two\",\n      \"originalLanguage\": {\n        \"id\": 1,\n        \"name\": \"English\"\n      },\n      \"alternateTitles\": [],\n      \"secondaryYear\": null,\n      \"secondaryYearSourceId\": 0,\n      \"sortTitle\": \"dune part two\",\n      \"sizeOnDisk\": 0,\n      \"status\": \"released\",\n      \"overview\": \"Follow the mythic journey of Paul Atreides as he unites with Chani and the Fremen while on a path of revenge against the conspirators who destroyed his family.\",\n      \"inCinemas\": \"2024-02-29T00:00:00Z\",\n      \"physicalRelease\": \"2024-05-14T00:00:00Z\",\n      \"digitalRelease\": \"2024-04-16T00:00:00Z\",\n      \"images\": [],\n      \"website\": \"\",\n      \"remotePoster\": \"\",\n      \"year\": 2024,\n      \"hasFile\": false,\n      \"youTubeTrailerId\": \"\",\n      \"studio\": \"Warner Bros. Pictures\",\n      \"path\": \"/movies/Dune Part Two (2024)\",\n      \"qualityProfileId\": 1,\n      \"monitored\": true,\n      \"minimumAvailability\": \"announced\",\n      \"isAvailable\": true,\n      \"folderName\": \"Dune Part Two (2024)\",\n      \"runtime\": 166,\n      \"cleanTitle\": \"duneparttwo\",\n      \"imdbId\": \"tt15239678\",\n      \"tmdbId\": 693134,\n      \"titleSlug\": \"dune-part-two-2024\",\n      \"certification\": \"PG-13\",\n      \"genres\": [\"Adventure\", \"Drama\", \"Science Fiction\"],\n      \"tags\": [],\n      \"added\": \"2024-01-10T00:00:00Z\",\n      \"ratings\": {\n        \"votes\": 234567,\n        \"value\": 8.9\n      },\n      \"movieFile\": null,\n      \"collection\": {\n        \"name\": \"Dune Collection\",\n        \"tmdbId\": 726871,\n        \"images\": []\n      },\n      \"popularity\": 89.245,\n      \"id\": 3\n    },\n    {\n      \"title\": \"Oppenheimer\",\n      \"originalTitle\": \"Oppenheimer\",\n      \"originalLanguage\": {\n        \"id\": 1,\n        \"name\": \"English\"\n      },\n      \"alternateTitles\": [],\n      \"secondaryYear\": null,\n      \"secondaryYearSourceId\": 0,\n      \"sortTitle\": \"oppenheimer\",\n      \"sizeOnDisk\": 0,\n      \"status\": \"released\",\n      \"overview\": \"The story of J. Robert Oppenheimer's role in the development of the atomic bomb during World War II.\",\n      \"inCinemas\": \"2023-07-21T00:00:00Z\",\n      \"physicalRelease\": \"2023-11-21T00:00:00Z\",\n      \"digitalRelease\": \"2023-10-31T00:00:00Z\",\n      \"images\": [],\n      \"website\": \"\",\n      \"remotePoster\": \"\",\n      \"year\": 2023,\n      \"hasFile\": false,\n      \"youTubeTrailerId\": \"\",\n      \"studio\": \"Universal Pictures\",\n      \"path\": \"/movies/Oppenheimer (2023)\",\n      \"qualityProfileId\": 1,\n      \"monitored\": true,\n      \"minimumAvailability\": \"announced\",\n      \"isAvailable\": true,\n      \"folderName\": \"Oppenheimer (2023)\",\n      \"runtime\": 180,\n      \"cleanTitle\": \"oppenheimer\",\n      \"imdbId\": \"tt15398776\",\n      \"tmdbId\": 872585,\n      \"titleSlug\": \"oppenheimer-2023\",\n      \"certification\": \"R\",\n      \"genres\": [\"Drama\", \"History\"],\n      \"tags\": [],\n      \"added\": \"2024-01-05T00:00:00Z\",\n      \"ratings\": {\n        \"votes\": 456789,\n        \"value\": 8.4\n      },\n      \"movieFile\": null,\n      \"collection\": null,\n      \"popularity\": 92.567,\n      \"id\": 4\n    },\n    {\n      \"title\": \"Spider-Man: Across the Spider-Verse\",\n      \"originalTitle\": \"Spider-Man: Across the Spider-Verse\",\n      \"originalLanguage\": {\n        \"id\": 1,\n        \"name\": \"English\"\n      },\n      \"alternateTitles\": [],\n      \"secondaryYear\": null,\n      \"secondaryYearSourceId\": 0,\n      \"sortTitle\": \"spider man across spider verse\",\n      \"sizeOnDisk\": 0,\n      \"status\": \"released\",\n      \"overview\": \"After reuniting with Gwen Stacy, Brooklyn's full-time, friendly neighborhood Spider-Man is catapulted across the Multiverse, where he encounters the Spider-Society.\",\n      \"inCinemas\": \"2023-06-02T00:00:00Z\",\n      \"physicalRelease\": \"2023-09-05T00:00:00Z\",\n      \"digitalRelease\": \"2023-08-08T00:00:00Z\",\n      \"images\": [],\n      \"website\": \"\",\n      \"remotePoster\": \"\",\n      \"year\": 2023,\n      \"hasFile\": false,\n      \"youTubeTrailerId\": \"\",\n      \"studio\": \"Sony Pictures Animation\",\n      \"path\": \"/movies/Spider-Man Across the Spider-Verse (2023)\",\n      \"qualityProfileId\": 2,\n      \"monitored\": true,\n      \"minimumAvailability\": \"announced\",\n      \"isAvailable\": true,\n      \"folderName\": \"Spider-Man Across the Spider-Verse (2023)\",\n      \"runtime\": 140,\n      \"cleanTitle\": \"spidermanacrossthespiderverse\",\n      \"imdbId\": \"tt9362722\",\n      \"tmdbId\": 569094,\n      \"titleSlug\": \"spider-man-across-the-spider-verse-2023\",\n      \"certification\": \"PG\",\n      \"genres\": [\"Animation\", \"Action\", \"Adventure\"],\n      \"tags\": [],\n      \"added\": \"2024-01-03T00:00:00Z\",\n      \"ratings\": {\n        \"votes\": 345678,\n        \"value\": 8.7\n      },\n      \"movieFile\": null,\n      \"collection\": {\n        \"name\": \"Spider-Verse Collection\",\n        \"tmdbId\": 573436,\n        \"images\": []\n      },\n      \"popularity\": 85.432,\n      \"id\": 5\n    }\n  ]\n}"
  },
  {
    "path": "dummy-data/rtorrent/download_list",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<methodResponse>\n<params>\n<param><value><array><data>\n<value><string>2BAC78C9E10D82415142E57D24601F2FD8927816</string></value>\n<value><string>8BB10DB9EA239106D4907601C342ABBA29BE4391</string></value>\n<value><string>2790CE71493BE7083929D5A1CE9CFD6B8394F224</string></value>\n</data></array></value></param>\n</params>\n</methodResponse>"
  },
  {
    "path": "dummy-data/rtorrent/global_down",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<methodResponse>\n<params>\n<param><value><i8>149279</i8></value></param>\n</params>\n</methodResponse>"
  },
  {
    "path": "dummy-data/rtorrent/global_up",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<methodResponse>\n<params>\n<param><value><i8>45616</i8></value></param>\n</params>\n</methodResponse>"
  },
  {
    "path": "dummy-data/sabnzbd/api",
    "content": "{\n  \"queue\": {\n    \"version\": \"4.1.0\",\n    \"paused\": false,\n    \"pause_int\": \"0\",\n    \"paused_all\": false,\n    \"diskspace1\": \"465.47\",\n    \"diskspace2\": \"465.47\", \n    \"diskspacetotal1\": \"931.51\",\n    \"diskspacetotal2\": \"931.51\",\n    \"speedlimit\": \"\",\n    \"speedlimit_abs\": \"\",\n    \"have_warnings\": \"0\",\n    \"finishaction\": null,\n    \"quota\": \"\",\n    \"left_quota\": \"0 \",\n    \"cache_art\": \"0\",\n    \"cache_size\": \"0 B\",\n    \"kbpersec\": \"8547.82\",\n    \"speed\": \"8.35\",\n    \"mbleft\": \"2847.93\",\n    \"mb\": \"2847.93\",\n    \"noofslots\": 3,\n    \"noofslots_total\": 8,\n    \"status\": \"Downloading\",\n    \"timeleft\": \"0:05:41\",\n    \"eta\": \"22:15:41\",\n    \"slots\": [\n      {\n        \"index\": 0,\n        \"nzo_id\": \"SABnzbd_nzo_abc123\",\n        \"unpackopts\": \"3\",\n        \"priority\": \"Normal\",\n        \"script\": \"None\",\n        \"filename\": \"Ubuntu.22.04.3.Desktop.amd64.iso\",\n        \"labels\": [],\n        \"password\": \"\",\n        \"cat\": \"software\",\n        \"mbleft\": \"1847.52\",\n        \"mb\": \"1847.52\",\n        \"size\": \"1.8 GB\",\n        \"sizeleft\": \"1.8 GB\",\n        \"percentage\": \"0\",\n        \"mbmissing\": \"0.00\",\n        \"direct_unpack\": \"0\",\n        \"status\": \"Downloading\",\n        \"timeleft\": \"0:03:41\",\n        \"eta\": \"unknown\",\n        \"avg_age\": \"4d\"\n      },\n      {\n        \"index\": 1,\n        \"nzo_id\": \"SABnzbd_nzo_def456\",\n        \"unpackopts\": \"3\", \n        \"priority\": \"High\",\n        \"script\": \"None\",\n        \"filename\": \"Movie.Collection.2023.1080p.BluRay.x264\",\n        \"labels\": [\"movie\"],\n        \"password\": \"\",\n        \"cat\": \"movies\",\n        \"mbleft\": \"756.41\",\n        \"mb\": \"756.41\",\n        \"size\": \"756.4 MB\",\n        \"sizeleft\": \"756.4 MB\",\n        \"percentage\": \"12\",\n        \"mbmissing\": \"0.00\",\n        \"direct_unpack\": \"0\",\n        \"status\": \"Downloading\",\n        \"timeleft\": \"0:01:35\",\n        \"eta\": \"unknown\",\n        \"avg_age\": \"1d\"\n      },\n      {\n        \"index\": 2,\n        \"nzo_id\": \"SABnzbd_nzo_ghi789\",\n        \"unpackopts\": \"3\",\n        \"priority\": \"Normal\", \n        \"script\": \"cleanup.py\",\n        \"filename\": \"TV.Show.S05E08.1080p.WEB.H264-GROUP\",\n        \"labels\": [\"tv\"],\n        \"password\": \"\",\n        \"cat\": \"tv\",\n        \"mbleft\": \"244.00\",\n        \"mb\": \"244.00\", \n        \"size\": \"244.0 MB\",\n        \"sizeleft\": \"244.0 MB\",\n        \"percentage\": \"65\",\n        \"mbmissing\": \"0.00\",\n        \"direct_unpack\": \"1\",\n        \"status\": \"Downloading\",\n        \"timeleft\": \"0:00:25\",\n        \"eta\": \"unknown\",\n        \"avg_age\": \"12h\"\n      }\n    ]\n  }\n}"
  },
  {
    "path": "dummy-data/scrutiny/api/summary",
    "content": "{\n  \"success\": true,\n  \"data\": {\n    \"summary\": {\n      \"0x5000cca264eb01d7\": {\n        \"device\": {\n          \"wwn\": \"0x5000cca264eb01d7\",\n          \"device_name\": \"Samsung SSD 980 1TB\",\n          \"device_uuid\": \"WWN-0x5000cca264eb01d7\",\n          \"device_serial_id\": \"S64HNE0T123456A\",\n          \"device_label\": \"\",\n          \"manufacture\": \"Samsung\",\n          \"model_name\": \"Samsung SSD 980 1TB\",\n          \"interface_type\": \"nvme\",\n          \"interface_speed\": \"\",\n          \"serial_number\": \"S64HNE0T123456A\",\n          \"firmware\": \"2B4QGXA7\",\n          \"rotational_speed\": 0,\n          \"capacity\": 1000204886016,\n          \"form_factor\": \"\",\n          \"smart_support\": true,\n          \"device_protocol\": \"NVMe\",\n          \"device_type\": \"\",\n          \"device_status\": 0,\n          \"created_at\": \"2024-01-15T10:30:00Z\",\n          \"updated_at\": \"2024-01-15T11:45:00Z\",\n          \"deleted_at\": null\n        },\n        \"smart\": {\n          \"collector_date\": \"2024-01-15T11:45:00Z\",\n          \"temp\": 35,\n          \"power_on_hours\": 2847,\n          \"power_cycle_count\": 1247\n        }\n      },\n      \"0x5000cca264eb01d8\": {\n        \"device\": {\n          \"wwn\": \"0x5000cca264eb01d8\",\n          \"device_name\": \"Western Digital WD Blue 2TB\",\n          \"device_uuid\": \"WWN-0x5000cca264eb01d8\", \n          \"device_serial_id\": \"WD-WCC4N7DS2468\",\n          \"device_label\": \"\",\n          \"manufacture\": \"Western Digital\",\n          \"model_name\": \"WDC WD20EZAZ-00GXCB0\",\n          \"interface_type\": \"ata\",\n          \"interface_speed\": \"\",\n          \"serial_number\": \"WD-WCC4N7DS2468\",\n          \"firmware\": \"80.00A80\",\n          \"rotational_speed\": 5400,\n          \"capacity\": 2000398934016,\n          \"form_factor\": \"\",\n          \"smart_support\": true,\n          \"device_protocol\": \"ATA\",\n          \"device_type\": \"\",\n          \"device_status\": 0,\n          \"created_at\": \"2024-01-15T10:30:00Z\",\n          \"updated_at\": \"2024-01-15T11:45:00Z\",\n          \"deleted_at\": null\n        },\n        \"smart\": {\n          \"collector_date\": \"2024-01-15T11:45:00Z\",\n          \"temp\": 41,\n          \"power_on_hours\": 8942,\n          \"power_cycle_count\": 892\n        }\n      },\n      \"0x500a0751e6b8a7c3\": {\n        \"device\": {\n          \"wwn\": \"0x500a0751e6b8a7c3\",\n          \"device_name\": \"Seagate Barracuda 4TB\",\n          \"device_uuid\": \"WWN-0x500a0751e6b8a7c3\",\n          \"device_serial_id\": \"ST4000DM004-2CV104\",\n          \"device_label\": \"\",\n          \"manufacture\": \"Seagate\",\n          \"model_name\": \"ST4000DM004-2CV104\",\n          \"interface_type\": \"ata\",\n          \"interface_speed\": \"\",\n          \"serial_number\": \"ZFN123AB\",\n          \"firmware\": \"0001\",\n          \"rotational_speed\": 5400,\n          \"capacity\": 4000787030016,\n          \"form_factor\": \"\",\n          \"smart_support\": true,\n          \"device_protocol\": \"ATA\",\n          \"device_type\": \"\",\n          \"device_status\": 2,\n          \"created_at\": \"2024-01-15T10:30:00Z\",\n          \"updated_at\": \"2024-01-15T11:45:00Z\",\n          \"deleted_at\": null\n        },\n        \"smart\": {\n          \"collector_date\": \"2024-01-15T11:45:00Z\",\n          \"temp\": 47,\n          \"power_on_hours\": 12456,\n          \"power_cycle_count\": 456\n        }\n      },\n      \"0x5000cca264eb01d9\": {\n        \"device\": {\n          \"wwn\": \"0x5000cca264eb01d9\",\n          \"device_name\": \"Kingston NV2 500GB\",\n          \"device_uuid\": \"WWN-0x5000cca264eb01d9\",\n          \"device_serial_id\": \"50026B7784123456\",\n          \"device_label\": \"\",\n          \"manufacture\": \"Kingston\",\n          \"model_name\": \"KINGSTON SNV2S500G\",\n          \"interface_type\": \"nvme\",\n          \"interface_speed\": \"\",\n          \"serial_number\": \"50026B7784123456\",\n          \"firmware\": \"SNV2S2.1.0\",\n          \"rotational_speed\": 0,\n          \"capacity\": 500107862016,\n          \"form_factor\": \"\",\n          \"smart_support\": true,\n          \"device_protocol\": \"NVMe\",\n          \"device_type\": \"\",\n          \"device_status\": 4,\n          \"created_at\": \"2024-01-15T10:30:00Z\",\n          \"updated_at\": \"2024-01-15T11:45:00Z\",\n          \"deleted_at\": null\n        },\n        \"smart\": {\n          \"collector_date\": \"2024-01-15T11:45:00Z\",\n          \"temp\": 52,\n          \"power_on_hours\": 1247,\n          \"power_cycle_count\": 89\n        }\n      },\n      \"0x5000cca264eb01da\": {\n        \"device\": {\n          \"wwn\": \"0x5000cca264eb01da\",\n          \"device_name\": \"Crucial MX500 1TB\",\n          \"device_uuid\": \"WWN-0x5000cca264eb01da\",\n          \"device_serial_id\": \"194251A12345\",\n          \"device_label\": \"\",\n          \"manufacture\": \"Crucial\",\n          \"model_name\": \"CT1000MX500SSD1\",\n          \"interface_type\": \"ata\",\n          \"interface_speed\": \"\",\n          \"serial_number\": \"194251A12345\",\n          \"firmware\": \"M3CR033\",\n          \"rotational_speed\": 0,\n          \"capacity\": 1000204886016,\n          \"form_factor\": \"\",\n          \"smart_support\": true,\n          \"device_protocol\": \"ATA\",\n          \"device_type\": \"\",\n          \"device_status\": 0,\n          \"created_at\": \"2024-01-15T10:30:00Z\",\n          \"updated_at\": \"2024-01-15T11:45:00Z\",\n          \"deleted_at\": null\n        },\n        \"smart\": {\n          \"collector_date\": \"2024-01-15T11:45:00Z\",\n          \"temp\": 39,\n          \"power_on_hours\": 5642,\n          \"power_cycle_count\": 1089\n        }\n      }\n    }\n  }\n}"
  },
  {
    "path": "dummy-data/sonarr/api/v3/health",
    "content": "[\n  {\n    \"source\": \"IndexerStatusCheck\",\n    \"type\": \"warning\",\n    \"message\": \"Indexer MyIndexer is unavailable due to recent indexer errors: Request timeout\",\n    \"wikiUrl\": \"https://wiki.servarr.com/sonarr/health#indexers-are-unavailable-due-to-recent-failures\"\n  },\n  {\n    \"source\": \"ImportMechanismCheck\", \n    \"type\": \"ok\",\n    \"message\": \"No issues with import mechanism checks\"\n  },\n  {\n    \"source\": \"DownloadClientStatusCheck\",\n    \"type\": \"ok\", \n    \"message\": \"All download clients are available\"\n  },\n  {\n    \"source\": \"RootFolderCheck\",\n    \"type\": \"warning\",\n    \"message\": \"Missing root folder: /media/tv\",\n    \"wikiUrl\": \"https://wiki.servarr.com/sonarr/health#missing-root-folder\"\n  },\n  {\n    \"source\": \"UpdateCheck\",\n    \"type\": \"ok\",\n    \"message\": \"Update available: 3.0.10.1567 -> 4.0.1.929\"\n  }\n]"
  },
  {
    "path": "dummy-data/sonarr/api/v3/queue",
    "content": "{\n  \"page\": 1,\n  \"pageSize\": 20,\n  \"sortKey\": \"progress\",\n  \"sortDirection\": \"descending\",\n  \"totalRecords\": 3,\n  \"records\": [\n    {\n      \"seriesId\": 1,\n      \"episodeId\": 12345,\n      \"seasonNumber\": 5,\n      \"episodeNumber\": 8,\n      \"title\": \"The Office S05E08 - Business Trip\",\n      \"size\": 1073741824,\n      \"sizeleft\": 0,\n      \"timeleft\": \"00:00:00\",\n      \"estimatedCompletionTime\": \"2024-01-15T10:30:00Z\",\n      \"status\": \"completed\",\n      \"trackedDownloadStatus\": \"ok\",\n      \"trackedDownloadState\": \"importPending\",\n      \"statusMessages\": [],\n      \"downloadId\": \"download123abc\",\n      \"protocol\": \"torrent\",\n      \"downloadClient\": \"qBittorrent\",\n      \"indexer\": \"Prowlarr\",\n      \"outputPath\": \"/downloads/complete/The.Office.US.S05E08.720p.WEB.x264-GROUP\",\n      \"id\": 1\n    },\n    {\n      \"seriesId\": 2,\n      \"episodeId\": 67890,\n      \"seasonNumber\": 3,\n      \"episodeNumber\": 15,\n      \"title\": \"Breaking Bad S03E15 - Half Measures\",\n      \"size\": 2147483648,\n      \"sizeleft\": 536870912,\n      \"timeleft\": \"00:15:23\",\n      \"estimatedCompletionTime\": \"2024-01-15T10:45:23Z\",\n      \"status\": \"downloading\",\n      \"trackedDownloadStatus\": \"ok\",\n      \"trackedDownloadState\": \"downloading\",\n      \"statusMessages\": [],\n      \"downloadId\": \"download456def\",\n      \"protocol\": \"torrent\",\n      \"downloadClient\": \"qBittorrent\",\n      \"indexer\": \"Prowlarr\",\n      \"outputPath\": \"/downloads/incomplete/Breaking.Bad.S03E15.1080p.BluRay.x264-GROUP\",\n      \"id\": 2\n    },\n    {\n      \"seriesId\": 3,\n      \"episodeId\": 11121,\n      \"seasonNumber\": 1,\n      \"episodeNumber\": 3,\n      \"title\": \"Better Call Saul S01E03 - Nacho\",\n      \"size\": 1610612736,\n      \"sizeleft\": 1073741824,\n      \"timeleft\": \"01:24:15\",\n      \"estimatedCompletionTime\": \"2024-01-15T12:09:15Z\",\n      \"status\": \"downloading\",\n      \"trackedDownloadStatus\": \"warning\",\n      \"trackedDownloadState\": \"downloading\",\n      \"statusMessages\": [\n        {\n          \"title\": \"Slow Download Speed\",\n          \"messages\": [\"Download speed is below expected threshold\"]\n        }\n      ],\n      \"downloadId\": \"download789ghi\",\n      \"protocol\": \"usenet\",\n      \"downloadClient\": \"SABnzbd\",\n      \"indexer\": \"NZBgeek\",\n      \"outputPath\": \"/downloads/incomplete/Better.Call.Saul.S01E03.720p.WEB.x264-GROUP\",\n      \"id\": 3\n    }\n  ]\n}"
  },
  {
    "path": "dummy-data/sonarr/api/v3/wanted/missing",
    "content": "{\n  \"page\": 1,\n  \"pageSize\": 20,\n  \"sortKey\": \"airDateUtc\",\n  \"sortDirection\": \"descending\",\n  \"totalRecords\": 15,\n  \"records\": [\n    {\n      \"seriesId\": 1,\n      \"tvdbId\": 73244,\n      \"seasonNumber\": 9,\n      \"episodeNumber\": 23,\n      \"title\": \"Finale\",\n      \"airDate\": \"2013-05-16\",\n      \"airDateUtc\": \"2013-05-17T01:00:00Z\",\n      \"overview\": \"One year later, Dunder Mifflin Scranton has thrived, but Jim and Pam are looking to a move to Philadelphia.\",\n      \"episodeFile\": null,\n      \"hasFile\": false,\n      \"monitored\": true,\n      \"absoluteEpisodeNumber\": 201,\n      \"series\": {\n        \"title\": \"The Office (US)\",\n        \"sortTitle\": \"office us\",\n        \"seasonCount\": 9,\n        \"status\": \"ended\",\n        \"overview\": \"A mockumentary about a group of typical office workers.\",\n        \"network\": \"NBC\",\n        \"airTime\": \"21:00\",\n        \"images\": [],\n        \"seasons\": [],\n        \"year\": 2005,\n        \"path\": \"/media/tv/The Office (US)\",\n        \"qualityProfileId\": 1,\n        \"languageProfileId\": 1,\n        \"seasonFolder\": true,\n        \"monitored\": true,\n        \"useSceneNumbering\": false,\n        \"runtime\": 22,\n        \"tvdbId\": 73244,\n        \"tvRageId\": 6061,\n        \"tvMazeId\": 526,\n        \"firstAired\": \"2005-03-24T00:00:00Z\",\n        \"lastInfoSync\": \"2024-01-15T09:00:00Z\",\n        \"seriesType\": \"standard\",\n        \"cleanTitle\": \"theoffice\",\n        \"imdbId\": \"tt0386676\",\n        \"titleSlug\": \"the-office-us\",\n        \"certification\": \"TV-14\",\n        \"genres\": [\"Comedy\"],\n        \"tags\": [],\n        \"added\": \"2024-01-01T00:00:00Z\",\n        \"ratings\": {\n          \"votes\": 654321,\n          \"value\": 8.9\n        },\n        \"statistics\": {\n          \"seasonCount\": 9,\n          \"episodeFileCount\": 200,\n          \"episodeCount\": 201,\n          \"totalEpisodeCount\": 201,\n          \"sizeOnDisk\": 107374182400,\n          \"percentOfEpisodes\": 99.5\n        },\n        \"id\": 1\n      },\n      \"id\": 4567\n    },\n    {\n      \"seriesId\": 2,\n      \"tvdbId\": 81189,\n      \"seasonNumber\": 5,\n      \"episodeNumber\": 16,\n      \"title\": \"Felina\",\n      \"airDate\": \"2013-09-29\",\n      \"airDateUtc\": \"2013-09-30T01:00:00Z\",\n      \"overview\": \"Walt's final confrontation with his past mistakes leads to a climactic conclusion.\",\n      \"episodeFile\": null,\n      \"hasFile\": false,\n      \"monitored\": true,\n      \"absoluteEpisodeNumber\": 62,\n      \"series\": {\n        \"title\": \"Breaking Bad\",\n        \"sortTitle\": \"breaking bad\",\n        \"seasonCount\": 5,\n        \"status\": \"ended\",\n        \"overview\": \"A high school chemistry teacher turned methamphetamine manufacturer.\",\n        \"network\": \"AMC\",\n        \"airTime\": \"21:00\",\n        \"images\": [],\n        \"seasons\": [],\n        \"year\": 2008,\n        \"path\": \"/media/tv/Breaking Bad\",\n        \"qualityProfileId\": 1,\n        \"languageProfileId\": 1,\n        \"seasonFolder\": true,\n        \"monitored\": true,\n        \"useSceneNumbering\": false,\n        \"runtime\": 47,\n        \"tvdbId\": 81189,\n        \"tvRageId\": 18164,\n        \"tvMazeId\": 169,\n        \"firstAired\": \"2008-01-20T00:00:00Z\",\n        \"lastInfoSync\": \"2024-01-15T09:00:00Z\",\n        \"seriesType\": \"standard\",\n        \"cleanTitle\": \"breakingbad\",\n        \"imdbId\": \"tt0903747\",\n        \"titleSlug\": \"breaking-bad\",\n        \"certification\": \"TV-MA\",\n        \"genres\": [\"Crime\", \"Drama\", \"Thriller\"],\n        \"tags\": [],\n        \"added\": \"2024-01-01T00:00:00Z\",\n        \"ratings\": {\n          \"votes\": 1234567,\n          \"value\": 9.5\n        },\n        \"statistics\": {\n          \"seasonCount\": 5,\n          \"episodeFileCount\": 61,\n          \"episodeCount\": 62,\n          \"totalEpisodeCount\": 62,\n          \"sizeOnDisk\": 161061273600,\n          \"percentOfEpisodes\": 98.4\n        },\n        \"id\": 2\n      },\n      \"id\": 8901\n    }\n  ]\n}"
  },
  {
    "path": "dummy-data/speedtesttracker/api/speedtest/latest",
    "content": "{\n    \"data\": {\n        \"download\": 42.452234,\n        \"upload\": 34.3948,\n        \"ping\": 12.9873\n    }\n}"
  },
  {
    "path": "dummy-data/tautulli/api/v2",
    "content": "{\n  \"response\": {\n    \"result\": \"success\",\n    \"message\": null,\n    \"data\": {\n      \"stream_count\": 3,\n      \"stream_count_direct_play\": 1,\n      \"stream_count_direct_stream\": 1,\n      \"stream_count_transcode\": 1,\n      \"total_bandwidth\": 15420,\n      \"wan_bandwidth\": 8240,\n      \"lan_bandwidth\": 7180,\n      \"sessions\": [\n        {\n          \"session_key\": \"425\",\n          \"session_id\": \"tautulli_session_425\",\n          \"media_index\": \"1\",\n          \"parent_media_index\": \"1\",\n          \"art\": \"/library/metadata/98765/art/1705317600\",\n          \"thumb\": \"/library/metadata/98765/thumb/1705317600\",\n          \"grandparent_thumb\": \"/library/metadata/98765/thumb/1705317600\",\n          \"title\": \"The One Where Monica Gets a Roommate\",\n          \"parent_title\": \"Season 1\",\n          \"grandparent_title\": \"Friends\",\n          \"original_title\": \"\",\n          \"year\": 1994,\n          \"media_type\": \"episode\",\n          \"rating_key\": \"98765\",\n          \"parent_rating_key\": \"98764\",\n          \"grandparent_rating_key\": \"98763\",\n          \"state\": \"playing\",\n          \"session_progress\": 42,\n          \"view_offset\": 630000,\n          \"duration\": 1500000,\n          \"remaining_time\": 870000,\n          \"progress_percent\": 42,\n          \"username\": \"john_doe\",\n          \"friendly_name\": \"John's iPhone\",\n          \"user_id\": 1,\n          \"user\": \"john_doe\",\n          \"ip_address\": \"192.168.1.105\",\n          \"ip_address_public\": \"203.0.113.45\",\n          \"location\": \"lan\",\n          \"secure\": 1,\n          \"relayed\": 0,\n          \"platform\": \"iOS\",\n          \"platform_name\": \"iPhone\",\n          \"platform_version\": \"17.2\",\n          \"product\": \"Plex for iOS\",\n          \"product_version\": \"8.25.1\",\n          \"profile\": \"Mobile\",\n          \"player\": \"PlexMobile\",\n          \"machine_id\": \"abc123def456\",\n          \"bandwidth\": 4820,\n          \"quality_profile\": \"4 Mbps 720p\",\n          \"video_resolution\": \"720p\",\n          \"video_framerate\": \"24p\",\n          \"video_codec\": \"h264\",\n          \"video_bitrate\": 4200,\n          \"video_width\": 1280,\n          \"video_height\": 720,\n          \"audio_codec\": \"aac\",\n          \"audio_bitrate\": 128,\n          \"audio_channels\": 2,\n          \"transcode_decision\": \"transcode\",\n          \"stream_container\": \"mkv\",\n          \"stream_video_codec\": \"h264\",\n          \"stream_audio_codec\": \"aac\"\n        },\n        {\n          \"session_key\": \"426\", \n          \"session_id\": \"tautulli_session_426\",\n          \"media_index\": \"3\",\n          \"parent_media_index\": \"2\",\n          \"art\": \"/library/metadata/45678/art/1705317600\",\n          \"thumb\": \"/library/metadata/45678/thumb/1705317600\",\n          \"grandparent_thumb\": \"/library/metadata/45678/thumb/1705317600\",\n          \"title\": \"The One with the Sonogram at the End\",\n          \"parent_title\": \"Season 1\", \n          \"grandparent_title\": \"Friends\",\n          \"original_title\": \"\",\n          \"year\": 1994,\n          \"media_type\": \"episode\",\n          \"rating_key\": \"45678\",\n          \"parent_rating_key\": \"45677\",\n          \"grandparent_rating_key\": \"45676\",\n          \"state\": \"playing\",\n          \"session_progress\": 18,\n          \"view_offset\": 270000,\n          \"duration\": 1500000,\n          \"remaining_time\": 1230000,\n          \"progress_percent\": 18,\n          \"username\": \"sarah_smith\",\n          \"friendly_name\": \"Sarah's TV\",\n          \"user_id\": 2,\n          \"user\": \"sarah_smith\",\n          \"ip_address\": \"192.168.1.110\",\n          \"ip_address_public\": \"203.0.113.45\",\n          \"location\": \"lan\",\n          \"secure\": 1,\n          \"relayed\": 0,\n          \"platform\": \"Roku\",\n          \"platform_name\": \"Roku Ultra\",\n          \"platform_version\": \"12.5.0\",\n          \"product\": \"Plex for Roku\",\n          \"product_version\": \"6.8.0\",\n          \"profile\": \"Roku\",\n          \"player\": \"Roku\",\n          \"machine_id\": \"def456ghi789\",\n          \"bandwidth\": 2360,\n          \"quality_profile\": \"2 Mbps 480p\",\n          \"video_resolution\": \"480p\",\n          \"video_framerate\": \"24p\", \n          \"video_codec\": \"h264\",\n          \"video_bitrate\": 2000,\n          \"video_width\": 720,\n          \"video_height\": 480,\n          \"audio_codec\": \"aac\",\n          \"audio_bitrate\": 128,\n          \"audio_channels\": 2,\n          \"transcode_decision\": \"direct_stream\",\n          \"stream_container\": \"mkv\",\n          \"stream_video_codec\": \"h264\",\n          \"stream_audio_codec\": \"aac\"\n        },\n        {\n          \"session_key\": \"427\",\n          \"session_id\": \"tautulli_session_427\", \n          \"media_index\": null,\n          \"parent_media_index\": null,\n          \"art\": \"/library/metadata/12345/art/1705317600\",\n          \"thumb\": \"/library/metadata/12345/thumb/1705317600\",\n          \"grandparent_thumb\": \"\",\n          \"title\": \"Inception\",\n          \"parent_title\": \"\",\n          \"grandparent_title\": \"\",\n          \"original_title\": \"Inception\",\n          \"year\": 2010,\n          \"media_type\": \"movie\",\n          \"rating_key\": \"12345\",\n          \"parent_rating_key\": \"\",\n          \"grandparent_rating_key\": \"\",\n          \"state\": \"playing\",\n          \"session_progress\": 67,\n          \"view_offset\": 5952000,\n          \"duration\": 8880000,\n          \"remaining_time\": 2928000,\n          \"progress_percent\": 67,\n          \"username\": \"movie_buff\",\n          \"friendly_name\": \"Living Room TV\",\n          \"user_id\": 3,\n          \"user\": \"movie_buff\",\n          \"ip_address\": \"10.0.0.15\",\n          \"ip_address_public\": \"198.51.100.25\",\n          \"location\": \"wan\",\n          \"secure\": 1,\n          \"relayed\": 1,\n          \"platform\": \"Android TV\",\n          \"platform_name\": \"NVIDIA Shield TV\",\n          \"platform_version\": \"11\",\n          \"product\": \"Plex for Android TV\",\n          \"product_version\": \"9.12.0\",\n          \"profile\": \"Android TV\",\n          \"player\": \"AndroidTV\",\n          \"machine_id\": \"ghi789jkl012\",\n          \"bandwidth\": 8240,\n          \"quality_profile\": \"8 Mbps 1080p\",\n          \"video_resolution\": \"1080p\",\n          \"video_framerate\": \"24p\",\n          \"video_codec\": \"h264\",\n          \"video_bitrate\": 8000,\n          \"video_width\": 1920,\n          \"video_height\": 1080,\n          \"audio_codec\": \"ac3\",\n          \"audio_bitrate\": 640,\n          \"audio_channels\": 6,\n          \"transcode_decision\": \"direct_play\",\n          \"stream_container\": \"mkv\",\n          \"stream_video_codec\": \"h264\",\n          \"stream_audio_codec\": \"ac3\"\n        }\n      ]\n    }\n  }\n}"
  },
  {
    "path": "dummy-data/tdarr/api/v2/cruddb",
    "content": "{\n  \"totalFileCount\": 3245,\n  \"totalTranscodeCount\": 3148,\n  \"totalHealthCheckCount\": 7278,\n  \"sizeDiff\": 5265.423687950708,\n  \"_id\": \"statistics\",\n  \"tdarrScore\": \"99.97\",\n  \"healthCheckScore\": \"99.97\",\n  \"table0Count\": 0,\n  \"table2Count\": 3244,\n  \"table3Count\": 0,\n  \"table4Count\": 1,\n  \"table5Count\": 3244,\n  \"table6Count\": 0,\n  \"table1Count\": 1,\n  \"pies\": [\n    [\n      \"All\",\n      \"all\",\n      3245,\n      3148,\n      5265.423687950708,\n      7278,\n      [\n        {\n          \"name\": \"Transcode success\",\n          \"value\": 1995\n        },\n        {\n          \"name\": \"Not required\",\n          \"value\": 1249\n        },\n        {\n          \"name\": \"Queued\",\n          \"value\": 1\n        }\n      ],\n      [\n        {\n          \"name\": \"Success\",\n          \"value\": 3244\n        },\n        {\n          \"name\": \"Queued\",\n          \"value\": 1\n        }\n      ],\n      [\n        {\n          \"name\": \"hevc\",\n          \"value\": 3172\n        },\n        {\n          \"name\": \"vp9\",\n          \"value\": 48\n        },\n        {\n          \"name\": \"h264\",\n          \"value\": 24\n        }\n      ],\n      [\n        {\n          \"name\": \"mkv\",\n          \"value\": 3115\n        },\n        {\n          \"name\": \"webm\",\n          \"value\": 48\n        },\n        {\n          \"name\": \"mp4\",\n          \"value\": 81\n        }\n      ],\n      [\n        {\n          \"name\": \"1080p\",\n          \"value\": 2582\n        },\n        {\n          \"name\": \"480p\",\n          \"value\": 406\n        },\n        {\n          \"name\": \"720p\",\n          \"value\": 224\n        },\n        {\n          \"name\": \"4KUHD\",\n          \"value\": 29\n        },\n        {\n          \"name\": \"576p\",\n          \"value\": 3\n        }\n      ],\n      [],\n      []\n    ],\n    [\n      \"Type1\",\n      \"t7_0knr-z\",\n      3,\n      0,\n      0,\n      3,\n      [\n        {\n          \"name\": \"Not required\",\n          \"value\": 3\n        }\n      ],\n      [\n        {\n          \"name\": \"Success\",\n          \"value\": 3\n        }\n      ],\n      [\n        {\n          \"name\": \"hevc\",\n          \"value\": 3\n        }\n      ],\n      [\n        {\n          \"name\": \"mkv\",\n          \"value\": 3\n        }\n      ],\n      [\n        {\n          \"name\": \"480p\",\n          \"value\": 3\n        }\n      ],\n      [],\n      []\n    ],\n    [\n      \"Type2\",\n      \"ekyBRmWbD\",\n      9,\n      13,\n      10.722183834761381,\n      65,\n      [\n        {\n          \"name\": \"Transcode success\",\n          \"value\": 9\n        }\n      ],\n      [\n        {\n          \"name\": \"Success\",\n          \"value\": 9\n        }\n      ],\n      [\n        {\n          \"name\": \"hevc\",\n          \"value\": 9\n        }\n      ],\n      [\n        {\n          \"name\": \"mkv\",\n          \"value\": 9\n        }\n      ],\n      [\n        {\n          \"name\": \"480p\",\n          \"value\": 1\n        },\n        {\n          \"name\": \"576p\",\n          \"value\": 1\n        },\n        {\n          \"name\": \"720p\",\n          \"value\": 4\n        },\n        {\n          \"name\": \"1080p\",\n          \"value\": 3\n        }\n      ],\n      [],\n      []\n    ],\n    [\n      \"Type3\",\n      \"-dy1H5yNz\",\n      2619,\n      2641,\n      2710.185842271894,\n      5837,\n      [\n        {\n          \"name\": \"Transcode success\",\n          \"value\": 1586\n        },\n        {\n          \"name\": \"Not required\",\n          \"value\": 1033\n        }\n      ],\n      [\n        {\n          \"name\": \"Success\",\n          \"value\": 2619\n        }\n      ],\n      [\n        {\n          \"name\": \"hevc\",\n          \"value\": 2571\n        },\n        {\n          \"name\": \"vp9\",\n          \"value\": 48\n        }\n      ],\n      [\n        {\n          \"name\": \"mkv\",\n          \"value\": 2510\n        },\n        {\n          \"name\": \"webm\",\n          \"value\": 48\n        },\n        {\n          \"name\": \"mp4\",\n          \"value\": 61\n        }\n      ],\n      [\n        {\n          \"name\": \"1080p\",\n          \"value\": 2050\n        },\n        {\n          \"name\": \"720p\",\n          \"value\": 186\n        },\n        {\n          \"name\": \"480p\",\n          \"value\": 383\n        }\n      ],\n      [],\n      []\n    ],\n    [\n      \"Type4\",\n      \"ASRD2TAeP\",\n      1,\n      11,\n      83.31165281962603,\n      32,\n      [\n        {\n          \"name\": \"Queued\",\n          \"value\": 1\n        }\n      ],\n      [\n        {\n          \"name\": \"Queued\",\n          \"value\": 1\n        }\n      ],\n      [\n        {\n          \"name\": \"h264\",\n          \"value\": 1\n        }\n      ],\n      [\n        {\n          \"name\": \"mp4\",\n          \"value\": 1\n        }\n      ],\n      [\n        {\n          \"name\": \"1080p\",\n          \"value\": 1\n        }\n      ],\n      [],\n      []\n    ],\n    [\n      \"Type5\",\n      \"KQ03rLWIw\",\n      11,\n      14,\n      17.225701110437512,\n      43,\n      [\n        {\n          \"name\": \"Not required\",\n          \"value\": 11\n        }\n      ],\n      [\n        {\n          \"name\": \"Success\",\n          \"value\": 11\n        }\n      ],\n      [\n        {\n          \"name\": \"hevc\",\n          \"value\": 11\n        }\n      ],\n      [\n        {\n          \"name\": \"mkv\",\n          \"value\": 11\n        }\n      ],\n      [\n        {\n          \"name\": \"720p\",\n          \"value\": 6\n        },\n        {\n          \"name\": \"480p\",\n          \"value\": 4\n        },\n        {\n          \"name\": \"1080p\",\n          \"value\": 1\n        }\n      ],\n      [],\n      []\n    ],\n    [\n      \"Type6\",\n      \"RQhHe9OCl\",\n      602,\n      473,\n      2420.9242209186777,\n      1300,\n      [\n        {\n          \"name\": \"Not required\",\n          \"value\": 202\n        },\n        {\n          \"name\": \"Transcode success\",\n          \"value\": 400\n        }\n      ],\n      [\n        {\n          \"name\": \"Success\",\n          \"value\": 602\n        }\n      ],\n      [\n        {\n          \"name\": \"hevc\",\n          \"value\": 578\n        },\n        {\n          \"name\": \"h264\",\n          \"value\": 23\n        }\n      ],\n      [\n        {\n          \"name\": \"mkv\",\n          \"value\": 582\n        },\n        {\n          \"name\": \"mp4\",\n          \"value\": 19\n        }\n      ],\n      [\n        {\n          \"name\": \"480p\",\n          \"value\": 15\n        },\n        {\n          \"name\": \"1080p\",\n          \"value\": 527\n        },\n        {\n          \"name\": \"4KUHD\",\n          \"value\": 29\n        },\n        {\n          \"name\": \"720p\",\n          \"value\": 28\n        },\n        {\n          \"name\": \"576p\",\n          \"value\": 2\n        }\n      ],\n      [],\n      []\n    ]\n  ],\n  \"streamStats\": {\n    \"duration\": {\n      \"average\": 3127,\n      \"highest\": 8548,\n      \"total\": 253273\n    },\n    \"bit_rate\": {\n      \"average\": 2242894,\n      \"highest\": 20149278,\n      \"total\": 181674395\n    },\n    \"nb_frames\": {\n      \"average\": 75320,\n      \"highest\": 204941,\n      \"total\": 6100852\n    }\n  },\n  \"avgNumberOfStreamsInVideo\": 5.049321824907522,\n  \"languages\": {\n    \"ara\": {\n      \"count\": 181\n    },\n    \"est\": {\n      \"count\": 62\n    },\n    \"lav\": {\n      \"count\": 62\n    },\n    \"may\": {\n      \"count\": 131\n    },\n    \"nor\": {\n      \"count\": 110\n    },\n    \"chi\": {\n      \"count\": 384\n    },\n    \"ind\": {\n      \"count\": 63\n    },\n    \"rum\": {\n      \"count\": 138\n    },\n    \"nob\": {\n      \"count\": 18\n    },\n    \"srp\": {\n      \"count\": 3\n    }\n  },\n  \"DBPollPeriod\": \"1s\",\n  \"DBFetchTime\": \"1s\",\n  \"DBLoadStatus\": \"Stable\",\n  \"DBQueue\": 0,\n  \"processWarning\": \"\",\n  \"processWarningQueues\": true\n}\n"
  },
  {
    "path": "dummy-data/traefik/api/version",
    "content": "{\n    \"Version\": \"3.1.7\",\n    \"Codename\": \"comte\",\n    \"startDate\": \"2024-11-20T05:55:46.259506879Z\"\n}\n"
  },
  {
    "path": "dummy-data/truenasscale/api/v2.0/system/version",
    "content": "\"TrueNAS-SCALE-22.12.4.2\""
  },
  {
    "path": "dummy-data/uptimekuma/api/status-page/default",
    "content": "{\n  \"config\": {\n    \"title\": \"Homer Dashboard Status\",\n    \"description\": \"Status page for all monitored services\",\n    \"icon\": \"/icon.svg\",\n    \"theme\": \"light\",\n    \"published\": true,\n    \"showTags\": true,\n    \"domainNames\": [\n      \"status.homer.local\"\n    ],\n    \"customCSS\": \"\",\n    \"footerText\": null,\n    \"showPoweredBy\": true,\n    \"googleAnalyticsId\": null,\n    \"showCertificateExpiry\": false,\n    \"certExpiryDays\": 14\n  },\n  \"incident\": null,\n  \"publicGroupList\": [\n    {\n      \"id\": 1,\n      \"name\": \"Web Services\",\n      \"weight\": 1,\n      \"monitorList\": [\n        {\n          \"id\": 1,\n          \"name\": \"Main Website\",\n          \"url\": \"https://example.com\",\n          \"type\": \"http\",\n          \"interval\": 60\n        },\n        {\n          \"id\": 2,\n          \"name\": \"API Server\",\n          \"url\": \"https://api.example.com\",\n          \"type\": \"http\", \n          \"interval\": 60\n        }\n      ]\n    },\n    {\n      \"id\": 2,\n      \"name\": \"Infrastructure\",\n      \"weight\": 2,\n      \"monitorList\": [\n        {\n          \"id\": 3,\n          \"name\": \"Database Server\",\n          \"url\": \"postgresql://db.example.com:5432\",\n          \"type\": \"postgres\",\n          \"interval\": 120\n        },\n        {\n          \"id\": 4,\n          \"name\": \"Redis Cache\",\n          \"url\": \"redis://cache.example.com:6379\",\n          \"type\": \"redis\",\n          \"interval\": 60\n        }\n      ]\n    }\n  ]\n}"
  },
  {
    "path": "dummy-data/uptimekuma/api/status-page/heartbeat/default",
    "content": "{\n  \"heartbeatList\": {\n    \"1\": [\n      {\n        \"status\": 1,\n        \"time\": \"2024-01-15 10:00:00\",\n        \"msg\": \"200 - OK\",\n        \"ping\": 45,\n        \"important\": false,\n        \"duration\": 0\n      },\n      {\n        \"status\": 1,\n        \"time\": \"2024-01-15 10:01:00\", \n        \"msg\": \"200 - OK\",\n        \"ping\": 52,\n        \"important\": false,\n        \"duration\": 0\n      },\n      {\n        \"status\": 1,\n        \"time\": \"2024-01-15 10:02:00\",\n        \"msg\": \"200 - OK\", \n        \"ping\": 38,\n        \"important\": false,\n        \"duration\": 0\n      }\n    ],\n    \"2\": [\n      {\n        \"status\": 1,\n        \"time\": \"2024-01-15 10:00:00\",\n        \"msg\": \"200 - OK\",\n        \"ping\": 67,\n        \"important\": false,\n        \"duration\": 0\n      },\n      {\n        \"status\": 1,\n        \"time\": \"2024-01-15 10:01:00\",\n        \"msg\": \"200 - OK\", \n        \"ping\": 71,\n        \"important\": false,\n        \"duration\": 0\n      },\n      {\n        \"status\": 1,\n        \"time\": \"2024-01-15 10:02:00\",\n        \"msg\": \"200 - OK\",\n        \"ping\": 63,\n        \"important\": false,\n        \"duration\": 0\n      }\n    ],\n    \"3\": [\n      {\n        \"status\": 1,\n        \"time\": \"2024-01-15 10:00:00\",\n        \"msg\": \"Connected successfully\",\n        \"ping\": 12,\n        \"important\": false,\n        \"duration\": 0\n      },\n      {\n        \"status\": 0,\n        \"time\": \"2024-01-15 10:02:00\",\n        \"msg\": \"Connection timeout\",\n        \"ping\": null,\n        \"important\": true,\n        \"duration\": 5000\n      },\n      {\n        \"status\": 1,\n        \"time\": \"2024-01-15 10:04:00\",\n        \"msg\": \"Connected successfully\", \n        \"ping\": 15,\n        \"important\": false,\n        \"duration\": 0\n      }\n    ],\n    \"4\": [\n      {\n        \"status\": 1,\n        \"time\": \"2024-01-15 10:00:00\",\n        \"msg\": \"PONG received\",\n        \"ping\": 3,\n        \"important\": false,\n        \"duration\": 0\n      },\n      {\n        \"status\": 1,\n        \"time\": \"2024-01-15 10:01:00\",\n        \"msg\": \"PONG received\",\n        \"ping\": 2,\n        \"important\": false,\n        \"duration\": 0\n      },\n      {\n        \"status\": 1,\n        \"time\": \"2024-01-15 10:02:00\",\n        \"msg\": \"PONG received\",\n        \"ping\": 4,\n        \"important\": false,\n        \"duration\": 0\n      }\n    ]\n  },\n  \"uptimeList\": {\n    \"1\": 1.0,\n    \"2\": 1.0,\n    \"3\": 0.95,\n    \"4\": 1.0\n  }\n}"
  },
  {
    "path": "dummy-data/vaultwarden/api/version",
    "content": "\"1.30.3\""
  },
  {
    "path": "dummy-data/wallabag/api/version",
    "content": "\"2.6.10\""
  },
  {
    "path": "dummy-data/wud/api/containers",
    "content": "[\n  {\n    \"id\": \"nginx-proxy\",\n    \"name\": \"nginx-proxy\", \n    \"watcher\": \"docker\",\n    \"image\": {\n      \"registry\": {\n        \"name\": \"hub.docker.com\",\n        \"url\": \"https://registry-1.docker.io/v2\"\n      },\n      \"name\": \"nginx\",\n      \"tag\": {\n        \"value\": \"1.25.3\",\n        \"semver\": true\n      },\n      \"digest\": {\n        \"watch\": false,\n        \"repo\": \"sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef\"\n      },\n      \"architecture\": \"amd64\",\n      \"os\": \"linux\",\n      \"created\": \"2023-12-15T10:30:00Z\"\n    },\n    \"result\": {\n      \"tag\": \"1.25.4\"\n    },\n    \"updateAvailable\": true,\n    \"status\": \"running\",\n    \"created\": \"2024-01-10T08:15:00Z\",\n    \"updated\": \"2024-01-15T11:30:00Z\"\n  },\n  {\n    \"id\": \"postgres-db\",\n    \"name\": \"postgres-db\",\n    \"watcher\": \"docker\", \n    \"image\": {\n      \"registry\": {\n        \"name\": \"hub.docker.com\",\n        \"url\": \"https://registry-1.docker.io/v2\"\n      },\n      \"name\": \"postgres\",\n      \"tag\": {\n        \"value\": \"15.5-alpine\",\n        \"semver\": true\n      },\n      \"digest\": {\n        \"watch\": false,\n        \"repo\": \"sha256:2345678901abcdef2345678901abcdef2345678901abcdef2345678901abcdef\"\n      },\n      \"architecture\": \"amd64\",\n      \"os\": \"linux\", \n      \"created\": \"2023-11-28T14:22:00Z\"\n    },\n    \"result\": {\n      \"tag\": \"16.1-alpine\"\n    },\n    \"updateAvailable\": true,\n    \"status\": \"running\",\n    \"created\": \"2024-01-08T12:45:00Z\",\n    \"updated\": \"2024-01-15T11:30:00Z\"\n  },\n  {\n    \"id\": \"redis-cache\",\n    \"name\": \"redis-cache\",\n    \"watcher\": \"docker\",\n    \"image\": {\n      \"registry\": {\n        \"name\": \"hub.docker.com\",\n        \"url\": \"https://registry-1.docker.io/v2\"\n      },\n      \"name\": \"redis\",\n      \"tag\": {\n        \"value\": \"7.2.4-alpine\",\n        \"semver\": true\n      },\n      \"digest\": {\n        \"watch\": false,\n        \"repo\": \"sha256:3456789012abcdef3456789012abcdef3456789012abcdef3456789012abcdef\"\n      },\n      \"architecture\": \"amd64\",\n      \"os\": \"linux\",\n      \"created\": \"2024-01-12T09:18:00Z\"\n    },\n    \"result\": {\n      \"tag\": \"7.2.4-alpine\"\n    },\n    \"updateAvailable\": false,\n    \"status\": \"running\",\n    \"created\": \"2024-01-12T16:20:00Z\",\n    \"updated\": \"2024-01-15T11:30:00Z\"\n  },\n  {\n    \"id\": \"portainer-agent\",\n    \"name\": \"portainer-agent\",\n    \"watcher\": \"docker\",\n    \"image\": {\n      \"registry\": {\n        \"name\": \"hub.docker.com\", \n        \"url\": \"https://registry-1.docker.io/v2\"\n      },\n      \"name\": \"portainer/agent\",\n      \"tag\": {\n        \"value\": \"2.19.4\",\n        \"semver\": true\n      },\n      \"digest\": {\n        \"watch\": false,\n        \"repo\": \"sha256:4567890123abcdef4567890123abcdef4567890123abcdef4567890123abcdef\"\n      },\n      \"architecture\": \"amd64\",\n      \"os\": \"linux\",\n      \"created\": \"2024-01-05T07:42:00Z\"\n    },\n    \"result\": {\n      \"tag\": \"2.19.4\"\n    },\n    \"updateAvailable\": false,\n    \"status\": \"running\",\n    \"created\": \"2024-01-05T14:30:00Z\",\n    \"updated\": \"2024-01-15T11:30:00Z\"\n  },\n  {\n    \"id\": \"app-backend\",\n    \"name\": \"app-backend\",\n    \"watcher\": \"docker\",\n    \"image\": {\n      \"registry\": {\n        \"name\": \"hub.docker.com\",\n        \"url\": \"https://registry-1.docker.io/v2\"\n      },\n      \"name\": \"node\",\n      \"tag\": {\n        \"value\": \"18.19.0-alpine\",\n        \"semver\": true\n      },\n      \"digest\": {\n        \"watch\": false,\n        \"repo\": \"sha256:5678901234abcdef5678901234abcdef5678901234abcdef5678901234abcdef\"\n      },\n      \"architecture\": \"amd64\",\n      \"os\": \"linux\",\n      \"created\": \"2023-12-28T11:15:00Z\"\n    },\n    \"result\": {\n      \"tag\": \"20.11.0-alpine\"\n    },\n    \"updateAvailable\": true,\n    \"status\": \"running\",\n    \"created\": \"2024-01-07T09:12:00Z\", \n    \"updated\": \"2024-01-15T11:30:00Z\"\n  },\n  {\n    \"id\": \"prometheus\",\n    \"name\": \"prometheus\",\n    \"watcher\": \"docker\",\n    \"image\": {\n      \"registry\": {\n        \"name\": \"quay.io\",\n        \"url\": \"https://quay.io/v2\"\n      },\n      \"name\": \"prometheus/prometheus\",\n      \"tag\": {\n        \"value\": \"v2.48.1\",\n        \"semver\": true\n      },\n      \"digest\": {\n        \"watch\": false,\n        \"repo\": \"sha256:6789012345abcdef6789012345abcdef6789012345abcdef6789012345abcdef\"\n      },\n      \"architecture\": \"amd64\", \n      \"os\": \"linux\",\n      \"created\": \"2023-12-20T16:33:00Z\"\n    },\n    \"result\": {\n      \"tag\": \"v2.49.1\"\n    },\n    \"updateAvailable\": true,\n    \"status\": \"running\",\n    \"created\": \"2024-01-03T13:55:00Z\",\n    \"updated\": \"2024-01-15T11:30:00Z\"\n  }\n]"
  },
  {
    "path": "entrypoint.sh",
    "content": "#!/bin/sh\n\n# Default assets & example configuration installation\nif [[ \"${INIT_ASSETS}\" == \"1\" ]] && [[ ! -f \"/www/assets/config.yml\" ]]; then\n    echo \"No configuration found, installing default config & assets\"\n    if [[ -w \"/www/assets/\" ]]; \n    then\n        while true; do echo n; done | cp -Ri /www/default-assets/* /www/assets/\n        yes n | cp -i /www/default-assets/config.yml.dist /www/assets/config.yml\n    else\n        echo \"Assets directory not writable, skipping default config install.\";\n        echo \"Check assets directory permissions & docker user or skip default assets install by setting the INIT_ASSETS env var to 0.\"\n    fi\nfi\n\necho \"Starting webserver\"\nexec 3>&1\nexec lighttpd -D -f /lighttpd.conf\n"
  },
  {
    "path": "eslint.config.js",
    "content": "import globals from \"globals\";\nimport pluginJs from \"@eslint/js\";\nimport pluginVue from \"eslint-plugin-vue\";\nimport eslintConfigPrettier from \"@vue/eslint-config-prettier\";\n\n/** @type {import('eslint').Linter.Config[]} */\nexport default [\n  { files: [\"**/*.{js,mjs,cjs,vue}\"] },\n  {\n    languageOptions: {\n      globals: {\n        ...globals.browser,\n        __APP_VERSION__: \"readable\",\n      },\n    },\n  },\n  pluginJs.configs.recommended,\n  ...pluginVue.configs[\"flat/recommended\"],\n  eslintConfigPrettier,\n  {\n    rules: {\n      \"vue/multi-word-component-names\": \"off\",\n      \"vue/require-default-prop\": \"off\",\n      \"vue/no-v-html\": \"off\",\n    },\n  },\n  {\n    ignores: [\"**/dist/**\", \"**/dist-ssr/**\", \"**/coverage/**\"],\n  },\n];\n"
  },
  {
    "path": "index.html",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" href=\"assets/icons/favicon.ico\" />\n    <link rel=\"apple-touch-icon\" href=\"assets/icons/apple-touch-icon.png\" sizes=\"180x180\">\n    <link rel=\"mask-icon\" href=\"assets/icons/logo.svg\">\n    <meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0,viewport-fit=cover\">\n    <title>Homer</title>\n  </head>\n  <body>\n    <div id=\"app-mount\"></div>\n    <script type=\"module\" src=\"/src/main.js\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "lighttpd-ipv6.sh",
    "content": "#!/bin/sh\n\n# Enable IPV6 if needed\nif [[ \"${IPV6_DISABLE}\" != \"1\" ]]; then\n\techo '$SERVER[\"socket\"] == \"[::]:\" + env.PORT {  }'\nfi\n"
  },
  {
    "path": "lighttpd.conf",
    "content": "include \"/etc/lighttpd/mime-types.conf\"\ninclude_shell \"/etc/lighttpd/ipv6.sh\"\n\nserver.port            = env.PORT\nserver.modules         = ( \"mod_alias\", \"mod_accesslog\" )\nserver.username        = \"lighttpd\"\nserver.groupname       = \"lighttpd\"\nserver.document-root   = \"/www\"\nalias.url              = ( env.SUBFOLDER => \"/www\" )\nserver.indexfiles      = (\"index.html\")\nserver.follow-symlink  = \"enable\"\nserver.feature-flags  += ( \"server.clock-jump-restart\" => 0 )\nserver.max-request-field-size = 65535\naccesslog.filename = \"/dev/fd/3\" \n\n# Avoid logging docker healthcheck request\n$HTTP[\"remote-ip\"] == \"127.0.0.1\" { accesslog.filename = \"\" }\n$HTTP[\"remote-ip\"] == \"[::1]\" { accesslog.filename = \"\" }\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"homer\",\n  \"version\": \"25.11.1\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"mock\": \"http-server dummy-data/ --cors\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview --port 5050\",\n    \"lint\": \"eslint . --fix\"\n  },\n  \"dependencies\": {\n    \"@fortawesome/fontawesome-free\": \"^6.7.2\",\n    \"bulma\": \"^1.0.4\",\n    \"lodash.merge\": \"^4.6.2\",\n    \"vue\": \"^3.5.26\",\n    \"yaml\": \"^2.8.2\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.39.2\",\n    \"@vitejs/plugin-vue\": \"^6.0.3\",\n    \"@vue/eslint-config-prettier\": \"^10.2.0\",\n    \"eslint\": \"^9.39.2\",\n    \"eslint-plugin-vue\": \"^9.33.0\",\n    \"globals\": \"^17.0.0\",\n    \"http-server\": \"^14.1.1\",\n    \"prettier\": \"^3.8.0\",\n    \"sass-embedded\": \"^1.97.2\",\n    \"vite\": \"^7.3.1\",\n    \"vite-plugin-pwa\": \"^1.2.0\"\n  },\n  \"license\": \"Apache-2.0\",\n  \"packageManager\": \"pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48\",\n  \"pnpm\": {\n    \"neverBuiltDependencies\": []\n  }\n}\n"
  },
  {
    "path": "public/assets/additional-page.yml.dist",
    "content": "---\n# Additional page configuration\n\n# Additional configurations are loaded using its file name, minus the extension, as an anchor (https://<mydashboad>#<config>). \n# `config.yml` is still used as a base configuration, and all values here will overwrite it, so you don't have to re-defined everything\n\n\nsubtitle: \"this is another dashboard page\"\n\n# This overwrites message config. Setting it to empty to remove message from this page and keep it only in the main one:\nmessage: ~\n\n# as we want to include a differente link here (so we can get back to home page), we need to replicate all links or they will be revome when overwriting the links field:\nlinks:\n  - name: \"Home\"\n    icon: \"fas fa-home\"\n    url: \"#\"\n  - name: \"Contribute\"\n    icon: \"fab fa-github\"\n    url: \"https://github.com/bastienwirtz/homer\"\n    target: \"_blank\" # optional html a tag target attribute\n  - name: \"Wiki\"\n    icon: \"fas fa-book\"\n    url: \"https://www.wikipedia.org/\"\n\nservices:\n  - name: \"More apps on another page!\"\n    icon: \"fas fa-cloud\"\n    items:\n      - name: \"Awesome app on a second page!\"\n        logo: \"assets/tools/sample.png\"\n        subtitle: \"Bookmark example\"\n        tag: \"app\"\n        url: \"https://www.reddit.com/r/selfhosted/\"\n        target: \"_blank\"\n"
  },
  {
    "path": "public/assets/config-demo.yml.dist",
    "content": "---\n# Homepage configuration\n# See https://fontawesome.com/search for icons options\n\ntitle: \"Demo dashboard\"\nsubtitle: \"Homer\"\nlogo: \"logo.png\"\n# icon: \"fas fa-skull-crossbones\" # Optional icon\n\nheader: true\nfooter: '<p>Created with <span class=\"has-text-danger\">❤️</span> with <a href=\"https://bulma.io/\">Bulma</a>, <a href=\"https://vuejs.org/\">Vue.js</a> & <a href=\"https://fontawesome.com/\">font awesome</a> // Fork me on <a href=\"https://github.com/bastienwirtz/homer\"><i class=\"fab fa-github-alt\"></i></a></p>' # set false if you want to hide it.\n\n# Optional theme customization\ntheme: default\n\ncolumns: \"3\"\n\ndefaults:\n  layout: list\n\n# Optional message\nmessage:\n  style: \"is-dark\" # See https://bulma.io/documentation/components/message/#colors for styling options.\n  title: \"👋 Welcome!\"\n  content: \"Homer is a dead simple static HOMepage for your servER (or anything else) to keep your services and favorite links on hand, based on a simple yaml configuration file.<br /> Learn more at <a href='https://github.com/bastienwirtz/homer'>github.com/bastienwirtz/homer</a>\"\n\n# Optional navbar\n# links: [] # Allows for navbar (dark mode, layout, and search) without any links\nlinks:\n  - name: \"Contribute!\"\n    icon: \"fab fa-github\"\n    url: \"https://github.com/bastienwirtz/homer\"\n    target: \"_blank\" # optional html a tag target attribute\n  - name: \"Documentation\"\n    icon: \"fas fa-book\"\n    url: \"https://github.com/bastienwirtz/homer/blob/main/README.md#table-of-contents\"\n  # this will link to a second homer page that will load config from additional-page.yml and keep default config values as in config.yml file\n  # see url field and assets/additional-page.yml.dist used in this example:\n  - name: \"another page!\"\n    icon: \"fas fa-file-alt\"\n    url: \"#additional-page\" \n\n# Services\n# First level array represent a group.\n# Leave only a \"items\" key if not using group (group name, icon & tagstyle are optional, section separation will not be displayed).\nservices:\n  - name: \"Try Homer\"\n    icon: \"fa-solid fa-arrow-right\"\n    items:\n      - name: \"Get started\"\n        icon: \"fa-solid fa-download\"\n        subtitle: \"Start using Homer in a few minutes\"\n        tag: \"setup\"\n        url: \"https://github.com/bastienwirtz/homer?tab=readme-ov-file#get-started\"\n      - name: \"Configuration\"\n        icon: \"fa-solid fa-sliders\"\n        subtitle: \"Configuration options documentation\"\n        tag: \"setup\"\n        url: \"https://github.com/bastienwirtz/homer/blob/main/docs/configuration.md\"\n      - name: \"Theming\"\n        icon: \"fa-solid fa-palette\"\n        subtitle: \"Customize Homer appearance\"\n        tag: \"theming\"\n        url: \"https://github.com/bastienwirtz/homer/blob/main/docs/theming.md\"\n      - name: \"Smart cards\"\n        icon: \"fa-solid fa-palette\"\n        subtitle: \"Displays dynamic information or actions.\"\n        tag: \"setup\"\n        url: \"https://github.com/bastienwirtz/homer/blob/main/docs/customservices.md\"\n      - name: \"Dashboard icons\"\n        icon: \"fa-solid fa-icons\"\n        tag: \"setup\"\n        url: \"\"\n        quick:\n          - name: \"selfh.st\"\n            url: \"https://selfh.st/icons/\"\n            icon: \"fa-solid fa-arrow-up-right-from-square\"\n            target: \"_blank\"\n          - name: \"homarr-labs\"\n            url: \"https://github.com/homarr-labs/dashboard-icons\"\n            icon: \"fa-solid fa-arrow-up-right-from-square\"\n            target: \"_blank\"\n      - name: \"Buy me a coffee!\"\n        subtitle: \"Sponsor this project\"\n        icon: \"fa-solid fa-mug-hot\"\n        url: \"https://www.buymeacoffee.com/bastien\"\n  - name: \"Smart cards showcase\"\n    icon: \"fa-solid fa-brain\"\n    class: \"highlight-purple\"\n    items: \n      - name: \"Octoprint\"\n        logo: \"https://cdn-icons-png.flaticon.com/512/3112/3112529.png\"\n        apikey: \"xxxxxxxxxxxx\"\n        endpoint: \"/dummy-data/octoprint\"\n        type: \"OctoPrint\"\n      - name: \"Pi-hole\"\n        logo: \"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/pi-hole.png\"\n        url: \"https://pi-hole.net/\"\n        endpoint: \"/dummy-data/pihole\"\n        type: \"PiHole\"\n      - name: \"Proxmox - Node1\"\n        logo: \"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/proxmox.png\"\n        type: \"Proxmox\"\n        tag: \"sys\"\n        url: \"https://www.proxmox.com/en/\"\n        endpoint: \"/dummy-data/proxmox\"\n        node: \"node1\"\n        warning_value: 50\n        danger_value: 80\n        api_token: \"xxxxxxxxxxxx\"\n      - name: \"PeaNUT\"\n        logo: \"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/peanut.png\"\n        url: \"https://github.com/Brandawg93/PeaNUT\"\n        endpoint: \"/dummy-data/peanut\"\n        type: \"PeaNUT\"\n        device: \"ups\"\n      - name: \"Weather\"\n        location: \"Lille\"\n        apikey: \"xxxxxxxxxxxx\" # insert your own API key here. Request one from https://openweathermap.org/api.\n        units: \"metric\"\n        endpoint: \"/dummy-data/openweather/weather\"\n        type: \"OpenWeather\"\n  - name: \"Ressources\"\n    icon: \"fa-regular fa-bookmark\"\n    class: highlight-inverted\n    items:\n      - name: \"Selfhosted community\"\n        icon: \"fa-brands fa-reddit-alien\"\n        tag: \"community\"\n        url: \"\"\n        quick:\n          - name: \"r/selfhosted\"\n            url: \"https://www.reddit.com/r/selfhosted/\"\n            icon: \"fa-solid fa-arrow-up-right-from-square\"\n            target: \"_blank\"\n          - name: \"c/selfhosted\"\n            url: \"https://lemmy.world/c/selfhosted\"\n            icon: \"fa-solid fa-arrow-up-right-from-square\"\n            target: \"_blank\"\n      - name: \"Awesome selfhosted\"\n        icon: \"fa-solid fa-star\"\n        subtitle: \"Another application\"\n        tag: \"awesome-list\"\n        url: \"https://github.com/awesome-selfhosted/awesome-selfhosted\"\n"
  },
  {
    "path": "public/assets/config.yml.dist",
    "content": "---\n# Homepage configuration\n# See https://fontawesome.com/search for icons options\n\ntitle: \"Demo dashboard\"\nsubtitle: \"Homer\"\nlogo: \"logo.png\"\n# icon: \"fas fa-skull-crossbones\" # Optional icon\n\nheader: true\nfooter: '<p>Created with <span class=\"has-text-danger\">❤️</span> with <a href=\"https://bulma.io/\">bulma</a>, <a href=\"https://vuejs.org/\">vuejs</a> & <a href=\"https://fontawesome.com/\">font awesome</a> // Fork me on <a href=\"https://github.com/bastienwirtz/homer\"><i class=\"fab fa-github-alt\"></i></a></p>' # set false if you want to hide it.\n\n# Optional theme customization\ntheme: default\n\n# Optional theme customization (color overrrides)\n# overrrides can also be done using CSS vars \ncolors:\n  light:\n    highlight-primary: \"#3367d6\"\n    highlight-secondary: \"#4285f4\"\n    highlight-hover: \"#5a95f5\"\n    background: \"#f5f5f5\"\n    card-background: \"#ffffff\"\n    text: \"#363636\"\n    text-header: \"#ffffff\"\n    text-title: \"#303030\"\n    text-subtitle: \"#424242\"\n    card-shadow: rgba(0, 0, 0, 0.1)\n    link: \"#3273dc\"\n    link-hover: \"#363636\"\n  dark:\n    highlight-primary: \"#3367d6\"\n    highlight-secondary: \"#4285f4\"\n    highlight-hover: \"#5a95f5\"\n    background: \"#131313\"\n    card-background: \"#2b2b2b\"\n    text: \"#eaeaea\"\n    text-header: \"#ffffff\"\n    text-title: \"#fafafa\"\n    text-subtitle: \"#f5f5f5\"\n    card-shadow: rgba(0, 0, 0, 0.4)\n    link: \"#3273dc\"\n    link-hover: \"#ffdd57\"\n\n# Optional message\nmessage:\n  #url: https://b4bz.io\n  style: \"is-dark\" # See https://bulma.io/documentation/components/message/#colors for styling options.\n  title: \"Demo !\"\n  icon: \"fa fa-grin\"\n  content: \"This is a dummy homepage demo. <br /> Find more information on <a href='https://github.com/bastienwirtz/homer'>github.com/bastienwirtz/homer</a>\"\n\n# Optional navbar\n# links: [] # Allows for navbar (dark mode, layout, and search) without any links\nlinks:\n  - name: \"Contribute\"\n    icon: \"fab fa-github\"\n    url: \"https://github.com/bastienwirtz/homer\"\n    target: \"_blank\" # optional html a tag target attribute\n  - name: \"Wiki\"\n    icon: \"fas fa-book\"\n    url: \"https://www.wikipedia.org/\"\n  # this will link to a second homer page that will load config from additional-page.yml and keep default config values as in config.yml file\n  # see url field and assets/additional-page.yml.dist used in this example:\n  #- name: \"another page!\"\n  #  icon: \"fas fa-file-alt\"\n  #  url: \"#additional-page\" \n\n# Services\n# First level array represent a group.\n# Leave only a \"items\" key if not using group (group name, icon & tagstyle are optional, section separation will not be displayed).\nservices:\n  - name: \"Applications\"\n    icon: \"fas fa-cloud\"\n    items:\n      - name: \"Get started\"\n        icon: \"fa-solid fa-download\"\n        subtitle: \"Start using Homer in a few minutes\"\n        tag: \"setup\"\n        url: \"https://github.com/bastienwirtz/homer?tab=readme-ov-file#get-started\"\n      - name: \"Configuration\"\n        icon: \"fa-solid fa-sliders\"\n        subtitle: \"Configuration options documentation\"\n        tag: \"setup\"\n        url: \"https://github.com/bastienwirtz/homer/blob/main/docs/configuration.md\"\n      - name: \"Theming\"\n        icon: \"fa-solid fa-palette\"\n        subtitle: \"Customize Homer appearance\"\n        tag: \"theming\"\n        url: \"https://github.com/bastienwirtz/homer/blob/main/docs/theming.md\"\n      - name: \"Smart cards\"\n        icon: \"fa-solid fa-palette\"\n        subtitle: \"Displays dynamic information or actions.\"\n        tag: \"setup\"\n        url: \"https://github.com/bastienwirtz/homer/blob/main/docs/customservices.md\"\n      - name: \"Dashboard icons\"\n        icon: \"fa-solid fa-icons\"\n        subtitle: \"Dashboard icons\"\n        tag: \"setup\"\n        url: \"https://github.com/walkxcode/dashboard-icons\"\n"
  },
  {
    "path": "public/assets/custom.css.sample",
    "content": "@charset \"UTF-8\";\n\n/* Custom card colors */\n/* Use with `class:` property of services in config.yml */\nbody #app .card.green {\n  background-color: #006600;\n  color: #00ff00;\n}\n"
  },
  {
    "path": "public/assets/icons/README.md",
    "content": "# PWA Icons / Images \n\nWe suggest you to create a svg or png icon (if it is a png icon, with the maximum resolution possible) for your application and use it to generate a favicon package in [Favicon Generator](https://realfavicongenerator.net/).\n\nOnce generated, download the ZIP and use android-* icons for pwa-*:\n\n- use `android-chrome-192x192.png` for `pwa-192x192.png`\n- use `android-chrome-512x512.png` for `pwa-512x512.png`\n- `apple-touch-icon.png` is `apple-touch-icon.png`\n- `favicon.ico` is `favicon.ico`\n"
  },
  {
    "path": "src/App.vue",
    "content": "<template>\n  <div\n    v-if=\"config\"\n    id=\"app\"\n    :class=\"[\n      `theme-${config.theme}`,\n      `page-${currentPage}`,\n      isDark ? 'dark' : 'light',\n      !config.footer ? 'no-footer' : '',\n    ]\"\n  >\n    <DynamicTheme v-if=\"config.colors\" :themes=\"config.colors\" />\n    <div id=\"bighead\">\n      <section v-if=\"config.header\" class=\"first-line\">\n        <div v-cloak class=\"container\">\n          <div class=\"logo\">\n            <a href=\"#\">\n              <img v-if=\"config.logo\" :src=\"config.logo\" alt=\"dashboard logo\" />\n            </a>\n            <i v-if=\"config.icon\" :class=\"config.icon\"></i>\n          </div>\n          <div class=\"dashboard-title\">\n            <span class=\"headline\">{{ config.subtitle }}</span>\n            <h1>{{ config.title }}</h1>\n          </div>\n        </div>\n      </section>\n\n      <Navbar\n        :open=\"showMenu\"\n        :links=\"config.links\"\n        @navbar-toggle=\"showMenu = !showMenu\"\n      >\n        <DarkMode\n          :default-value=\"config.defaults.colorTheme\"\n          @updated=\"isDark = $event\"\n        />\n\n        <SettingToggle\n          name=\"vlayout\"\n          icon=\"fa-list\"\n          icon-alt=\"fa-columns\"\n          :default-value=\"config.defaults.layout == 'columns'\"\n          @updated=\"vlayout = $event\"\n        />\n\n        <SearchInput\n          class=\"navbar-item is-inline-block-mobile\"\n          :hotkey=\"searchHotkey()\"\n          @input=\"filterServices($event)\"\n          @search-focus=\"showMenu = true\"\n          @search-open=\"navigateToFirstService\"\n          @search-cancel=\"filterServices()\"\n        />\n      </Navbar>\n    </div>\n    <section id=\"main-section\" class=\"section\">\n      <div v-cloak class=\"container\">\n        <ConnectivityChecker\n          v-if=\"config.connectivityCheck\"\n          @network-status-update=\"offline = $event\"\n        />\n\n        <GetStarted v-if=\"configurationNeeded\" />\n\n        <div v-if=\"!offline\">\n          <!-- Optional messages -->\n          <Message :item=\"config.message\" />\n\n          <!-- Unified layout -->\n          <div\n            :class=\"[\n              'columns',\n              'is-multiline',\n              { 'layout-vertical': vlayout && !filter },\n            ]\"\n          >\n            <ServiceGroup\n              v-for=\"(group, groupIndex) in services\"\n              :key=\"`${currentPage}-${groupIndex}`\"\n              :group=\"group\"\n              :is-vertical=\"vlayout && !filter\"\n              :proxy=\"config.proxy\"\n              :columns=\"config.columns\"\n              :group-index=\"groupIndex\"\n            />\n          </div>\n        </div>\n      </div>\n    </section>\n\n    <footer class=\"footer\">\n      <div class=\"container\">\n        <div\n          v-if=\"config.footer\"\n          class=\"content has-text-centered\"\n          v-html=\"config.footer\"\n        ></div>\n      </div>\n    </footer>\n  </div>\n</template>\n\n<script>\nimport { parse } from \"yaml\";\nimport merge from \"lodash.merge\";\n\nimport Navbar from \"./components/Navbar.vue\";\nimport GetStarted from \"./components/GetStarted.vue\";\nimport ConnectivityChecker from \"./components/ConnectivityChecker.vue\";\nimport ServiceGroup from \"./components/ServiceGroup.vue\";\nimport Message from \"./components/Message.vue\";\nimport SearchInput from \"./components/SearchInput.vue\";\nimport SettingToggle from \"./components/SettingToggle.vue\";\nimport DarkMode from \"./components/DarkMode.vue\";\nimport DynamicTheme from \"./components/DynamicTheme.vue\";\n\nimport defaultConfig from \"./assets/defaults.yml?raw\";\n\nexport default {\n  name: \"App\",\n  components: {\n    Navbar,\n    GetStarted,\n    ConnectivityChecker,\n    ServiceGroup,\n    Message,\n    SearchInput,\n    SettingToggle,\n    DarkMode,\n    DynamicTheme,\n  },\n  data: function () {\n    return {\n      loaded: false,\n      currentPage: null,\n      configNotFound: false,\n      config: null,\n      services: null,\n      offline: false,\n      filter: \"\",\n      vlayout: true,\n      isDark: null,\n      showMenu: false,\n    };\n  },\n  computed: {\n    configurationNeeded: function () {\n      return (this.loaded && !this.services) || this.configNotFound;\n    },\n  },\n  created: async function () {\n    this.buildDashboard();\n    window.onhashchange = this.buildDashboard;\n    this.loaded = true;\n    console.info(`Homer v${__APP_VERSION__}`);\n  },\n  beforeUnmount() {\n    window.onhashchange = null;\n  },\n  methods: {\n    searchHotkey() {\n      if (this.config.hotkey && this.config.hotkey.search) {\n        return this.config.hotkey.search;\n      }\n    },\n    buildDashboard: async function () {\n      const defaults = parse(defaultConfig);\n      let config;\n      try {\n        config = await this.getConfig();\n        this.currentPage = window.location.hash.substring(1) || \"default\";\n\n        if (this.currentPage !== \"default\") {\n          let pageConfig = await this.getConfig(\n            `assets/${this.currentPage}.yml`,\n          );\n          config = Object.assign(config, pageConfig);\n        }\n      } catch (error) {\n        console.log(error);\n        config = this.handleErrors(\"⚠️ Error loading configuration\", error);\n      }\n      this.config = merge(defaults, config);\n      this.services = this.config.services;\n\n      document.title =\n        this.config.documentTitle ||\n        [this.config.title, this.config.subtitle].filter(Boolean).join(\" | \");\n\n      if (this.config.stylesheet) {\n        let stylesheet = \"\";\n        let addtionnal_styles = this.config.stylesheet;\n        if (!Array.isArray(this.config.stylesheet)) {\n          addtionnal_styles = [addtionnal_styles];\n        }\n        for (const file of addtionnal_styles) {\n          stylesheet += `@import \"${file}\";`;\n        }\n        this.createStylesheet(stylesheet);\n      }\n    },\n    getConfig: function (path = \"assets/config.yml\") {\n      return fetch(path).then((response) => {\n        if (response.status == 404 || response.redirected) {\n          this.configNotFound = true;\n          return {};\n        }\n\n        if (!response.ok) {\n          throw Error(`${response.statusText}: ${response.body}`);\n        }\n\n        const that = this;\n        return response\n          .text()\n          .then((body) => {\n            return parse(body, { merge: true });\n          })\n          .then(function (config) {\n            if (config.externalConfig) {\n              return that.getConfig(config.externalConfig);\n            }\n            return config;\n          });\n      });\n    },\n    matchesFilter: function (item) {\n      const needle = this.filter?.toLowerCase();\n      return (\n        item.name.toLowerCase().includes(needle) ||\n        (item.subtitle && item.subtitle.toLowerCase().includes(needle)) ||\n        (item.tag && item.tag.toLowerCase().includes(needle)) ||\n        (item.keywords && item.keywords.toLowerCase().includes(needle))\n      );\n    },\n    navigateToFirstService: function (target) {\n      try {\n        const service = this.services[0].items[0];\n        window.open(service.url, target || service.target || \"_self\");\n      } catch {\n        console.warn(\"fail to open service\");\n      }\n    },\n    filterServices: function (filter) {\n      this.filter = filter;\n\n      if (!filter) {\n        this.services = this.config.services;\n        return;\n      }\n\n      const searchResultItems = [];\n      for (const group of this.config.services) {\n        if (group.items !== null) {\n          for (const item of group.items) {\n            if (this.matchesFilter(item)) {\n              searchResultItems.push(item);\n            }\n          }\n        }\n      }\n\n      this.services = [\n        {\n          name: filter,\n          icon: \"fas fa-search\",\n          items: searchResultItems,\n        },\n      ];\n    },\n    handleErrors: function (title, content) {\n      return {\n        message: {\n          title: title,\n          style: \"is-danger\",\n          content: content,\n        },\n      };\n    },\n    createStylesheet: function (css) {\n      let style = document.createElement(\"style\");\n      style.appendChild(document.createTextNode(css));\n      document.head.appendChild(style);\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "src/assets/app.scss",
    "content": "@charset \"utf-8\";\n\n// foncdation\n@import url(\"@/assets/components/layers.scss\");\n@import url(\"~/bulma/css/bulma.css\") layer(framework);\n@import url(\"@/assets/components/base.scss\") layer(base);\n@import url(\"@/assets/components/highlights.scss\") layer(base);\n\n// themes\n@import url(\"@/assets/themes/classic.scss\") layer(theme);\n@import url(\"@/assets/themes/walkxcode.scss\") layer(theme);\n@import url(\"@/assets/themes/neon.scss\") layer(theme);\n"
  },
  {
    "path": "src/assets/components/base.scss",
    "content": "@import url(\"~/@fortawesome/fontawesome-free/css/all.css\") layer(base);\n@import url(\"@/assets/components/status.scss\") layer(base);\n@import url(\"@/assets/webfonts/webfonts.scss\") layer(base);\n\n@mixin ellipsis() {\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n@layer base {\n  html,\n  body,\n  body #app-mount,\n  body #app {\n    height: 100%;\n    background-color: var(--background);\n  }\n\n  body {\n    font-family: \"Noto Sans\", sans-serif;\n    font-optical-sizing: auto;\n    font-weight: 400;\n    font-style: normal;\n    font-variation-settings: \"wdth\" 100;\n\n    #app {\n      height: auto;\n      min-height: 100%;\n      background-image: var(--background-image);\n      background-size: cover;\n      background-position: center;\n      color: var(--text);\n      transition: background-color cubic-bezier(0.165, 0.84, 0.44, 1) 300ms;\n\n      a {\n        color: var(--link);\n        &:hover {\n          color: var(--link-hover);\n        }\n      }\n\n      .title {\n        font-weight: 500;\n        color: var(--text-title);\n      }\n      .subtitle {\n        color: var(--text-subtitle);\n      }\n\n      .card {\n        background-color: var(--card-background);\n        box-shadow: 0 2px 15px 0 var(--card-shadow);\n        &:hover {\n          background-color: var(--card-background);\n        }\n      }\n      .component-error .card {\n        border: 1px solid rgba(255, 33, 33, 0.664);\n        background-color: rgba(255, 58, 58, 0.24);\n      }\n\n      .message {\n        .message-body {\n          color: var(--text);\n          background-color: var(--card-background);\n        }\n      }\n\n      .footer {\n        background-color: var(--card-background);\n        box-shadow: 0 2px 15px 0 var(--card-shadow);\n      }\n    }\n\n    h1 {\n      font-size: 2rem;\n    }\n\n    h2 {\n      font-size: 1.3rem;\n      margin-top: 1.2rem;\n      margin-bottom: 0.5rem;\n\n      .fas,\n      .fab,\n      .far {\n        font-size: 1.4rem;\n        margin-right: 10px;\n      }\n\n      span {\n        font-weight: bold;\n        color: var(--highlight-secondary);\n      }\n    }\n\n    [v-cloak] {\n      display: none;\n    }\n\n    #bighead {\n      color: var(--text-header);\n\n      .dashboard-title {\n        padding-top: 6px;\n      }\n\n      .first-line {\n        min-height: 100px;\n        vertical-align: center;\n        background-color: var(--highlight-primary);\n\n        h1 {\n          margin-top: -12px;\n          font-size: 2rem;\n        }\n\n        .headline {\n          margin-top: 5px;\n          font-size: 0.9rem;\n        }\n\n        .container {\n          min-height: 80px;\n          padding: 10px 0;\n        }\n\n        .logo {\n          float: left;\n          i {\n            vertical-align: top;\n            padding: 8px 15px;\n            font-size: 50px;\n          }\n\n          img {\n            padding: 10px;\n            max-height: 70px;\n            max-width: 70px;\n          }\n        }\n      }\n      .navbar {\n        background-color: var(--highlight-secondary);\n\n        a {\n          color: var(--text-header);\n          padding: 8px 12px;\n          &:hover,\n          &:focus {\n            color: var(--text-header);\n            background-color: var(--highlight-hover);\n          }\n        }\n        .navbar-menu {\n          background-color: inherit;\n        }\n      }\n      .navbar-end {\n        text-align: right;\n      }\n    }\n\n    #main-section {\n      padding: 0 0 2.5rem 0;\n\n      h2 {\n        padding-bottom: 0px;\n        @include ellipsis();\n        i {\n          color: var(--highlight-primary);\n        }\n      }\n\n      .title {\n        font-size: 1.1em;\n        line-height: 1.3em;\n        font-weight: 500;\n        margin-bottom: 3px;\n        @include ellipsis();\n      }\n\n      .subtitle {\n        font-size: 0.9em;\n        font-weight: 300;\n        @include ellipsis();\n      }\n\n      .container {\n        padding: 1.2rem 0.75rem;\n      }\n\n      .message {\n        margin-top: 45px;\n        box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1);\n\n        .message-header {\n          font-weight: bold;\n        }\n\n        .message-body {\n          border: none;\n        }\n      }\n\n      .media.no-subtitle {\n        display: flex;\n        align-items: center;\n      }\n\n      .media-left {\n        margin-inline-end: 0.5rem;\n      }\n\n      .media-content {\n        overflow: hidden;\n        text-overflow: inherit;\n      }\n\n      .tag {\n        color: #ffffff;\n        position: absolute;\n        bottom: 1rem;\n        right: -0.2rem;\n        width: 3px;\n        overflow: hidden;\n        transition: all 0.2s ease-out;\n        padding: 0;\n\n        &:not([class*=\"is-\"]) {\n          background-color: var(--highlight-secondary);\n        }\n\n        .tag-text {\n          display: none;\n        }\n      }\n\n      .card {\n        border: none;\n        border-radius: 0.75rem;\n        box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1);\n        transition: cubic-bezier(0.165, 0.84, 0.44, 1) 300ms;\n        overflow: visible;\n\n        a {\n          outline: none;\n        }\n      }\n\n      .card:hover {\n        transform: translate(0, -3px);\n\n        .tag {\n          width: auto;\n          padding: 0 0.75em;\n\n          .tag-text {\n            display: block;\n          }\n        }\n      }\n\n      .card-content {\n        height: 85px;\n        padding: 1.3rem;\n      }\n\n      .layout-vertical {\n        h2.group-title {\n          padding-bottom: 0.75rem;\n        }\n\n        .card {\n          border-radius: 0;\n        }\n\n        .column div:first-of-type .card {\n          border-top-left-radius: 0.75rem;\n          border-top-right-radius: 0.75rem;\n        }\n\n        .column div:last-child .card {\n          border-bottom-left-radius: 0.75rem;\n          border-bottom-right-radius: 0.75rem;\n        }\n      }\n    }\n\n    .footer {\n      position: fixed;\n      left: 0;\n      right: 0;\n      bottom: 0;\n      padding: 0.5rem;\n      text-align: left;\n      color: #676767;\n      font-size: 0.85rem;\n      transition: background-color cubic-bezier(0.165, 0.84, 0.44, 1) 300ms;\n    }\n\n    .no-footer {\n      #main-section {\n        padding-bottom: 0;\n      }\n\n      .footer {\n        display: none;\n      }\n    }\n\n    .search-bar {\n      position: relative;\n      display: inline-block;\n      input {\n        border: none;\n        background-color: var(--highlight-hover);\n        border-radius: 5px;\n        margin-top: 2px;\n        padding: 2px 12px 2px 30px;\n        transition: all 100ms linear;\n        color: #ffffff;\n        height: 30px;\n        width: 100px;\n\n        &:focus {\n          color: #000000;\n          width: 250px;\n          background-color: #ffffff;\n        }\n      }\n\n      .search-label::before {\n        font-family: \"Font Awesome 6 Free\";\n        position: absolute;\n        top: 14px;\n        left: 16px;\n        content: \"\\f002\";\n        font-weight: 900;\n        width: 20px;\n        height: 20px;\n        color: #ffffff;\n      }\n\n      &:focus-within .search-label::before {\n        color: #6e6e6e;\n      }\n    }\n\n    .offline-message {\n      text-align: center;\n      margin: 35px 0;\n\n      i {\n        font-size: 2rem;\n      }\n\n      i.fa-redo-alt {\n        font-size: 1.3rem;\n        line-height: 1rem;\n        vertical-align: middle;\n        cursor: pointer;\n        color: #3273dc;\n      }\n    }\n  }\n\n  .group-logo {\n    float: left;\n  }\n}\n"
  },
  {
    "path": "src/assets/components/highlights.scss",
    "content": ".highlight-primary {\n  --highlight-color: var(--highlight-primary);\n}\n\n.highlight-green {\n  --highlight-color: var(--highlight-green);\n}\n\n.highlight-orange {\n  --highlight-color: var(--highlight-orange);\n}\n\n.highlight-pink {\n  --highlight-color: var(--highlight-pink);\n}\n\n.highlight-purple {\n  --highlight-color: var(--highlight-purple);\n}\n\n.highlight-red {\n  --highlight-color: var(--highlight-red);\n}\n\n.highlight-blue {\n  --highlight-color: var(--highlight-blue);\n}\n\n.highlight-inverted {\n  --highlight-color: var(--highlight-variant-inverted);\n}\n\n*[class^=\"highlight-\"],\n*[class*=\" highlight-\"] {\n  i {\n    color: var(--highlight-color);\n  }\n}\n"
  },
  {
    "path": "src/assets/components/layers.scss",
    "content": "@layer framework, base, theme;\n"
  },
  {
    "path": "src/assets/components/status.scss",
    "content": ".status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n\n  &.offline:before,\n  &.error:before {\n    background-color: #d65c68;\n    box-shadow: 0 0 5px 1px #d65c68;\n    color: #d65c68;\n  }\n\n  &.pending:before {\n    background-color: #e8bb7d;\n    box-shadow: 0 0 5px 1px #e8bb7d;\n  }\n\n  &.online:before,\n  &.ready:before {\n    background-color: #94e185;\n    box-shadow: 0 0 5px 1px #94e185;\n  }\n\n  &.in-progress:before {\n    background-color: #8fe87d;\n    box-shadow: 0 0 5px 1px #8fe87d;\n    animation: pulse 1s alternate infinite;\n  }\n\n  @keyframes pulse {\n    0% {\n      background: rgba(255, 255, 255, 0.2);\n      box-shadow:\n        inset 0px 0px 10px 2px rgba(0, 255, 182, 0.3),\n        0px 0px 5px 2px rgba(0, 255, 135, 0.2);\n    }\n    100% {\n      background: rgba(255, 255, 255, 1);\n      box-shadow:\n        inset 0px 0px 10px 2px rgba(0, 255, 182, 0.5),\n        0px 0px 15px 2px rgba(0, 255, 135, 1);\n    }\n  }\n\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 8px;\n    height: 8px;\n    margin-right: 10px;\n    border-radius: 8px;\n  }\n}\n"
  },
  {
    "path": "src/assets/defaults.yml",
    "content": "---\n# Default configuration\n\ntitle: \"Dashboard\"\nsubtitle: \"Homer\"\n\nheader: true\nfooter: '<p>Created with <span class=\"has-text-danger\">❤️</span> with <a href=\"https://bulma.io/\">bulma</a>, <a href=\"https://vuejs.org/\">vuejs</a> & <a href=\"https://fontawesome.com/\">font awesome</a> // Fork me on <a href=\"https://github.com/bastienwirtz/homer\"><i class=\"fab fa-github-alt\"></i></a></p>' # set false if you want to hide it.\n\ncolumns: 3\nconnectivityCheck: true\n\ndefaults:\n  # columns, list\n  layout: columns\n  # auto, light, dark\n  colorTheme: auto\n\ntheme: default\ncolors: ~\n\nmessage: ~\nlinks: []\nservices: []\n\n\nproxy: ~"
  },
  {
    "path": "src/assets/themes/classic.scss",
    "content": "// Theme colors\n.light {\n  --highlight-primary: #3367d6;\n  --highlight-secondary: #4285f4;\n  --highlight-hover: #5a95f5;\n  --background: #f5f5f5;\n  --card-background: #ffffff;\n  --text: #363636;\n  --text-header: #ffffff;\n  --text-title: #303030;\n  --text-subtitle: #424242;\n  --card-shadow: rgba(0, 0, 0, 0.1);\n  --link: #3273dc;\n  --link-hover: #363636;\n  --background-image: none;\n\n  --highlight-variant-inverted: #363636;\n}\n\n.dark {\n  --highlight-primary: #3367d6;\n  --highlight-secondary: #4285f4;\n  --highlight-hover: #5a95f5;\n  --background: #131313;\n  --card-background: #282828;\n  --text: #eaeaea;\n  --text-header: #ffffff;\n  --text-title: #fafafa;\n  --text-subtitle: #b6b6b6; \n  --card-shadow: rgba(0, 0, 0, 0.4);\n  --link: #3273dc;\n  --link-hover: #144aa2;\n  --background-image: none;\n\n  --highlight-variant-inverted: #f5f5f5;\n}\n\n#app {\n  --highlight-blue: #444b6e;\n  --highlight-red: #c83e4d;\n  --highlight-pink: #ff6392;\n  --highlight-orange: #ff8a08;\n  --highlight-green: #22a699;\n  --highlight-purple: #711db0;\n}\n"
  },
  {
    "path": "src/assets/themes/neon.scss",
    "content": "// Theme colors\n.theme-neon.light {\n  --highlight-primary: #b5ff57;\n  --highlight-secondary: #b5ff57;\n  --highlight-hover: #e7e4e4;\n  --background: #ffffff;\n  --card-background: #ffffff;\n  --text: #363636;\n  --text-header: #1f2229;\n  --text-title: #303030;\n  --text-subtitle: #424242;\n  --card-shadow: rgba(46, 39, 39, 0.1);\n  --link: #b5ff57;\n  --link-hover: #8cce36;\n  --background-image: none;\n  --highlight-variant-inverted: #2d2d2d;\n}\n\n.theme-neon.dark {\n  --highlight-primary: #b5ff57;\n  --highlight-secondary: #b5ff57;\n  --highlight-hover: #1f2229;\n  --background: #14161a;\n  --card-background: #14161a;\n  --text: #eaeaea;\n  --text-header: #ffffff;\n  --text-title: #fafafa;\n  --text-subtitle: #768198;\n  --card-shadow: rgba(46, 39, 39, 0.1);\n  --link: #b5ff57;\n  --link-hover: #aeff45;\n  --background-image: none;\n  --highlight-variant-inverted: #696969;\n}\n\n// theme\n.theme-neon {\n  --subtitle-color: #768198;\n  --border-radius: 0.75em;\n\n  .first-line {\n    padding-top: 25px;\n    margin-bottom: 5px;\n    background-color: transparent;\n\n    .headline {\n      color: var(--subtitle-color);\n      font-size: 1em;\n    }\n\n    h1 {\n      font-size: 2.5em;\n      font-weight: bold;\n    }\n\n    .dashboard-title::after {\n      content: \"\";\n      position: absolute;\n      bottom: -4px;\n      width: 4em;\n      height: 0.25rem;\n      background-color: var(--highlight-primary);\n      border-radius: 2px;\n    }\n  }\n\n  .navbar {\n    background-color: transparent;\n  }\n\n  #bighead {\n    .logo {\n      display: none;\n    }\n    .navbar a {\n      color: var(--text);\n\n      &:hover {\n        color: var(--text-header);\n        border-radius: var(--border-radius);\n        background-color: transparent;\n      }\n    }\n  }\n\n  *[class^=\"highlight-\"],\n  *[class*=\" highlight-\"] {\n    .card:hover {\n      border: 1px solid var(--highlight-color);\n    }\n  }\n\n  .tag {\n    color: var(--highlight-variant-inverted);\n  }\n\n  .card {\n    box-shadow: 0px 10px 15px -3px rgba(0, 0, 0, 0.1);\n    border: 1px solid hsl(221, 14%, 24%);\n    border-radius: var(--border-radius);\n\n    .message {\n      border: 1px solid hsl(221, 14%, 24%);\n      border-radius: var(--border-radius);\n    }\n  }\n\n  .layout-vertical .card {\n    margin-bottom: 0.75em;\n  }\n\n  .message-body {\n    background-color: transparent;\n  }\n}\n"
  },
  {
    "path": "src/assets/themes/walkxcode.scss",
    "content": "// Theme colors\n.theme-walkxcode.light {\n  --highlight-primary: #111111;\n  --highlight-secondary: #fff5f2;\n  --highlight-hover: #bebebe;\n  --background: #fff5f2;\n  --card-background: rgba(255, 245, 242, 0.8);\n  --text: #000000;\n  --text-header: #000000;\n  --text-title: #000000;\n  --text-subtitle: #111111;\n  --card-shadow: rgba(0, 0, 0, 0.5);\n  --link: #3273dc;\n  --link-hover: #2e4053;\n  --background-image: url(\"/assets/themes/walkxcode/wallpaper-light.webp\");\n}\n\n.theme-walkxcode.dark {\n  --highlight-primary: #3367d6;\n  --highlight-secondary: #4285f4;\n  --highlight-hover: #1f2347;\n  --background: #12152b;\n  --card-background: rgba(24, 28, 58, 0.8);\n  --text: #eaeaea;\n  --text-header: #fafafa;\n  --text-title: #fafafa;\n  --text-subtitle: #8b8d9c;\n  --card-shadow: rgba(0, 0, 0, 0.5);\n  --link: #ffffff;\n  --link-hover: #fafafa;\n  --background-image: url(\"/assets/themes/walkxcode/wallpaper.webp\");\n}\n\n// theme\n.theme-walkxcode {\n  --border-radius: 1rem;\n\n  #bighead {\n    .first-line,\n    .navbar,\n    .navbar a:focus,\n    .navbar a:hover {\n      background-color: rgba(0, 0, 0, 0);\n    }\n    .search-bar input {\n      opacity: 20%;\n    }\n  }\n\n  .group-title {\n    font-weight: 800;\n  }\n\n  .titles {\n    font-weight: 700;\n  }\n\n  .subtitle {\n    font-weight: 500;\n  }\n\n  .card {\n    border-radius: var(--border-radius);\n  }\n\n  .layout-vertical {\n    .card {\n      border-radius: 0;\n    }\n\n    .column div:first-of-type .card {\n      border-top-left-radius: var(--border-radius);\n      border-top-right-radius: var(--border-radius);\n    }\n\n    .column div:last-child .card {\n      border-bottom-left-radius: var(--border-radius);\n      border-bottom-right-radius: var(--border-radius);\n    }\n  }\n\n  .tag {\n    color: var(--highlight-variant-inverted);\n  }\n}\n"
  },
  {
    "path": "src/assets/webfonts/noto/OFL.txt",
    "content": "Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)\r\n\r\nThis Font Software is licensed under the SIL Open Font License, Version 1.1.\r\nThis license is copied below, and is also available with a FAQ at:\r\nhttps://openfontlicense.org\r\n\r\n\r\n-----------------------------------------------------------\r\nSIL OPEN FONT LICENSE Version 1.1 - 26 February 2007\r\n-----------------------------------------------------------\r\n\r\nPREAMBLE\r\nThe goals of the Open Font License (OFL) are to stimulate worldwide\r\ndevelopment of collaborative font projects, to support the font creation\r\nefforts of academic and linguistic communities, and to provide a free and\r\nopen framework in which fonts may be shared and improved in partnership\r\nwith others.\r\n\r\nThe OFL allows the licensed fonts to be used, studied, modified and\r\nredistributed freely as long as they are not sold by themselves. The\r\nfonts, including any derivative works, can be bundled, embedded, \r\nredistributed and/or sold with any software provided that any reserved\r\nnames are not used by derivative works. The fonts and derivatives,\r\nhowever, cannot be released under any other type of license. The\r\nrequirement for fonts to remain under this license does not apply\r\nto any document created using the fonts or their derivatives.\r\n\r\nDEFINITIONS\r\n\"Font Software\" refers to the set of files released by the Copyright\r\nHolder(s) under this license and clearly marked as such. This may\r\ninclude source files, build scripts and documentation.\r\n\r\n\"Reserved Font Name\" refers to any names specified as such after the\r\ncopyright statement(s).\r\n\r\n\"Original Version\" refers to the collection of Font Software components as\r\ndistributed by the Copyright Holder(s).\r\n\r\n\"Modified Version\" refers to any derivative made by adding to, deleting,\r\nor substituting -- in part or in whole -- any of the components of the\r\nOriginal Version, by changing formats or by porting the Font Software to a\r\nnew environment.\r\n\r\n\"Author\" refers to any designer, engineer, programmer, technical\r\nwriter or other person who contributed to the Font Software.\r\n\r\nPERMISSION & CONDITIONS\r\nPermission is hereby granted, free of charge, to any person obtaining\r\na copy of the Font Software, to use, study, copy, merge, embed, modify,\r\nredistribute, and sell modified and unmodified copies of the Font\r\nSoftware, subject to the following conditions:\r\n\r\n1) Neither the Font Software nor any of its individual components,\r\nin Original or Modified Versions, may be sold by itself.\r\n\r\n2) Original or Modified Versions of the Font Software may be bundled,\r\nredistributed and/or sold with any software, provided that each copy\r\ncontains the above copyright notice and this license. These can be\r\nincluded either as stand-alone text files, human-readable headers or\r\nin the appropriate machine-readable metadata fields within text or\r\nbinary files as long as those fields can be easily viewed by the user.\r\n\r\n3) No Modified Version of the Font Software may use the Reserved Font\r\nName(s) unless explicit written permission is granted by the corresponding\r\nCopyright Holder. This restriction only applies to the primary font name as\r\npresented to the users.\r\n\r\n4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font\r\nSoftware shall not be used to promote, endorse or advertise any\r\nModified Version, except to acknowledge the contribution(s) of the\r\nCopyright Holder(s) and the Author(s) or with their explicit written\r\npermission.\r\n\r\n5) The Font Software, modified or unmodified, in part or in whole,\r\nmust be distributed entirely under this license, and must not be\r\ndistributed under any other license. The requirement for fonts to\r\nremain under this license does not apply to any document created\r\nusing the Font Software.\r\n\r\nTERMINATION\r\nThis license becomes null and void if any of the above conditions are\r\nnot met.\r\n\r\nDISCLAIMER\r\nTHE FONT SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\r\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF\r\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT\r\nOF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE\r\nCOPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\r\nINCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL\r\nDAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\r\nFROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM\r\nOTHER DEALINGS IN THE FONT SOFTWARE.\r\n"
  },
  {
    "path": "src/assets/webfonts/noto/README.txt",
    "content": "Noto Sans Variable Font\n=======================\n\nThis download contains Noto Sans as both variable fonts and static fonts.\n\nNoto Sans is a variable font with these axes:\n  wdth\n  wght\n\nThis means all the styles are contained in these files:\n  NotoSans-VariableFont_wdth,wght.ttf\n  NotoSans-Italic-VariableFont_wdth,wght.ttf\n\nIf your app fully supports variable fonts, you can now pick intermediate styles\nthat aren’t available as static fonts. Not all apps support variable fonts, and\nin those cases you can use the static font files for Noto Sans:\n  static/NotoSans_ExtraCondensed-Thin.ttf\n  static/NotoSans_ExtraCondensed-ExtraLight.ttf\n  static/NotoSans_ExtraCondensed-Light.ttf\n  static/NotoSans_ExtraCondensed-Regular.ttf\n  static/NotoSans_ExtraCondensed-Medium.ttf\n  static/NotoSans_ExtraCondensed-SemiBold.ttf\n  static/NotoSans_ExtraCondensed-Bold.ttf\n  static/NotoSans_ExtraCondensed-ExtraBold.ttf\n  static/NotoSans_ExtraCondensed-Black.ttf\n  static/NotoSans_Condensed-Thin.ttf\n  static/NotoSans_Condensed-ExtraLight.ttf\n  static/NotoSans_Condensed-Light.ttf\n  static/NotoSans_Condensed-Regular.ttf\n  static/NotoSans_Condensed-Medium.ttf\n  static/NotoSans_Condensed-SemiBold.ttf\n  static/NotoSans_Condensed-Bold.ttf\n  static/NotoSans_Condensed-ExtraBold.ttf\n  static/NotoSans_Condensed-Black.ttf\n  static/NotoSans_SemiCondensed-Thin.ttf\n  static/NotoSans_SemiCondensed-ExtraLight.ttf\n  static/NotoSans_SemiCondensed-Light.ttf\n  static/NotoSans_SemiCondensed-Regular.ttf\n  static/NotoSans_SemiCondensed-Medium.ttf\n  static/NotoSans_SemiCondensed-SemiBold.ttf\n  static/NotoSans_SemiCondensed-Bold.ttf\n  static/NotoSans_SemiCondensed-ExtraBold.ttf\n  static/NotoSans_SemiCondensed-Black.ttf\n  static/NotoSans-Thin.ttf\n  static/NotoSans-ExtraLight.ttf\n  static/NotoSans-Light.ttf\n  static/NotoSans-Regular.ttf\n  static/NotoSans-Medium.ttf\n  static/NotoSans-SemiBold.ttf\n  static/NotoSans-Bold.ttf\n  static/NotoSans-ExtraBold.ttf\n  static/NotoSans-Black.ttf\n  static/NotoSans_ExtraCondensed-ThinItalic.ttf\n  static/NotoSans_ExtraCondensed-ExtraLightItalic.ttf\n  static/NotoSans_ExtraCondensed-LightItalic.ttf\n  static/NotoSans_ExtraCondensed-Italic.ttf\n  static/NotoSans_ExtraCondensed-MediumItalic.ttf\n  static/NotoSans_ExtraCondensed-SemiBoldItalic.ttf\n  static/NotoSans_ExtraCondensed-BoldItalic.ttf\n  static/NotoSans_ExtraCondensed-ExtraBoldItalic.ttf\n  static/NotoSans_ExtraCondensed-BlackItalic.ttf\n  static/NotoSans_Condensed-ThinItalic.ttf\n  static/NotoSans_Condensed-ExtraLightItalic.ttf\n  static/NotoSans_Condensed-LightItalic.ttf\n  static/NotoSans_Condensed-Italic.ttf\n  static/NotoSans_Condensed-MediumItalic.ttf\n  static/NotoSans_Condensed-SemiBoldItalic.ttf\n  static/NotoSans_Condensed-BoldItalic.ttf\n  static/NotoSans_Condensed-ExtraBoldItalic.ttf\n  static/NotoSans_Condensed-BlackItalic.ttf\n  static/NotoSans_SemiCondensed-ThinItalic.ttf\n  static/NotoSans_SemiCondensed-ExtraLightItalic.ttf\n  static/NotoSans_SemiCondensed-LightItalic.ttf\n  static/NotoSans_SemiCondensed-Italic.ttf\n  static/NotoSans_SemiCondensed-MediumItalic.ttf\n  static/NotoSans_SemiCondensed-SemiBoldItalic.ttf\n  static/NotoSans_SemiCondensed-BoldItalic.ttf\n  static/NotoSans_SemiCondensed-ExtraBoldItalic.ttf\n  static/NotoSans_SemiCondensed-BlackItalic.ttf\n  static/NotoSans-ThinItalic.ttf\n  static/NotoSans-ExtraLightItalic.ttf\n  static/NotoSans-LightItalic.ttf\n  static/NotoSans-Italic.ttf\n  static/NotoSans-MediumItalic.ttf\n  static/NotoSans-SemiBoldItalic.ttf\n  static/NotoSans-BoldItalic.ttf\n  static/NotoSans-ExtraBoldItalic.ttf\n  static/NotoSans-BlackItalic.ttf\n\nGet started\n-----------\n\n1. Install the font files you want to use\n\n2. Use your app's font picker to view the font family and all the\navailable styles\n\nLearn more about variable fonts\n-------------------------------\n\n  https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts\n  https://variablefonts.typenetwork.com\n  https://medium.com/variable-fonts\n\nIn desktop apps\n\n  https://theblog.adobe.com/can-variable-fonts-illustrator-cc\n  https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts\n\nOnline\n\n  https://developers.google.com/fonts/docs/getting_started\n  https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide\n  https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts\n\nInstalling fonts\n\n  MacOS: https://support.apple.com/en-us/HT201749\n  Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux\n  Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows\n\nAndroid Apps\n\n  https://developers.google.com/fonts/docs/android\n  https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts\n\nLicense\n-------\nPlease read the full license text (OFL.txt) to understand the permissions,\nrestrictions and requirements for usage, redistribution, and modification.\n\nYou can use them in your products & projects – print or digital,\ncommercial or otherwise.\n\nThis isn't legal advice, please consider consulting a lawyer and see the full\nlicense for all details.\n"
  },
  {
    "path": "src/assets/webfonts/webfonts.scss",
    "content": "/* latin */\n@font-face {\n  font-family: 'Noto Sans';\n  font-style: normal;\n  font-weight: 100 900;\n  font-stretch: 100%;\n  font-display: swap;\n  src: url(\"./noto/noto-latin-normal.woff2\") format('woff2');\n  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n\n"
  },
  {
    "path": "src/components/ConnectivityChecker.vue",
    "content": "<template>\n  <div\n    v-if=\"offline\"\n    class=\"offline-message mb-4\"\n    role=\"alert\"\n    aria-live=\"polite\"\n  >\n    <i class=\"fa-solid fa-triangle-exclamation\"></i>\n    <h1>\n      Network unreachable\n      <button\n        aria-label=\"Retry connection check\"\n        class=\"retry-button\"\n        @click=\"checkOffline\"\n      >\n        <i class=\"fas fa-redo-alt\"></i>\n      </button>\n    </h1>\n    <p>\n      <a\n        href=\"https://github.com/bastienwirtz/homer/blob/main/docs/configuration.md#connectivity-checks\"\n        >More information →</a\n      >\n    </p>\n  </div>\n</template>\n\n<script>\nexport default {\n  name: \"ConnectivityChecker\",\n  data: function () {\n    return {\n      offline: false,\n    };\n  },\n  created: function () {\n    if (/t=\\d+/.test(window.location.href)) {\n      window.history.replaceState({}, document.title, window.location.pathname);\n    }\n    this.checkOffline();\n\n    document.addEventListener(\n      \"visibilitychange\",\n      this.handleVisibilityChange,\n      false,\n    );\n    window.addEventListener(\"online\", this.handleOnline, false);\n    window.addEventListener(\"offline\", this.handleOffline, false);\n  },\n  beforeUnmount: function () {\n    document.removeEventListener(\n      \"visibilitychange\",\n      this.handleVisibilityChange,\n    );\n    window.removeEventListener(\"online\", this.handleOnline);\n    window.removeEventListener(\"offline\", this.handleOffline);\n  },\n  methods: {\n    handleVisibilityChange: function () {\n      if (document.visibilityState === \"visible\") {\n        this.checkOffline();\n      }\n    },\n    handleOnline: function () {\n      this.checkOffline();\n    },\n    handleOffline: function () {\n      this.offline = true;\n    },\n    checkOffline: function () {\n      // Global online check\n      if (!navigator.onLine) {\n        this.offline = true;\n        return;\n      }\n\n      // Check if the current URL is reachable\n      let that = this;\n\n      const aliveCheckUrl = new URL(window.location);\n      aliveCheckUrl.searchParams.set(\"t\", new Date().valueOf());\n\n      return fetch(aliveCheckUrl, {\n        method: \"HEAD\",\n        cache: \"no-store\",\n        redirect: \"manual\",\n      })\n        .then(function (response) {\n          // opaqueredirect means request has been redirected, to auth provider probably\n          if (\n            (response.type === \"opaqueredirect\" && !response.ok) ||\n            [401, 403].indexOf(response.status) != -1\n          ) {\n            window.location.href = aliveCheckUrl;\n          }\n          that.offline = !response.ok;\n        })\n        .catch(function () {\n          that.offline = true;\n        })\n        .finally(function () {\n          that.$emit(\"network-status-update\", that.offline);\n        });\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/DarkMode.vue",
    "content": "<template>\n  <a\n    aria-label=\"Toggle dark mode\"\n    class=\"navbar-item is-inline-block-mobile\"\n    @click=\"toggleTheme()\"\n  >\n    <i\n      :class=\"`${faClasses[mode]}`\"\n      class=\"fa-fw\"\n      :title=\"`${titles[mode]}`\"\n    ></i>\n  </a>\n</template>\n\n<script>\nexport default {\n  name: \"Darkmode\",\n  props: {\n    defaultValue: String,\n  },\n  emits: [\"updated\"],\n  data: function () {\n    return {\n      isDark: null,\n      faClasses: null,\n      titles: null,\n      mode: null,\n    };\n  },\n  created: function () {\n    this.faClasses = [\"fas fa-adjust\", \"fas fa-circle\", \"far fa-circle\"];\n    this.titles = [\"Auto-switch\", \"Light theme\", \"Dark theme\"];\n    this.mode = 0;\n    if (\"overrideDark\" in localStorage) {\n      // Light theme is 1 and Dark theme is 2\n      this.mode = JSON.parse(localStorage.overrideDark) ? 2 : 1;\n    } else {\n      switch (this.defaultValue) {\n        case \"light\":\n          this.mode = 1;\n          break;\n        case \"dark\":\n          this.mode = 2;\n          break;\n        default:\n          this.mode = 0;\n      }\n    }\n    this.isDark = this.getIsDark();\n    this.$emit(\"updated\", this.isDark);\n    this.watchIsDark();\n  },\n  methods: {\n    toggleTheme: function () {\n      this.mode = (this.mode + 1) % 3;\n      switch (this.mode) {\n        // Default behavior\n        case 0:\n          localStorage.removeItem(\"overrideDark\");\n          break;\n        // Force light theme\n        case 1:\n          localStorage.overrideDark = false;\n          break;\n        // Force dark theme\n        case 2:\n          localStorage.overrideDark = true;\n          break;\n        default:\n          // Should be unreachable\n          break;\n      }\n\n      this.isDark = this.getIsDark();\n      this.$emit(\"updated\", this.isDark);\n    },\n\n    getIsDark: function () {\n      const values = [\n        matchMedia(\"(prefers-color-scheme: dark)\").matches,\n        false,\n        true,\n      ];\n      return values[this.mode];\n    },\n\n    watchIsDark: function () {\n      matchMedia(\"(prefers-color-scheme: dark)\").addEventListener(\n        \"change\",\n        () => {\n          this.isDark = this.getIsDark();\n          this.$emit(\"updated\", this.isDark);\n        },\n      );\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/DynamicTheme.vue",
    "content": "<template>\n  <DynamicStyle>\n    :root, body #app.light {\n    {{ getVars(themes.light) }}\n    } @media (prefers-color-scheme: light), (prefers-color-scheme:\n    no-preference) { :root, body #app {\n    {{ getVars(themes.light) }}\n    } } body #app.dark {\n    {{ getVars(themes.dark) }}\n    } @media (prefers-color-scheme: dark) { :root, body #app {\n    {{ getVars(themes.dark) }}\n    } }\n  </DynamicStyle>\n</template>\n\n<script>\nexport default {\n  name: \"DynamicTheme\",\n  props: {\n    themes: Object,\n  },\n  methods: {\n    getVars: function (theme) {\n      let vars = [];\n      for (const themeVars in theme) {\n        let value = theme[themeVars];\n        if (!value) {\n          value = \"initial\";\n        } else if (themeVars == \"background-image\") {\n          value = `url(${theme[themeVars]})`;\n        }\n        vars.push(`--${themeVars}: ${value}`);\n      }\n      return vars.join(\";\");\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/GetStarted.vue",
    "content": "<template>\n  <article>\n    <div class=\"m-6 has-text-centered py-6 title\">\n      <p class=\"is-size-5 mb-2\">No configuration found!</p>\n      <p>Check out the documentation to start building your dashboard.</p>\n      <p>\n        <a\n          class=\"button is-primary mt-5 has-text-weight-bold\"\n          href=\"https://github.com/bastienwirtz/homer/blob/main/docs/configuration.md#configuration\"\n          target=\"_blank\"\n        >\n          Get started →\n        </a>\n      </p>\n    </div>\n  </article>\n</template>\n\n<script>\nexport default {\n  name: \"GetStarted\",\n};\n</script>\n\n<style lang=\"scss\" scoped>\nbody #app a {\n  font-weight: 900;\n  color: #ffffff;\n}\n</style>\n"
  },
  {
    "path": "src/components/GroupHeader.vue",
    "content": "<template>\n  <h2 :class=\"group.class\">\n    <i v-if=\"group.icon\" :class=\"['fa-fw', group.icon]\"></i>\n    <div v-else-if=\"group.logo\" class=\"group-logo media-left\">\n      <figure class=\"image is-48x48\">\n        <img :src=\"group.logo\" :alt=\"`${group.name} logo`\" />\n      </figure>\n    </div>\n    {{ group.name }}\n  </h2>\n</template>\n\n<script>\nexport default {\n  name: \"GroupHeader\",\n  props: {\n    group: {\n      type: Object,\n      required: true,\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/Message.vue",
    "content": "<template>\n  <article v-if=\"show\" class=\"message\" :class=\"message.style\">\n    <div v-if=\"message.title || message.icon\" class=\"message-header\">\n      <p>\n        <i v-if=\"message.icon\" :class=\"`fa-fw ${message.icon}`\"></i>\n        {{ message.title }}\n      </p>\n    </div>\n    <div\n      v-if=\"message.content\"\n      class=\"message-body\"\n      v-html=\"message.content\"\n    ></div>\n  </article>\n</template>\n\n<script>\nexport default {\n  name: \"Message\",\n  props: {\n    item: Object,\n  },\n  data: function () {\n    return {\n      message: {},\n    };\n  },\n  computed: {\n    show: function () {\n      return this.message.title || this.message.content;\n    },\n  },\n  watch: {\n    item: function (item) {\n      this.message = Object.assign({}, item);\n    },\n  },\n  created: async function () {\n    // Look for a new message if an endpoint is provided.\n    this.message = Object.assign({}, this.item);\n    await this.getMessage();\n  },\n  methods: {\n    getMessage: async function () {\n      if (!this.item) {\n        return;\n      }\n      if (this.item.url) {\n        let fetchedMessage = await this.downloadMessage(this.item.url);\n        if (this.item.mapping) {\n          fetchedMessage = this.mapRemoteMessage(fetchedMessage);\n        }\n\n        // keep the original config value if no value is provided by the endpoint\n        const message = this.message;\n        for (const prop of [\"title\", \"style\", \"content\", \"icon\"]) {\n          if (prop in fetchedMessage && fetchedMessage[prop] !== null) {\n            message[prop] = fetchedMessage[prop];\n          }\n        }\n        this.message = { ...message }; // Force computed property to re-evaluate\n      }\n\n      if (this.item.refreshInterval) {\n        setTimeout(this.getMessage, this.item.refreshInterval);\n      }\n    },\n\n    downloadMessage: function (url) {\n      return fetch(url, { headers: { Accept: \"application/json\" } }).then(\n        function (response) {\n          if (response.status != 200) {\n            return;\n          }\n          return response.json();\n        },\n      );\n    },\n\n    mapRemoteMessage: function (message) {\n      let mapped = {};\n      // map property from message into mapped according to mapping config (only if field has a value):\n      for (const prop in this.item.mapping)\n        if (message[this.item.mapping[prop]])\n          mapped[prop] = message[this.item.mapping[prop]];\n      return mapped;\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/Navbar.vue",
    "content": "<template>\n  <div v-cloak v-if=\"links\" class=\"container-fluid\">\n    <nav class=\"navbar\" role=\"navigation\" aria-label=\"main navigation\">\n      <div class=\"container\">\n        <div class=\"navbar-brand\">\n          <a\n            role=\"button\"\n            aria-label=\"menu\"\n            aria-expanded=\"false\"\n            class=\"navbar-burger\"\n            :class=\"{ 'is-active': showMenu }\"\n            @click=\"$emit('navbar-toggle')\"\n          >\n            <span aria-hidden=\"true\"></span>\n            <span aria-hidden=\"true\"></span>\n            <span aria-hidden=\"true\"></span>\n          </a>\n        </div>\n        <div class=\"navbar-menu\" :class=\"{ 'is-active': showMenu }\">\n          <div class=\"navbar-start\">\n            <a\n              v-for=\"(link, key) in links\"\n              :key=\"key\"\n              class=\"navbar-item\"\n              rel=\"noreferrer\"\n              :href=\"link.url\"\n              :target=\"link.target\"\n            >\n              <i v-if=\"link.icon\" :class=\"['fa-fw', link.icon]\"></i>\n              {{ link.name }}\n            </a>\n          </div>\n          <div class=\"navbar-end\">\n            <slot></slot>\n          </div>\n        </div>\n      </div>\n    </nav>\n  </div>\n</template>\n\n<script>\nexport default {\n  name: \"Navbar\",\n  props: {\n    open: {\n      type: Boolean,\n      default: false,\n    },\n    links: Array,\n  },\n  emits: [\"navbar-toggle\"],\n  computed: {\n    showMenu: function () {\n      return this.open && this.isSmallScreen();\n    },\n  },\n  methods: {\n    isSmallScreen: function () {\n      return window.matchMedia(\"screen and (max-width: 1023px)\").matches;\n    },\n  },\n};\n</script>\n\n<style lang=\"scss\" scoped>\n@media (min-width: 1023px) {\n  i.fa-fw {\n    width: 0.8em;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/SearchInput.vue",
    "content": "<template>\n  <search class=\"search-bar\">\n    <form role=\"search\">\n      <label for=\"search\" class=\"search-label\"></label>\n      <input\n        id=\"search\"\n        ref=\"search\"\n        name=\"search\"\n        type=\"search\"\n        :value=\"value\"\n        @input.stop=\"search($event.target.value)\"\n        @keydown.enter.exact.prevent=\"open()\"\n        @keydown.alt.enter.prevent=\"open('_blank')\"\n      />\n    </form>\n  </search>\n</template>\n\n<script>\nexport default {\n  name: \"SearchInput\",\n  props: {\n    value: String,\n    hotkey: {\n      type: String,\n      default: \"/\",\n    },\n  },\n  emits: [\"search-open\", \"search-focus\", \"search-cancel\", \"input\"],\n  mounted() {\n    this._keyListener = function (event) {\n      if (!this.hasFocus() && event.key === this.hotkey) {\n        event.preventDefault();\n        this.focus();\n      }\n      if (event.key === \"Escape\") {\n        this.cancel();\n      }\n    };\n    document.addEventListener(\"keydown\", this._keyListener.bind(this));\n\n    // fill search from get parameter.\n    const search = new URLSearchParams(window.location.search).get(\"search\");\n    if (search) {\n      this.$refs.search.value = search;\n      this.search(search);\n      this.focus();\n    }\n  },\n  beforeUnmount() {\n    document.removeEventListener(\"keydown\", this._keyListener);\n  },\n  methods: {\n    open: function (target = null) {\n      if (!this.$refs.search.value) {\n        return;\n      }\n      this.$emit(\"search-open\", target);\n    },\n    focus: function () {\n      this.$emit(\"search-focus\");\n      this.$nextTick(() => {\n        this.$refs.search.focus();\n      });\n    },\n    hasFocus: function () {\n      return document.activeElement == this.$refs.search;\n    },\n    setSearchURL: function (value) {\n      const url = new URL(window.location);\n      if (value === \"\") {\n        url.searchParams.delete(\"search\");\n      } else {\n        url.searchParams.set(\"search\", value);\n      }\n      window.history.replaceState(\"search\", null, url);\n    },\n    cancel: function () {\n      this.setSearchURL(\"\");\n      this.$refs.search.value = \"\";\n      this.$refs.search.blur();\n      this.$emit(\"search-cancel\");\n    },\n    search: function (value) {\n      this.setSearchURL(value);\n      this.$emit(\"input\", value.toLowerCase());\n    },\n  },\n};\n</script>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "src/components/Service.vue",
    "content": "<template>\n  <Generic v-if=\"isGeneric\" :item=\"item\"></Generic>\n  <component :is=\"component\" v-else :item=\"item\" :proxy=\"proxy\"></component>\n</template>\n\n<script>\nimport { defineAsyncComponent } from \"vue\";\nimport errorComponent from \"./services/_error.vue\";\nconst defaultService = \"Generic\";\n\nexport default {\n  name: \"Service\",\n  props: {\n    item: Object,\n    proxy: Object,\n  },\n  computed: {\n    isGeneric() {\n      return defaultService === (this.item.type || defaultService);\n    },\n    component() {\n      return defineAsyncComponent({\n        loader: () => import(`./services/${this.item.type}.vue`),\n        errorComponent: errorComponent,\n        timeout: 3000,\n      });\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/ServiceGroup.vue",
    "content": "<template>\n  <!-- Vertical layout: Group container wrapper -->\n  <div v-if=\"isVertical\" :class=\"['column', `is-${12 / columns}`]\">\n    <GroupHeader v-if=\"group.name\" :group=\"group\" class=\"group-title\" />\n    <Service\n      v-for=\"(item, index) in group.items\"\n      :key=\"`srv-${groupIndex}-${index}-${item.name || item.type}`\"\n      :item=\"item\"\n      :proxy=\"proxy\"\n      :class=\"item.class || group.class\"\n    />\n  </div>\n\n  <!-- Horizontal layout: Direct rendering -->\n  <template v-else>\n    <GroupHeader\n      v-if=\"group.name\"\n      :key=\"`header-${groupIndex}`\"\n      :group=\"group\"\n      class=\"column is-full group-title\"\n    />\n    <Service\n      v-for=\"(item, index) in group.items\"\n      :key=\"`srv-${groupIndex}-${index}-${item.name || item.type}`\"\n      :item=\"item\"\n      :proxy=\"proxy\"\n      :class=\"[\n        'column',\n        `is-${12 / columns}`,\n        `${item.class || group.class || ''}`,\n      ]\"\n    />\n  </template>\n</template>\n\n<script>\nimport Service from \"./Service.vue\";\nimport GroupHeader from \"./GroupHeader.vue\";\n\nexport default {\n  name: \"ServiceGroup\",\n  components: {\n    Service,\n    GroupHeader,\n  },\n  props: {\n    group: {\n      type: Object,\n      required: true,\n    },\n    isVertical: {\n      type: Boolean,\n      default: false,\n    },\n    proxy: {\n      type: String,\n      default: null,\n    },\n    columns: {\n      type: String,\n      required: true,\n    },\n    groupIndex: {\n      type: Number,\n      required: true,\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/SettingToggle.vue",
    "content": "<template>\n  <a\n    class=\"navbar-item is-inline-block-mobile\"\n    @click.prevent=\"toggleSetting()\"\n  >\n    <span><i :class=\"['fas', 'fa-fw', value ? icon : secondaryIcon]\"></i></span>\n    <slot></slot>\n  </a>\n</template>\n\n<script>\nexport default {\n  name: \"SettingToggle\",\n  props: {\n    name: String,\n    icon: String,\n    iconAlt: String,\n    defaultValue: Boolean,\n  },\n  emits: [\"updated\"],\n  data: function () {\n    return {\n      secondaryIcon: null,\n      value: true,\n    };\n  },\n  created: function () {\n    this.secondaryIcon = this.iconAlt || this.icon;\n\n    if (this.name in localStorage) {\n      this.value = JSON.parse(localStorage[this.name]);\n    } else {\n      this.value = this.defaultValue;\n    }\n\n    this.$emit(\"updated\", this.value);\n  },\n  methods: {\n    toggleSetting: function () {\n      this.value = !this.value;\n      localStorage[this.name] = this.value;\n      this.$emit(\"updated\", this.value);\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/services/AdGuardHome.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <template v-else-if=\"stats\">\n          {{ percentage }}&percnt; blocked\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <div class=\"status\" :class=\"protection\">\n        {{ protection }}\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"AdGuardHome\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => {\n    return {\n      status: null,\n      stats: null,\n    };\n  },\n  computed: {\n    percentage: function () {\n      if (this.stats) {\n        return (\n          (this.stats.num_blocked_filtering * 100) /\n          this.stats.num_dns_queries\n        ).toFixed(2);\n      }\n      return \"\";\n    },\n    protection: function () {\n      if (this.status) {\n        return this.status.protection_enabled ? \"enabled\" : \"disabled\";\n      } else return \"unknown\";\n    },\n  },\n  created: function () {\n    this.fetchStatus();\n    if (!this.item.subtitle) {\n      this.fetchStats();\n    }\n  },\n  methods: {\n    fetchStatus: async function () {\n      this.status = await this.fetch(\"/control/status\").catch((e) =>\n        console.log(e),\n      );\n    },\n    fetchStats: async function () {\n      this.stats = await this.fetch(\"/control/stats\").catch((e) =>\n        console.log(e),\n      );\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n\n  &.enabled:before {\n    background-color: #94e185;\n    border-color: #78d965;\n    box-shadow: 0px 0px 4px 1px #94e185;\n  }\n\n  &.disabled:before {\n    background-color: #c9404d;\n    border-color: #c42c3b;\n    box-shadow: 0px 0px 4px 1px #c9404d;\n  }\n\n  &.unknown:before {\n    background-color: #c9c740;\n    border-color: #ccc935;\n    box-shadow: 0px 0px 4px 1px #c9c740;\n  }\n\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 7px;\n    height: 7px;\n    margin-right: 10px;\n    border: 1px solid #000;\n    border-radius: 7px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/CopyToClipboard.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #indicator>\n      <div class=\"status\">\n        <i\n          class=\"fa-regular fa-copy fa-xl\"\n          :class=\"{ scale: animate }\"\n          @click=\"copy()\"\n          @animationend=\"animate = false\"\n        ></i>\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"CopyToClipboard\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    animate: false,\n  }),\n  methods: {\n    copy() {\n      navigator.clipboard.writeText(this.item.clipboard);\n      this.animate = true;\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.scale {\n  -webkit-animation: scale-up 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;\n  animation: scale-up 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;\n}\n.light i {\n  color: black;\n}\n.dark i {\n  color: white;\n}\n/**\n * ----------------------------------------\n * animation scale-down-center\n * ----------------------------------------\n */\n@-webkit-keyframes scale-up {\n  0% {\n    -webkit-transform: scale(1);\n    transform: scale(1);\n  }\n  50% {\n    -webkit-transform: scale(1.25);\n    transform: scale(1.25);\n  }\n  100% {\n    -webkit-transform: scale(1);\n    transform: scale(1);\n  }\n}\n@keyframes scale-up {\n  0% {\n    -webkit-transform: scale(1);\n    transform: scale(1);\n  }\n  50% {\n    -webkit-transform: scale(1.25);\n    transform: scale(1.25);\n  }\n  100% {\n    -webkit-transform: scale(1);\n    transform: scale(1);\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/DockerSocketProxy.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #indicator>\n      <div class=\"notifs\">\n        <strong\n          v-if=\"running > 0\"\n          class=\"notif running\"\n          title=\"Running Containers\"\n        >\n          {{ running }}\n        </strong>\n        <strong\n          v-if=\"stopped > 0\"\n          class=\"notif stopped\"\n          title=\"Stopped Containers\"\n        >\n          {{ stopped }}\n        </strong>\n        <strong v-if=\"errors > 0\" class=\"notif errors\" title=\"Error\">\n          {{ errors }}\n        </strong>\n        <strong\n          v-if=\"serverError\"\n          class=\"notif errors\"\n          title=\"Connection error to Docker Socket Proxy API\"\n        >\n          Unavailable\n        </strong>\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\nexport default {\n  name: \"DockerSocketProxy\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => {\n    return {\n      running: null,\n      stopped: null,\n      errors: null,\n      serverError: false,\n    };\n  },\n  created: function () {\n    const checkInterval = parseInt(this.item.checkInterval, 10) || 0;\n    if (checkInterval > 0) {\n      setInterval(() => this.fetchData(), checkInterval);\n    }\n    this.fetchData();\n  },\n  methods: {\n    fetchData: function () {\n      const handleError = (e) => {\n        console.error(e);\n        this.serverError = true;\n      };\n\n      // Fetch all containers (including stopped) from Docker Socket Proxy\n      this.fetch(\"/containers/json?all=true\") // Docker endpoint for container statuses\n        .then((containers) => {\n          this.running = containers.filter(\n            (container) => container.State === \"running\",\n          ).length;\n          this.stopped = containers.filter(\n            (container) => container.State === \"exited\",\n          ).length;\n        })\n        .catch(handleError);\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.notifs {\n  position: absolute;\n  color: white;\n  font-family: sans-serif;\n  top: 0.3em;\n  right: 0.5em;\n\n  .notif {\n    display: inline-block;\n    padding: 0.2em 0.35em;\n    border-radius: 0.25em;\n    position: relative;\n    margin-left: 0.3em;\n    font-size: 0.8em;\n\n    &.running {\n      background-color: #4fb5d6;\n    }\n\n    &.stopped {\n      background-color: #d08d2e;\n    }\n\n    &.errors {\n      background-color: #e51111;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Docuseal.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <template v-else-if=\"versionstring\">\n          Version {{ versionstring }}\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <div v-if=\"status\" class=\"status\" :class=\"status\">\n        {{ status }}\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Docuseal\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    status: null,\n    versionstring: null,\n  }),\n  created() {\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      const params = {\n        cache: \"no-cache\",\n      };\n      this.fetch(\"/version\", params, false)\n        .then((response) => {\n          this.status = \"online\";\n          this.versionstring = response;\n        })\n        .catch((e) => {\n          this.status = \"offline\";\n          console.log(e);\n        });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n  white-space: nowrap;\n  margin-left: 0.25rem;\n\n  &.online:before {\n    background-color: #94e185;\n    border-color: #78d965;\n    box-shadow: 0 0 5px 1px #94e185;\n  }\n\n  &.offline:before {\n    background-color: #c9404d;\n    border-color: #c42c3b;\n    box-shadow: 0 0 5px 1px #c9404d;\n  }\n\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 7px;\n    height: 7px;\n    margin-right: 10px;\n    border: 1px solid #000;\n    border-radius: 7px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Emby.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <template v-else>\n          {{ embyCount }}\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <div v-if=\"status\" class=\"status\" :class=\"status\">\n        {{ status }}\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Emby\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    status: \"\",\n    albumCount: 0,\n    songCount: 0,\n    movieCount: 0,\n    seriesCount: 0,\n    episodeCount: 0,\n  }),\n  computed: {\n    embyCount: function () {\n      if (this.item.libraryType === \"music\")\n        return `${this.songCount} songs, ${this.albumCount} albums`;\n      else if (this.item.libraryType === \"movies\")\n        return `${this.movieCount} movies`;\n      else if (this.item.libraryType === \"series\")\n        return `${this.episodeCount} eps, ${this.seriesCount} series`;\n      else return `wrong library type 💀`;\n    },\n  },\n  created() {\n    this.fetchServerStatus();\n\n    if (!this.item.subtitle && this.status !== \"dead\")\n      this.fetchServerMediaStats();\n  },\n  methods: {\n    fetchServerStatus: async function () {\n      this.fetch(\"/System/info/public\")\n        .then((response) => {\n          if (response.Id) this.status = \"running\";\n          else throw new Error();\n        })\n        .catch((e) => {\n          console.log(e);\n          this.status = \"dead\";\n        });\n    },\n    fetchServerMediaStats: async function () {\n      const headers = {\n        \"X-Emby-Token\": this.item.apikey,\n      };\n\n      var data = await this.fetch(\"/items/counts\", { headers }).catch((e) => {\n        console.log(e);\n      });\n\n      this.albumCount = data.AlbumCount;\n      this.songCount = data.SongCount;\n      this.movieCount = data.MovieCount;\n      this.seriesCount = data.SeriesCount;\n      this.episodeCount = data.EpisodeCount;\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n\n  &.running:before {\n    background-color: #94e185;\n    border-color: #78d965;\n    box-shadow: 0 0 5px 1px #94e185;\n  }\n\n  &.dead:before {\n    background-color: #c9404d;\n    border-color: #c42c3b;\n    box-shadow: 0 0 5px 1px #c9404d;\n  }\n\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 7px;\n    height: 7px;\n    margin-right: 10px;\n    border: 1px solid #000;\n    border-radius: 7px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/FreshRSS.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #indicator>\n      <div class=\"notifs\">\n        <strong\n          v-if=\"subscriptions > 0\"\n          class=\"notif subscriptions\"\n          title=\"Subscriptions\"\n        >\n          {{ subscriptions }}\n        </strong>\n        <strong v-if=\"unread > 0\" class=\"notif unread\" title=\"Unread\">\n          {{ unread }}\n        </strong>\n        <strong\n          v-if=\"serverError\"\n          class=\"notif errors\"\n          title=\"Connection error to the FreshRSS API, check url username and password in config.yml\"\n          >?</strong\n        >\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"FreshRSS\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => {\n    return {\n      subscriptions: 0,\n      unread: 0,\n      serverError: false,\n    };\n  },\n  created: function () {\n    const updateInterval = parseInt(this.item.updateInterval, 10) || 0;\n    if (updateInterval > 0)\n      setInterval(() => this.fetchConfig(), updateInterval);\n\n    this.fetchConfig();\n  },\n  methods: {\n    fetchConfig: async function () {\n      if (!this.auth) {\n        const match = await this.fetch(\n          `/api/greader.php/accounts/ClientLogin?Email=${this.item.username}&Passwd=${this.item.password}`,\n          { method: \"GET\", cache: \"no-cache\" },\n          false,\n        ).then((body) => {\n          return body.match(/Auth=(([([a-z0-9]+)\\/([([a-z0-9]+))/i);\n        });\n        if (match !== null) this.auth = match[1];\n      }\n\n      const headers = {\n        Authorization: `GoogleLogin auth=${this.auth}`,\n      };\n\n      this.fetch(\n        `/api/greader.php/reader/api/0/subscription/list?output=json`,\n        { headers },\n      )\n        .then((subscription) => {\n          this.subscriptions = subscription.subscriptions.length;\n        })\n        .catch((e) => {\n          console.error(e);\n          this.serverError = true;\n        });\n      this.fetch(`/api/greader.php/reader/api/0/unread-count?output=json`, {\n        headers,\n      })\n        .then((unreadcount) => {\n          this.unread = unreadcount.max;\n        })\n        .catch((e) => {\n          console.error(e);\n          this.serverError = true;\n        });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.notifs {\n  position: absolute;\n  color: white;\n  font-family: sans-serif;\n  top: 0.3em;\n  right: 0.5em;\n\n  .notif {\n    display: inline-block;\n    padding: 0.2em 0.35em;\n    border-radius: 0.25em;\n    position: relative;\n    margin-left: 0.3em;\n    font-size: 0.8em;\n\n    &.subscriptions {\n      background-color: #4fb5d6;\n    }\n\n    &.unread {\n      background-color: #d08d2e;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Gatus.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <i class=\"fa-solid fa-signal\"></i> {{ up }}/{{ total }}\n        <template v-if=\"avgRespTime > 0\">\n          <span class=\"separator\"> | </span>\n          <i class=\"fa-solid fa-stopwatch\"></i> {{ avgRespTime }} ms avg.\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <div v-if=\"status !== false\" class=\"status\" :class=\"status\">\n        {{ percentageGood }}&percnt;\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Gatus\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    up: 0,\n    down: 0,\n    total: 0,\n    avgRespTime: NaN,\n    percentageGood: NaN,\n    status: false,\n    statusMessage: false,\n  }),\n  created() {\n    const updateInterval = parseInt(this.item.updateInterval, 10) || 0;\n    if (updateInterval > 0) {\n      setInterval(() => this.fetchStatus(), updateInterval);\n    }\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      this.fetch(\"/api/v1/endpoints/statuses\", {\n        method: \"GET\",\n        cache: \"no-cache\",\n      })\n        .then((response) => {\n          // Apply filtering by groups, if defined\n          if (this.item.groups) {\n            response = response?.filter((job) => {\n              return this.item.groups.includes(job.group) === true;\n            });\n          }\n\n          // Initialise counts, avg times\n          this.total = response.length;\n          this.up = 0;\n\n          let totalrestime = 0;\n          let totalresults = 0;\n\n          response.forEach((job) => {\n            if (job.results[job.results.length - 1].success === true) {\n              this.up++;\n            }\n\n            if (!this.item.hideaverages) {\n              // Update array of average times\n              let totalduration = 0;\n              let rescounter = 0;\n              job.results.forEach((res) => {\n                totalduration += parseInt(res.duration, 10) / 1000000;\n                rescounter++;\n              });\n\n              totalrestime += totalduration;\n              totalresults += rescounter;\n            } else {\n              totalrestime = 0;\n              totalresults = 1;\n            }\n          });\n\n          // Rest are down\n          this.down = this.total - this.up;\n\n          // Calculate overall average response time\n          this.avgRespTime = (totalrestime / totalresults).toFixed(2);\n\n          // Update representations\n          if (this.up == 0 || this.total == 0) {\n            this.percentageGood = 0;\n          } else {\n            this.percentageGood = Math.round((this.up / this.total) * 100);\n          }\n\n          // Status flag\n          if (this.up == 0 && this.down == 0) {\n            this.status = false;\n          } else if (this.down == this.total) {\n            this.status = \"bad\";\n          } else if (this.up == this.total) {\n            this.status = \"good\";\n          } else {\n            this.status = \"warn\";\n          }\n        })\n        .catch((e) => {\n          console.error(e);\n        });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n  &.good:before {\n    background-color: #94e185;\n    border-color: #78d965;\n    box-shadow: 0 0 5px 1px #94e185;\n  }\n  &.warn:before {\n    background-color: #f8a306;\n    border-color: #e1b35e;\n    box-shadow: 0 0 5px 1px #f8a306;\n  }\n  &.bad:before {\n    background-color: #c9404d;\n    border-color: #c42c3b;\n    box-shadow: 0 0 5px 1px #c9404d;\n  }\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 7px;\n    height: 7px;\n    margin-right: 10px;\n    border: 1px solid #000;\n    border-radius: 7px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Generic.vue",
    "content": "<template>\n  <div>\n    <div class=\"card\" :style=\"`background-color:${item.background};`\">\n      <a :href=\"item.url\" :target=\"item.target\" rel=\"noreferrer\">\n        <div class=\"card-content\">\n          <div :class=\"mediaClass\">\n            <slot name=\"icon\">\n              <div v-if=\"item.logo\" class=\"media-left\">\n                <figure class=\"image is-48x48\">\n                  <img :src=\"item.logo\" :alt=\"`${item.name} logo`\" />\n                </figure>\n              </div>\n              <div v-if=\"item.icon\" class=\"media-left\">\n                <figure class=\"image is-48x48\">\n                  <i style=\"font-size: 32px\" :class=\"['fa-fw', item.icon]\"></i>\n                </figure>\n              </div>\n            </slot>\n            <div class=\"media-content\">\n              <slot name=\"content\">\n                <p class=\"title\">{{ item.name }}</p>\n                <p v-if=\"item.quick\" class=\"quicklinks\">\n                  <a\n                    v-for=\"(link, linkIndex) in item.quick\"\n                    :key=\"linkIndex\"\n                    :style=\"`background-color:${link.color};`\"\n                    :href=\"link.url\"\n                    :target=\"link.target\"\n                    rel=\"noreferrer\"\n                  >\n                    <span v-if=\"link.icon\"\n                      ><i\n                        style=\"font-size: 12px\"\n                        :class=\"['fa-fw', link.icon]\"\n                      ></i\n                    ></span>\n                    {{ link.name }}\n                  </a>\n                </p>\n                <p v-if=\"item.subtitle\" class=\"subtitle\">\n                  {{ item.subtitle }}\n                </p>\n              </slot>\n            </div>\n            <slot name=\"indicator\" class=\"indicator\"></slot>\n          </div>\n          <div v-if=\"item.tag\" class=\"tag\" :class=\"item.tagstyle\">\n            <strong class=\"tag-text\">#{{ item.tag }}</strong>\n          </div>\n        </div>\n      </a>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  name: \"Generic\",\n  props: {\n    item: Object,\n  },\n  computed: {\n    mediaClass: function () {\n      return { media: true, \"no-subtitle\": !this.item.subtitle };\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.media-left {\n  .image {\n    display: flex;\n    align-items: center;\n  }\n\n  img {\n    max-height: 100%;\n    object-fit: contain;\n  }\n}\n\na[href=\"\"] {\n  pointer-events: none;\n  cursor: default;\n}\n\n.quicklinks {\n  float: right;\n  a {\n    font-size: 0.75rem;\n    padding: 3px 6px;\n    margin-left: 6px;\n    border-radius: 100px;\n    background-color: var(--background);\n    z-index: 9999;\n    pointer-events: all;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Gitea.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <template v-else-if=\"versionstring\">\n          Version {{ versionstring }}\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <div v-if=\"status\" class=\"status\" :class=\"status\">\n        {{ status }}\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Gitea\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    fetchOk: null,\n    versionstring: null,\n  }),\n  computed: {\n    status: function () {\n      return this.fetchOk ? \"online\" : \"offline\";\n    },\n  },\n  created() {\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      this.fetch(\"/swagger.v1.json\")\n        .then((response) => {\n          this.fetchOk = true;\n          this.versionstring = response.info.version;\n        })\n        .catch((e) => {\n          this.fetchOk = false;\n          console.log(e);\n        });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n  white-space: nowrap;\n  margin-left: 0.25rem;\n\n  &.online:before {\n    background-color: #94e185;\n    border-color: #78d965;\n    box-shadow: 0 0 5px 1px #94e185;\n  }\n\n  &.offline:before {\n    background-color: #c9404d;\n    border-color: #c42c3b;\n    box-shadow: 0 0 5px 1px #c9404d;\n  }\n\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 7px;\n    height: 7px;\n    margin-right: 10px;\n    border: 1px solid #000;\n    border-radius: 7px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Glances.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-for=\"(statItem, index) in item.stats\" :key=\"statItem\">\n          <span v-if=\"stats[statItem]\" :title=\"stats[statItem].label\">\n            <i :class=\"stats[statItem].icon\"></i> {{ stats[statItem].value }}\n            {{ stats[statItem].unit }}\n            <span v-if=\"index != item.stats.length - 1\"> / </span>\n          </span>\n        </template>\n      </p>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Glances\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    stats: [],\n    error: null,\n  }),\n  created() {\n    const updateInterval = parseInt(this.item.updateInterval, 10) || 0;\n    if (updateInterval > 0) {\n      setInterval(() => this.fetchStat(), updateInterval);\n    }\n    this.fetchStat();\n  },\n  methods: {\n    fetchStat: async function () {\n      this.fetch(`/api/4/quicklook`)\n        .then((response) => {\n          this.stats[\"load\"] = {\n            value: response.load,\n            label: \"System load\",\n            icon: \"fa-solid fa-bolt\",\n            unit: \"%\",\n          };\n          this.stats[\"cpu\"] = {\n            value: response.cpu,\n            label: `CPU usage (${response.cpu_name})`,\n            icon: \"fa-solid fa-microchip\",\n            unit: \"%\",\n          };\n          this.stats[\"mem\"] = {\n            value: response.mem,\n            label: `RAM usage`,\n            icon: \"fa-solid fa-memory\",\n            unit: \"%\",\n          };\n          this.stats[\"swap\"] = {\n            value: response.swap,\n            label: `Swap usage`,\n            icon: \"fa-solid fa-file-arrow-down\",\n            unit: \"%\",\n          };\n        })\n        .catch((e) => {\n          console.log(e);\n          this.error = \"Unable to get metrics\";\n        });\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/services/Gotify.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"messages > 0\">\n          <template v-if=\"messages > 100\">100+</template>\n          <template v-else>{{ messages }}</template>\n          <template v-if=\"messages === 1\"> message</template>\n          <template v-else> messages</template>\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <div v-if=\"status\" class=\"status\" :class=\"status\"></div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Gotify\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    health: {},\n    messages: 0,\n  }),\n  computed: {\n    status: function () {\n      const statuses = [this.health.health, this.health.database];\n\n      if (statuses.includes(\"red\")) {\n        return \"red\";\n      } else if (statuses.includes(\"orange\")) {\n        return \"orange\";\n      }\n\n      return \"green\";\n    },\n  },\n  created() {\n    this.fetchStatus();\n    this.fetchMessages();\n  },\n  methods: {\n    fetchStatus: async function () {\n      await this.fetch(`/health`)\n        .catch((e) => console.log(e))\n        .then((resp) => (this.health = resp));\n    },\n    fetchMessages: async function () {\n      const headers = {\n        \"X-Gotify-Key\": this.item.apikey,\n      };\n      await this.fetch(`/message?limit=100`, { headers })\n        .catch((e) => console.log(e))\n        .then((resp) => (this.messages = resp.messages.length));\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n\n  &.green:before {\n    background-color: #94e185;\n    border-color: #78d965;\n    box-shadow: 0 0 5px 1px #94e185;\n  }\n\n  &.orange:before {\n    background-color: #ee863e;\n    border-color: #e77322;\n    box-shadow: 0 0 5px 1px #ee863e;\n  }\n\n  &.red:before {\n    background-color: #c9404d;\n    border-color: #c42c3b;\n    box-shadow: 0 0 5px 1px #c9404d;\n  }\n\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 7px;\n    height: 7px;\n    margin-right: 10px;\n    border: 1px solid #000;\n    border-radius: 7px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Healthchecks.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #indicator>\n      <div class=\"notifs\">\n        <strong v-if=\"up > 0\" class=\"notif up\" title=\"Up\">\n          {{ up }}\n        </strong>\n        <strong v-if=\"down > 0\" class=\"notif down\" title=\"Down\">\n          {{ down }}\n        </strong>\n        <strong v-if=\"grace > 0\" class=\"notif grace\" title=\"Grace\">\n          {{ grace }}\n        </strong>\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Healthchecks\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    api: null,\n  }),\n  computed: {\n    up: function () {\n      if (!this.api) {\n        return \"\";\n      }\n      return this.api.checks?.filter((check) => {\n        return check.status.toLowerCase() === \"up\";\n      }).length;\n    },\n    down: function () {\n      if (!this.api) {\n        return \"\";\n      }\n      return this.api.checks?.filter((check) => {\n        return check.status.toLowerCase() === \"down\";\n      }).length;\n    },\n    grace: function () {\n      if (!this.api) {\n        return \"\";\n      }\n      return this.api.checks?.filter((check) => {\n        return check.status.toLowerCase() === \"grace\";\n      }).length;\n    },\n  },\n  created() {\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      const apikey = this.item.apikey;\n      if (!apikey) {\n        console.error(\n          \"apikey is not present in config.yml for the Healthchecks entry!\",\n        );\n        return;\n      }\n\n      const headers = {\n        \"X-Api-Key\": this.item.apikey,\n      };\n\n      this.api = await this.fetch(\"/api/v1/checks/\", { headers }).catch((e) => {\n        console.error(e);\n      });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.notifs {\n  position: absolute;\n  color: white;\n  font-family: sans-serif;\n  top: 0.3em;\n  right: 0.5em;\n\n  .notif {\n    display: inline-block;\n    padding: 0.2em 0.35em;\n    border-radius: 0.25em;\n    position: relative;\n    margin-left: 0.3em;\n    font-size: 0.8em;\n\n    &.up {\n      background-color: #4fd671;\n    }\n\n    &.down {\n      background-color: #e51111;\n    }\n\n    &.grace {\n      background-color: #cdd02e;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/HomeAssistant.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <template v-else>\n          {{ details }}\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <div v-if=\"status\" class=\"status\" :class=\"status\">\n        {{ status }}\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"HomeAssistant\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    status: \"\",\n    version: \"\",\n    entities: 0,\n    location_name: \"\",\n    separator: \" \",\n    items: [\"name\", \"version\"],\n  }),\n  computed: {\n    headers: function () {\n      return {\n        Authorization: `Bearer ${this.item.apikey}`,\n        \"Content-Type\": \"application/json\",\n      };\n    },\n    details: function () {\n      const details = [];\n      const items = this.items;\n      const separator = this.separator;\n\n      for (const i in items) {\n        const key = items[i];\n\n        switch (key) {\n          case \"version\":\n            details.push(`v${this.version}`);\n            break;\n          case \"name\":\n            details.push(`${this.location_name}`);\n            break;\n          case \"entities\":\n            details.push(`${this.entities} entities`);\n            break;\n          default:\n            details.push(`undefined key ${key} `);\n        }\n      }\n\n      return details.join(separator);\n    },\n  },\n  created() {\n    this.fetchServerStatus().then(() => {\n      if (!this.item.subtitle && this.status !== \"dead\") {\n        if (this.item.items) this.items = this.item.items;\n        if (this.item.separator) this.separator = this.item.separator;\n\n        this.fetchServerStats();\n      }\n    });\n  },\n  methods: {\n    fetchServerStatus: async function () {\n      const headers = this.headers;\n\n      return this.fetch(\"/api/\", { headers })\n        .then((response) => {\n          if (response && response.message) this.status = \"running\";\n          else throw new Error();\n        })\n        .catch((e) => {\n          console.log(e);\n          this.status = \"dead\";\n        });\n    },\n    fetchServerStats: async function () {\n      const headers = this.headers;\n\n      this.fetch(\"/api/config\", { headers })\n        .then((response) => {\n          if (response) {\n            if (response.version) this.version = response.version;\n            if (response.location_name)\n              this.location_name = response.location_name;\n          } else throw new Error();\n        })\n        .catch((e) => {\n          console.log(e);\n          this.status = \"dead\";\n        });\n\n      this.fetch(\"/api/states\", { headers })\n        .then((response) => {\n          if (response) {\n            this.entities = response.length;\n          } else throw new Error();\n        })\n        .catch((e) => {\n          console.log(e);\n          this.status = \"dead\";\n        });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n\n  &.running:before {\n    background-color: #94e185;\n    border-color: #78d965;\n    box-shadow: 0 0 5px 1px #94e185;\n  }\n\n  &.dead:before {\n    background-color: #c9404d;\n    border-color: #c42c3b;\n    box-shadow: 0 0 5px 1px #c9404d;\n  }\n\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 7px;\n    height: 7px;\n    margin-right: 10px;\n    border: 1px solid #000;\n    border-radius: 7px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Immich.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #indicator>\n      <div class=\"notifs\">\n        <strong v-if=\"users > 0\" class=\"notif users\" title=\"Users\">\n          {{ users }}\n        </strong>\n        <strong v-if=\"photos > 0\" class=\"notif photos\" title=\"Photos\">\n          {{ photos }}\n        </strong>\n        <strong v-if=\"videos > 0\" class=\"notif videos\" title=\"Videos\">\n          {{ videos }}\n        </strong>\n        <strong v-if=\"usage > 0\" class=\"notif usage\" title=\"Usage\">\n          {{ humanizeSize }}\n        </strong>\n        <strong\n          v-if=\"serverError\"\n          class=\"notif errors\"\n          title=\"Connection error to Immich API, check your url and apikey in config.yml\"\n          >?</strong\n        >\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Immich\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => {\n    return {\n      users: null,\n      photos: null,\n      videos: null,\n      usage: null,\n      serverError: false,\n    };\n  },\n  computed: {\n    humanizeSize: function () {\n      let bytes = this.usage;\n      if (Math.abs(bytes) < 1024) return bytes + \" B\";\n\n      const units = [\"KiB\", \"MiB\", \"GiB\", \"TiB\"];\n      let u = -1;\n      do {\n        bytes /= 1024;\n        ++u;\n      } while (\n        Math.round(Math.abs(bytes) * 100) / 100 >= 1024 &&\n        u < units.length - 1\n      );\n\n      return bytes.toFixed(2) + \" \" + units[u];\n    },\n  },\n  created: function () {\n    const updateInterval = parseInt(this.item.updateInterval, 10) || 0;\n    if (updateInterval > 0) {\n      setInterval(() => this.fetchConfig(), updateInterval);\n    }\n    this.fetchConfig();\n  },\n  methods: {\n    fetchConfig: function () {\n      const headers = {\n        \"x-api-key\": this.item.apikey,\n      };\n\n      this.fetch(`/api/server/statistics`, { headers })\n        .then((stats) => {\n          this.photos = stats.photos;\n          this.videos = stats.videos;\n          this.usage = stats.usage;\n          this.users = stats.usageByUser.length;\n        })\n        .catch((e) => {\n          console.error(e);\n          this.serverError = true;\n        });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.notifs {\n  position: absolute;\n  color: white;\n  font-family: sans-serif;\n  top: 0.3em;\n  right: 0.5em;\n  .notif {\n    display: inline-block;\n    padding: 0.2em 0.35em;\n    border-radius: 0.25em;\n    position: relative;\n    margin-left: 0.3em;\n    font-size: 0.8em;\n    &.photos {\n      background-color: #4fb5d6;\n    }\n\n    &.videos {\n      background-color: #d08d2e;\n    }\n\n    &.usage {\n      background-color: #e51111;\n    }\n\n    &.users {\n      background-color: #8dd475;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Jellystat.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #indicator>\n      <div class=\"notifs\">\n        <strong\n          v-if=\"streams > 0\"\n          class=\"notif playing\"\n          :title=\"`${streams} active stream${streams > 1 ? 's' : ''}`\"\n        >\n          {{ streams }}\n        </strong>\n        <i\n          v-if=\"error\"\n          class=\"notif error fa-solid fa-triangle-exclamation\"\n          title=\"Unable to fetch current status\"\n        ></i>\n      </div>\n    </template>\n  </Generic>\n</template>\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Jellyfin\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    stats: null,\n    error: false,\n  }),\n  computed: {\n    streams: function () {\n      if (!this.stats) {\n        return \"\";\n      }\n      let nb_streams = 0;\n      for (let stream of this.stats) {\n        if (\"NowPlayingItem\" in stream) nb_streams++;\n      }\n      return nb_streams;\n    },\n  },\n  created() {\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      const headers = {\n        Authorization: `bearer ${this.item.apikey}`,\n      };\n      try {\n        const response = await this.fetch(\"/proxy/getSessions\", { headers });\n        this.error = false;\n        this.stats = response;\n      } catch (e) {\n        this.error = true;\n        console.error(e);\n      }\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.notifs {\n  position: absolute;\n  color: white;\n  font-family: sans-serif;\n  top: 0.3em;\n  right: 0.5em;\n\n  .notif {\n    display: inline-block;\n    padding: 0.2em 0.35em;\n    border-radius: 0.25em;\n    position: relative;\n    margin-left: 0.3em;\n    font-size: 0.8em;\n\n    &.playing {\n      background-color: #28a9a3;\n    }\n\n    &.error {\n      border-radius: 50%;\n      aspect-ratio: 1;\n      background-color: #e51111;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Lidarr.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #indicator>\n      <div class=\"notifs\">\n        <strong v-if=\"activity > 0\" class=\"notif activity\" title=\"Activity\">\n          {{ activity }}\n        </strong>\n        <strong v-if=\"missing > 0\" class=\"notif missing\" title=\"Missing\">\n          {{ missing }}\n        </strong>\n        <strong v-if=\"warnings > 0\" class=\"notif warnings\" title=\"Warning\">\n          {{ warnings }}\n        </strong>\n        <strong v-if=\"errors > 0\" class=\"notif errors\" title=\"Error\">\n          {{ errors }}\n        </strong>\n        <strong\n          v-if=\"serverError\"\n          class=\"notif errors\"\n          title=\"Connection error to Lidarr API, check url and apikey in config.yml\"\n          >?</strong\n        >\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Lidarr\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => {\n    return {\n      activity: null,\n      missing: null,\n      warnings: null,\n      errors: null,\n      serverError: false,\n    };\n  },\n  created: function () {\n    const checkInterval = parseInt(this.item.checkInterval, 10) || 0;\n    if (checkInterval > 0) {\n      setInterval(() => this.fetchConfig(), checkInterval);\n    }\n\n    this.fetchConfig();\n  },\n  methods: {\n    fetchConfig: function () {\n      const handleError = (e) => {\n        console.error(e);\n        this.serverError = true;\n      };\n      this.fetch(`/api/v1/health?apikey=${this.item.apikey}`)\n        .then((health) => {\n          this.warnings = 0;\n          this.errors = 0;\n          for (var i = 0; i < health.length; i++) {\n            if (health[i].type == \"warning\") {\n              this.warnings++;\n            } else if (health[i].type == \"error\") {\n              this.errors++;\n            }\n          }\n        })\n        .catch(handleError);\n      this.fetch(`/api/v1/queue/status?apikey=${this.item.apikey}`)\n        .then((queue) => {\n          this.activity = queue.totalCount;\n        })\n        .catch(handleError);\n      this.fetch(`/api/v1/wanted/missing?apikey=${this.item.apikey}`)\n        .then((queue) => {\n          this.missing = queue.totalRecords;\n        })\n        .catch(handleError);\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.notifs {\n  position: absolute;\n  color: white;\n  font-family: sans-serif;\n  top: 0.3em;\n  right: 0.5em;\n  .notif {\n    display: inline-block;\n    padding-right: 0.35em;\n    padding-left: 0.35em;\n    padding-top: 0.2em;\n    padding-bottom: 0.2em;\n    border-radius: 0.25em;\n    position: relative;\n    margin-left: 0.3em;\n    font-size: 0.8em;\n    &.activity {\n      background-color: #4fb5d6;\n    }\n\n    &.missing {\n      background-color: #9d00ff;\n    }\n\n    &.warnings {\n      background-color: #d08d2e;\n    }\n\n    &.errors {\n      background-color: #e51111;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Linkding.vue",
    "content": "<template>\n  <Generic v-for=\"bookmark in bookmarks\" :key=\"bookmark.name\" :item=\"bookmark\">\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\nimport Generic from \"./Generic.vue\";\n\nexport default {\n  name: \"Linkding\",\n  components: {\n    Generic,\n  },\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    bookmarks: [],\n  }),\n  computed: {\n    calculatedLimit: function () {\n      const limit = parseInt(this.item.limit) || 5;\n      return Math.min(Math.max(limit, 1), 15);\n    },\n  },\n  created() {\n    this.fetchBookmarks();\n  },\n  methods: {\n    fetchBookmarks: async function () {\n      const headers = {\n        Authorization: `Token ${this.item.token}`,\n        Accept: \"application/json\",\n      };\n\n      let query = \"\";\n      if (this.item.query) {\n        query = `&q=${encodeURIComponent(this.item.query)}`;\n      }\n\n      let url = `/api/bookmarks/?limit=${this.calculatedLimit}${query}`;\n\n      this.fetch(url, {\n        headers,\n      })\n        .then((ld_response) => {\n          this.bookmarks = ld_response.results.map((bookmark) => ({\n            name: `${bookmark.title}`,\n            subtitle: `${bookmark.description}`,\n            url: bookmark.url,\n            logo: `${bookmark.favicon_url}`,\n            tag: `${bookmark.tag_names.join(\" #\")}`,\n          }));\n        })\n        .catch((e) => {\n          console.log(e);\n        });\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/services/Matrix.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <template v-else-if=\"versionstring\">\n          Version {{ versionstring }}\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <div v-if=\"status\" class=\"status\" :class=\"status\">\n        {{ status }}\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Matrix\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    fetchOk: null,\n    versionstring: null,\n  }),\n  computed: {\n    status: function () {\n      return this.fetchOk ? \"online\" : \"offline\";\n    },\n  },\n  created() {\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      this.fetch(\"_matrix/federation/v1/version\")\n        .then((response) => {\n          this.fetchOk = true;\n          this.versionstring = response.server.version;\n        })\n        .catch((e) => {\n          this.fetchOk = false;\n          console.log(e);\n        });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n  white-space: nowrap;\n  margin-left: 0.25rem;\n\n  &.online:before {\n    background-color: #94e185;\n    border-color: #78d965;\n    box-shadow: 0 0 5px 1px #94e185;\n  }\n\n  &.offline:before {\n    background-color: #c9404d;\n    border-color: #c42c3b;\n    box-shadow: 0 0 5px 1px #c9404d;\n  }\n\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 7px;\n    height: 7px;\n    margin-right: 10px;\n    border: 1px solid #000;\n    border-radius: 7px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Mealie.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <template v-else-if=\"mealtext\">\n          {{ mealtext }}\n        </template>\n        <template v-else-if=\"statsText\">\n          {{ statsText }}\n        </template>\n      </p>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Mealie\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    stats: null,\n    meal: null,\n  }),\n  computed: {\n    mealtext: function () {\n      if (this.meal && this.meal.length > 0) {\n        return `Today: ${this.meal[0].recipe.name}`;\n      }\n      return null;\n    },\n    statsText: function () {\n      if (this.stats) {\n        return `Happily keeping ${this.stats.totalRecipes} recipes organized`;\n      }\n      return null;\n    },\n  },\n  created() {\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      const headers = {\n        Authorization: \"Bearer \" + this.item.apikey,\n        Accept: \"application/json\",\n      };\n\n      if (this.item.subtitle != null) return;\n\n      this.meal = await this.fetch(\"/api/groups/mealplans/today\", {\n        headers,\n      }).catch((e) => console.log(e));\n      this.stats = await this.fetch(\"/api/admin/about/statistics\", {\n        headers,\n      }).catch((e) => console.log(e));\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/services/Medusa.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #indicator>\n      <div class=\"notifs\">\n        <strong\n          v-if=\"config !== null && config.system.news.unread > 0\"\n          class=\"notif news\"\n          title=\"News\"\n          >{{ config.system.news.unread }}</strong\n        >\n        <strong\n          v-if=\"config !== null && config.main.logs.numWarnings > 0\"\n          class=\"notif warnings\"\n          title=\"Warning\"\n          >{{ config.main.logs.numWarnings }}</strong\n        >\n        <strong\n          v-if=\"config !== null && config.main.logs.numErrors > 0\"\n          class=\"notif errors\"\n          title=\"Error\"\n          >{{ config.main.logs.numErrors }}</strong\n        >\n        <strong\n          v-if=\"serverError\"\n          class=\"notif errors\"\n          title=\"Connection error to Medusa API, check url and apikey in config.yml\"\n          >?</strong\n        >\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Medusa\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => {\n    return {\n      config: null,\n      serverError: false,\n    };\n  },\n  created: function () {\n    this.fetchConfig();\n  },\n  methods: {\n    fetchConfig: function () {\n      this.fetch(\"/api/v2/config\", {\n        headers: { \"X-Api-Key\": this.item.apikey },\n      })\n        .then((conf) => {\n          this.config = conf;\n        })\n        .catch((e) => {\n          console.log(e);\n          this.serverError = true;\n        });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.notifs {\n  position: absolute;\n  color: white;\n  font-family: sans-serif;\n  top: 0.3em;\n  right: 0.5em;\n  .notif {\n    padding-right: 0.35em;\n    padding-left: 0.35em;\n    padding-top: 0.2em;\n    padding-bottom: 0.2em;\n    border-radius: 0.25em;\n    position: relative;\n    margin-left: 0.3em;\n    font-size: 0.8em;\n    &.news {\n      background-color: #777777;\n    }\n\n    &.warnings {\n      background-color: #d08d2e;\n    }\n\n    &.errors {\n      background-color: #e51111;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Miniflux.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\"> {{ item.subtitle }} </template>\n        <template v-else-if=\"unreadEntries\">\n          <template v-if=\"unreadFeeds < 2\">\n            {{ unreadEntries }} unread\n          </template>\n          <template v-else>\n            {{ unreadEntries }} unread in {{ unreadFeeds }} feeds\n          </template>\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <i v-if=\"loading\" class=\"fa fa-circle-notch fa-spin\"></i>\n      <div v-else-if=\"style == 'status'\" class=\"status\" :class=\"statusClass\">\n        {{ status }}\n      </div>\n      <div v-else class=\"notifs\">\n        <strong v-if=\"unreadEntries > 0\" class=\"notif unread\" title=\"Unread\">\n          {{ unreadEntries }}\n        </strong>\n        <strong\n          v-if=\"!isHealthy\"\n          class=\"notif errors\"\n          title=\"Connection error to Miniflux API, check url and apikey in config.yml\"\n        >\n          ?\n        </strong>\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Miniflux\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    unreadEntries: 0,\n    unreadFeeds: 0,\n    isHealthy: false,\n    loading: true,\n    style: \"status\",\n  }),\n  computed: {\n    status: function () {\n      if (!this.isHealthy) {\n        return \"Error\";\n      }\n      return this.unreadEntries > 0 ? \"Unread\" : \"Online\";\n    },\n    statusClass: function () {\n      return this.status.toLowerCase();\n    },\n  },\n  created() {\n    const checkInterval = parseInt(this.item.checkInterval, 10) || 0;\n    if (checkInterval > 0) {\n      setInterval(() => this.fetchConfig(), checkInterval);\n    }\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      const headers = {\n        \"X-Auth-Token\": this.item.apikey,\n      };\n\n      let counters;\n      try {\n        counters = await this.fetch(\"/v1/feeds/counters\", { headers });\n        this.isHealthy = true;\n      } catch (e) {\n        console.log(e);\n      } finally {\n        this.loading = false;\n      }\n\n      if (!this.isHealthy) {\n        return;\n      }\n\n      const unreads = Object.values(counters.unreads || {});\n      this.unreadFeeds = unreads.length;\n      this.unreadEntries = unreads.reduce((accumulator, value) => {\n        return accumulator + value;\n      }, 0);\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n\n  &.online:before {\n    background-color: #94e185;\n    border-color: #78d965;\n    box-shadow: 0 0 5px 1px #94e185;\n  }\n\n  &.unread:before {\n    background-color: #1774ff;\n    border-color: #1774ff;\n    box-shadow: 0 0 5px 1px #1774ff;\n  }\n\n  &.error:before {\n    background-color: #c9404d;\n    border-color: #c42c3b;\n    box-shadow: 0 0 5px 1px #c9404d;\n  }\n\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 7px;\n    height: 7px;\n    margin-right: 10px;\n    border: 1px solid #000;\n    border-radius: 7px;\n  }\n}\n\n.notifs {\n  position: absolute;\n  color: white;\n  font-family: sans-serif;\n  top: 0.3em;\n  right: 0.5em;\n\n  .notif {\n    display: inline-block;\n    padding: 0.2em 0.35em;\n    border-radius: 0.25em;\n    position: relative;\n    margin-left: 0.3em;\n    font-size: 0.8em;\n\n    &.unread {\n      background-color: #4fb5d6;\n    }\n\n    &.errors {\n      background-color: #e51111;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Mylar.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #indicator>\n      <div class=\"notifs\">\n        <strong v-if=\"wanted > 0\" class=\"notif wanted\" title=\"Wanted\">\n          {{ wanted }}\n        </strong>\n        <strong v-if=\"upcoming > 0\" class=\"notif upcoming\" title=\"Upcoming\">\n          {{ upcoming }}\n        </strong>\n        <strong\n          v-if=\"serverError\"\n          class=\"notif errors\"\n          title=\"Connection error to Mylar API, check url and apikey in config.yml\"\n        >\n          ?\n        </strong>\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Mylar\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => {\n    return {\n      upcoming: null,\n      wanted: null,\n      warnings: null,\n      errors: null,\n      serverError: false,\n    };\n  },\n  created: function () {\n    this.fetchConfig();\n  },\n  methods: {\n    fetchConfig: function () {\n      const handleError = (e) => {\n        console.error(e);\n        this.serverError = true;\n      };\n      this.fetch(`/api?cmd=getUpcoming&apikey=${this.item.apikey}`)\n        .then((upcoming) => {\n          this.upcoming = upcoming.length;\n        })\n        .catch(handleError);\n      this.fetch(`/api?cmd=getWanted&apikey=${this.item.apikey}`)\n        .then((wanted) => {\n          this.wanted = wanted.issues.length + wanted.annuals.length;\n        })\n        .catch(handleError);\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.notifs {\n  position: absolute;\n  color: white;\n  font-family: sans-serif;\n  top: 0.3em;\n  right: 0.5em;\n\n  .notif {\n    display: inline-block;\n    padding: 0.2em 0.35em;\n    border-radius: 0.25em;\n    position: relative;\n    margin-left: 0.3em;\n    font-size: 0.8em;\n\n    &.wanted {\n      background-color: #4fb5d6;\n    }\n\n    &.upcoming {\n      background-color: #9d00ff;\n    }\n\n    &.warnings {\n      background-color: #d08d2e;\n    }\n\n    &.errors {\n      background-color: #e51111;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Nextcloud.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"versionstring\"> Version {{ versionstring }} </template>\n      </p>\n    </template>\n    <template #indicator>\n      <div v-if=\"status\" class=\"status\" :class=\"status\">\n        {{ status }}\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Nextcloud\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    fetchOk: null,\n    versionstring: null,\n    maintenance: null,\n  }),\n  computed: {\n    status: function () {\n      if (!this.fetchOk) {\n        return \"offline\";\n      }\n      return this.maintenance ? \"maintenance\" : \"online\";\n    },\n  },\n  created() {\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      this.fetch(\"/status.php\")\n        .then((response) => {\n          this.fetchOk = true;\n          this.versionstring = response.versionstring;\n          this.maintenance = response.maintenance;\n        })\n        .catch((e) => {\n          this.fetchOk = false;\n          console.log(e);\n        });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n  white-space: nowrap;\n  margin-left: 0.25rem;\n\n  &.online:before {\n    background-color: #94e185;\n    border-color: #78d965;\n    box-shadow: 0 0 5px 1px #94e185;\n  }\n\n  &.maintenance:before {\n    background-color: #f8a306;\n    border-color: #e1b35e;\n    box-shadow: 0 0 5px 1px #f8a306;\n  }\n\n  &.offline:before {\n    background-color: #c9404d;\n    border-color: #c42c3b;\n    box-shadow: 0 0 5px 1px #c9404d;\n  }\n\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 7px;\n    height: 7px;\n    margin-right: 10px;\n    border: 1px solid #000;\n    border-radius: 7px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/OctoPrint.vue",
    "content": "<template>\n  <Generic :item=\"item\" :title=\"state\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle && !state\">\n          {{ item.subtitle }}\n        </template>\n        <template\n          v-if=\"!error && display == 'text' && statusClass == 'in-progress'\"\n        >\n          <i class=\"fa-solid fa-gear mr-1\"></i>\n          <b v-if=\"completion\">{{ completion.toFixed() }}%</b>\n          <span class=\"separator mx-1\"> | </span>\n          <span v-if=\"printTime\" :title=\"`${formatTime(printTimeLeft)} left`\">\n            <i class=\"fa-solid fa-stopwatch mr-1\"></i>\n            {{ formatTime(printTime) }}\n          </span>\n        </template>\n        <template v-if=\"!error && display == 'text' && statusClass == 'ready'\">\n          <i class=\"fa-solid fa-temperature-half mr-1\"></i>\n          <b v-if=\"printer.temperature.bed\"\n            >{{ printer.temperature.bed.actual.toFixed() }} C</b\n          >\n          <span class=\"separator mx-1\"> | </span>\n          <b v-if=\"printer.temperature.tool0\"\n            >{{ printer.temperature.tool0.actual.toFixed() }} C</b\n          >\n        </template>\n        <template v-if=\"!error && display == 'bar'\">\n          <progress\n            v-if=\"completion\"\n            class=\"progress is-primary\"\n            :value=\"completion\"\n            max=\"100\"\n            :title=\"`${state} - ${completion.toFixed()}%, ${formatTime(\n              printTimeLeft,\n            )} left`\"\n          >\n            {{ completion }}%\n          </progress>\n        </template>\n        <span v-if=\"error\" :title=\"error\">{{ error }}</span>\n      </p>\n    </template>\n    <template #indicator>\n      <i :class=\"['status', statusClass]\" :title=\"state\"></i>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"OctoPrint\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    printTime: null,\n    printTimeLeft: null,\n    completion: null,\n    state: null,\n    printer: null,\n    error: null,\n  }),\n  computed: {\n    statusClass: function () {\n      switch (this.state) {\n        case \"Operational\":\n          return \"ready\";\n        case \"Offline\":\n          return \"offline\";\n        case \"Printing\":\n          return \"in-progress\";\n        default:\n          return \"pending\";\n      }\n    },\n  },\n  created() {\n    this.display = this.item.display == \"bar\" ? this.item.display : \"text\";\n    this.fetchPrinterStatus();\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      try {\n        const response = await this.fetch(`api/job?apikey=${this.item.apikey}`);\n        this.printTime = response.progress.printTime;\n        this.printTimeLeft = response.progress.printTimeLeft;\n        this.completion = response.progress.completion;\n        this.state = response.state;\n        this.error = response.error;\n      } catch (e) {\n        this.error = `Fail to fetch octoprint data (${e.message})`;\n        console.error(e);\n      }\n    },\n    fetchPrinterStatus: async function () {\n      try {\n        const response = await this.fetch(\n          `api/printer?apikey=${this.item.apikey}`,\n        );\n        this.printer = response;\n        this.error = response.error;\n      } catch (e) {\n        this.error = `Fail to fetch octoprint data (${e.message})`;\n        console.error(e);\n      }\n    },\n    formatTime: function (seconds) {\n      const days = Math.floor(seconds / 86400);\n      let remainingSeconds = seconds % 86400;\n      const hours = Math.floor(remainingSeconds / 3600);\n      remainingSeconds %= 3600;\n      const minutes = Math.floor(remainingSeconds / 60);\n      const secs = remainingSeconds % 60;\n\n      const formattedHrs = hours.toString().padStart(2, \"0\");\n      const formattedMins = minutes.toString().padStart(2, \"0\");\n      const formattedSecs = secs.toString().padStart(2, \"0\");\n\n      if (days > 0) {\n        return `${days}d ${formattedHrs}h ${formattedMins}m`;\n      } else if (hours > 0) {\n        return `${formattedHrs}h ${formattedMins}m ${formattedSecs}s`;\n      } else if (minutes > 0) {\n        return `${formattedMins}m ${formattedSecs}s`;\n      } else {\n        return `${secs} seconds`;\n      }\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.fa-triangle-exclamation::before {\n  color: #d65c68;\n}\n\n.progress {\n  height: 8px;\n  width: 90%;\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Olivetin.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <template v-else-if=\"versionstring\">\n          Version {{ versionstring }}\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <div v-if=\"status\" class=\"status\" :class=\"status\">\n        {{ status }}\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Olivetin\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    status: null,\n    versionstring: null,\n  }),\n  created() {\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      this.fetch(\"/webUiSettings.json\")\n        .then((response) => {\n          this.status = \"online\";\n          this.versionstring = response.CurrentVersion;\n        })\n        .catch((e) => {\n          this.status = \"offline\";\n          console.log(e);\n        });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n  white-space: nowrap;\n  margin-left: 0.25rem;\n\n  &.online:before {\n    background-color: #94e185;\n    border-color: #78d965;\n    box-shadow: 0 0 5px 1px #94e185;\n  }\n\n  &.offline:before {\n    background-color: #c9404d;\n    border-color: #c42c3b;\n    box-shadow: 0 0 5px 1px #c9404d;\n  }\n\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 7px;\n    height: 7px;\n    margin-right: 10px;\n    border: 1px solid #000;\n    border-radius: 7px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/OpenHAB.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <template v-else>\n          {{ details }}\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <div v-if=\"status\" class=\"status\" :class=\"status\">\n        {{ status }}\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"OpenHAB\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    status: \"\",\n    things: {\n      count: 0,\n      online: 0,\n    },\n    items: {\n      count: 0,\n    },\n  }),\n  computed: {\n    headers: function () {\n      const basicAuth = `${this.item.apikey}:`;\n\n      return {\n        Authorization: `Basic ${btoa(basicAuth)}`,\n      };\n    },\n    details: function () {\n      const details = [];\n\n      if (this.item.things) {\n        details.push(\n          `${this.things.count} things (${this.things.online} Online)`,\n        );\n      }\n\n      if (this.item.items) {\n        details.push(`${this.items.count} items`);\n      }\n\n      return details.join(\", \");\n    },\n  },\n  created() {\n    this.fetchServerStatus();\n\n    if (!this.item.subtitle && this.status !== \"dead\") {\n      this.fetchServerStats();\n    }\n  },\n  methods: {\n    fetchServerStatus: async function () {\n      const headers = this.headers;\n      this.fetch(\"/rest/systeminfo\", { headers })\n        .then((response) => {\n          if (response && response.systemInfo) this.status = \"running\";\n          else throw new Error();\n        })\n        .catch((e) => {\n          console.log(e);\n          this.status = \"dead\";\n        });\n    },\n    fetchServerStats: async function () {\n      const headers = this.headers;\n\n      if (this.item.things) {\n        const data = await this.fetch(\"/rest/things?summary=true\", {\n          headers,\n        }).catch((e) => {\n          console.log(e);\n        });\n\n        this.things.count = data.length;\n        this.things.online = data.filter(\n          (e) => e.statusInfo.status === \"ONLINE\",\n        ).length;\n      }\n\n      if (this.item.items) {\n        const data = await this.fetch(\"/rest/items\", { headers }).catch((e) => {\n          console.log(e);\n        });\n\n        this.items.count = data.length;\n      }\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n\n  &.running:before {\n    background-color: #94e185;\n    border-color: #78d965;\n    box-shadow: 0 0 5px 1px #94e185;\n  }\n\n  &.dead:before {\n    background-color: #c9404d;\n    border-color: #c42c3b;\n    box-shadow: 0 0 5px 1px #c9404d;\n  }\n\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 7px;\n    height: 7px;\n    margin-right: 10px;\n    border: 1px solid #000;\n    border-radius: 7px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/OpenWeather.vue",
    "content": "<template>\n  <div :class=\"{ 'component-error': error }\">\n    <div class=\"card\" :class=\"item.class\">\n      <a\n        :href=\"`https://openweathermap.org/city/${id}`\"\n        :target=\"item.target\"\n        rel=\"noreferrer\"\n      >\n        <div class=\"card-content\">\n          <div class=\"media\">\n            <div v-if=\"icon\" class=\"media-left\" :class=\"item.background\">\n              <figure class=\"image is-48x48\">\n                <img\n                  :src=\"`https://openweathermap.org/img/wn/${icon}@2x.png`\"\n                  :alt=\"conditions\"\n                  :title=\"conditions\"\n                />\n              </figure>\n            </div>\n            <div class=\"media-content\">\n              <div>\n                <p class=\"title is-4\">{{ name }}</p>\n                <p v-if=\"error\" class=\"subtitle is-6\">\n                  Fail to load weather information\n                </p>\n                <p v-else class=\"subtitle is-6\">\n                  <span>\n                    {{ temperature }}\n                  </span>\n                  <span class=\"location-time\">\n                    {{ locationTime }}\n                  </span>\n                </p>\n              </div>\n            </div>\n            <div v-if=\"error\" name=\"indicator\" class=\"indicator\">⚠️</div>\n          </div>\n          <div v-if=\"item.tag\" class=\"tag\" :class=\"item.tagstyle\">\n            <strong class=\"tag-text\">#{{ item.tag }}</strong>\n          </div>\n        </div>\n      </a>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  name: \"OpenWeather\",\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    id: null,\n    icon: null,\n    name: null,\n    temp: null,\n    conditions: null,\n    error: false,\n    timezoneOffset: 0,\n  }),\n  computed: {\n    temperature: function () {\n      if (!this.temp) return \"\";\n\n      let unit = \"K\";\n      if (this.item.units === \"metric\") {\n        unit = \"°C\";\n      } else if (this.item.units === \"imperial\") {\n        unit = \"°F\";\n      }\n      return `${this.temp} ${unit}`;\n    },\n    locationTime: function () {\n      return this.calcTime(this.timezoneOffset);\n    },\n  },\n  created() {\n    this.fetchWeather();\n  },\n  methods: {\n    fetchWeather: async function () {\n      let locationQuery;\n\n      // Use location ID if specified, otherwise retrieve value from location (name).\n      if (this.item.locationId) {\n        locationQuery = `id=${this.item.locationId}`;\n      } else {\n        locationQuery = `q=${this.item.location}`;\n      }\n\n      const apiKey = this.item.apikey || this.item.apiKey;\n\n      let url = `https://api.openweathermap.org/data/2.5/weather?${locationQuery}&appid=${apiKey}&units=${this.item.units}`;\n      if (this.item.endpoint) {\n        url = this.item.endpoint;\n      }\n      fetch(url)\n        .then((response) => {\n          if (!response.ok) {\n            throw Error(response.statusText);\n          }\n          return response.json();\n        })\n        .then((weather) => {\n          this.id = weather.id;\n          this.name = weather.name;\n          this.temp = parseInt(weather.main.temp).toFixed(1);\n          this.icon = weather.weather[0].icon;\n          this.conditions = weather.weather[0].description;\n          this.timezoneOffset = weather.timezone;\n        })\n        .catch((e) => {\n          console.log(e);\n          this.name = this.item.name;\n          this.error = true;\n        });\n    },\n    calcTime: (offset) => {\n      const localTime = new Date();\n      const utcTime =\n        localTime.getTime() + localTime.getTimezoneOffset() * 60000;\n      const calculatedTime = new Date(utcTime + 1000 * offset);\n      return calculatedTime.toLocaleString([], {\n        hour: \"2-digit\",\n        minute: \"2-digit\",\n      });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n// Add a border around the weather image.\n// Otherwise the image is not always distinguishable.\n.media-left {\n  &.circle,\n  &.square {\n    background-color: #e4e4e4;\n  }\n\n  &.circle {\n    border-radius: 90%;\n  }\n\n  img {\n    max-height: 100%;\n  }\n}\n\n.error {\n  color: #de0000;\n}\n\n// Change background color in dark mode.\n.is-dark {\n  .media-left {\n    &.circle,\n    &.square {\n      background-color: #909090;\n    }\n  }\n}\n\n//Location Time\n.location-time {\n  margin-left: 20px;\n}\n</style>\n"
  },
  {
    "path": "src/components/services/PaperlessNG.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <template v-else-if=\"api\">\n          happily storing {{ api.count }} documents\n        </template>\n      </p>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Paperless\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    api: null,\n  }),\n  created() {\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      if (this.item.subtitle != null) return;\n\n      const apikey = this.item.apikey;\n      if (!apikey) {\n        console.error(\n          \"apikey is not present in config.yml for the paperless entry!\",\n        );\n        return;\n      }\n      this.api = await this.fetch(\"/api/documents/\", {\n        headers: {\n          Authorization: \"Token \" + this.item.apikey,\n        },\n      }).catch((e) => console.log(e));\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/services/PeaNUT.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <template v-else-if=\"load\"> {{ load }}&percnt; UPS Load </template>\n      </p>\n    </template>\n    <template #indicator>\n      <div v-if=\"ups_status\" class=\"status\" :class=\"status_class\">\n        {{ status_text }}\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"PeaNUT\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    ups_status: \"\",\n    ups_load: 0,\n  }),\n  computed: {\n    status_text: function () {\n      switch (this.ups_status) {\n        case \"OL\":\n          return \"online\";\n        case \"OB\":\n          return \"on battery\";\n        case \"LB\":\n          return \"low battery\";\n        default:\n          return \"unknown\";\n      }\n    },\n    status_class: function () {\n      switch (this.ups_status) {\n        case \"OL\":\n          return \"online\";\n        case \"OB\": // On battery\n          return \"pending\";\n        case \"LB\": // Low battery\n          return \"offline\";\n        default:\n          return \"unknown\";\n      }\n    },\n    load: function () {\n      if (this.ups_load) {\n        return this.ups_load.toFixed(1);\n      }\n      return \"\";\n    },\n  },\n  created() {\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      const device = this.item.device || \"\";\n\n      const result = await this.fetch(`/api/v1/devices/${device}`).catch((e) =>\n        console.log(e),\n      );\n\n      this.ups_status = result[\"ups.status\"] || \"\";\n      this.ups_load = result[\"ups.load\"] || 0;\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/services/PiAlert.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #indicator>\n      <div class=\"notifs\">\n        <strong class=\"notif total\" title=\"Total Devices\">\n          {{ total }}\n        </strong>\n        <strong class=\"notif connected\" title=\"Connected Devices\">\n          {{ connected }}\n        </strong>\n        <strong class=\"notif newdevices\" title=\"New Devices\">\n          {{ newdevices }}\n        </strong>\n        <strong class=\"notif alert\" title=\"Down Alerts\">\n          {{ downalert }}\n        </strong>\n        <strong\n          v-if=\"serverError\"\n          class=\"notif alert\"\n          title=\"Connection error to PiAlert server, check the url in config.yml\"\n          >?</strong\n        >\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"PiAlert\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => {\n    return {\n      total: 0,\n      connected: 0,\n      newdevices: 0,\n      downalert: 0,\n      serverError: false,\n    };\n  },\n  created() {\n    const updateInterval = parseInt(this.item.updateInterval, 10) || 0;\n    if (updateInterval > 0) {\n      setInterval(() => this.fetchStatus(), updateInterval);\n    }\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      this.fetch(\"/php/server/devices.php?action=getDevicesTotals\")\n        .then((response) => {\n          this.total = response[0];\n          this.connected = response[1];\n          this.newdevices = response[3];\n          this.downalert = response[4];\n        })\n        .catch((e) => {\n          console.log(e);\n          this.serverError = true;\n        });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.notifs {\n  position: absolute;\n  color: white;\n  font-family: sans-serif;\n  top: 0.3em;\n  right: 0.5em;\n\n  .notif {\n    display: inline-block;\n    padding: 0.2em 0.35em;\n    border-radius: 0.25em;\n    position: relative;\n    margin-left: 0.3em;\n    font-size: 0.8em;\n\n    &.total {\n      background-color: #4fb5d6;\n    }\n\n    &.connected {\n      background-color: #4fd671;\n    }\n\n    &.newdevices {\n      background-color: #d08d2e;\n    }\n\n    &.alert {\n      background-color: #e51111;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/PiHole.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <template v-else-if=\"percentage\">\n          {{ percentage }}&percnt; blocked\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <div v-if=\"status\" class=\"status\" :class=\"status\">\n        {{ status }}\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"PiHole\",\n  mixins: [service],\n  props: {\n    item: {\n      type: Object,\n      required: true,\n    },\n  },\n  data: () => ({\n    status: \"\",\n    percent_blocked: 0,\n    sessionId: null,\n    sessionExpiry: null,\n    retryCount: 0,\n    maxRetries: 3,\n    retryDelay: 5000,\n    localCheckInterval: 1000, // Default value or a fallback\n    pollInterval: null,\n  }),\n  computed: {\n    percentage: function () {\n      if (this.percent_blocked >= 0) {\n        return this.percent_blocked.toFixed(1);\n      }\n      return \"\";\n    },\n    isAuthenticated() {\n      return (\n        this.sessionId && this.sessionExpiry && Date.now() < this.sessionExpiry\n      );\n    },\n  },\n  created() {\n    if (parseInt(this.item.apiVersion, 10) === 6) {\n      // Set the interval to the checkInterval or default to 5 minutes\n      this.localCheckInterval = parseInt(this.item.checkInterval, 10) || 300000;\n      this.loadCachedSession();\n      this.startStatusPolling();\n    } else {\n      this.fetchStatus_v5();\n    }\n  },\n  beforeUnmount() {\n    if (parseInt(this.item.apiVersion, 10) === 6) {\n      this.stopStatusPolling();\n    }\n  },\n  methods: {\n    handleError: function (error, status) {\n      console.error(error);\n      this.subtitle = error;\n      this.status = status;\n    },\n    startStatusPolling: function () {\n      this.fetchStatus();\n      if (this.localCheckInterval < 1000) {\n        this.localCheckInterval = 1000;\n      }\n      this.pollInterval = setInterval(\n        this.fetchStatus,\n        this.localCheckInterval,\n      );\n    },\n    stopStatusPolling: function () {\n      if (this.pollInterval) {\n        clearInterval(this.pollInterval);\n      }\n    },\n    loadCachedSession: function () {\n      try {\n        const cachedSession = localStorage.getItem(\n          `pihole_session_${this.item.url}`,\n        );\n        if (cachedSession) {\n          const session = JSON.parse(cachedSession);\n          if (session.expiry > Date.now()) {\n            this.sessionId = session.sid;\n            this.sessionExpiry = session.expiry;\n          } else {\n            this.removeCacheSession();\n          }\n        }\n      } catch (e) {\n        this.handleError(`Failed to load cached session: ${e}`, \"error\");\n        this.removeCacheSession();\n      }\n    },\n    removeCacheSession: function () {\n      localStorage.removeItem(`pihole_session_${this.item.url}`);\n      this.sessionId = null;\n      this.sessionExpiry = null;\n    },\n    authenticate: async function () {\n      try {\n        const authResponse = await this.fetch(\"/api/auth\", {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({ password: this.item.apikey }),\n        });\n\n        if (authResponse?.session?.sid) {\n          this.sessionId = authResponse.session.sid;\n          this.sessionExpiry =\n            Date.now() + authResponse.session.validity * 1000;\n\n          localStorage.setItem(\n            `pihole_session_${this.item.url}`,\n            JSON.stringify({\n              sid: this.sessionId,\n              expiry: this.sessionExpiry,\n            }),\n          );\n\n          this.retryCount = 0;\n          return true;\n        }\n        throw new Error(\"Invalid authentication response\");\n      } catch (e) {\n        this.handleError(`Authentication failed: ${e}`, \"disabled\");\n        return false;\n      }\n    },\n    retryWithDelay: async function () {\n      console.log(\"Retrying authentication...\");\n      if (this.retryCount < this.maxRetries) {\n        this.retryCount++;\n        await new Promise((resolve) => setTimeout(resolve, this.retryDelay));\n        return this.fetchStatus();\n      }\n      return false;\n    },\n    fetchStatus: async function () {\n      try {\n        if (!this.isAuthenticated && this.item.apikey) {\n          const authenticated = await this.authenticate();\n          if (!authenticated) return;\n        }\n\n        const [summary_response, status_response] = await Promise.all([\n          this.fetch(\n            `api/stats/summary?sid=${encodeURIComponent(this.sessionId)}`,\n          ),\n          this.fetch(\n            `api/dns/blocking?sid=${encodeURIComponent(this.sessionId)}`,\n          ),\n        ]);\n\n        if (\n          summary_response?.queries?.percent_blocked === undefined ||\n          status_response?.blocking === undefined\n        ) {\n          throw new Error(\"Invalid response format\");\n        }\n\n        this.status = status_response.blocking;\n        this.percent_blocked = summary_response.queries.percent_blocked;\n        this.retryCount = 0;\n      } catch (e) {\n        const isAuthError =\n          e.message.includes(\"401 error\") || e.message.includes(\"403 error\");\n        if (isAuthError && this.item.apikey) {\n          this.removeCacheSession();\n          return this.retryWithDelay();\n        }\n        this.handleError(`Failed to fetch status: ${e.message || e}`, \"error\");\n        this.removeCacheSession();\n      }\n    },\n    async fetchStatus_v5() {\n      const authQueryParams = this.item.apikey\n        ? `?summaryRaw&auth=${this.item.apikey}`\n        : \"\";\n      const result = await this.fetch(`/api.php${authQueryParams}`).catch((e) =>\n        this.handleError(`Failed to fetch status: ${e}`, \"error\"),\n      );\n\n      this.status = result.status;\n      this.percent_blocked = result.ads_percentage_today;\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n\n  &.enabled:before {\n    background-color: #94e185;\n    border-color: #78d965;\n    box-shadow: 0 0 5px 1px #94e185;\n  }\n\n  &.disabled:before {\n    background-color: #f5a623;\n    border-color: #e59400;\n    box-shadow: 0 0 5px 1px #f5a623;\n  }\n\n  &.error:before {\n    background-color: #c9404d;\n    border-color: #c42c3b;\n    box-shadow: 0 0 5px 1px #c9404d;\n  }\n\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 7px;\n    height: 7px;\n    margin-right: 10px;\n    border: 1px solid #000;\n    border-radius: 7px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Ping.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #indicator>\n      <div v-if=\"status\" class=\"status\" :class=\"status\">\n        {{ status }}\n      </div>\n    </template>\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <template v-else>\n          {{ rttLabel }}\n        </template>\n      </p>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Ping\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    status: null,\n    rtt: null,\n  }),\n  computed: {\n    rttLabel: function () {\n      if (this.status === \"online\") {\n        return `${this.rtt}ms`;\n      }\n      return \"unavailable\";\n    },\n  },\n  created() {\n    const updateInterval = parseInt(this.item.updateInterval, 10) || 0;\n    if (updateInterval > 0) {\n      setInterval(this.fetchStatus, updateInterval);\n    }\n\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      const method =\n        typeof this.item.method === \"string\"\n          ? this.item.method.toUpperCase()\n          : \"HEAD\";\n\n      if (![\"GET\", \"HEAD\", \"OPTION\"].includes(method)) {\n        console.error(`Ping: ${method} is not a supported HTTP method`);\n        return;\n      }\n\n      const startTime = performance.now();\n      const timeout = parseInt(this.item.timeout, 10) || 2000;\n      const params = {\n        method,\n        cache: \"no-cache\",\n        signal: AbortSignal.timeout(timeout),\n      };\n\n      this.fetch(\"/\", params, false)\n        .then(() => {\n          this.status = \"online\";\n          const endTime = performance.now();\n          this.rtt = Math.round(endTime - startTime);\n        })\n        .catch(() => {\n          this.status = \"offline\";\n          this.rtt = null; // Reset rtt on failure\n        });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n  white-space: nowrap;\n  margin-left: 0.25rem;\n\n  &.online:before {\n    background-color: #94e185;\n    border-color: #78d965;\n    box-shadow: 0 0 5px 1px #94e185;\n  }\n\n  &.offline:before {\n    background-color: #c9404d;\n    border-color: #c42c3b;\n    box-shadow: 0 0 5px 1px #c9404d;\n  }\n\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 7px;\n    height: 7px;\n    margin-right: 10px;\n    border: 1px solid #000;\n    border-radius: 7px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Plex.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #indicator>\n      <div class=\"notifs\">\n        <strong\n          v-if=\"streams > 0\"\n          class=\"notif activity\"\n          title=\"Active Streams\"\n        >\n          {{ streams }}\n        </strong>\n        <strong v-if=\"series > 0\" class=\"notif series\" title=\"Total Series\">\n          {{ series }}\n        </strong>\n        <strong v-if=\"movies > 0\" class=\"notif movies\" title=\"Total Movies\">\n          {{ movies }}\n        </strong>\n        <strong v-if=\"warnings > 0\" class=\"notif warnings\" title=\"Warning\">\n          {{ warnings }}\n        </strong>\n        <strong v-if=\"errors > 0\" class=\"notif errors\" title=\"Error\">\n          {{ errors }}\n        </strong>\n        <strong\n          v-if=\"serverError\"\n          class=\"notif errors\"\n          title=\"Connection error to Plex API, check url and token in config.yml\"\n        >\n          ?\n        </strong>\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\nexport default {\n  name: \"Plex\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => {\n    return {\n      streams: null,\n      series: null,\n      movies: null,\n      warnings: null,\n      errors: null,\n      serverError: false,\n    };\n  },\n  created: function () {\n    const checkInterval = parseInt(this.item.checkInterval, 10) || 0;\n    if (checkInterval > 0) {\n      setInterval(() => this.fetchData(), checkInterval);\n    }\n    this.fetchData();\n  },\n  methods: {\n    fetchData: function () {\n      const handleError = (e) => {\n        console.error(e);\n        this.serverError = true;\n      };\n      this.fetch(`/status/sessions?X-Plex-Token=${this.item.token}`, {}, false)\n        .then((str) => {\n          const parser = new DOMParser();\n          const xml = parser.parseFromString(str, \"application/xml\");\n          const metadata = xml.getElementsByTagName(\"MediaContainer\")[0];\n          this.streams = metadata ? metadata.getAttribute(\"size\") || 0 : 0;\n        })\n        .catch(handleError);\n      this.fetch(`/library/sections?X-Plex-Token=${this.item.token}`, {}, false)\n        .then((str) => {\n          const parser = new DOMParser();\n          const xml = parser.parseFromString(str, \"application/xml\");\n          const directories = xml.getElementsByTagName(\"Directory\");\n          const seriesDirIds = [];\n          const movieDirIds = [];\n          for (let dir of directories) {\n            if (dir.getAttribute(\"type\") === \"show\") {\n              seriesDirIds.push(dir.getAttribute(\"key\"));\n            } else if (dir.getAttribute(\"type\") === \"movie\") {\n              movieDirIds.push(dir.getAttribute(\"key\"));\n            }\n          }\n          let seriesCount = 0;\n          Promise.all(\n            seriesDirIds.map((seriesDirId) =>\n              fetch(\n                `${this.endpoint}/library/sections/${seriesDirId}/all?X-Plex-Token=${this.item.token}`,\n              )\n                .then((response) => response.text())\n                .then((str) => {\n                  const xml = parser.parseFromString(str, \"application/xml\");\n                  seriesCount += xml.getElementsByTagName(\"Directory\").length;\n                })\n                .catch(handleError),\n            ),\n          )\n            .then(() => {\n              this.series = seriesCount;\n            })\n            .catch(handleError);\n\n          let movieCount = 0;\n          Promise.all(\n            movieDirIds.map((movieDirId) =>\n              fetch(\n                `${this.endpoint}/library/sections/${movieDirId}/all?X-Plex-Token=${this.item.token}`,\n              )\n                .then((response) => response.text())\n                .then((str) => {\n                  const xml = parser.parseFromString(str, \"application/xml\");\n                  movieCount += xml.getElementsByTagName(\"Video\").length;\n                })\n                .catch(handleError),\n            ),\n          ).then(() => {\n            this.movies = movieCount;\n          });\n        })\n        .catch(handleError);\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.notifs {\n  position: absolute;\n  color: white;\n  font-family: sans-serif;\n  top: 0.3em;\n  right: 0.5em;\n\n  .notif {\n    display: inline-block;\n    padding: 0.2em 0.35em;\n    border-radius: 0.25em;\n    position: relative;\n    margin-left: 0.3em;\n    font-size: 0.8em;\n    &.activity {\n      background-color: #4fb5d6;\n    }\n    &.series {\n      background-color: #ffa500;\n    }\n    &.movies {\n      background-color: #008000;\n    }\n    &.warnings {\n      background-color: #d08d2e;\n    }\n    &.errors {\n      background-color: #e51111;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Portainer.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <template v-else-if=\"versionstring\">\n          Version {{ versionstring }}\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <div class=\"notifs\">\n        <strong v-if=\"running > 0\" class=\"notif running\" title=\"Running\">\n          {{ running }}\n        </strong>\n        <strong v-if=\"dead > 0\" class=\"notif dead\" title=\"Dead\">\n          {{ dead }}\n        </strong>\n        <strong\n          v-if=\"misc > 0\"\n          class=\"notif misc\"\n          title=\"Other (creating, paused, exited, etc.)\"\n        >\n          {{ misc }}\n        </strong>\n      </div>\n      <div v-if=\"status\" class=\"status\" :class=\"status\">\n        {{ status }}\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Portainer\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    endpoints: null,\n    containers: null,\n    fetchOk: null,\n    versionstring: null,\n  }),\n  computed: {\n    running: function () {\n      if (!this.containers) {\n        return \"\";\n      }\n      return this.containers.filter((container) => {\n        return container.State.toLowerCase() === \"running\";\n      }).length;\n    },\n    dead: function () {\n      if (!this.containers) {\n        return \"\";\n      }\n      return this.containers.filter((container) => {\n        return container.State.toLowerCase() === \"dead\";\n      }).length;\n    },\n    misc: function () {\n      if (!this.containers) {\n        return \"\";\n      }\n      return this.containers.filter((container) => {\n        return (\n          container.State.toLowerCase() !== \"running\" &&\n          container.State.toLowerCase() !== \"dead\"\n        );\n      }).length;\n    },\n    status: function () {\n      return this.fetchOk ? \"online\" : \"offline\";\n    },\n  },\n  created() {\n    this.fetchStatus();\n    this.fetchVersion();\n  },\n  methods: {\n    fetchStatus: async function () {\n      const headers = {\n        \"X-Api-Key\": this.item.apikey,\n      };\n\n      this.endpoints = await this.fetch(\"/api/endpoints\", { headers }).catch(\n        (e) => {\n          console.error(e);\n        },\n      );\n\n      let containers = [];\n      for (let endpoint of this.endpoints) {\n        if (\n          this.item.environments &&\n          !this.item.environments.includes(endpoint.Name)\n        ) {\n          continue;\n        }\n        const uri = `/api/endpoints/${endpoint.Id}/docker/containers/json?all=1`;\n        const endpointContainers = await this.fetch(uri, { headers }).catch(\n          (e) => {\n            console.error(e);\n          },\n        );\n\n        if (endpointContainers) {\n          containers = containers.concat(endpointContainers);\n        }\n      }\n\n      this.containers = containers;\n    },\n    fetchVersion: async function () {\n      const headers = {\n        \"X-Api-Key\": this.item.apikey,\n      };\n      this.fetch(\"/api/status\", { headers })\n        .then((response) => {\n          this.fetchOk = true;\n          this.versionstring = response.Version;\n        })\n        .catch((e) => {\n          this.fetchOk = false;\n          console.error(e);\n        });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n  white-space: nowrap;\n  margin-left: 0.25rem;\n\n  &.online:before {\n    background-color: #94e185;\n    border-color: #78d965;\n    box-shadow: 0 0 5px 1px #94e185;\n  }\n\n  &.offline:before {\n    background-color: #c9404d;\n    border-color: #c42c3b;\n    box-shadow: 0 0 5px 1px #c9404d;\n  }\n\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 7px;\n    height: 7px;\n    margin-right: 10px;\n    border: 1px solid #000;\n    border-radius: 7px;\n  }\n}\n\n.notifs {\n  position: absolute;\n  color: white;\n  font-family: sans-serif;\n  top: 0.3em;\n  right: 0.5em;\n\n  .notif {\n    display: inline-block;\n    padding: 0.2em 0.35em;\n    border-radius: 0.25em;\n    position: relative;\n    margin-left: 0.3em;\n    font-size: 0.8em;\n\n    &.running {\n      background-color: #4fd671;\n    }\n\n    &.dead {\n      background-color: #e51111;\n    }\n\n    &.misc {\n      background-color: #2ed0c8;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Prometheus.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <template v-else-if=\"api\"> {{ count }} {{ level }} alerts </template>\n      </p>\n    </template>\n    <template #indicator>\n      <div v-if=\"api\" class=\"status\" :class=\"level\">\n        {{ count }}\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nconst AlertsStatus = Object.freeze({\n  firing: \"firing\",\n  pending: \"pending\",\n  inactive: \"inactive\",\n});\n\nexport default {\n  name: \"Prometheus\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    api: {\n      status: \"\",\n      count: 0,\n      alerts: {\n        firing: 0,\n        inactive: 0,\n        pending: 0,\n      },\n    },\n  }),\n  computed: {\n    count: function () {\n      return (\n        this.countFiring() || this.countPending() || this.countInactive() || 0\n      );\n    },\n    level: function () {\n      if (this.countFiring()) {\n        return AlertsStatus.firing;\n      } else if (this.countPending()) {\n        return AlertsStatus.pending;\n      }\n      return AlertsStatus.inactive;\n    },\n  },\n  created() {\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      this.api = await this.fetch(\"api/v1/alerts\").catch((e) => console.log(e));\n    },\n    countFiring: function () {\n      if (this.api) {\n        return this.api.data?.alerts?.filter(\n          (alert) => alert.state === AlertsStatus.firing,\n        ).length;\n      }\n      return 0;\n    },\n    countPending: function () {\n      if (this.api) {\n        return this.api.data?.alerts?.filter(\n          (alert) => alert.state === AlertsStatus.pending,\n        ).length;\n      }\n      return 0;\n    },\n    countInactive: function () {\n      if (this.api) {\n        return this.api.data?.alerts?.filter(\n          (alert) => alert.state === AlertsStatus.pending,\n        ).length;\n      }\n      return 0;\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.media-left {\n  .image {\n    display: flex;\n    align-items: center;\n  }\n\n  img {\n    max-height: 100%;\n  }\n}\n.status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n\n  &.firing:before {\n    background-color: #d65c68;\n    border-color: #e87d88;\n    box-shadow: 0 0 5px 1px #d65c68;\n  }\n\n  &.pending:before {\n    background-color: #e8bb7d;\n    border-color: #d6a35c;\n    box-shadow: 0 0 5px 1px #e8bb7d;\n  }\n\n  &.inactive:before {\n    background-color: #8fe87d;\n    border-color: #70d65c;\n    box-shadow: 0 0 5px 1px #8fe87d;\n  }\n\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 7px;\n    height: 7px;\n    margin-right: 10px;\n    border: 1px solid #000;\n    border-radius: 7px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Prowlarr.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #indicator>\n      <div class=\"notifs\">\n        <strong v-if=\"warnings > 0\" class=\"notif warnings\" title=\"Warning\">\n          {{ warnings }}\n        </strong>\n        <strong v-if=\"errors > 0\" class=\"notif errors\" title=\"Error\">\n          {{ errors }}\n        </strong>\n        <strong\n          v-if=\"serverError\"\n          class=\"notif errors\"\n          title=\"Connection error to Prowlarr API, check url and apikey in config.yml\"\n        >\n          ?\n        </strong>\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Prowlarr\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => {\n    return {\n      warnings: null,\n      errors: null,\n      serverError: false,\n    };\n  },\n  created: function () {\n    const checkInterval = parseInt(this.item.checkInterval, 10) || 0;\n    if (checkInterval > 0) {\n      setInterval(() => this.fetchConfig(), checkInterval);\n    }\n\n    this.fetchConfig();\n  },\n  methods: {\n    fetchConfig: function () {\n      this.fetch(`/api/v1/health?apikey=${this.item.apikey}`)\n        .then((health) => {\n          this.warnings = 0;\n          this.errors = 0;\n          for (var i = 0; i < health.length; i++) {\n            if (health[i].type == \"warning\") {\n              this.warnings++;\n            } else if (health[i].type == \"error\") {\n              this.errors++;\n            }\n          }\n        })\n        .catch((e) => {\n          console.error(e);\n          this.serverError = true;\n        });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.notifs {\n  position: absolute;\n  color: white;\n  font-family: sans-serif;\n  top: 0.3em;\n  right: 0.5em;\n\n  .notif {\n    display: inline-block;\n    padding: 0.2em 0.35em;\n    border-radius: 0.25em;\n    position: relative;\n    margin-left: 0.3em;\n    font-size: 0.8em;\n\n    &.warnings {\n      background-color: #d08d2e;\n    }\n\n    &.errors {\n      background-color: #e51111;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Proxmox.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <template v-else-if=\"vms\">\n          <div v-if=\"loading\">\n            <strong>Loading...</strong>\n          </div>\n          <div v-else-if=\"error\">\n            <strong class=\"danger\">Error loading info</strong>\n          </div>\n          <div\n            v-else\n            class=\"metrics\"\n            :class=\"{\n              'is-size-7-mobile': item.small_font_on_small_screens,\n              'is-small': item.small_font_on_desktop,\n            }\"\n          >\n            <span v-if=\"isValueShown('vms')\" class=\"margined\"\n              >VMs:\n              <span class=\"is-number\"\n                ><span class=\"has-text-weight-bold\">{{ vms.running }}</span\n                ><span v-if=\"isValueShown('vms_total')\"\n                  >/{{ vms.total }}</span\n                ></span\n              ></span\n            >\n            <span v-if=\"isValueShown('lxcs')\" class=\"margined\"\n              >LXCs:\n              <span class=\"is-number\"\n                ><span class=\"has-text-weight-bold\">{{ lxcs.running }}</span\n                ><span v-if=\"isValueShown('lxcs_total')\"\n                  >/{{ lxcs.total }}</span\n                ></span\n              ></span\n            >\n            <span v-if=\"isValueShown('disk')\" class=\"margined\"\n              >Disk:\n              <span\n                class=\"has-text-weight-bold is-number\"\n                :class=\"statusClass(diskUsed)\"\n                >{{ diskUsed }}%</span\n              ></span\n            >\n            <span v-if=\"isValueShown('mem')\" class=\"margined\"\n              >Mem:\n              <span\n                class=\"has-text-weight-bold is-number\"\n                :class=\"statusClass(memoryUsed)\"\n                >{{ memoryUsed }}%</span\n              ></span\n            >\n            <span v-if=\"isValueShown('cpu')\" class=\"margined\"\n              >CPU:\n              <span\n                class=\"has-text-weight-bold is-number\"\n                :class=\"statusClass(cpuUsed)\"\n                >{{ cpuUsed }}%</span\n              ></span\n            >\n          </div>\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <i v-if=\"loading\" class=\"fa fa-circle-notch fa-spin fa-2xl\"></i>\n      <i v-if=\"error\" class=\"fa fa-exclamation-circle fa-2xl danger\"></i>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Proxmox\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    vms: {\n      total: 0,\n      running: 0,\n    },\n    lxcs: {\n      total: 0,\n      running: 0,\n    },\n    memoryUsed: 0,\n    diskUsed: 0,\n    cpuUsed: 0,\n    hide: [],\n    error: false,\n    loading: true,\n  }),\n  created() {\n    if (this.item.hide) this.hide = this.item.hide;\n    this.fetchStatus();\n  },\n  methods: {\n    statusClass(value) {\n      if (value > this.item.danger_value) return \"danger\";\n      if (value > this.item.warning_value) return \"warning\";\n      return \"healthy\";\n    },\n    fetchStatus: async function () {\n      try {\n        const options = {\n          headers: {\n            Authorization: this.item.api_token,\n          },\n        };\n        const status = await this.fetch(\n          `/api2/json/nodes/${this.item.node}/status`,\n          options,\n        );\n        // main metrics:\n        const decimalsToShow = this.item.hide_decimals ? 0 : 1;\n        this.memoryUsed = (\n          (status.data.memory.used * 100) /\n          status.data.memory.total\n        ).toFixed(decimalsToShow);\n        this.diskUsed = (\n          (status.data.rootfs.used * 100) /\n          status.data.rootfs.total\n        ).toFixed(decimalsToShow);\n        this.cpuUsed = (status.data.cpu * 100).toFixed(decimalsToShow);\n        // vms:\n        if (this.isValueShown(\"vms\")) {\n          const vms = await this.fetch(\n            `/api2/json/nodes/${this.item.node}/qemu`,\n            options,\n          );\n          this.parseVMsAndLXCs(vms, this.vms);\n        }\n        // lxc containers:\n        if (this.isValueShown(\"lxcs\")) {\n          const lxcs = await this.fetch(\n            `/api2/json/nodes/${this.item.node}/lxc`,\n            options,\n          );\n          this.parseVMsAndLXCs(lxcs, this.lxcs);\n        }\n        this.error = false;\n      } catch (err) {\n        console.log(err);\n        this.error = true;\n      }\n      this.loading = false;\n    },\n    parseVMsAndLXCs(items, value) {\n      value.total += items.data.length;\n      value.running += items.data.filter((i) => i.status === \"running\").length;\n      // if no vms, hide this value:\n      if (value.total == 0) this.hide.push(\"lxcs\");\n    },\n    isValueShown(value) {\n      return this.hide.indexOf(value) == -1;\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.healthy {\n  color: green;\n}\n.warning {\n  color: orange;\n}\n.danger {\n  color: red;\n}\n.metrics .margined:not(:first-child) {\n  margin-left: 0.3rem;\n}\n.is-small {\n  font-size: small;\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Radarr.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #indicator>\n      <div class=\"notifs\">\n        <strong v-if=\"activity > 0\" class=\"notif activity\" title=\"Activity\">\n          {{ activity }}\n        </strong>\n        <strong v-if=\"missing > 0\" class=\"notif missing\" title=\"Missing\">\n          {{ missing }}\n        </strong>\n        <strong v-if=\"warnings > 0\" class=\"notif warnings\" title=\"Warning\">\n          {{ warnings }}\n        </strong>\n        <strong v-if=\"errors > 0\" class=\"notif errors\" title=\"Error\">\n          {{ errors }}\n        </strong>\n        <strong\n          v-if=\"serverError\"\n          class=\"notif errors\"\n          title=\"Connection error to Radarr API, check url and apikey in config.yml\"\n          >?</strong\n        >\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nconst V3_API = \"/api/v3\";\nconst LEGACY_API = \"/api\";\n\nexport default {\n  name: \"Radarr\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => {\n    return {\n      activity: null,\n      missing: null,\n      warnings: null,\n      errors: null,\n      serverError: false,\n    };\n  },\n  computed: {\n    apiPath() {\n      return this.item.legacyApi ? LEGACY_API : V3_API;\n    },\n  },\n  created: function () {\n    const checkInterval = parseInt(this.item.checkInterval, 10) || 0;\n    if (checkInterval > 0) {\n      setInterval(() => this.fetchConfig(), checkInterval);\n    }\n\n    this.fetchConfig();\n  },\n  methods: {\n    fetchConfig: function () {\n      const handleError = (e) => {\n        console.error(e);\n        this.serverError = true;\n      };\n      this.fetch(`${this.apiPath}/health?apikey=${this.item.apikey}`)\n        .then((health) => {\n          this.warnings = 0;\n          this.errors = 0;\n          for (var i = 0; i < health.length; i++) {\n            if (health[i].type == \"warning\") {\n              this.warnings++;\n            } else if (health[i].type == \"error\") {\n              this.errors++;\n            }\n          }\n        })\n        .catch(handleError);\n      if (!this.item.legacyApi) {\n        this.fetch(`${this.apiPath}/queue/details?apikey=${this.item.apikey}`)\n          .then((queue) => {\n            for (var i = 0; i < queue.length; i++) {\n              if (queue[i].trackedDownloadStatus == \"warning\") {\n                this.warnings++;\n              } else if (queue[i].trackedDownloadStaus == \"error\") {\n                this.errors++;\n              }\n            }\n          })\n          .catch(handleError);\n      }\n      this.fetch(`${this.apiPath}/queue?apikey=${this.item.apikey}`)\n        .then((queue) => {\n          this.activity = 0;\n\n          if (this.item.legacyApi) {\n            for (var i = 0; i < queue.length; i++) {\n              if (queue[i].movie) {\n                this.activity++;\n              }\n            }\n          } else {\n            this.activity = queue.totalRecords;\n          }\n        })\n        .catch(handleError);\n      if (!this.item.legacyApi) {\n        this.fetch(\n          `${this.apiPath}/wanted/missing?pageSize=1&apikey=${this.item.apikey}`,\n        )\n          .then((overview) => {\n            this.fetch(\n              `${this.apiPath}/wanted/missing?pageSize=${overview.totalRecords}&apikey=${this.item.apikey}`,\n            ).then((movies) => {\n              this.missing = movies.records.filter(\n                (m) => m.monitored && m.isAvailable && !m.hasFile,\n              ).length;\n            });\n          })\n          .catch(handleError);\n      }\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.notifs {\n  position: absolute;\n  color: white;\n  font-family: sans-serif;\n  top: 0.3em;\n  right: 0.5em;\n  .notif {\n    display: inline-block;\n    padding: 0.2em 0.35em;\n    border-radius: 0.25em;\n    position: relative;\n    margin-left: 0.3em;\n    font-size: 0.8em;\n    &.activity {\n      background-color: #4fb5d6;\n    }\n\n    &.missing {\n      background-color: #9d00ff;\n    }\n    &.warnings {\n      background-color: #d08d2e;\n    }\n\n    &.errors {\n      background-color: #e51111;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Readarr.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #indicator>\n      <div class=\"notifs\">\n        <strong v-if=\"activity > 0\" class=\"notif activity\" title=\"Activity\">\n          {{ activity }}\n        </strong>\n        <strong v-if=\"missing > 0\" class=\"notif missing\" title=\"Missing\">\n          {{ missing }}\n        </strong>\n        <strong v-if=\"warnings > 0\" class=\"notif warnings\" title=\"Warning\">\n          {{ warnings }}\n        </strong>\n        <strong v-if=\"errors > 0\" class=\"notif errors\" title=\"Error\">\n          {{ errors }}\n        </strong>\n        <strong\n          v-if=\"serverError\"\n          class=\"notif errors\"\n          title=\"Connection error to Readarr API, check url and apikey in config.yml\"\n          >?</strong\n        >\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nconst API = \"/api/v1\";\n\nexport default {\n  name: \"Readarr\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => {\n    return {\n      activity: null,\n      missing: null,\n      warnings: null,\n      errors: null,\n      serverError: false,\n    };\n  },\n  created: function () {\n    this.fetchConfig();\n  },\n  methods: {\n    fetchConfig: function () {\n      const handleError = (e) => {\n        console.error(e);\n        this.serverError = true;\n      };\n      this.fetch(`${API}/health?apikey=${this.item.apikey}`)\n        .then((health) => {\n          this.warnings = health.filter((h) => h.type === \"warning\").length;\n          this.errors = health.filter((h) => h.type === \"errors\").length;\n        })\n        .catch(handleError);\n      this.fetch(`${API}/queue?apikey=${this.item.apikey}`)\n        .then((queue) => {\n          this.activity = queue.totalRecords;\n        })\n        .catch(handleError);\n      this.fetch(`${API}/wanted/missing?apikey=${this.item.apikey}`)\n        .then((missing) => {\n          this.missing = missing.totalRecords;\n        })\n        .catch(handleError);\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.notifs {\n  position: absolute;\n  color: white;\n  font-family: sans-serif;\n  top: 0.3em;\n  right: 0.5em;\n  display: flex;\n  gap: 0.2rem;\n  .notif {\n    padding: 0.2em 0.35em;\n    border-radius: 0.25em;\n    font-size: 0.8em;\n    &.activity {\n      background-color: #4fb5d6;\n    }\n\n    &.missing {\n      background-color: #9d00ff;\n    }\n\n    &.warnings {\n      background-color: #d08d2e;\n    }\n\n    &.errors {\n      background-color: #e51111;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Rtorrent.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <span v-if=\"error\" class=\"error\">An error has occurred.</span>\n        <template v-else>\n          <span class=\"down\">\n            <i class=\"fas fa-download\"></i> {{ downRate }}\n          </span>\n          <span class=\"up\"> <i class=\"fas fa-upload\"></i> {{ upRate }} </span>\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <span v-if=\"!error\" class=\"count\"\n        >{{ count }}\n        <template v-if=\"count === 1\">torrent</template>\n        <template v-else>torrents</template>\n      </span>\n    </template>\n  </Generic>\n</template>\n\n<script>\n// Units to add to download and upload rates.\nconst units = [\"B\", \"kiB\", \"MiB\", \"GiB\"];\n\n// Take the rate in bytes and keep dividing it by 1k until the lowest\n// value for which we have a unit is determined. Return the value with\n// up to two decimals as a string and unit/s appended.\nconst displayRate = (rate) => {\n  let i = 0;\n\n  while (rate > 1000 && i < units.length) {\n    rate /= 1000;\n    i++;\n  }\n\n  return (\n    Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(\n      rate || 0,\n    ) + ` ${units[i]}/s`\n  );\n};\n\nexport default {\n  name: \"RTorrent\",\n  props: { item: Object },\n  // Properties for download, upload, torrent count and errors.\n  data: () => ({ dl: null, ul: null, count: null, error: null }),\n  // Computed properties for the rate labels.\n  computed: {\n    downRate: function () {\n      return displayRate(this.dl);\n    },\n    upRate: function () {\n      return displayRate(this.ul);\n    },\n  },\n  created() {\n    // Set intervals if configured so the rates and/or torrent count\n    // will be updated.\n    const rateInterval = parseInt(this.item.rateInterval, 10) || 0;\n    const torrentInterval = parseInt(this.item.torrentInterval, 10) || 0;\n\n    if (rateInterval > 0) {\n      setInterval(() => this.fetchRates(), rateInterval);\n    }\n\n    if (torrentInterval > 0) {\n      setInterval(() => this.fetchCount(), torrentInterval);\n    }\n\n    // Fetch the initial values.\n    this.fetchRates();\n    this.fetchCount();\n  },\n  methods: {\n    // Perform two calls to the XML-RPC service and fetch download\n    // and upload rates. Values are saved to the `ul` and `dl`\n    // properties.\n    fetchRates: async function () {\n      this.getRate(\"throttle.global_up.rate\")\n        .then((ul) => (this.ul = ul))\n        .catch(() => (this.error = true));\n\n      this.getRate(\"throttle.global_down.rate\")\n        .then((dl) => (this.dl = dl))\n        .catch(() => (this.error = true));\n    },\n    // Perform a call to the XML-RPC service to fetch the number of\n    // torrents.\n    fetchCount: async function () {\n      this.getCount().catch(() => (this.error = true));\n    },\n    // Fetch a numeric value from the XML-RPC service by requesting\n    // the specified method name and parsing the XML. The response\n    // is expected to adhere to the structure of a single numeric\n    // value.\n    getRate: async function (methodName) {\n      return this.getXml(methodName).then((xml) =>\n        parseInt(\n          xml.getElementsByTagName(\"value\")[0].firstChild.textContent,\n          10,\n        ),\n      );\n    },\n    // Fetch the numer of torrents by requesting the download list\n    // and counting the number of entries therein.\n    getCount: async function () {\n      return this.getXml(\"download_list\").then((xml) => {\n        const arrayEl = xml.getElementsByTagName(\"array\");\n        this.count = arrayEl\n          ? arrayEl[0].getElementsByTagName(\"value\").length\n          : 0;\n      });\n    },\n    // Perform a call to the XML-RPC service and parse the response\n    // as XML, which is then returned.\n    getXml: async function (methodName) {\n      const headers = { \"Content-Type\": \"text/xml\" };\n\n      if (this.item.username && this.item.password) {\n        headers[\"Authorization\"] =\n          `${this.item.username}:${this.item.password}`;\n      }\n\n      return fetch(`${this.item.xmlrpc.replace(/\\/$/, \"\")}/RPC2`, {\n        method: \"POST\",\n        headers,\n        body: `<methodCall><methodName>${methodName}</methodName></methodCall>`,\n      })\n        .then((response) => {\n          if (!response.ok) {\n            throw Error(response.statusText);\n          }\n\n          return response.text();\n        })\n        .then((text) =>\n          Promise.resolve(new DOMParser().parseFromString(text, \"text/xml\")),\n        );\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.error {\n  color: #e51111 !important;\n}\n.down {\n  margin-right: 1em;\n}\n.count {\n  color: var(--text);\n  font-size: 0.8em;\n}\n</style>\n"
  },
  {
    "path": "src/components/services/SABnzbd.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #indicator>\n      <div class=\"notifs\">\n        <strong\n          v-if=\"downloads > 0\"\n          class=\"notif downloading\"\n          :title=\"`${downloads} active download${downloads > 1 ? 's' : ''}`\"\n        >\n          {{ downloads }}\n        </strong>\n        <i\n          v-if=\"error\"\n          class=\"notif error fa-solid fa-triangle-exclamation\"\n          title=\"Unable to fetch current status\"\n        ></i>\n      </div>\n    </template>\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p v-if=\"item.subtitle\" class=\"subtitle\">\n        {{ item.subtitle }}\n      </p>\n      <template v-else>\n        <p class=\"subtitle is-6\">\n          <span v-if=\"error\" class=\"error\">An error has occurred.</span>\n          <template v-else>\n            <span class=\"down monospace\">\n              <p class=\"fas fa-download\"></p>\n              {{ downRate }}\n            </span>\n          </template>\n        </p>\n      </template>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nconst units = [\"KB\", \"MB\", \"GB\"];\n\n// Function to convert rate into a human-readable format\nconst displayRate = (rate) => {\n  let i = 0;\n\n  while (rate > 1000 && i < units.length) {\n    rate /= 1000;\n    i++;\n  }\n  return (\n    Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(\n      rate || 0,\n    ) + ` ${units[i]}/s`\n  );\n};\n\nexport default {\n  name: \"SABnzbd\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    stats: null,\n    error: false,\n    dlSpeed: null,\n    ulSpeed: null,\n  }),\n  computed: {\n    downloads() {\n      if (!this.stats) {\n        return \"\";\n      }\n      return this.stats.noofslots;\n    },\n    downRate() {\n      return displayRate(this.dlSpeed);\n    },\n  },\n  created() {\n    const downloadInterval = parseInt(this.item.downloadInterval, 10) || 0;\n    if (downloadInterval > 0) {\n      setInterval(() => this.fetchStatus(), downloadInterval);\n    }\n\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      try {\n        const response = await this.fetch(\n          `/api?output=json&apikey=${this.item.apikey}&mode=queue`,\n        );\n        this.error = false;\n        this.stats = response.queue;\n\n        // Fetching download speed from \"speed\" (convert to KB/s if needed)\n        this.dlSpeed = parseFloat(response.queue.speed) * 1024; // Convert MB to KB\n      } catch (e) {\n        this.error = true;\n        console.error(e);\n      }\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.notifs {\n  position: absolute;\n  color: white;\n  font-family: sans-serif;\n  top: 0.3em;\n  right: 0.5em;\n\n  .notif {\n    display: inline-block;\n    padding: 0.2em 0.35em;\n    border-radius: 0.25em;\n    position: relative;\n    margin-left: 0.3em;\n    font-size: 0.8em;\n\n    &.downloading {\n      background-color: #4fb5d6;\n    }\n\n    &.error {\n      border-radius: 50%;\n      aspect-ratio: 1;\n      background-color: #e51111;\n    }\n  }\n}\n\n.error {\n  color: #e51111 !important;\n}\n\n.down {\n  margin-right: 1em;\n}\n\n.monospace {\n  font-weight: 300;\n  font-family: monospace;\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Scrutiny.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #indicator>\n      <div class=\"notifs\">\n        <strong v-if=\"passed > 0\" class=\"notif passed\" title=\"Passed\">\n          {{ passed }}\n        </strong>\n        <strong v-if=\"failed > 0\" class=\"notif failed\" title=\"Failed\">\n          {{ failed }}\n        </strong>\n        <strong v-if=\"unknown > 0\" class=\"notif unknown\" title=\"Unknown\">\n          {{ unknown }}\n        </strong>\n        <strong\n          v-if=\"serverError\"\n          class=\"notif errors\"\n          title=\"Connection error to Scrutiny API, check your url in config.yml\"\n          >?</strong\n        >\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Scrutiny\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => {\n    return {\n      passed: null,\n      failed: null,\n      unknown: null,\n      serverError: false,\n    };\n  },\n  created: function () {\n    const updateInterval = parseInt(this.item.updateInterval, 10) || 0;\n    if (updateInterval > 0) {\n      setInterval(() => this.fetchSummary(), updateInterval);\n    }\n    this.fetchSummary();\n  },\n  methods: {\n    fetchSummary: function () {\n      this.fetch(`/api/summary`)\n        .then((scrutinyData) => {\n          const devices = Object.values(scrutinyData.data.summary);\n          this.passed =\n            devices.filter((device) => device.device.device_status === 0)\n              ?.length || 0;\n          this.failed =\n            devices.filter(\n              (device) =>\n                device.device.device_status > 0 &&\n                device.device.device_status <= 3,\n            )?.length || 0;\n          this.unknown = devices.length - (this.passed + this.failed) || 0;\n        })\n        .catch((e) => {\n          console.error(e);\n          this.serverError = true;\n        });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.notifs {\n  position: absolute;\n  color: white;\n  font-family: sans-serif;\n  top: 0.3em;\n  right: 0.5em;\n  .notif {\n    display: inline-block;\n    padding: 0.2em 0.35em;\n    border-radius: 0.25em;\n    position: relative;\n    margin-left: 0.3em;\n    font-size: 0.8em;\n    &.passed {\n      background-color: green;\n    }\n\n    &.failed {\n      background-color: #e51111;\n    }\n\n    &.unknown {\n      background-color: #d08d2e;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Sonarr.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #indicator>\n      <div class=\"notifs\">\n        <strong v-if=\"activity > 0\" class=\"notif activity\" title=\"Activity\">\n          {{ activity }}\n        </strong>\n        <strong v-if=\"missing > 0\" class=\"notif missing\" title=\"Missing\">\n          {{ missing }}\n        </strong>\n        <strong v-if=\"warnings > 0\" class=\"notif warnings\" title=\"Warning\">\n          {{ warnings }}\n        </strong>\n        <strong v-if=\"errors > 0\" class=\"notif errors\" title=\"Error\">\n          {{ errors }}\n        </strong>\n        <strong\n          v-if=\"serverError\"\n          class=\"notif errors\"\n          title=\"Connection error to Sonarr API, check url and apikey in config.yml\"\n        >\n          ?\n        </strong>\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nconst V3_API = \"/api/v3\";\nconst LEGACY_API = \"/api\";\n\nexport default {\n  name: \"Sonarr\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => {\n    return {\n      activity: null,\n      missing: null,\n      warnings: null,\n      errors: null,\n      serverError: false,\n    };\n  },\n  computed: {\n    apiPath() {\n      return this.item.legacyApi ? LEGACY_API : V3_API;\n    },\n  },\n  created: function () {\n    const checkInterval = parseInt(this.item.checkInterval, 10) || 0;\n    if (checkInterval > 0) {\n      setInterval(() => this.fetchConfig(), checkInterval);\n    }\n\n    this.fetchConfig();\n  },\n  methods: {\n    fetchConfig: function () {\n      const handleError = (e) => {\n        console.error(e);\n        this.serverError = true;\n      };\n      this.fetch(`${this.apiPath}/health?apikey=${this.item.apikey}`)\n        .then((health) => {\n          this.warnings = health.filter((h) => h.type === \"warning\").length;\n          this.errors = health.filter((h) => h.type === \"errors\").length;\n        })\n        .catch(handleError);\n      this.fetch(`${this.apiPath}/queue?apikey=${this.item.apikey}`)\n        .then((queue) => {\n          this.activity = 0;\n          if (this.item.legacyApi) {\n            for (var i = 0; i < queue.length; i++) {\n              if (queue[i].series) {\n                this.activity++;\n              }\n            }\n          } else {\n            this.activity = queue.totalRecords;\n          }\n        })\n        .catch(handleError);\n      this.fetch(`${this.apiPath}/wanted/missing?apikey=${this.item.apikey}`)\n        .then((missing) => {\n          this.missing = missing.totalRecords;\n        })\n        .catch(handleError);\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.notifs {\n  position: absolute;\n  color: white;\n  font-family: sans-serif;\n  top: 0.3em;\n  right: 0.5em;\n\n  .notif {\n    display: inline-block;\n    padding: 0.2em 0.35em;\n    border-radius: 0.25em;\n    position: relative;\n    margin-left: 0.3em;\n    font-size: 0.8em;\n\n    &.activity {\n      background-color: #4fb5d6;\n    }\n\n    &.missing {\n      background-color: #9d00ff;\n    }\n\n    &.warnings {\n      background-color: #d08d2e;\n    }\n\n    &.errors {\n      background-color: #e51111;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/SpeedtestTracker.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"speedtest\">\n          <i class=\"fas fa-arrow-down\"></i> {{ download }} Mbit/s |\n          <i class=\"fas fa-arrow-up\"></i> {{ upload }} Mbit/s |\n          <i class=\"fas fa-stopwatch\"></i> {{ ping }} ms\n        </template>\n      </p>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"SpeedtestTracker\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    speedtest: null,\n  }),\n  computed: {\n    download: function () {\n      return this.format(this.speedtest?.download);\n    },\n    upload: function () {\n      return this.format(this.speedtest?.upload);\n    },\n    ping: function () {\n      return this.format(this.speedtest?.ping);\n    },\n  },\n  created() {\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      this.fetch(\"/api/speedtest/latest\")\n        .then((response) => {\n          this.speedtest = response.data;\n        })\n        .catch((e) => console.log(e));\n    },\n    format: function (value) {\n      return value ? parseFloat(value).toFixed(2) : \"n/a\";\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/services/Tautulli.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #indicator>\n      <div class=\"notifs\">\n        <strong\n          v-if=\"streams > 0\"\n          class=\"notif playing\"\n          :title=\"`${streams} active stream${streams > 1 ? 's' : ''}`\"\n        >\n          {{ streams }}\n        </strong>\n        <i\n          v-if=\"error\"\n          class=\"notif error fa-solid fa-triangle-exclamation\"\n          title=\"Unable to fetch current status\"\n        ></i>\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Tautulli\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    stats: null,\n    error: false,\n  }),\n  computed: {\n    streams: function () {\n      if (!this.stats) {\n        return \"\";\n      }\n      return this.stats.stream_count;\n    },\n  },\n  created() {\n    const checkInterval = parseInt(this.item.checkInterval, 10) || 0;\n    if (checkInterval > 0) {\n      setInterval(() => this.fetchStatus(), checkInterval);\n    }\n\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      try {\n        const response = await this.fetch(\n          `/api/v2?apikey=${this.item.apikey}&cmd=get_activity`,\n        );\n        this.error = false;\n        this.stats = response.response.data;\n      } catch (e) {\n        this.error = true;\n        console.error(e);\n      }\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.notifs {\n  position: absolute;\n  color: white;\n  font-family: sans-serif;\n  top: 0.3em;\n  right: 0.5em;\n\n  .notif {\n    display: inline-block;\n    padding: 0.2em 0.35em;\n    border-radius: 0.25em;\n    position: relative;\n    margin-left: 0.3em;\n    font-size: 0.8em;\n\n    &.playing {\n      background-color: #28a9a3;\n    }\n\n    &.error {\n      border-radius: 50%;\n      aspect-ratio: 1;\n      background-color: #e51111;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Tdarr.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #indicator>\n      <div class=\"notifs\">\n        <strong\n          v-if=\"queue > 0\"\n          class=\"notif queue\"\n          :title=\"`${queue} items queued`\"\n        >\n          {{ queue }}\n        </strong>\n        <strong\n          v-if=\"errored > 0\"\n          class=\"notif errored\"\n          :title=\"`${errored} items`\"\n        >\n          {{ errored }}\n        </strong>\n        <i\n          v-if=\"error\"\n          class=\"notif error fa-solid fa-triangle-exclamation\"\n          title=\"Unable to fetch current status\"\n        ></i>\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Tdarr\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    stats: null,\n    error: false,\n  }),\n  computed: {\n    queue: function () {\n      if (!this.stats) {\n        return \"\";\n      }\n      return this.stats.table1Count;\n    },\n    errored: function () {\n      if (!this.stats) {\n        return \"\";\n      }\n      return this.stats.table6Count;\n    },\n  },\n  created() {\n    const checkInterval = parseInt(this.item.checkInterval, 10) || 0;\n    if (checkInterval > 0) {\n      setInterval(() => this.fetchStatus(), checkInterval);\n    }\n\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      try {\n        const options = {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n            Accept: \"application/json\",\n          },\n          body: JSON.stringify({\n            headers: { \"content-Type\": \"application/json\" },\n            data: {\n              collection: \"StatisticsJSONDB\",\n              mode: \"getById\",\n              docID: \"statistics\",\n              obj: {},\n            },\n            timeout: 1000,\n          }),\n        };\n        const response = await this.fetch(`/api/v2/cruddb`, options);\n        this.error = false;\n        this.stats = response;\n      } catch (e) {\n        this.error = true;\n        console.error(e);\n      }\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.notifs {\n  position: absolute;\n  color: white;\n  font-family: sans-serif;\n  top: 0.3em;\n  right: 0.5em;\n\n  .notif {\n    display: inline-block;\n    padding: 0.2em 0.35em;\n    border-radius: 0.25em;\n    position: relative;\n    margin-left: 0.3em;\n    font-size: 0.8em;\n\n    &.queue {\n      background-color: #28a9a3;\n    }\n\n    &.errored {\n      background-color: #e51111;\n    }\n\n    &.error {\n      border-radius: 50%;\n      aspect-ratio: 1;\n      background-color: #e51111;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/ThemeChooser.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <div class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <div class=\"select is-small\">\n          <select v-model=\"theme\" @change=\"switchTheme\">\n            <option value=\"\" disabled selected>Available themes</option>\n            <option value=\"theme-classic\">classic</option>\n            <option value=\"theme-neon\">neon</option>\n            <option value=\"theme-walkxcode\">walkxcode</option>\n          </select>\n        </div>\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nlet currentTheme;\nconst app = document.getElementById(\"app\");\n\nexport default {\n  name: \"ThemeChooser\",\n  props: {\n    item: Object,\n  },\n  data: () => {\n    return {\n      theme: null,\n    };\n  },\n  created: function () {\n    currentTheme = Array.from(app.classList).filter((word) =>\n      word.startsWith(\"theme-\"),\n    )[0];\n    this.theme = currentTheme;\n  },\n  methods: {\n    switchTheme: function () {\n      app.classList.replace(currentTheme, this.theme);\n      currentTheme = this.theme;\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.select,\nselect {\n  width: 100%;\n  background-color: var(--card-background);\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Traefik.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <template v-else-if=\"versionstring\">\n          Version {{ versionstring }}\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <div v-if=\"status\" class=\"status\" :class=\"status\">\n        {{ status }}\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Traefik\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    fetchOk: null,\n    versionstring: null,\n  }),\n  computed: {\n    status: function () {\n      return this.fetchOk ? \"online\" : \"offline\";\n    },\n  },\n  created() {\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      let headers = {};\n      if (this.item.basic_auth) {\n        const encodedCredentials = btoa(this.item.basic_auth);\n        headers[\"Authorization\"] = `Basic ${encodedCredentials}`;\n      }\n      this.fetch(\"/api/version\", { headers })\n        .then((response) => {\n          this.fetchOk = true;\n          this.versionstring = response.Version;\n        })\n        .catch((e) => {\n          this.fetchOk = false;\n          console.log(e);\n        });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n  white-space: nowrap;\n  margin-left: 0.25rem;\n\n  &.online:before {\n    background-color: #94e185;\n    border-color: #78d965;\n    box-shadow: 0 0 5px 1px #94e185;\n  }\n\n  &.offline:before {\n    background-color: #c9404d;\n    border-color: #c42c3b;\n    box-shadow: 0 0 5px 1px #c9404d;\n  }\n\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 7px;\n    height: 7px;\n    margin-right: 10px;\n    border: 1px solid #000;\n    border-radius: 7px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Transmission.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p v-if=\"item.subtitle\" class=\"subtitle is-6\">{{ item.subtitle }}</p>\n      <p v-else class=\"subtitle is-6\">\n        <span v-if=\"error\" class=\"error\">An error has occurred.</span>\n        <template v-else>\n          <span class=\"down monospace\">\n            <p class=\"fas fa-download\"></p>\n            {{ downRate }}\n          </span>\n          <span class=\"up monospace\">\n            <p class=\"fas fa-upload\"></p>\n            {{ upRate }}\n          </span>\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <span v-if=\"!error\" class=\"count\"\n        >{{ count || 0 }}\n        <template v-if=\"(count || 0) === 1\">torrent</template>\n        <template v-else>torrents</template>\n      </span>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\nconst units = [\"B\", \"KB\", \"MB\", \"GB\"];\n\n// Take the rate in bytes and keep dividing it by 1k until the lowest\n// value for which we have a unit is determined. Return the value with\n// up to two decimals as a string and unit/s appended.\nconst displayRate = (rate) => {\n  let unitIndex = 0;\n\n  while (rate > 1000 && unitIndex < units.length - 1) {\n    rate /= 1000;\n    unitIndex++;\n  }\n  return (\n    Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(\n      rate || 0,\n    ) + ` ${units[unitIndex]}/s`\n  );\n};\n\nexport default {\n  name: \"Transmission\",\n  mixins: [service],\n  props: { item: Object },\n  data: () => ({\n    dl: null,\n    ul: null,\n    count: null,\n    error: null,\n    sessionId: null,\n    retry: 0,\n  }),\n  computed: {\n    downRate: function () {\n      return displayRate(this.dl);\n    },\n    upRate: function () {\n      return displayRate(this.ul);\n    },\n  },\n  created() {\n    const interval = parseInt(this.item.interval, 10) || 0;\n\n    // Set up interval if configured\n    if (interval > 0) {\n      setInterval(() => this.getStats(), interval);\n    }\n\n    // Initial fetch\n    this.getStats();\n  },\n  methods: {\n    /**\n     * Makes a request to Transmission RPC API with proper session handling\n     * @param {string} method - The RPC method to call\n     * @returns {Promise<Object>} RPC response\n     */\n    transmissionRequest: async function (method) {\n      const options = this.getRequestHeaders(method);\n\n      // Add session ID header if we have one\n      if (this.sessionId) {\n        options.headers[\"X-Transmission-Session-Id\"] = this.sessionId;\n      }\n\n      try {\n        return await this.fetch(\"transmission/rpc\", options);\n      } catch (error) {\n        // Handle Transmission's 409 session requirement\n        if (error.cause.status == 409 && this.retry <= 1) {\n          const sessionId = await this.getSession();\n          if (sessionId) {\n            this.sessionId = sessionId;\n            this.retry++;\n            return this.transmissionRequest(method);\n          }\n        }\n        console.error(\"Transmission RPC error:\", error);\n        throw error;\n      }\n    },\n    getRequestHeaders: function (method) {\n      const options = {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({ method }),\n      };\n\n      if (this.item.auth) {\n        options.headers[\"Authorization\"] = `Basic ${btoa(this.item.auth)}`;\n      }\n\n      return options;\n    },\n    getSession: async function () {\n      try {\n        await this.fetch(\n          \"transmission/rpc\",\n          this.getRequestHeaders(\"session-get\"),\n        );\n      } catch (error) {\n        if (error.cause.status == 409) {\n          return error.cause.headers.get(\"X-Transmission-Session-Id\");\n        }\n      }\n    },\n    getStats: async function () {\n      try {\n        // Get session stats for transfer rates and torrent count\n        const statsResponse = await this.transmissionRequest(\"session-stats\");\n        if (statsResponse?.result !== \"success\") {\n          throw new Error(\n            `Transmission RPC failed: ${statsResponse?.result || \"Unknown error\"}`,\n          );\n        }\n\n        const stats = statsResponse.arguments;\n        this.dl = stats.downloadSpeed ?? 0;\n        this.ul = stats.uploadSpeed ?? 0;\n        this.count = stats.activeTorrentCount ?? 0;\n        this.error = false;\n      } catch (e) {\n        this.error = true;\n        console.error(\"Transmission service error:\", e);\n      }\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.error {\n  color: #e51111 !important;\n}\n\n.down {\n  margin-right: 1em;\n}\n\n.count {\n  color: var(--text);\n  font-size: 0.8em;\n}\n\n.monospace {\n  font-weight: 300;\n  font-family: monospace;\n}\n</style>\n"
  },
  {
    "path": "src/components/services/TruenasScale.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <template v-else-if=\"versionstring\">\n          <span class=\"is-hidden-touch\">Version {{ versionstring }}</span>\n          <span class=\"is-hidden-desktop\"\n            >Version {{ versionstring.split(\"-\").pop() }}</span\n          >\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <div v-if=\"status\" class=\"status\" :class=\"status\">\n        {{ status }}\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"TruenasScale\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    fetchOk: null,\n    versionstring: null,\n  }),\n  computed: {\n    status: function () {\n      return this.fetchOk ? \"online\" : \"offline\";\n    },\n  },\n  created() {\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      let headers = {};\n      if (this.item.api_token) {\n        headers[\"Authorization\"] = `Bearer ${this.item.api_token}`;\n      }\n      this.fetch(\"/api/v2.0/system/version\", { headers })\n        .then((response) => {\n          this.fetchOk = true;\n          this.versionstring = response;\n        })\n        .catch((e) => {\n          this.fetchOk = false;\n          console.log(e);\n        });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n  white-space: nowrap;\n  margin-left: 0.25rem;\n\n  &.online:before {\n    background-color: #94e185;\n    border-color: #78d965;\n    box-shadow: 0 0 5px 1px #94e185;\n  }\n\n  &.offline:before {\n    background-color: #c9404d;\n    border-color: #c42c3b;\n    box-shadow: 0 0 5px 1px #c9404d;\n  }\n\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 7px;\n    height: 7px;\n    margin-right: 10px;\n    border: 1px solid #000;\n    border-radius: 7px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/UptimeKuma.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <template v-else-if=\"status\">\n          {{ statusMessage }}\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <div v-if=\"status\" class=\"status\" :class=\"status\">\n        {{ uptime }}&percnt;\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"UptimeKuma\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    incident: null,\n    heartbeat: null,\n  }),\n  computed: {\n    dashboard: function () {\n      return this.item.slug ? this.item.slug : \"default\";\n    },\n    status: function () {\n      if (!this.incident) {\n        return \"\";\n      }\n      return this.incident.incident == null ? this.pageStatus : \"bad\";\n    },\n    lastHeartBeatList: function () {\n      let result = {};\n\n      for (let id in this.heartbeat.heartbeatList) {\n        let index = this.heartbeat.heartbeatList[id].length - 1;\n        result[id] = this.heartbeat.heartbeatList[id][index];\n      }\n\n      return result;\n    },\n    pageStatus: function () {\n      if (!this.heartbeat) {\n        return \"\";\n      }\n      if (Object.keys(this.heartbeat.heartbeatList).length === 0) {\n        return \"\";\n      }\n      let result = \"good\";\n      let hasUp = false;\n      for (let id in this.lastHeartBeatList) {\n        let beat = this.lastHeartBeatList[id];\n        if (beat.status == 1) {\n          hasUp = true;\n        } else {\n          result = \"warn\";\n        }\n      }\n      if (!hasUp) {\n        result = \"bad\";\n      }\n      return result;\n    },\n    statusMessage: function () {\n      if (!this.incident) {\n        return \"\";\n      }\n      if (this.incident.incident) {\n        return this.incident.incident.title;\n      }\n\n      let message = \"\";\n      switch (this.pageStatus) {\n        case \"good\":\n          message = \"All Systems Operational\";\n          break;\n        case \"warn\":\n          message = \"Partially Degraded Service\";\n          break;\n        case \"bad\":\n          message = \"Degraded Service\";\n          break;\n        default:\n          message = \"Unknown service status\";\n      }\n      return message;\n    },\n    uptime: function () {\n      if (!this.heartbeat) {\n        return 0;\n      }\n      const data = Object.values(this.heartbeat.uptimeList);\n      const percent = data.reduce((a, b) => a + b, 0) / data.length || 0;\n      return (percent * 100).toFixed(1);\n    },\n  },\n  created() {\n    /* eslint-disable */\n    this.item.url = `${this.item.url}/status/${this.dashboard}`;\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: function () {\n      const now = Date.now()\n      this.fetch(`/api/status-page/${this.dashboard}?cachebust=${now}`)\n        .catch((e) => console.error(e))\n        .then((resp) => (this.incident = resp));\n\n      this.fetch(\n        `/api/status-page/heartbeat/${this.dashboard}?cachebust=${now}`\n      )\n        .catch((e) => console.error(e))\n        .then((resp) => (this.heartbeat = resp));\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n\n  &.good:before {\n    background-color: #94e185;\n    border-color: #78d965;\n    box-shadow: 0 0 5px 1px #94e185;\n  }\n\n  &.warn:before {\n    background-color: #f8a306;\n    border-color: #e1b35e;\n    box-shadow: 0 0 5px 1px #f8a306;\n  }\n\n  &.bad:before {\n    background-color: #c9404d;\n    border-color: #c42c3b;\n    box-shadow: 0 0 5px 1px #c9404d;\n  }\n\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 7px;\n    height: 7px;\n    margin-right: 10px;\n    border: 1px solid #000;\n    border-radius: 7px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Vaultwarden.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <template v-else-if=\"versionstring\">\n          Version {{ versionstring }}\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <div v-if=\"status\" class=\"status\" :class=\"status\">\n        {{ status }}\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Vaultwarden\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    fetchOk: null,\n    versionstring: null,\n  }),\n  computed: {\n    status: function () {\n      return this.fetchOk ? \"online\" : \"offline\";\n    },\n  },\n  created() {\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      this.fetch(\"api/version\")\n        .then((response) => {\n          this.fetchOk = true;\n          this.versionstring = response;\n        })\n        .catch((e) => {\n          this.fetchOk = false;\n          console.log(e);\n        });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n  white-space: nowrap;\n  margin-left: 0.25rem;\n\n  &.online:before {\n    background-color: #94e185;\n    border-color: #78d965;\n    box-shadow: 0 0 5px 1px #94e185;\n  }\n\n  &.offline:before {\n    background-color: #c9404d;\n    border-color: #c42c3b;\n    box-shadow: 0 0 5px 1px #c9404d;\n  }\n\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 7px;\n    height: 7px;\n    margin-right: 10px;\n    border: 1px solid #000;\n    border-radius: 7px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/WUD.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #indicator>\n      <div class=\"notifs\">\n        <strong v-if=\"running > 0\" class=\"notif warnings\" title=\"Running\">\n          {{ running }}\n        </strong>\n        <strong v-if=\"update > 0\" class=\"notif errors\" title=\"Update\">\n          {{ update }}\n        </strong>\n        <strong\n          v-if=\"serverError\"\n          class=\"notif errors\"\n          title=\"Connection error to WUD API, check url in config.yml\"\n        >\n          ?\n        </strong>\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"WUD\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => {\n    return {\n      running: null,\n      update: null,\n      serverError: false,\n    };\n  },\n  created: function () {\n    this.fetchConfig();\n  },\n  methods: {\n    fetchConfig: function () {\n      this.fetch(`/api/containers`)\n        .then((containers) => {\n          this.running = 0;\n          this.update = 0;\n          for (var i = 0; i < containers.length; i++) {\n            this.running++;\n            if (containers[i].updateAvailable) {\n              this.update++;\n            }\n          }\n        })\n        .catch(() => {\n          this.serverError = true;\n        });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.notifs {\n  position: absolute;\n  color: white;\n  font-family: sans-serif;\n  top: 0.3em;\n  right: 0.5em;\n\n  .notif {\n    display: inline-block;\n    padding: 0.2em 0.35em;\n    border-radius: 0.25em;\n    position: relative;\n    margin-left: 0.3em;\n    font-size: 0.8em;\n\n    &.warnings {\n      background-color: #d08d2e;\n    }\n\n    &.errors {\n      background-color: #e51111;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/Wallabag.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <template v-if=\"item.subtitle\">\n          {{ item.subtitle }}\n        </template>\n        <template v-else-if=\"versionstring\">\n          Version {{ versionstring }}\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <div v-if=\"status\" class=\"status\" :class=\"status\">\n        {{ status }}\n      </div>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Wallabag\",\n  mixins: [service],\n  props: {\n    item: Object,\n  },\n  data: () => ({\n    status: null,\n    versionstring: null,\n  }),\n  created() {\n    this.fetchStatus();\n  },\n  methods: {\n    fetchStatus: async function () {\n      this.fetch(\"/api/version\")\n        .then((response) => {\n          this.status = \"online\";\n          this.versionstring = response;\n        })\n        .catch((e) => {\n          this.status = \"offline\";\n          console.log(e);\n        });\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.status {\n  font-size: 0.8rem;\n  color: var(--text-title);\n  white-space: nowrap;\n  margin-left: 0.25rem;\n\n  &.online:before {\n    background-color: #94e185;\n    border-color: #78d965;\n    box-shadow: 0 0 5px 1px #94e185;\n  }\n\n  &.offline:before {\n    background-color: #c9404d;\n    border-color: #c42c3b;\n    box-shadow: 0 0 5px 1px #c9404d;\n  }\n\n  &:before {\n    content: \" \";\n    display: inline-block;\n    width: 7px;\n    height: 7px;\n    margin-right: 10px;\n    border: 1px solid #000;\n    border-radius: 7px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/services/_error.vue",
    "content": "<template>\n  <Generic :item=\"item\" :title=\"error.message\" class=\"component-error\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">Failed to load component</p>\n    </template>\n    <template #indicator>⚠️</template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\n\nexport default {\n  name: \"Error\",\n  mixins: [service],\n  props: {\n    item: Object,\n    error: Object,\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/services/qBittorrent.vue",
    "content": "<template>\n  <Generic :item=\"item\">\n    <template #content>\n      <p class=\"title is-4\">{{ item.name }}</p>\n      <p class=\"subtitle is-6\">\n        <span v-if=\"error\" class=\"error\">An error has occurred.</span>\n        <template v-else>\n          <span class=\"down monospace\">\n            <p class=\"fas fa-download\"></p>\n            {{ downRate }}\n          </span>\n          <span class=\"up monospace\">\n            <p class=\"fas fa-upload\"></p>\n            {{ upRate }}\n          </span>\n        </template>\n      </p>\n    </template>\n    <template #indicator>\n      <span v-if=\"!error\" class=\"count\"\n        >{{ count }}\n        <template v-if=\"count === 1\">torrent</template>\n        <template v-else>torrents</template>\n      </span>\n    </template>\n  </Generic>\n</template>\n\n<script>\nimport service from \"@/mixins/service.js\";\nconst units = [\"B\", \"KB\", \"MB\", \"GB\"];\n\n// Take the rate in bytes and keep dividing it by 1k until the lowest\n// value for which we have a unit is determined. Return the value with\n// up to two decimals as a string and unit/s appended.\nconst displayRate = (rate) => {\n  let i = 0;\n\n  while (rate > 1000 && i < units.length) {\n    rate /= 1000;\n    i++;\n  }\n  return (\n    Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(\n      rate || 0,\n    ) + ` ${units[i]}/s`\n  );\n};\n\nexport default {\n  name: \"QBittorrent\",\n  mixins: [service],\n  props: { item: Object },\n  data: () => ({ dl: null, ul: null, count: null, error: null }),\n  computed: {\n    downRate: function () {\n      return displayRate(this.dl);\n    },\n    upRate: function () {\n      return displayRate(this.ul);\n    },\n  },\n  created() {\n    const rateInterval = parseInt(this.item.rateInterval, 10) || 0;\n    const torrentInterval = parseInt(this.item.torrentInterval, 10) || 0;\n    if (rateInterval > 0) {\n      setInterval(() => this.getRate(), rateInterval);\n    }\n    if (torrentInterval > 0) {\n      setInterval(() => this.fetchCount(), torrentInterval);\n    }\n\n    this.getRate();\n    this.fetchCount();\n  },\n  methods: {\n    fetchCount: async function () {\n      try {\n        const body = await this.fetch(\"/api/v2/torrents/info\");\n        this.error = false;\n        this.count = body.length;\n      } catch (e) {\n        this.error = true;\n        console.error(e);\n      }\n    },\n    getRate: async function () {\n      try {\n        const body = await this.fetch(\"/api/v2/transfer/info\");\n        this.error = false;\n        this.dl = body.dl_info_speed;\n        this.ul = body.up_info_speed;\n      } catch (e) {\n        this.error = true;\n        console.error(e);\n      }\n    },\n  },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.error {\n  color: #e51111 !important;\n}\n\n.down {\n  margin-right: 1em;\n}\n\n.count {\n  color: var(--text);\n  font-size: 0.8em;\n}\n\n.monospace {\n  font-weight: 300;\n  font-family: monospace;\n}\n</style>\n"
  },
  {
    "path": "src/main.js",
    "content": "import \"./assets/app.scss\";\nimport { createApp, h } from \"vue\";\nimport App from \"./App.vue\";\n\nconst app = createApp(App);\nimport Generic from \"./components/services/Generic.vue\";\n\napp\n  .component(\"Generic\", Generic)\n  .component(\"DynamicStyle\", (_props, context) => {\n    return h(\"style\", {}, context.slots);\n  });\n\napp.mount(\"#app-mount\");\n"
  },
  {
    "path": "src/mixins/service.js",
    "content": "export default {\n  props: {\n    proxy: Object,\n  },\n  created: function () {\n    // custom service often consume info from an API using the item link (url) as a base url,\n    // but sometimes the base url is different. An optional alternative URL can be provided with the \"endpoint\" key.\n    this.endpoint = this.item.endpoint || this.item.url;\n\n    if (this.endpoint && this.endpoint.endsWith(\"/\")) {\n      this.endpoint = this.endpoint.slice(0, -1);\n    }\n  },\n  methods: {\n    fetch: function (path, init, json = true) {\n      let options = {};\n\n      if (this.proxy?.useCredentials) {\n        options.credentials = \"include\";\n      }\n\n      if (this.proxy?.headers && !!this.proxy.headers) {\n        options.headers = this.proxy.headers;\n      }\n\n      // Each item can override the credential settings\n      if (this.item.useCredentials !== undefined) {\n        options.credentials =\n          this.item.useCredentials === true ? \"include\" : \"omit\";\n      }\n\n      // Each item can have their own headers\n      if (this.item.headers !== undefined && !!this.item.headers) {\n        options.headers = this.item.headers;\n      }\n\n      options = Object.assign(options, init);\n\n      if (path.startsWith(\"/\")) {\n        path = path.slice(1);\n      }\n\n      let url = this.endpoint;\n\n      if (path) {\n        url = `${this.endpoint}/${path}`;\n      }\n\n      return fetch(url, options).then((response) => {\n        let success = response.ok;\n        if (Array.isArray(this.item.successCodes)) {\n          success = this.item.successCodes.includes(response.status);\n        }\n\n        if (!success) {\n          throw new Error(\n            `Fail to fetch ressource: (${response.status} error)`,\n            { cause: response },\n          );\n        }\n\n        return json ? response.json() : response.text();\n      });\n    },\n  },\n};\n"
  },
  {
    "path": "vite.config.js",
    "content": "import { VitePWA } from \"vite-plugin-pwa\";\nimport { fileURLToPath, URL } from \"url\";\nimport fs from \"fs\";\nimport path from \"path\";\nimport process from \"process\";\n\nimport { defineConfig } from \"vite\";\nimport vue from \"@vitejs/plugin-vue\";\n\nimport { version } from \"./package.json\";\n\nfunction writeVersionPlugin() {\n  return {\n    name: \"write-version\",\n    closeBundle() {\n      fs.writeFileSync(\"dist/VERSION\", version);\n    },\n  };\n}\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n  base: \"\",\n  build: {\n    assetsDir: \"resources\",\n  },\n  define: {\n    __APP_VERSION__: JSON.stringify(version),\n  },\n  plugins: [\n    writeVersionPlugin(),\n    // Custom plugin to serve dummy-data JSON files without sourcemap injection\n    {\n      name: \"dummy-data-json-handler\",\n      configureServer(server) {\n        server.middlewares.use((req, res, next) => {\n          if (req.url?.startsWith(\"/dummy-data/\")) {\n            // Remove query parameters from URL to get the actual file path\n            const urlWithoutQuery = req.url.split(\"?\")[0];\n            const filePath = path.join(process.cwd(), urlWithoutQuery);\n\n            if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {\n              res.end(fs.readFileSync(filePath, \"utf8\"));\n              return;\n            }\n          }\n          next();\n        });\n      },\n    },\n    vue(),\n    VitePWA({\n      registerType: \"autoUpdate\",\n      useCredentials: true,\n      manifestFilename: \"assets/manifest.json\",\n      manifest: {\n        name: \"Homer dashboard\",\n        short_name: \"Homer\",\n        description: \"Home Server Dashboard\",\n        theme_color: \"#3367D6\",\n        start_url: \"../\",\n        scope: \"../\",\n        icons: [\n          {\n            src: \"./icons/pwa-192x192.png\",\n            sizes: \"192x192\",\n            type: \"image/png\",\n          },\n          {\n            src: \"./icons/pwa-512x512.png\",\n            sizes: \"512x512\",\n            type: \"image/png\",\n          },\n        ],\n      },\n      workbox: {\n        navigateFallback: null,\n      },\n    }),\n  ],\n  resolve: {\n    alias: {\n      \"~\": fileURLToPath(new URL(\"./node_modules\", import.meta.url)),\n      \"@\": fileURLToPath(new URL(\"./src\", import.meta.url)),\n    },\n  },\n  css: {\n    preprocessorOptions: {\n      scss: {\n        api: \"modern-compiler\",\n      },\n    },\n  },\n});\n"
  }
]