Compare commits

..

57 Commits

Author SHA1 Message Date
shamoon
b48d283dc2 Retry Omada login on HTML response; preserve cookies 2026-03-04 11:34:44 -08:00
shamoon
d313e0a124 Add some debug logging for omada 2026-03-02 09:22:39 -08:00
shamoon
51d718a21a Fix: small fixes for Omada proxy (#6372) 2026-02-28 11:36:43 -08:00
shamoon
29e2502d74 Fix: Await async proxy handlers (#6371) 2026-02-28 11:22:54 -08:00
shamoon
d529f81cb4 Enhancement: fallback for missing si network stats (#6367) 2026-02-27 10:39:02 -08:00
Erkan
1645c1b8a1 Documentation: clarify array value quoting in Docker label mapping syntax (#6356)
Co-authored-by: shamoon <shamoon@users.noreply.github.com>
2026-02-23 22:16:22 +00:00
shamoon
614a87d768 DRY 2026-02-20 20:25:34 -08:00
shamoon
862c5d9f38 Add missing tracearr to docs 2026-02-20 20:21:27 -08:00
shamoon
d3374dc461 Feature: sparkyfitness service widget (#6346) 2026-02-20 20:20:59 -08:00
shamoon
795e2505ca Fix links in CONTRIBUTING.md for development guidelines 2026-02-20 09:54:23 -08:00
shamoon
cb8421df0b feature request template search reminder 2026-02-16 14:38:34 -08:00
shamoon
152888d611 Enhancement: cover more basic statuses in containers list (#6334) 2026-02-16 08:43:06 -08:00
shamoon
ea527e4fb1 Enhancement: add "Temperature" label to list of possible CPU sensors (#6331) 2026-02-16 00:09:14 -08:00
shamoon
09bab7637e Chore: merge Overseerr into Seerr, add aliases (#6330) 2026-02-15 19:12:15 -08:00
shamoon
597f6ecf16 Enhancement: jellyseer completed (#6329) 2026-02-15 18:44:03 -08:00
shamoon
fe0b214334 Chore: rename Jellyseerr widget to Seerr and update references (#6322) 2026-02-14 16:11:00 -08:00
shamoon
cdc96438cd Adjust process list vertical offset 2026-02-14 07:14:14 -08:00
shamoon
ca7dfb56c8 Improvement: better handle highlighting with units (#6318) 2026-02-13 10:00:46 -08:00
shamoon
95852d23c2 Lower Codecov coverage thresholds 2026-02-09 19:36:33 -08:00
Bothari
84231a1754 Feature: add Tracearr widget for displaying active Plex streams (#6306)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-02-10 03:35:54 +00:00
Matt Popovich
f4f54cea60 Documentation: clarify jellyfin api key location, add ports (#6298)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-02-07 09:38:17 +00:00
shamoon
06595ef107 Revise PR template checklist for clarity and links 2026-02-06 07:53:26 -08:00
dependabot[bot]
91b9aa479a Chore(deps): Bump actions/setup-node from 4 to 6 (#6285)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 13:48:18 -08:00
dependabot[bot]
08cde2f597 Chore(deps): Bump actions/checkout from 4 to 6 (#6284)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 13:40:57 -08:00
shamoon
0ce175cda5 Bump version to 1.10.1 2026-02-05 07:02:59 -08:00
shamoon
7f1de58e71 Fix: safely stringify replacement values 2026-02-05 07:02:07 -08:00
shamoon
f729290e96 Enhancement: better display of Arcane widget errors (#6281) 2026-02-05 00:33:38 -08:00
shamoon
4974cd96b6 Chore: move to Zensical docs (#6279) 2026-02-04 23:05:11 -08:00
shamoon
4450a6e1d0 Merge branch 'main' into dev 2026-02-04 22:12:16 -08:00
shamoon
ac11efc5c7 Fix eslint warnings in test 2026-02-04 22:06:33 -08:00
shamoon
3c005d239e Add Codecov badge to README 2026-02-04 22:03:08 -08:00
shamoon
c4e77d4b1d Bump version to 1.10.0 2026-02-04 21:57:02 -08:00
shamoon
9d415ac45d Merge branch 'dev' 2026-02-04 21:56:48 -08:00
github-actions[bot]
8b9720ca93 New Crowdin translations by GitHub Action (#6220) 2026-02-04 21:56:35 -08:00
shamoon
ad4ac465ae Merge branch 'dev' 2026-02-04 21:50:34 -08:00
shamoon
872a3600aa Chore: homepage tests (#6278) 2026-02-04 19:58:39 -08:00
Kyle Mendell
7d019185a3 Feature: arcane service widget (#6274)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-02-05 02:07:07 +00:00
Aleksei Sviridkin
99f1540d8c Enhancement: DNS fallback for Alpine/musl compatibility (#6265)
Signed-off-by: Aleksei Sviridkin <f@lex.la>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-02-02 21:16:46 -08:00
shamoon
97e909ebf4 Chore: move to eslint (#6270) 2026-02-02 15:18:30 -08:00
shamoon
4d4fab391c Documentation: clarify URL port for netalertx widget version 2 2026-02-02 00:57:16 -08:00
dependabot[bot]
1233b5e803 Chore(deps): Bump i18next from 25.5.3 to 25.8.0 (#6263)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-01 21:32:49 +00:00
dependabot[bot]
7e3fa97679 Chore(deps-dev): Bump tailwindcss from 4.0.9 to 4.1.18 (#6262)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-01 21:23:48 +00:00
dependabot[bot]
64c81615ec Chore(deps-dev): Bump next-js and eslint-config-next from 15.2.4 to 15.5.11 (#6261)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-02-01 13:02:36 -08:00
dependabot[bot]
5c15466ac4 Chore(deps): Bump winston from 3.17.0 to 3.19.0 (#6264)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-01 20:46:59 +00:00
dependabot[bot]
9cdb70527b Chore(deps): Bump swr from 2.3.3 to 2.4.0 (#6260)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-01 20:36:13 +00:00
Zhelyan Radoev
062b1bcfbb Fix: fix authentik widget login counts for v2 api (#6257) 2026-02-01 12:26:51 -08:00
shamoon
4ebc24a1b4 Enhancement: support jellyfin 10.12 breaking API changes (#6252) 2026-01-30 22:05:19 -08:00
muertocaloh
79b63e4099 Feature: Dispatcharr widget (#6035)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-01-29 19:16:07 +00:00
Kristiyan Nikolov
c86a007ed0 Enhancement: Add support for PWA icons and shortcuts (#6235)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-01-28 18:36:17 +00:00
shamoon
ca9506e485 Fix vikunja map function 2026-01-27 07:18:24 -08:00
shamoon
1aec61811f Enhancement: handle Vikunja v1rc4 breaking changes (#6234) 2026-01-26 10:27:36 -08:00
shamoon
6c945d6573 Feature: dockhand service widget (#6229) 2026-01-23 13:20:56 -08:00
shamoon
09893343a9 Documentation: use blurred image for bkgd instead of filter 2026-01-20 16:49:38 -08:00
shamoon
6b6090e303 Documentation: use blurred image for bkgd instead of filter 2026-01-20 16:43:01 -08:00
shamoon
d3f1832f70 Merge branch 'main' into dev 2026-01-19 09:29:16 -08:00
shamoon
f524531a13 Fix truenas proxy widget logging 2026-01-19 07:49:46 -08:00
shamoon
d6dde5fc41 Documentaiton: clarify backend port usage in NetAlertX widget docs 2026-01-19 07:46:11 -08:00
687 changed files with 36667 additions and 542 deletions

21
.codecov.yml Normal file
View File

@@ -0,0 +1,21 @@
codecov:
require_ci_to_pass: true
coverage:
precision: 2
round: down
range: "0...100"
status:
project:
default:
target: 100%
threshold: 15%
patch:
default:
target: 100%
threshold: 10%
comment:
layout: "reach,diff,flags,files"
behavior: default
require_changes: false

View File

@@ -1,42 +0,0 @@
{
"extends": [
"next/core-web-vitals",
"prettier",
"plugin:react-hooks/recommended"
],
"plugins": ["prettier"],
"rules": {
"import/no-cycle": [
"error",
{
"maxDepth": 1
}
],
"import/order": [
"error",
{
"newlines-between": "always"
}
],
"no-else-return": [
"error",
{
"allowElseIf": true
}
]
},
"settings": {
"import/resolver": {
"node": {
"paths": ["src"]
}
}
},
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module",
"ecmaFeatures": {
"modules": true
}
}
}

View File

@@ -1,6 +1,10 @@
title: "[Feature Request] "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
#### ⚠️ Don't forget to search [existing issues](https://github.com/gethomepage/homepage/search?q=&type=issues) and [discussions](https://github.com/gethomepage/homepage/search?q=&type=discussions) (including closed ones!).
- type: textarea
id: description
attributes:

View File

@@ -35,7 +35,8 @@ What type of change does your PR introduce to Homepage?
## Checklist:
- [ ] If applicable, I have added corresponding documentation changes.
- [ ] If applicable, I have reviewed the [feature / enhancement](https://gethomepage.dev/more/development/#new-feature-guidelines) and / or [service widget guidelines](https://gethomepage.dev/more/development/#service-widget-guidelines).
- [ ] I have checked that all code style checks pass using [pre-commit hooks](https://gethomepage.dev/more/development/#code-formatting-with-pre-commit-hooks) and [linting checks](https://gethomepage.dev/more/development/#code-linting).
- [ ] If applicable, I have added or updated tests for new features and bug fixes (see [testing](https://gethomepage.dev/widgets/authoring/getting-started/#testing)).
- [ ] If applicable, I have reviewed the [feature / enhancement](https://gethomepage.dev/widgets/authoring/getting-started/#new-feature-guidelines) and / or [service widget guidelines](https://gethomepage.dev/widgets/authoring/getting-started/#service-widget-guidelines).
- [ ] I have checked that all code style checks pass using [pre-commit hooks](https://gethomepage.dev/widgets/authoring/getting-started/#code-formatting-with-pre-commit-hooks) and [linting checks](https://gethomepage.dev/widgets/authoring/getting-started/#code-linting).
- [ ] If applicable, I have tested my code for new features & regressions on both mobile & desktop devices, using the latest version of major browsers.
- [ ] In the description above I have disclosed the use of AI tools in the coding of this PR.

View File

@@ -9,7 +9,9 @@ on:
workflow_dispatch:
permissions:
contents: write
contents: read
pages: write
id-token: write
jobs:
pre-commit:
@@ -35,44 +37,34 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: 3.x
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v5
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
restore-keys: |
mkdocs-material-
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@v7
- run: sudo apt-get install pngquant
- run: pip install mkdocs-material mkdocs-redirects "mkdocs-material[imaging]"
- name: Test Docs Build
run: MKINSIDERS=false mkdocs build
run: uv run --frozen zensical build --clean
deploy:
name: Build & Deploy Docs
if: github.repository == 'gethomepage/homepage' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
needs:
- pre-commit
steps:
- uses: actions/configure-pages@v5
- uses: actions/checkout@v6
- name: Configure Git Credentials
run: |
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
- uses: actions/setup-python@v6
with:
python-version: 3.x
- run: echo "cache_id=${{github.sha}}" >> $GITHUB_ENV
- uses: actions/cache@v5
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
restore-keys: |
mkdocs-material-
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@v7
- run: sudo apt-get install pngquant
- run: pip install git+https://${GH_TOKEN}@github.com/benphelps/mkdocs-material-insiders.git
- run: pip install mkdocs-redirects "mkdocs-material[imaging]"
- name: Docs Deploy
run: MKINSIDERS=true mkdocs gh-deploy --force
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
- name: Build Docs
run: uv run --frozen zensical build --clean
- uses: actions/upload-pages-artifact@v4
with:
path: site
- uses: actions/deploy-pages@v4
id: deployment

37
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Tests
on:
pull_request:
push:
workflow_dispatch:
jobs:
vitest:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v6
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
# Run Vitest directly so `--shard` is parsed as an option
- run: pnpm -s exec vitest run --coverage --shard ${{ matrix.shard }}/4 --pool forks
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info
flags: vitest,shard-${{ matrix.shard }}
name: vitest-shard-${{ matrix.shard }}
fail_ci_if_error: true

2
.gitignore vendored
View File

@@ -46,7 +46,7 @@ next-env.d.ts
# IDEs
/.idea/
# MkDocs documentation
# Zensical documentation
site*/
.cache/

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

View File

@@ -38,11 +38,11 @@ People _love_ thorough bug reports. I'm not even kidding.
## Development Guidelines
Please see the [documentation regarding development](https://gethomepage.dev/more/development/) and specifically the [guidelines for new service widgets](https://gethomepage.dev/more/development/#service-widget-guidelines) if you are considering making one.
Please see the [documentation regarding development](https://gethomepage.dev/widgets/authoring/getting-started/#development) and specifically the [guidelines for new service widgets](https://gethomepage.dev/widgets/authoring/getting-started/#service-widget-guidelines) if you are considering making one.
## Use a Consistent Coding Style
Please see information in the docs regarding [code formatting with pre-commit hooks](https://gethomepage.dev/more/development/#code-formatting-with-pre-commit-hooks).
Please see information in the docs regarding [code formatting with pre-commit hooks](https://gethomepage.dev/widgets/authoring/getting-started/#code-formatting-with-pre-commit-hooks).
## License

View File

@@ -16,6 +16,8 @@
<p align="center">
<a href="https://github.com/gethomepage/homepage/actions/workflows/docker-publish.yml"><img alt="GitHub Workflow Status (with event)" src="https://img.shields.io/github/actions/workflow/status/gethomepage/homepage/docker-publish.yml"></a>
&nbsp;
<a href="https://codecov.io/gh/gethomepage/homepage"><img src="https://codecov.io/gh/gethomepage/homepage/graph/badge.svg?token=7SKFL4D9K7"/></a>
&nbsp;
<a href="https://crowdin.com/project/gethomepage" target="_blank"><img src="https://badges.crowdin.net/gethomepage/localized.svg"></a>
&nbsp;
<a href="https://discord.gg/k4ruYNrudu"><img alt="Discord" src="https://img.shields.io/discord/1019316731635834932"></a>
@@ -154,16 +156,16 @@ This is a [Next.js](https://nextjs.org/) application, see their documentation fo
The homepage documentation is available at [https://gethomepage.dev/](https://gethomepage.dev/).
Homepage uses Material for MkDocs for documentation. To run the documentation locally, first install the dependencies:
Homepage uses Zensical for documentation. To run the documentation locally, first install the dependencies:
```bash
pip install -r requirements.txt
uv sync
```
Then run the development server:
```bash
mkdocs serve # or build, to build the static site
uv run zensical serve # or build, to build the static site
```
# Support & Suggestions

View File

@@ -177,6 +177,16 @@ labels:
- homepage.widget.fields=["field1","field2"] # optional
```
!!! note
If you use mapping syntax (`:`) for labels instead of list syntax (`-`), array values like `fields` must be wrapped in single quotes so they are passed as a string:
```yaml
labels:
...
homepage.widget.fields: '["field1","field2"]'
```
Multiple widgets can be specified by incrementing the index, e.g.
```yaml

View File

@@ -123,6 +123,58 @@ blockHighlights:
Any unspecified level falls back to the built-in defaults.
## Progressive Web App (PWA)
A progressive web app is an app that can be installed on a device and provide user experience like a native app. Homepage comes with built-in support for PWA with some default configurations, but you can customize them.
More information on PWAs can be found in [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps).
## App icons
You can set custom icons for installable apps. More information about how you can set them can be found in the [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/icons).
The default value is the Homepage icon in sizes 192x192 and 512x512.
```yaml
pwa:
icons:
- src: https://developer.mozilla.org/favicon-192x192.png
type: image/png
sizes: 192x192
- src: https://developer.mozilla.org/favicon-512x512.png
type: image/png
sizes: 512x512
```
For icon `src` you can pass either full URL or a local path relative to the `/app/public` directory. See [Background Image](#background-image) for more detailed information on how to provide your own files.
### Shortcuts
Shortcuts can e used to specify links to tabs, to be preselected when the homepage is opened as an app.
More information about how you can set them can be found in the [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/shortcuts).
```yaml
pwa:
shortcuts:
- name: First
url: "/#first" # opens the first tab
- name: Second
url: "/#second" # opens the second tab
- name: Third
url: "/#third" # opens the third tab
```
### Other PWA configurations
Homepage sets few other PWA configurations, that are based on global settings in `settings.yaml`:
- `name`, `short_name` - Both equal to the [`title`](#title) setting.
- `theme_color`, `background_color` - Both based on the [`color`](#color-palette) and [`theme`](#theme) settings.
- `display` - It is always set to "standalone".
- `start_url` - Equal to the [`startUrl`](#start-url) setting.
More information for wach of the PWA configurations can be found in the [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference).
## Layout
You can configure service and bookmarks sections to be either "column" or "row" based layouts, like so:

View File

@@ -104,7 +104,7 @@
body {
background-color: transparent !important;
background-image: url("https://raw.githubusercontent.com/gethomepage/homepage/main/docs/assets/blossom_valley.jpg");
background-image: url("https://raw.githubusercontent.com/gethomepage/homepage/main/docs/assets/blossom_valley_blur.jpg");
background-size: cover;
background-attachment: fixed;
background-position: center;
@@ -119,20 +119,6 @@ body[data-md-color-scheme="default"] {
color: rgba(255, 255, 255, 1);
}
.blur-overlay {
z-index: -1;
position: fixed;
width: 100%;
height: 100%;
background: hsl(0deg 0% 0% / 10%);
backdrop-filter: blur(128px);
-webkit-backdrop-filter: blur(128px);
}
[data-md-color-scheme="default"] .blur-overlay {
background: hsla(0, 0%, 0%, 0);
}
.md-nav--lifted > .md-nav__list > .md-nav__item--active > .md-nav__link,
.md-nav--secondary .md-nav__title {
background: none;

View File

@@ -33,6 +33,32 @@ Once dependencies have been installed you can lint your code with
pnpm lint
```
## Testing
Homepage uses [Vitest](https://vitest.dev/) for unit and component tests.
Run the test suite:
```bash
pnpm test
```
Run the test suite with coverage:
```bash
pnpm test:coverage
```
### What tests to include
- New or updated widgets should generally include a component test near the widget component (for example `src/widgets/<widget>/component.test.jsx`) that covers realistic behavior: loading/placeholder state, error state, and a representative "happy path" render.
- If you add or change a widget definition file (`src/widgets/<widget>/widget.js`), add/update its corresponding unit test (`src/widgets/<widget>/widget.test.js`) to cover the config/mapping behavior.
- If your widget requires a custom proxy (`src/widgets/<widget>/proxy.js`), add a proxy unit test (`src/widgets/<widget>/proxy.test.js`) that validates:
- request construction (URL, query params, headers/auth)
- response mapping (what the widget consumes)
- error pathways (upstream error, unexpected payloads)
- Avoid placing test files under `src/pages/**` (Next.js treats files there as routes). Page tests should live under `src/__tests__/pages/**`.
## Code formatting with pre-commit hooks
To ensure a consistent style and formatting across the project source, the project utilizes Git [`pre-commit`](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) hooks to perform some formatting and linting before a commit is allowed.

View File

@@ -201,3 +201,18 @@ export default async function customProxyHandler(req, res, map) {
```
Proxy handlers are a complex topic and require a good understanding of JavaScript and the Homepage codebase. If you are new to Homepage, we recommend using the built-in proxy handlers.
## Testing proxy handlers
Proxy handlers are a common source of regressions because they deal with authentication, request formatting, and sometimes odd upstream API behavior.
When you add a new proxy handler or custom widget proxy, include tests that focus on behavior:
- **Request construction:** the correct URL/path, query params, headers, and auth (and that secrets are not accidentally logged).
- **Response mapping:** the payload shape expected by the widget/component (including optional/missing fields).
- **Error handling:** upstream non-200s, invalid JSON, timeouts, and unexpected payloads should produce a predictable result.
Test locations:
- Shared handlers live in `src/utils/proxy/handlers/*.js` with tests alongside them (for example `src/utils/proxy/handlers/generic.test.js`).
- Widget-specific proxies live in `src/widgets/<widget>/proxy.js` with tests in `src/widgets/<widget>/proxy.test.js`.

View File

@@ -7,13 +7,17 @@ You can include all or some of the available resources. If you do not want to se
The disk path is the path reported by `df` (Mounted On), or the mount point of the disk.
!!! note
Any disk you wish to access must be mounted to your container as a volume.
The cpu and memory resource information are the container's usage while [glances](glances.md) displays statistics for the host machine on which it is installed.
The resources widget primarily relies on a popular tool called [systeminformation](https://systeminformation.io). Thus, any limitiations of that software apply, for example, BRTFS RAID is not supported for the disk usage. In this case users may want to use the [glances widget](glances.md) instead.
_Note: unfortunately, the package used for getting CPU temp ([systeminformation](https://systeminformation.io)) is not compatible with some setups and will not report any value(s) for CPU temp._
!!! warning
**Any disk you wish to access must be mounted to your container as a volume.**
The package used for getting CPU temp ([systeminformation](https://systeminformation.io)) is not compatible with some setups and will not report any value(s) for CPU temp.
```yaml
- resources:
@@ -75,3 +79,10 @@ You can additionally supply an optional `expanded` property set to true in order
```
![194136533-c4238c82-4d67-41a4-b3c8-18bf26d33ac2](https://user-images.githubusercontent.com/3441425/194728642-a9885274-922b-4027-acf5-a746f58fdfce.png)
To monitor a named host network interface in Docker (for example `network: eno1`), mount host `/sys` (read-only):
```yaml
volumes:
- /sys:/sys:ro
```

View File

@@ -0,0 +1,18 @@
---
title: Arcane
description: Arcane Widget Configuration
---
Learn more about [Arcane](https://github.com/getarcaneapp/arcane).
**Allowed fields** (max 4): `running`, `stopped`, `total`, `images`, `images_used`, `images_unused`, `image_updates`.
**Default fields**: `running`, `stopped`, `total`, `image_updates`.
```yaml
widget:
type: arcane
url: http://localhost:3552
env: 0 # required, 0 is Arcane default local environment
key: your-api-key
fields: ["running", "stopped", "total", "image_updates"] # optional
```

View File

@@ -0,0 +1,17 @@
---
title: Dispatcharr
description: Dispatcharr Widget Configuration
---
Learn more about [Dispatcharr](https://github.com/Dispatcharr/Dispatcharr).
Allowed fields: `["channels", "streams"]`.
```yaml
widget:
type: dispatcharr
url: http://dispatcharr.host.or.ip
username: username
password: password
enableActiveStreams: true # optional, defaults to false
```

View File

@@ -0,0 +1,20 @@
---
title: Dockhand
description: Dockhand Widget Configuration
---
Learn more about [Dockhand](https://dockhand.pro/).
Note: The widget currently supports Dockhand's **local** authentication only.
**Allowed fields:** (max 4): `running`, `stopped`, `paused`, `total`, `cpu`, `memory`, `images`, `volumes`, `events_today`, `pending_updates`, `stacks`.
**Default fields:** `running`, `total`, `cpu`, `memory`.
```yaml
widget:
type: dockhand
url: http://localhost:3001
environment: local # optional: name or id; aggregates all when omitted
username: your-user # required for local auth
password: your-pass # required for local auth
```

View File

@@ -9,6 +9,7 @@ You can also find a list of all available service widgets in the sidebar navigat
- [Adguard Home](adguard-home.md)
- [APC UPS](apcups.md)
- [Arcane](arcane.md)
- [ArgoCD](argocd.md)
- [Atsumeru](atsumeru.md)
- [Audiobookshelf](audiobookshelf.md)
@@ -32,6 +33,8 @@ You can also find a list of all available service widgets in the sidebar navigat
- [Deluge](deluge.md)
- [DeveLanCacheUI](develancacheui.md)
- [DiskStation](diskstation.md)
- [Dispatcharr](dispatcharr.md)
- [Dockhand](dockhand.md)
- [DownloadStation](downloadstation.md)
- [Emby](emby.md)
- [ESPHome](esphome.md)
@@ -64,7 +67,7 @@ You can also find a list of all available service widgets in the sidebar navigat
- [Jackett](jackett.md)
- [JDownloader](jdownloader.md)
- [Jellyfin](jellyfin.md)
- [Jellyseerr](jellyseerr.md)
- [Seerr](seerr.md)
- [Jellystat](jellystat.md)
- [Kavita](kavita.md)
- [Komga](komga.md)
@@ -98,7 +101,6 @@ You can also find a list of all available service widgets in the sidebar navigat
- [OpenMediaVault](openmediavault.md)
- [OpenWRT](openwrt.md)
- [OPNsense](opnsense.md)
- [Overseerr](overseerr.md)
- [PaperlessNGX](paperlessngx.md)
- [Peanut](peanut.md)
- [pfSense](pfsense.md)

View File

@@ -5,15 +5,21 @@ description: Jellyfin Widget Configuration
Learn more about [Jellyfin](https://github.com/jellyfin/jellyfin).
You can create an API key from inside Jellyfin at `Settings > Advanced > Api Keys`.
You can create an API key from inside the Jellyfin Administration Dashboard under `Advanced > API Keys`.
As of v0.6.11 the widget supports fields `["movies", "series", "episodes", "songs"]`. These blocks are disabled by default but can be enabled with the `enableBlocks` option, and the "Now Playing" feature (enabled by default) can be disabled with the `enableNowPlaying` option.
| Jellyfin Version | Homepage Widget Version |
| ---------------- | ----------------------- |
| < 10.12 | 1 (default) |
| >= 10.12 | 2 |
```yaml
widget:
type: jellyfin
url: http://jellyfin.host.or.ip
url: http://jellyfin.host.or.ip:port
key: apikeyapikeyapikeyapikeyapikey
version: 2 # optional, default is 1
enableBlocks: true # optional, defaults to false
enableNowPlaying: true # optional, defaults to true
enableUser: true # optional, defaults to false

View File

@@ -1,18 +0,0 @@
---
title: Jellyseerr
description: Jellyseerr Widget Configuration
---
Learn more about [Jellyseerr](https://github.com/Fallenbagel/jellyseerr).
Find your API key under `Settings > General > API Key`.
Allowed fields: `["pending", "approved", "available", "issues"]`.
Default fields: `["pending", "approved", "available"]`.
```yaml
widget:
type: jellyseerr
url: http://jellyseerr.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
```

View File

@@ -19,7 +19,7 @@ Provide the `API_TOKEN` (f.k.a. `SYNC_api_token`) as the `key` in your config.
```yaml
widget:
type: netalertx
url: http://ip:port
url: http://ip:port # use backend port for widget version 2+
key: yournetalertxapitoken
version: 2 # optional, default is 1
```

View File

@@ -1,17 +0,0 @@
---
title: Overseerr
description: Overseerr Widget Configuration
---
Learn more about [Overseerr](https://github.com/sct/overseerr).
Find your API key under `Settings > General`.
Allowed fields: `["pending", "approved", "available", "processing"]`.
```yaml
widget:
type: overseerr
url: http://overseerr.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
```

View File

@@ -12,7 +12,7 @@ Allowed fields: no configurable fields for this widget.
```yaml
widget:
type: tautulli
url: http://tautulli.host.or.ip
url: http://tautulli.host.or.ip:port
key: apikeyapikeyapikeyapikeyapikey
enableUser: true # optional, defaults to false
showEpisodeNumber: true # optional, defaults to false

View File

@@ -0,0 +1,20 @@
---
title: Seerr Widget
description: Seerr Widget Configuration
---
Learn more about [Seerr](https://github.com/seerr-team/seerr).
Find your API key under `Settings > General > API Key`.
_Jellyseerr and Overseerr merged into Seerr. Use `type: seerr` (legacy `type: jellyseerr` and `type: overseerr` are aliased)._
Allowed fields: `["pending", "approved", "available", "completed", "processing", "issues"]`.
Default fields: `["pending", "approved", "completed"]`.
```yaml
widget:
type: seerr
url: http://seerr.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
```

View File

@@ -0,0 +1,15 @@
---
title: SparkyFitness
description: SparkyFitness Widget Configuration
---
Learn more about [SparkyFitness](https://github.com/CodeWithCJ/SparkyFitness).
Allowed fields: `["eaten", "burned", "remaining", "steps"]`.
```yaml
widget:
type: sparkyfitness
url: http://sparkyfitness.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
```

View File

@@ -0,0 +1,21 @@
---
title: Tracearr
description: Tracearr Widget Configuration
---
Learn more about [Tracearr](https://www.tracearr.com/).
Provides detailed information about currently active streams across multiple servers.
Allowed fields (for summary view): `["streams", "transcodes", "directplay", "bitrate"]`.
```yaml
widget:
type: tracearr
url: http://tracearr.host.or.ip:3000
key: apikeyapikeyapikeyapikeyapikey
view: both # optional, "summary", "details", or "both", defaults to "details"
enableUser: true # optional, defaults to false
showEpisodeNumber: true # optional, defaults to false
expandOneStreamToTwoRows: false # optional, defaults to true
```

View File

@@ -9,10 +9,16 @@ Allowed fields: `["projects", "tasks7d", "tasksOverdue", "tasksInProgress"]`.
A list of the next 5 tasks ordered by due date is disabled by default, but can be enabled with the `enableTaskList` option.
| Vikunja Version | Homepage Widget Version |
| --------------- | ----------------------- |
| < v1.0.0-rc4 | 1 (default) |
| >= v1.0.0-rc4 | 2 |
```yaml
widget:
type: vikunja
url: http[s]://vikunja.host.or.ip[:port]
key: vikunjaapikey
enableTaskList: true # optional, defaults to false
version: 2 # optional, defaults to 1
```

78
eslint.config.mjs Normal file
View File

@@ -0,0 +1,78 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { fixupConfigRules } from "@eslint/compat";
import { FlatCompat } from "@eslint/eslintrc";
import js from "@eslint/js";
import prettier from "eslint-plugin-prettier";
import { defineConfig, globalIgnores } from "eslint/config";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default defineConfig([
{
extends: fixupConfigRules(compat.extends("next/core-web-vitals", "prettier", "plugin:react-hooks/recommended")),
plugins: {
prettier,
},
languageOptions: {
ecmaVersion: 6,
sourceType: "module",
parserOptions: {
ecmaFeatures: {
modules: true,
},
},
},
settings: {
"import/resolver": {
node: {
paths: ["src"],
},
},
},
rules: {
"import/no-cycle": [
"error",
{
maxDepth: 1,
},
],
"import/order": [
"error",
{
"newlines-between": "always",
},
],
"no-else-return": [
"error",
{
allowElseIf: true,
},
],
},
},
// Vitest tests often intentionally place imports after `vi.mock(...)` to ensure
// modules under test see the mocked dependencies. `import/order` can't safely
// auto-fix those cases, so disable it for test files.
{
files: ["src/**/*.test.{js,jsx}", "src/**/*.spec.{js,jsx}"],
rules: {
"import/order": "off",
},
},
globalIgnores(["./config/", "./coverage/", "./.venv/", "./.next/", "./site/"]),
]);

View File

@@ -33,6 +33,7 @@ nav:
- widgets/services/index.md
- widgets/services/adguard-home.md
- widgets/services/apcups.md
- widgets/services/arcane.md
- widgets/services/argocd.md
- widgets/services/atsumeru.md
- widgets/services/audiobookshelf.md
@@ -56,6 +57,8 @@ nav:
- widgets/services/deluge.md
- widgets/services/develancacheui.md
- widgets/services/diskstation.md
- widgets/services/dispatcharr.md
- widgets/services/dockhand.md
- widgets/services/downloadstation.md
- widgets/services/emby.md
- widgets/services/esphome.md
@@ -88,7 +91,6 @@ nav:
- widgets/services/jackett.md
- widgets/services/jdownloader.md
- widgets/services/jellyfin.md
- widgets/services/jellyseerr.md
- widgets/services/jellystat.md
- widgets/services/kavita.md
- widgets/services/komga.md
@@ -122,7 +124,6 @@ nav:
- widgets/services/openmediavault.md
- widgets/services/opnsense.md
- widgets/services/openwrt.md
- widgets/services/overseerr.md
- widgets/services/pangolin.md
- widgets/services/paperlessngx.md
- widgets/services/peanut.md
@@ -148,8 +149,10 @@ nav:
- widgets/services/rutorrent.md
- widgets/services/sabnzbd.md
- widgets/services/scrutiny.md
- widgets/services/seerr.md
- widgets/services/slskd.md
- widgets/services/sonarr.md
- widgets/services/sparkyfitness.md
- widgets/services/speedtest-tracker.md
- widgets/services/spoolman.md
- widgets/services/stash.md
@@ -162,6 +165,7 @@ nav:
- widgets/services/technitium.md
- widgets/services/tdarr.md
- widgets/services/traefik.md
- widgets/services/tracearr.md
- widgets/services/transmission.md
- widgets/services/trilium.md
- widgets/services/truenas.md

View File

@@ -1,6 +1,5 @@
// prettyBytes taken from https://github.com/sindresorhus/pretty-bytes
/* eslint-disable no-param-reassign */
const BYTE_UNITS = ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const BIBYTE_UNITS = ["B", "kiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"];
@@ -37,7 +36,6 @@ function prettyBytes(number, options) {
...options,
};
// eslint-disable-next-line no-nested-ternary
const UNITS = options.bits ? (options.binary ? BIBIT_UNITS : BIT_UNITS) : options.binary ? BIBYTE_UNITS : BYTE_UNITS;
if (options.signed && number === 0) {
@@ -45,7 +43,7 @@ function prettyBytes(number, options) {
}
const isNegative = number < 0;
// eslint-disable-next-line no-nested-ternary
const prefix = isNegative ? "-" : options.signed ? "+" : "";
if (isNegative) {

View File

@@ -1,13 +1,16 @@
{
"name": "homepage",
"version": "1.9.0",
"version": "1.10.1",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint .",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest",
"telemetry": "next telemetry disable"
},
"dependencies": {
@@ -18,14 +21,14 @@
"dockerode": "^4.0.7",
"follow-redirects": "^1.15.11",
"gamedig": "^5.3.2",
"i18next": "^25.5.3",
"i18next": "^25.8.0",
"ical.js": "^2.1.0",
"js-yaml": "^4.1.1",
"json-rpc-2.0": "^1.7.0",
"luxon": "^3.6.1",
"memory-cache": "^0.2.0",
"minecraftstatuspinger": "^1.2.2",
"next": "^15.5.9",
"next": "^15.5.11",
"next-i18next": "^12.1.0",
"ping": "^0.4.4",
"pretty-bytes": "^7.1.0",
@@ -35,31 +38,39 @@
"react-i18next": "^15.5.3",
"react-icons": "^5.5.0",
"recharts": "^3.1.2",
"swr": "^2.3.3",
"swr": "^2.4.0",
"systeminformation": "^5.27.11",
"tough-cookie": "^6.0.0",
"urbackup-server-api": "^0.91.0",
"winston": "^3.17.0",
"winston": "^3.19.0",
"ws": "^8.18.3",
"xml-js": "^1.6.11"
},
"devDependencies": {
"@eslint/compat": "^2.0.2",
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.18",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.25.1",
"eslint-config-next": "^15.2.4",
"eslint-config-next": "^15.5.11",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.2.0",
"jsdom": "^26.1.0",
"postcss": "^8.5.6",
"prettier": "^3.7.3",
"prettier-plugin-organize-imports": "^4.3.0",
"tailwind-scrollbar": "^4.0.2",
"tailwindcss": "^4.0.9",
"typescript": "^5.7.3"
"tailwindcss": "^4.1.18",
"typescript": "^5.7.3",
"vitest": "^3.2.4"
},
"optionalDependencies": {
"osx-temperature-sensor": "^1.0.8"

1763
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -107,6 +107,16 @@
"episodes": "Episodes",
"songs": "Liedjies"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Vanlyn af",
"offline_alt": "Vanlyn af",
@@ -705,6 +715,10 @@
"uptime": "Optyd",
"volumeAvailable": "Beskikbaar"
},
"dispatcharr": {
"channels": "Kanale",
"streams": "Uitsendings"
},
"mylar": {
"series": "Reekse",
"issues": "Kwessies",
@@ -794,10 +808,10 @@
"series": "Reekse"
},
"booklore": {
"libraries": "Libraries",
"books": "Books",
"reading": "Reading",
"finished": "Finished"
"libraries": "Biblioteke",
"books": "Boeke",
"reading": "Lees",
"finished": "Klaar"
},
"jdownloader": {
"downloadCount": "Tou",
@@ -1136,5 +1150,26 @@
"songs": "Liedjies",
"time": "Tyd",
"artists": "Kunstenaars"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Lopend",
"stopped": "Gestop",
"cpu": "SVE",
"memory": "Geheue",
"images": "Beelde",
"volumes": "Volumes",
"events_today": "Vandag se byeenkomste",
"pending_updates": "Hangende opdaterings",
"stacks": "Stapels",
"paused": "Onderbreek",
"total": "Totaal",
"environment_not_found": "Omgewing Nie Gevind Nie"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "حلقات",
"songs": "أغاني"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "المُشكِلات",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Епизоди",
"songs": "Песни"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Издания",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Episodis",
"songs": "Cançons"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Problemes",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Epizody",
"songs": "Skladby"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Problémy",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Episoder",
"songs": "Sange"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Problemer",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Episoden",
"songs": "Songs"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transkodierung",
"bitrate": "Bitrate",
"no_active": "Keine aktiven Streams",
"movies": "Filme",
"series": "Serien",
"episodes": "Episoden",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -604,7 +614,7 @@
"orgs": "Orgs",
"sites": "Sites",
"resources": "Ressourcen",
"targets": "Targets",
"targets": "Ziele",
"traffic": "Traffic",
"in": "In",
"out": "Out"
@@ -705,6 +715,10 @@
"uptime": "Betriebszeit",
"volumeAvailable": "Verfügbar"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Serien",
"issues": "Probleme",
@@ -795,7 +809,7 @@
},
"booklore": {
"libraries": "Libraries",
"books": "Books",
"books": "Bücher",
"reading": "Reading",
"finished": "Finished"
},
@@ -1136,5 +1150,26 @@
"songs": "Titel",
"time": "Zeit",
"artists": "Künstler"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Heutige Ereignisse",
"pending_updates": "Ausstehende Updates",
"stacks": "Stacks",
"paused": "Pausiert",
"total": "Total",
"environment_not_found": "Umgebung nicht gefunden"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Επεισόδια",
"songs": "Τραγούδια"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Issues",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Episodes",
"songs": "Songs"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -174,6 +184,13 @@
"no_active": "No Active Streams",
"plex_connection_error": "Check Plex Connection"
},
"tracearr": {
"no_active": "No Active Streams",
"streams": "Streams",
"transcodes": "Transcodes",
"directplay": "Direct Play",
"bitrate": "Bitrate"
},
"omada": {
"connectedAp": "Connected APs",
"activeUser": "Active devices",
@@ -272,17 +289,13 @@
"approved": "Approved",
"available": "Available"
},
"jellyseerr": {
"seerr": {
"pending": "Pending",
"approved": "Approved",
"available": "Available",
"issues": "Open Issues"
},
"overseerr": {
"pending": "Pending",
"completed": "Completed",
"processing": "Processing",
"approved": "Approved",
"available": "Available"
"issues": "Open Issues"
},
"netalertx": {
"total": "Total",
@@ -705,6 +718,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Issues",
@@ -1136,5 +1153,32 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
},
"sparkyfitness": {
"eaten": "Eaten",
"burned": "Burned",
"remaining": "Remaining",
"steps": "Steps"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Epizodoj",
"songs": "Kantoj"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Issues",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Episodios",
"songs": "Canciones"
},
"jellyfin": {
"playing": "Reproduciendo",
"transcoding": "Convirtiendo",
"bitrate": "Tasa de Bits",
"no_active": "No hay Streams activos",
"movies": "Películas",
"series": "Series",
"episodes": "Episodios",
"songs": "Canciones"
},
"esphome": {
"offline": "Fuera de línea",
"offline_alt": "Fuera de línea",
@@ -705,6 +715,10 @@
"uptime": "Tiempo activo",
"volumeAvailable": "Disponible"
},
"dispatcharr": {
"channels": "Canales",
"streams": "Transmisiones"
},
"mylar": {
"series": "Series",
"issues": "Números",
@@ -794,10 +808,10 @@
"series": "Series"
},
"booklore": {
"libraries": "Libraries",
"books": "Books",
"reading": "Reading",
"finished": "Finished"
"libraries": "Librerías",
"books": "Libros",
"reading": "Lectura",
"finished": "Finalizado"
},
"jdownloader": {
"downloadCount": "En cola",
@@ -1136,5 +1150,26 @@
"songs": "Canciones",
"time": "Tiempo",
"artists": "Artistas"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Activo",
"stopped": "Detenido",
"cpu": "CPU",
"memory": "Memoria",
"images": "Imágenes",
"volumes": "Volumen",
"events_today": "Eventos de hoy",
"pending_updates": "Actualizaciones pendientes",
"stacks": "Entornos",
"paused": "En Pausa",
"total": "Total",
"environment_not_found": "Entorno no encontrado"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Episodes",
"songs": "Abestiak"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Arazoak",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Episodes",
"songs": "Songs"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Issues",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Épisodes",
"songs": "Morceaux"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Hors ligne",
"offline_alt": "Hors ligne",
@@ -705,6 +715,10 @@
"uptime": "Disponibilité",
"volumeAvailable": "Disponible"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Séries",
"issues": "Anomalies",
@@ -983,11 +997,11 @@
},
"zabbix": {
"unclassified": "Non classé",
"information": "Informations",
"warning": "Attention",
"average": "Moyenne",
"high": "Élevé",
"disaster": ""
"information": "Information",
"warning": "Avertissement",
"average": "Moyen",
"high": "Haut",
"disaster": "Désastre"
},
"lubelogger": {
"vehicle": "Véhicule",
@@ -1136,5 +1150,26 @@
"songs": "Musiques",
"time": "Durée",
"artists": "Artistes"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "פרקים",
"songs": "שירים"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "מכובה",
"offline_alt": "מכובה",
@@ -705,6 +715,10 @@
"uptime": "זמן פעילות",
"volumeAvailable": "זמין"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "סדרות",
"issues": "גיליונות",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Episodes",
"songs": "Songs"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Issues",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Epizode",
"songs": "Pjesme"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Vrijeme rada",
"volumeAvailable": "Dostupno"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Serije",
"issues": "Problemi",
@@ -1136,5 +1150,26 @@
"songs": "Pjesme",
"time": "Vrijeme",
"artists": "Izvođači"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Epizód",
"songs": "Zeneszám"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Nem elérhető",
"offline_alt": "Nem elérhető",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Problémák",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Episode",
"songs": "Lagu"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Isu",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Episodi",
"songs": "Canzoni"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Problemi",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "エピソード",
"songs": "曲"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "",
"offline_alt": "オフライン",
@@ -705,6 +715,10 @@
"uptime": "稼働時間",
"volumeAvailable": "利用可能"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "課題",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "에피소드",
"songs": "음악"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "오프라인",
"offline_alt": "오프라인",
@@ -705,6 +715,10 @@
"uptime": "가동 시간",
"volumeAvailable": "사용 가능"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "시리즈",
"issues": "이슈",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Episodes",
"songs": "Songs"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Issues",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Episod",
"songs": "Lagu"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Issues",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Afleveringen",
"songs": "Nummers"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Beschikbaar"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Problemen",
@@ -1136,5 +1150,26 @@
"songs": "Nummers",
"time": "Tijd",
"artists": "Artiesten"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Episoder",
"songs": "Sanger"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Issues",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Odcinki",
"songs": "Piosenki"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Czas działania",
"volumeAvailable": "Dostępne"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Seriale",
"issues": "Zgłoszenia",
@@ -794,10 +808,10 @@
"series": "Serie"
},
"booklore": {
"libraries": "Libraries",
"books": "Books",
"reading": "Reading",
"finished": "Finished"
"libraries": "Biblioteki",
"books": "Książki",
"reading": "Czytane",
"finished": "Skończone"
},
"jdownloader": {
"downloadCount": "W kolejce",
@@ -1136,5 +1150,26 @@
"songs": "Piosenki",
"time": "Czas",
"artists": "Wykonawcy"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Działające",
"stopped": "Zatrzymane",
"cpu": "CPU",
"memory": "Pamięć",
"images": "Obrazy",
"volumes": "Woluminy",
"events_today": "Zdarzenia dzisiaj",
"pending_updates": "Oczekujące aktualizacje",
"stacks": "Stosy",
"paused": "Wstrzymane",
"total": "Razem",
"environment_not_found": "Środowisko nie znalezione"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Episódios",
"songs": "Canções"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Problemas",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Episódios",
"songs": "Canções"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Tempo ativo",
"volumeAvailable": "Disponível"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Séries",
"issues": "Problemas",
@@ -1136,5 +1150,26 @@
"songs": "Músicas",
"time": "Tempo",
"artists": "Artistas"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Episoade",
"songs": "Melodii"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Issues",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Эпизоды",
"songs": "Песни"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Не в сети",
"offline_alt": "Не в сети",
@@ -705,6 +715,10 @@
"uptime": "Время работы",
"volumeAvailable": "Доступно"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Серии",
"issues": "Вопросы",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Epizódy",
"songs": "Skladby"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Dostupnosť",
"volumeAvailable": "Dostupné"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Problémy",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Epizode",
"songs": "Pesmi"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Težave",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Епизоде",
"songs": "Песме"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Није на мрежи",
"offline_alt": "Није на мрежи",
@@ -705,6 +715,10 @@
"uptime": "Време рада",
"volumeAvailable": "Доступно"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Серије",
"issues": "Издања",
@@ -1136,5 +1150,26 @@
"songs": "Песме",
"time": "Време",
"artists": "Извођачи"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Avsnitt",
"songs": "Songs"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Issues",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Episodes",
"songs": "Songs"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Issues",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Episodes",
"songs": "Songs"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Issues",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Bölümler",
"songs": "Şarkılar"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Çevrimdışı",
"offline_alt": "Çevrimdışı",
@@ -705,6 +715,10 @@
"uptime": "Çalışma süresi",
"volumeAvailable": "Uygun"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Diziler",
"issues": "Sorunlar",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Епізоди",
"songs": "Пісні"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Офлайн",
"offline_alt": "Офлайн",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Питання",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "Episodes",
"songs": "Bài hát"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "Issues",
@@ -1136,5 +1150,26 @@
"songs": "Bài hát",
"time": "Thời gian",
"artists": "Nghệ sĩ"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "集",
"songs": "曲目"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "出版",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "剧集",
"songs": "歌曲"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "离线",
"offline_alt": "离线",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "问题",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

View File

@@ -107,6 +107,16 @@
"episodes": "集",
"songs": "曲目"
},
"jellyfin": {
"playing": "Playing",
"transcoding": "Transcoding",
"bitrate": "Bitrate",
"no_active": "No Active Streams",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"songs": "Songs"
},
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
@@ -705,6 +715,10 @@
"uptime": "Uptime",
"volumeAvailable": "Available"
},
"dispatcharr": {
"channels": "Channels",
"streams": "Streams"
},
"mylar": {
"series": "Series",
"issues": "問題",
@@ -1136,5 +1150,26 @@
"songs": "Songs",
"time": "Time",
"artists": "Artists"
},
"arcane": {
"containers": "Containers",
"images": "Images",
"image_updates": "Image Updates",
"images_unused": "Unused",
"environment_required": "Environment ID Required"
},
"dockhand": {
"running": "Running",
"stopped": "Stopped",
"cpu": "CPU",
"memory": "Memory",
"images": "Images",
"volumes": "Volumes",
"events_today": "Events Today",
"pending_updates": "Pending Updates",
"stacks": "Stacks",
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
}
}

8
pyproject.toml Normal file
View File

@@ -0,0 +1,8 @@
[project]
name = "homepage-docs"
version = "1.0.0"
description = "Documentation for the Homepage project"
requires-python = ">=3.13"
dependencies = [
"zensical>=0.0.21",
]

View File

@@ -1,47 +0,0 @@
Babel==2.12.1
backrefs==5.9
cairocffi==1.7.1
CairoSVG==2.7.1
certifi==2023.7.22
cffi==1.17.1
cfgv==3.4.0
charset-normalizer==3.2.0
click==8.1.7
colorama==0.4.6
cssselect2==0.7.0
defusedxml==0.7.1
distlib==0.3.9
filelock==3.17.0
ghp-import==2.1.0
identify==2.6.7
idna==3.4
Jinja2==3.1.2
Markdown==3.4.4
MarkupSafe==2.1.3
mergedeep==1.3.4
mkdocs==1.6.0
mkdocs-get-deps==0.2.0
mkdocs-material==9.6.18
mkdocs-material-extensions==1.3.1
mkdocs-redirects==1.2.1
nodeenv==1.9.1
packaging==23.1
paginate==0.5.6
pathspec==0.11.2
pillow==10.4.0
platformdirs==3.10.0
pre-commit==3.5.0
pycparser==2.22
Pygments==2.16.1
pymdown-extensions==10.3
python-dateutil==2.8.2
PyYAML==6.0.1
pyyaml_env_tag==0.1
regex==2023.8.8
requests==2.31.0
six==1.16.0
tinycss2==1.4.0
urllib3==2.0.5
virtualenv==20.29.2
watchdog==3.0.0
webencodings==0.5.1

View File

@@ -0,0 +1,37 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
// Next's Head implementation relies on internal Next contexts; stub it for unit tests.
vi.mock("next/head", () => ({
default: ({ children }) => <>{children}</>,
}));
vi.mock("utils/contexts/color", () => ({
ColorProvider: ({ children }) => <>{children}</>,
}));
vi.mock("utils/contexts/theme", () => ({
ThemeProvider: ({ children }) => <>{children}</>,
}));
vi.mock("utils/contexts/settings", () => ({
SettingsProvider: ({ children }) => <>{children}</>,
}));
vi.mock("utils/contexts/tab", () => ({
TabProvider: ({ children }) => <>{children}</>,
}));
import App from "pages/_app.jsx";
describe("pages/_app", () => {
it("renders the active page component with pageProps", () => {
function Page({ message }) {
return <div>msg:{message}</div>;
}
render(<App Component={Page} pageProps={{ message: "hello" }} />);
expect(screen.getByText("msg:hello")).toBeInTheDocument();
expect(document.querySelector('meta[name="viewport"]')).toBeTruthy();
});
});

View File

@@ -0,0 +1,24 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
vi.mock("next/document", () => ({
Html: ({ children }) => <div data-testid="html">{children}</div>,
Head: ({ children }) => <div data-testid="head">{children}</div>,
Main: () => <main data-testid="main" />,
NextScript: () => <script data-testid="nextscript" />,
}));
import Document from "pages/_document.jsx";
describe("pages/_document", () => {
it("renders the PWA meta + custom css links", () => {
const html = renderToStaticMarkup(<Document />);
expect(html).toContain('meta name="mobile-web-app-capable" content="yes"');
expect(html).toContain('link rel="manifest" href="/site.webmanifest?v=4"');
expect(html).toContain('link rel="preload" href="/api/config/custom.css" as="style"');
expect(html).toContain('link rel="stylesheet" href="/api/config/custom.css"');
expect(html).toContain('data-testid="main"');
expect(html).toContain('data-testid="nextscript"');
});
});

View File

@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { bookmarksResponse } = vi.hoisted(() => ({
bookmarksResponse: vi.fn(),
}));
vi.mock("utils/config/api-response", () => ({
bookmarksResponse,
}));
import handler from "pages/api/bookmarks";
describe("pages/api/bookmarks", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns bookmarksResponse()", async () => {
bookmarksResponse.mockResolvedValueOnce({ ok: true });
const req = { query: {} };
const res = createMockRes();
await handler(req, res);
expect(res.body).toEqual({ ok: true });
});
});

View File

@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { fs, config, logger } = vi.hoisted(() => ({
fs: {
existsSync: vi.fn(),
readFileSync: vi.fn(),
},
config: {
CONF_DIR: "/conf",
},
logger: {
error: vi.fn(),
},
}));
vi.mock("fs", () => ({
default: fs,
...fs,
}));
vi.mock("utils/config/config", () => config);
vi.mock("utils/logger", () => ({
default: () => logger,
}));
import handler from "pages/api/config/[path]";
describe("pages/api/config/[path]", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 422 for unsupported files", async () => {
const req = { query: { path: "not-supported.txt" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(422);
});
it("returns empty content when the file doesn't exist", async () => {
fs.existsSync.mockReturnValueOnce(false);
const req = { query: { path: "custom.css" } };
const res = createMockRes();
await handler(req, res);
expect(res.headers["Content-Type"]).toBe("text/css");
expect(res.statusCode).toBe(200);
expect(res.body).toBe("");
});
it("returns file content when the file exists", async () => {
fs.existsSync.mockReturnValueOnce(true);
fs.readFileSync.mockReturnValueOnce("body{}");
const req = { query: { path: "custom.js" } };
const res = createMockRes();
await handler(req, res);
expect(res.headers["Content-Type"]).toBe("text/javascript");
expect(res.statusCode).toBe(200);
expect(res.body).toBe("body{}");
});
it("logs and returns 500 when reading the file throws", async () => {
fs.existsSync.mockReturnValueOnce(true);
fs.readFileSync.mockImplementationOnce(() => {
throw new Error("boom");
});
const req = { query: { path: "custom.css" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toBe("Internal Server Error");
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,153 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { state, DockerCtor, getDockerArguments, logger } = vi.hoisted(() => {
const state = {
docker: null,
dockerArgs: { conn: { socketPath: "/var/run/docker.sock" }, swarm: false },
};
function DockerCtor() {
return state.docker;
}
return {
state,
DockerCtor,
getDockerArguments: vi.fn(() => state.dockerArgs),
logger: { error: vi.fn() },
};
});
vi.mock("dockerode", () => ({
default: DockerCtor,
}));
vi.mock("utils/config/docker", () => ({
default: getDockerArguments,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
import handler from "pages/api/docker/stats/[...service]";
describe("pages/api/docker/stats/[...service]", () => {
beforeEach(() => {
vi.clearAllMocks();
state.dockerArgs = { conn: { socketPath: "/var/run/docker.sock" }, swarm: false };
state.docker = {
listContainers: vi.fn(),
getContainer: vi.fn(),
listTasks: vi.fn(),
};
});
it("returns 400 when container name/server params are missing", async () => {
const req = { query: { service: [] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "docker query parameters are required" });
});
it("returns 500 when docker returns a non-array containers payload", async () => {
state.docker.listContainers.mockResolvedValue(Buffer.from("bad"));
const req = { query: { service: ["c", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "query failed" });
});
it("returns stats for an existing container", async () => {
state.docker.listContainers.mockResolvedValue([{ Names: ["/myapp"], Id: "cid1" }]);
const containerStats = { cpu_stats: { cpu_usage: { total_usage: 1 } } };
state.docker.getContainer.mockReturnValue({
stats: vi.fn().mockResolvedValue(containerStats),
});
const req = { query: { service: ["myapp", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ stats: containerStats });
});
it("uses swarm tasks to locate a container and reports a friendly error when stats cannot be retrieved", async () => {
state.dockerArgs.swarm = true;
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "local1" }]);
state.docker.listTasks.mockResolvedValue([
{ Status: { ContainerStatus: { ContainerID: "local1" } } },
{ Status: { ContainerStatus: { ContainerID: "remote1" } } },
]);
state.docker.getContainer.mockReturnValue({
stats: vi.fn().mockRejectedValue(new Error("nope")),
});
const req = { query: { service: ["svc", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ error: "Unable to retrieve stats" });
});
it("returns stats for a swarm task container when present locally", async () => {
state.dockerArgs.swarm = true;
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "local1" }]);
state.docker.listTasks.mockResolvedValue([{ Status: { ContainerStatus: { ContainerID: "local1" } } }]);
const containerStats = { cpu_stats: { cpu_usage: { total_usage: 2 } } };
state.docker.getContainer.mockReturnValue({
stats: vi.fn().mockResolvedValue(containerStats),
});
const req = { query: { service: ["svc", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ stats: containerStats });
});
it("returns 404 when no container or swarm task is found", async () => {
state.dockerArgs.swarm = true;
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "local1" }]);
state.docker.listTasks.mockResolvedValue([]);
const req = { query: { service: ["missing", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: "not found" });
});
it("logs and returns 500 when the docker query throws", async () => {
getDockerArguments.mockImplementationOnce(() => {
throw new Error("boom");
});
const req = { query: { service: ["myapp", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: { message: "boom" } });
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,211 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { state, DockerCtor, getDockerArguments, logger } = vi.hoisted(() => {
const state = {
docker: null,
dockerCtorArgs: [],
dockerArgs: { conn: { socketPath: "/var/run/docker.sock" }, swarm: false },
};
function DockerCtor(conn) {
state.dockerCtorArgs.push(conn);
return state.docker;
}
return {
state,
DockerCtor,
getDockerArguments: vi.fn(() => state.dockerArgs),
logger: { error: vi.fn() },
};
});
vi.mock("dockerode", () => ({
default: DockerCtor,
}));
vi.mock("utils/config/docker", () => ({
default: getDockerArguments,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
import handler from "pages/api/docker/status/[...service]";
describe("pages/api/docker/status/[...service]", () => {
beforeEach(() => {
vi.clearAllMocks();
state.dockerCtorArgs.length = 0;
state.dockerArgs = { conn: { socketPath: "/var/run/docker.sock" }, swarm: false };
state.docker = {
listContainers: vi.fn(),
getContainer: vi.fn(),
getService: vi.fn(),
listTasks: vi.fn(),
};
});
it("returns 400 when container name/server params are missing", async () => {
const req = { query: { service: [] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "docker query parameters are required" });
});
it("returns 500 when docker returns a non-array containers payload", async () => {
state.docker.listContainers.mockResolvedValue(Buffer.from("bad"));
const req = { query: { service: ["c", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "query failed" });
});
it("inspects an existing container and returns status + health", async () => {
state.docker.listContainers.mockResolvedValue([{ Names: ["/myapp"], Id: "cid1" }]);
state.docker.getContainer.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ State: { Status: "running", Health: { Status: "healthy" } } }),
});
const req = { query: { service: ["myapp", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(getDockerArguments).toHaveBeenCalledWith("local");
expect(state.dockerCtorArgs).toHaveLength(1);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ status: "running", health: "healthy" });
});
it("returns 404 when container does not exist and swarm is disabled", async () => {
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "cid1" }]);
const req = { query: { service: ["missing", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ status: "not found" });
});
it("reports replicated swarm service status based on desired replicas", async () => {
state.dockerArgs.swarm = true;
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "cid1" }]);
state.docker.getService.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ Spec: { Mode: { Replicated: { Replicas: "2" } } } }),
});
state.docker.listTasks.mockResolvedValue([{ Status: {} }, { Status: {} }]);
const req = { query: { service: ["svc", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ status: "running 2/2" });
});
it("reports partial status for replicated services with fewer running tasks", async () => {
state.dockerArgs.swarm = true;
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "cid1" }]);
state.docker.getService.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ Spec: { Mode: { Replicated: { Replicas: "3" } } } }),
});
state.docker.listTasks.mockResolvedValue([{ Status: {} }]);
const req = { query: { service: ["svc", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ status: "partial 1/3" });
});
it("handles global services by inspecting a local task container when possible", async () => {
state.dockerArgs.swarm = true;
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "local1" }]);
state.docker.getService.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ Spec: { Mode: { Global: {} } } }),
});
state.docker.listTasks.mockResolvedValue([
{ Status: { ContainerStatus: { ContainerID: "local1" }, State: "running" } },
]);
state.docker.getContainer.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ State: { Status: "running", Health: { Status: "unhealthy" } } }),
});
const req = { query: { service: ["svc", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ status: "running", health: "unhealthy" });
});
it("falls back to task status when global service container inspect fails", async () => {
state.dockerArgs.swarm = true;
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "local1" }]);
state.docker.getService.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ Spec: { Mode: { Global: {} } } }),
});
state.docker.listTasks.mockResolvedValue([
{ Status: { ContainerStatus: { ContainerID: "local1" }, State: "pending" } },
]);
state.docker.getContainer.mockReturnValue({
inspect: vi.fn().mockRejectedValue(new Error("nope")),
});
const req = { query: { service: ["svc", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ status: "pending" });
});
it("returns 404 when swarm is enabled but the service does not exist", async () => {
state.dockerArgs.swarm = true;
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "cid1" }]);
state.docker.getService.mockReturnValue({
inspect: vi.fn().mockRejectedValue(new Error("not found")),
});
const req = { query: { service: ["svc", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ status: "not found" });
});
it("logs and returns 500 when the docker query throws", async () => {
getDockerArguments.mockImplementationOnce(() => {
throw new Error("boom");
});
const req = { query: { service: ["svc", "local"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: { message: "boom" } });
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,64 @@
import { createHash } from "crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
function sha256(input) {
return createHash("sha256").update(input).digest("hex");
}
const { readFileSync, checkAndCopyConfig, CONF_DIR } = vi.hoisted(() => ({
readFileSync: vi.fn(),
checkAndCopyConfig: vi.fn(),
CONF_DIR: "/conf",
}));
vi.mock("fs", () => ({
readFileSync,
}));
vi.mock("utils/config/config", () => ({
default: checkAndCopyConfig,
CONF_DIR,
}));
import handler from "pages/api/hash";
describe("pages/api/hash", () => {
const originalBuildTime = process.env.HOMEPAGE_BUILDTIME;
beforeEach(() => {
vi.clearAllMocks();
process.env.HOMEPAGE_BUILDTIME = originalBuildTime;
});
it("returns a combined sha256 hash of known config files and build time", async () => {
process.env.HOMEPAGE_BUILDTIME = "build-1";
// Return deterministic contents based on file name.
readFileSync.mockImplementation((filePath) => {
const name = filePath.split("/").pop();
return `content:${name}`;
});
const req = { query: {} };
const res = createMockRes();
await handler(req, res);
const configs = [
"docker.yaml",
"settings.yaml",
"services.yaml",
"bookmarks.yaml",
"widgets.yaml",
"custom.css",
"custom.js",
];
const hashes = configs.map((c) => sha256(`content:${c}`));
const expected = sha256(hashes.join("") + "build-1");
expect(checkAndCopyConfig).toHaveBeenCalled();
expect(res.body).toEqual({ hash: expected });
});
});

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import createMockRes from "test-utils/create-mock-res";
import handler from "pages/api/healthcheck";
describe("pages/api/healthcheck", () => {
it("returns 'up'", () => {
const req = {};
const res = createMockRes();
handler(req, res);
expect(res.body).toBe("up");
});
});

View File

@@ -0,0 +1,210 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { getKubeConfig, coreApi, metricsApi, MetricsCtor, logger } = vi.hoisted(() => {
const metricsApi = {
getPodMetrics: vi.fn(),
};
function MetricsCtor() {
return metricsApi;
}
return {
getKubeConfig: vi.fn(),
coreApi: { listNamespacedPod: vi.fn() },
metricsApi,
MetricsCtor,
logger: { error: vi.fn() },
};
});
vi.mock("@kubernetes/client-node", () => ({
CoreV1Api: function CoreV1Api() {},
Metrics: MetricsCtor,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/kubernetes", () => ({
getKubeConfig,
}));
import handler from "pages/api/kubernetes/stats/[...service]";
describe("pages/api/kubernetes/stats/[...service]", () => {
beforeEach(() => {
vi.clearAllMocks();
getKubeConfig.mockReturnValue({
makeApiClient: () => coreApi,
});
});
it("returns 400 when namespace/appName params are missing", async () => {
const req = { query: { service: [] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "kubernetes query parameters are required" });
});
it("returns 500 when kubernetes is not configured", async () => {
getKubeConfig.mockReturnValue(null);
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "No kubernetes configuration" });
});
it("returns 500 when listNamespacedPod fails", async () => {
coreApi.listNamespacedPod.mockRejectedValue({ statusCode: 500, body: "nope", response: "nope" });
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Error communicating with kubernetes" });
});
it("returns 404 when no pods match the selector", async () => {
coreApi.listNamespacedPod.mockResolvedValue({ items: [] });
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({
error: "no pods found with namespace=default and labelSelector=app.kubernetes.io/name=app",
});
});
it("computes limits even when metrics are missing (404 from metrics server)", async () => {
coreApi.listNamespacedPod.mockResolvedValue({
items: [
{
metadata: { name: "pod-a" },
spec: {
containers: [
{ resources: { limits: { cpu: "500m", memory: "1Gi" } } },
{ resources: { limits: { cpu: "250m" } } },
],
},
},
],
});
metricsApi.getPodMetrics.mockRejectedValue({ statusCode: 404, body: "no metrics", response: "no metrics" });
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({
stats: {
mem: 0,
cpu: 0,
cpuLimit: 0.75,
memLimit: 1000000000,
cpuUsage: 0,
memUsage: 0,
},
});
});
it("logs when metrics lookup fails with a non-404 error and still returns computed limits", async () => {
coreApi.listNamespacedPod.mockResolvedValue({
items: [
{
metadata: { name: "pod-a" },
spec: {
containers: [{ resources: { limits: { cpu: "500m", memory: "1Gi" } } }],
},
},
],
});
metricsApi.getPodMetrics.mockRejectedValue({ statusCode: 500, body: "boom", response: "boom" });
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(logger.error).toHaveBeenCalled();
expect(res.statusCode).toBe(200);
expect(res.body.stats.cpuLimit).toBe(0.5);
expect(res.body.stats.memLimit).toBe(1000000000);
expect(res.body.stats.cpu).toBe(0);
expect(res.body.stats.mem).toBe(0);
});
it("aggregates usage for matched pods and reports percent usage", async () => {
coreApi.listNamespacedPod.mockResolvedValue({
items: [
{
metadata: { name: "pod-a" },
spec: { containers: [{ resources: { limits: { cpu: "1000m", memory: "2Gi" } } }] },
},
{
metadata: { name: "pod-b" },
spec: { containers: [{ resources: { limits: { cpu: "500m", memory: "1Gi" } } }] },
},
],
});
metricsApi.getPodMetrics.mockResolvedValue({
items: [
// includes a non-selected pod, should be ignored
{ metadata: { name: "other" }, containers: [{ usage: { cpu: "100m", memory: "10Mi" } }] },
{
metadata: { name: "pod-a" },
containers: [{ usage: { cpu: "250m", memory: "100Mi" } }, { usage: { cpu: "250m", memory: "100Mi" } }],
},
{ metadata: { name: "pod-b" }, containers: [{ usage: { cpu: "500m", memory: "1Gi" } }] },
],
});
const req = { query: { service: ["default", "app"], podSelector: "app=test" } };
const res = createMockRes();
await handler(req, res);
const { stats } = res.body;
expect(stats.cpuLimit).toBe(1.5);
expect(stats.memLimit).toBe(3000000000);
expect(stats.cpu).toBeCloseTo(1.0, 5);
expect(stats.mem).toBe(1200000000);
expect(stats.cpuUsage).toBeCloseTo((100 * 1.0) / 1.5, 5);
expect(stats.memUsage).toBeCloseTo((100 * 1200000000) / 3000000000, 5);
});
it("returns 500 when an unexpected error is thrown", async () => {
getKubeConfig.mockImplementationOnce(() => {
throw new Error("boom");
});
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "unknown error" });
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { getKubeConfig, coreApi, logger } = vi.hoisted(() => ({
getKubeConfig: vi.fn(),
coreApi: { listNamespacedPod: vi.fn() },
logger: { error: vi.fn() },
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/kubernetes", () => ({
getKubeConfig,
}));
import handler from "pages/api/kubernetes/status/[...service]";
describe("pages/api/kubernetes/status/[...service]", () => {
beforeEach(() => {
vi.clearAllMocks();
getKubeConfig.mockReturnValue({
makeApiClient: () => coreApi,
});
});
it("returns 400 when namespace/appName params are missing", async () => {
const req = { query: { service: [] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "kubernetes query parameters are required" });
});
it("returns 500 when kubernetes is not configured", async () => {
getKubeConfig.mockReturnValue(null);
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "No kubernetes configuration" });
});
it("returns 500 when listNamespacedPod fails", async () => {
coreApi.listNamespacedPod.mockRejectedValue({ statusCode: 500, body: "nope", response: "nope" });
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Error communicating with kubernetes" });
});
it("returns 404 when no pods match the selector", async () => {
coreApi.listNamespacedPod.mockResolvedValue({ items: [] });
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ status: "not found" });
});
it("returns partial when some pods are ready but not all", async () => {
coreApi.listNamespacedPod.mockResolvedValue({
items: [{ status: { phase: "Running" } }, { status: { phase: "Pending" } }],
});
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ status: "partial" });
});
it("returns running when all pods are ready", async () => {
coreApi.listNamespacedPod.mockResolvedValue({
items: [{ status: { phase: "Running" } }, { status: { phase: "Succeeded" } }],
});
const req = { query: { service: ["default", "app"], podSelector: "app=test" } };
const res = createMockRes();
await handler(req, res);
expect(coreApi.listNamespacedPod).toHaveBeenCalledWith({
namespace: "default",
labelSelector: "app=test",
});
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ status: "running" });
});
it("returns 500 when an unexpected error is thrown", async () => {
getKubeConfig.mockImplementationOnce(() => {
throw new Error("boom");
});
const req = { query: { service: ["default", "app"] } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "unknown error" });
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { getServiceItem, ping, logger } = vi.hoisted(() => ({
getServiceItem: vi.fn(),
ping: { probe: vi.fn() },
logger: { debug: vi.fn() },
}));
vi.mock("utils/config/service-helpers", () => ({
getServiceItem,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("ping", () => ({
promise: ping,
}));
import handler from "pages/api/ping";
describe("pages/api/ping", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 400 when service item isn't found", async () => {
getServiceItem.mockResolvedValueOnce(null);
const req = { query: { groupName: "g", serviceName: "s" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain("Unable to find service");
});
it("returns 400 when ping host isn't configured", async () => {
getServiceItem.mockResolvedValueOnce({ ping: "" });
const req = { query: { groupName: "g", serviceName: "s" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body.error).toBe("No ping host given");
});
it("pings the hostname extracted from a URL", async () => {
getServiceItem.mockResolvedValueOnce({ ping: "http://example.com:1234/path" });
ping.probe.mockResolvedValueOnce({ alive: true });
const req = { query: { groupName: "g", serviceName: "s" } };
const res = createMockRes();
await handler(req, res);
expect(ping.probe).toHaveBeenCalledWith("example.com");
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ alive: true });
});
it("returns 400 when ping throws", async () => {
getServiceItem.mockResolvedValueOnce({ ping: "example.com" });
ping.probe.mockRejectedValueOnce(new Error("nope"));
const req = { query: { groupName: "g", serviceName: "s" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain("Error attempting ping");
});
});

View File

@@ -0,0 +1,148 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { getProxmoxConfig, httpProxy, logger } = vi.hoisted(() => ({
getProxmoxConfig: vi.fn(),
httpProxy: vi.fn(),
logger: { error: vi.fn() },
}));
vi.mock("utils/config/proxmox", () => ({
getProxmoxConfig,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
import handler from "pages/api/proxmox/stats/[...service]";
describe("pages/api/proxmox/stats/[...service]", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 400 when node param is missing", async () => {
const req = { query: { service: [], type: "qemu" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Proxmox node parameter is required" });
});
it("returns 500 when proxmox config is missing", async () => {
getProxmoxConfig.mockReturnValue(null);
const req = { query: { service: ["pve", "100"], type: "qemu" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Proxmox server configuration not found" });
});
it("returns 400 when node config is missing and legacy credentials are not present", async () => {
getProxmoxConfig.mockReturnValue({ other: { url: "http://x", token: "t", secret: "s" } });
const req = { query: { service: ["pve", "100"], type: "qemu" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual(
expect.objectContaining({
error: expect.stringContaining("Proxmox config not found for the specified node"),
}),
);
});
it("returns status/cpu/mem for a successful Proxmox response using per-node credentials", async () => {
getProxmoxConfig.mockReturnValue({
pve: { url: "http://pve", token: "tok", secret: "sec" },
});
httpProxy.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ data: { status: "running", cpu: 0.2, mem: 123 } })),
]);
const req = { query: { service: ["pve", "100"], type: "qemu" } };
const res = createMockRes();
await handler(req, res);
expect(httpProxy).toHaveBeenCalledWith("http://pve/api2/json/nodes/pve/qemu/100/status/current", {
method: "GET",
headers: { Authorization: "PVEAPIToken=tok=sec" },
});
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ status: "running", cpu: 0.2, mem: 123 });
});
it("falls back to legacy top-level credentials when no node block exists", async () => {
getProxmoxConfig.mockReturnValue({ url: "http://pve", token: "tok", secret: "sec" });
httpProxy.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ data: { cpu: 0.1, mem: 1 } })),
]);
const req = { query: { service: ["pve", "100"], type: "lxc" } };
const res = createMockRes();
await handler(req, res);
expect(httpProxy).toHaveBeenCalledWith("http://pve/api2/json/nodes/pve/lxc/100/status/current", expect.any(Object));
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ status: "unknown", cpu: 0.1, mem: 1 });
});
it("returns a non-200 status when Proxmox responds with an error", async () => {
getProxmoxConfig.mockReturnValue({ url: "http://pve", token: "tok", secret: "sec" });
httpProxy.mockResolvedValueOnce([401, "application/json", Buffer.from(JSON.stringify({ error: "no" }))]);
const req = { query: { service: ["pve", "100"], type: "qemu" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(401);
expect(res.body).toEqual({ error: "Failed to fetch Proxmox qemu status" });
});
it("returns 500 when the Proxmox response is missing expected data", async () => {
getProxmoxConfig.mockReturnValue({ url: "http://pve", token: "tok", secret: "sec" });
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({}))]);
const req = { query: { service: ["pve", "100"], type: "qemu" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Invalid response from Proxmox API" });
});
it("logs and returns 500 when an unexpected error occurs", async () => {
getProxmoxConfig.mockReturnValue({ url: "http://pve", token: "tok", secret: "sec" });
httpProxy.mockRejectedValueOnce(new Error("boom"));
const req = { query: { service: ["pve", "100"], type: "qemu" } };
const res = createMockRes();
await handler(req, res);
expect(logger.error).toHaveBeenCalled();
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Failed to fetch Proxmox status" });
});
});

View File

@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { cachedRequest, logger } = vi.hoisted(() => ({
cachedRequest: vi.fn(),
logger: { error: vi.fn() },
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/proxy/http", () => ({
cachedRequest,
}));
import handler from "pages/api/releases";
describe("pages/api/releases", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns cached GitHub releases", async () => {
cachedRequest.mockResolvedValueOnce([{ tag_name: "v1" }]);
const req = {};
const res = createMockRes();
await handler(req, res);
expect(res.body).toEqual([{ tag_name: "v1" }]);
});
it("returns [] when cachedRequest throws", async () => {
cachedRequest.mockRejectedValueOnce(new Error("nope"));
const req = {};
const res = createMockRes();
await handler(req, res);
expect(res.body).toEqual([]);
});
});

View File

@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
import handler from "pages/api/revalidate";
describe("pages/api/revalidate", () => {
it("revalidates and returns {revalidated:true}", async () => {
const req = {};
const res = createMockRes();
res.revalidate = vi.fn().mockResolvedValueOnce(undefined);
await handler(req, res);
expect(res.revalidate).toHaveBeenCalledWith("/");
expect(res.body).toEqual({ revalidated: true });
});
it("returns 500 when revalidate throws", async () => {
const req = {};
const res = createMockRes();
res.revalidate = vi.fn().mockRejectedValueOnce(new Error("nope"));
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toBe("Error revalidating");
});
});

View File

@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { providers, getSettings, widgetsFromConfig, cachedRequest } = vi.hoisted(() => ({
providers: {
custom: { name: "Custom", url: false, suggestionUrl: null },
google: { name: "Google", url: "https://google?q=", suggestionUrl: "https://google/suggest?q=" },
empty: { name: "NoSuggest", url: "x", suggestionUrl: null },
},
getSettings: vi.fn(),
widgetsFromConfig: vi.fn(),
cachedRequest: vi.fn(),
}));
vi.mock("components/widgets/search/search", () => ({
searchProviders: {
custom: providers.custom,
google: providers.google,
empty: providers.empty,
},
}));
vi.mock("utils/config/config", () => ({
getSettings,
}));
vi.mock("utils/config/widget-helpers", () => ({
widgetsFromConfig,
}));
vi.mock("utils/proxy/http", () => ({
cachedRequest,
}));
import handler from "pages/api/search/searchSuggestion";
describe("pages/api/search/searchSuggestion", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset provider objects since handler mutates the Custom provider.
providers.custom.url = false;
providers.custom.suggestionUrl = null;
});
it("returns empty suggestions when providerName is unknown", async () => {
const req = { query: { query: "hello", providerName: "Unknown" } };
const res = createMockRes();
await handler(req, res);
expect(res.body).toEqual(["hello", []]);
});
it("returns empty suggestions when provider has no suggestionUrl", async () => {
const req = { query: { query: "hello", providerName: "NoSuggest" } };
const res = createMockRes();
await handler(req, res);
expect(res.body).toEqual(["hello", []]);
});
it("calls cachedRequest for a standard provider", async () => {
cachedRequest.mockResolvedValueOnce(["q", ["a"]]);
const req = { query: { query: "hello world", providerName: "Google" } };
const res = createMockRes();
await handler(req, res);
expect(cachedRequest).toHaveBeenCalledWith("https://google/suggest?q=hello%20world", 5, "Mozilla/5.0");
expect(res.body).toEqual(["q", ["a"]]);
});
it("resolves Custom provider suggestionUrl from widgets.yaml when present", async () => {
widgetsFromConfig.mockResolvedValueOnce([
{ type: "search", options: { url: "https://custom?q=", suggestionUrl: "https://custom/suggest?q=" } },
]);
cachedRequest.mockResolvedValueOnce(["q", ["x"]]);
const req = { query: { query: "hello", providerName: "Custom" } };
const res = createMockRes();
await handler(req, res);
expect(cachedRequest).toHaveBeenCalledWith("https://custom/suggest?q=hello", 5, "Mozilla/5.0");
expect(res.body).toEqual(["q", ["x"]]);
});
it("falls back to quicklaunch custom settings when no search widget is configured", async () => {
widgetsFromConfig.mockResolvedValueOnce([]);
getSettings.mockReturnValueOnce({
quicklaunch: { provider: "custom", url: "https://ql?q=", suggestionUrl: "https://ql/suggest?q=" },
});
cachedRequest.mockResolvedValueOnce(["q", ["y"]]);
const req = { query: { query: "hello", providerName: "Custom" } };
const res = createMockRes();
await handler(req, res);
expect(cachedRequest).toHaveBeenCalledWith("https://ql/suggest?q=hello", 5, "Mozilla/5.0");
});
});

View File

@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { servicesResponse } = vi.hoisted(() => ({
servicesResponse: vi.fn(),
}));
vi.mock("utils/config/api-response", () => ({
servicesResponse,
}));
import handler from "pages/api/services/index";
describe("pages/api/services/index", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns servicesResponse()", async () => {
servicesResponse.mockResolvedValueOnce({ services: [] });
const req = {};
const res = createMockRes();
await handler(req, res);
expect(res.body).toEqual({ services: [] });
});
});

View File

@@ -0,0 +1,360 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { state, getServiceWidget, calendarProxy } = vi.hoisted(() => ({
state: {
genericResult: { ok: true },
},
getServiceWidget: vi.fn(),
calendarProxy: vi.fn(),
}));
vi.mock("utils/logger", () => ({
default: () => ({ debug: vi.fn(), error: vi.fn() }),
}));
vi.mock("utils/config/service-helpers", () => ({ default: getServiceWidget }));
const handlerFn = vi.hoisted(() => ({ handler: vi.fn() }));
vi.mock("utils/proxy/handlers/generic", () => ({ default: handlerFn.handler }));
// Calendar proxy is only used for an exception; keep it stubbed.
vi.mock("widgets/calendar/proxy", () => ({ default: calendarProxy }));
// Provide a minimal widget registry for mapping tests.
vi.mock("widgets/widgets", () => ({
default: {
linkwarden: {
api: "{url}/api/v1/{endpoint}",
mappings: {
collections: { endpoint: "collections" },
},
},
segments: {
api: "{url}/{endpoint}",
mappings: {
item: { endpoint: "items/{id}", segments: ["id"] },
},
},
queryparams: {
api: "{url}/{endpoint}",
mappings: {
list: { endpoint: "list", params: ["limit"], optionalParams: ["q"] },
},
},
endpointproxy: {
api: "{url}/{endpoint}",
mappings: {
list: { endpoint: "list", proxyHandler: handlerFn.handler, headers: { "X-Test": "1" } },
},
},
regex: {
api: "{url}/{endpoint}",
allowedEndpoints: /^ok\//,
},
ical: {
api: "{url}/{endpoint}",
proxyHandler: calendarProxy,
},
unifi_console: {
api: "{url}/{endpoint}",
proxyHandler: handlerFn.handler,
},
},
}));
import servicesProxy from "pages/api/services/proxy";
function createMockRes() {
const res = {
statusCode: undefined,
body: undefined,
status: (code) => {
res.statusCode = code;
return res;
},
json: (data) => {
res.body = data;
return res;
},
send: (data) => {
res.body = data;
return res;
},
end: () => res,
setHeader: vi.fn(),
};
return res;
}
describe("pages/api/services/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("maps opaque endpoints using widget.mappings and calls the handler", async () => {
getServiceWidget.mockResolvedValue({ type: "linkwarden" });
handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint }));
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "collections" } };
const res = createMockRes();
await servicesProxy(req, res);
expect(handlerFn.handler).toHaveBeenCalled();
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ endpoint: "collections" });
});
it("returns 403 for unsupported endpoint mapping", async () => {
getServiceWidget.mockResolvedValue({ type: "linkwarden" });
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "nope" } };
const res = createMockRes();
await servicesProxy(req, res);
expect(res.statusCode).toBe(403);
expect(res.body).toEqual({ error: "Unsupported service endpoint" });
});
it("returns 403 for unknown widget types", async () => {
getServiceWidget.mockResolvedValue({ type: "does_not_exist" });
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "collections" } };
const res = createMockRes();
await servicesProxy(req, res);
expect(res.statusCode).toBe(403);
expect(res.body).toEqual({ error: "Unknown proxy service type" });
});
it("quick-returns the proxy handler when no endpoint is provided", async () => {
getServiceWidget.mockResolvedValue({ type: "linkwarden" });
handlerFn.handler.mockImplementation(async (_req, res) => res.status(200).json(state.genericResult));
const req = { method: "GET", query: { group: "g", service: "s", index: "0" } };
const res = createMockRes();
await servicesProxy(req, res);
expect(handlerFn.handler).toHaveBeenCalledTimes(1);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ ok: true });
});
it("applies the calendar exception and always delegates to calendarProxyHandler", async () => {
getServiceWidget.mockResolvedValue({ type: "calendar" });
calendarProxy.mockImplementation(async (_req, res) => res.status(200).json({ ok: "calendar" }));
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "events" } };
const res = createMockRes();
await servicesProxy(req, res);
expect(calendarProxy).toHaveBeenCalledTimes(1);
expect(handlerFn.handler).not.toHaveBeenCalled();
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ ok: "calendar" });
});
it("applies the unifi_console exception when service and group are unifi_console", async () => {
getServiceWidget.mockResolvedValue({ type: "something_else" });
handlerFn.handler.mockImplementation(async (_req, res) => res.status(200).json({ ok: "unifi" }));
const req = {
method: "GET",
query: { group: "unifi_console", service: "unifi_console", index: "0" },
};
const res = createMockRes();
await servicesProxy(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ ok: "unifi" });
});
it("rejects unsupported mapping methods", async () => {
getServiceWidget.mockResolvedValue({ type: "linkwarden" });
// Inject a mapping with a method requirement through the mocked registry.
const widgets = (await import("widgets/widgets")).default;
const originalMethod = widgets.linkwarden.mappings.collections.method;
widgets.linkwarden.mappings.collections.method = "POST";
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "collections" } };
const res = createMockRes();
await servicesProxy(req, res);
expect(res.statusCode).toBe(403);
expect(res.body).toEqual({ error: "Unsupported method" });
widgets.linkwarden.mappings.collections.method = originalMethod;
});
it("replaces endpoint segments and rejects unsupported segment keys/values", async () => {
getServiceWidget.mockResolvedValue({ type: "segments" });
handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint }));
const res1 = createMockRes();
await servicesProxy(
{
method: "GET",
query: { group: "g", service: "s", index: "0", endpoint: "item", segments: JSON.stringify({ id: "123" }) },
},
res1,
);
expect(res1.statusCode).toBe(200);
expect(res1.body).toEqual({ endpoint: "items/123" });
const res2 = createMockRes();
await servicesProxy(
{
method: "GET",
query: { group: "g", service: "s", index: "0", endpoint: "item", segments: JSON.stringify({ nope: "123" }) },
},
res2,
);
expect(res2.statusCode).toBe(403);
expect(res2.body).toEqual({ error: "Unsupported segment" });
const res3 = createMockRes();
await servicesProxy(
{
method: "GET",
query: { group: "g", service: "s", index: "0", endpoint: "item", segments: JSON.stringify({ id: "../123" }) },
},
res3,
);
expect(res3.statusCode).toBe(403);
expect(res3.body).toEqual({ error: "Unsupported segment" });
});
it("adds query params based on mapping params + optionalParams", async () => {
getServiceWidget.mockResolvedValue({ type: "queryparams" });
handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint }));
const req = {
method: "GET",
query: {
group: "g",
service: "s",
index: "0",
endpoint: "list",
query: JSON.stringify({ limit: 10, q: "test" }),
},
};
const res = createMockRes();
await servicesProxy(req, res);
expect(res.statusCode).toBe(200);
expect(res.body.endpoint).toBe("list?limit=10&q=test");
});
it("passes mapping headers via req.extraHeaders and uses mapping.proxyHandler when provided", async () => {
getServiceWidget.mockResolvedValue({ type: "endpointproxy" });
handlerFn.handler.mockImplementation(async (req, res) =>
res.status(200).json({ headers: req.extraHeaders ?? null }),
);
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "list" } };
const res = createMockRes();
await servicesProxy(req, res);
expect(handlerFn.handler).toHaveBeenCalledTimes(1);
expect(res.statusCode).toBe(200);
expect(res.body.headers).toEqual({ "X-Test": "1" });
});
it("allows regex endpoints when widget.allowedEndpoints matches", async () => {
getServiceWidget.mockResolvedValue({ type: "regex" });
handlerFn.handler.mockImplementation(async (_req, res) => res.status(200).json({ ok: true }));
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "ok/test" } };
const res = createMockRes();
await servicesProxy(req, res);
expect(res.statusCode).toBe(200);
});
it("rejects unmapped proxy requests when no mapping and regex does not match", async () => {
getServiceWidget.mockResolvedValue({ type: "regex" });
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "nope" } };
const res = createMockRes();
await servicesProxy(req, res);
expect(res.statusCode).toBe(403);
expect(res.body).toEqual({ error: "Unmapped proxy request." });
});
it("falls back to the service proxy handler when mapping.proxyHandler is not a function", async () => {
getServiceWidget.mockResolvedValue({ type: "mapbroken" });
handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint }));
const widgets = (await import("widgets/widgets")).default;
widgets.mapbroken = {
api: "{url}/{endpoint}",
mappings: {
x: { endpoint: "ok", proxyHandler: "nope" },
},
};
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "x" } };
const res = createMockRes();
await servicesProxy(req, res);
expect(handlerFn.handler).toHaveBeenCalled();
expect(res.statusCode).toBe(200);
expect(res.body.endpoint).toBe("ok");
});
it("returns 403 when a widget defines a non-function proxyHandler", async () => {
getServiceWidget.mockResolvedValue({ type: "brokenhandler" });
const widgets = (await import("widgets/widgets")).default;
widgets.brokenhandler = {
api: "{url}/{endpoint}",
proxyHandler: "nope",
};
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "any" } };
const res = createMockRes();
await servicesProxy(req, res);
expect(res.statusCode).toBe(403);
expect(res.body).toEqual({ error: "Unknown proxy service type" });
});
it("returns 500 on unexpected errors", async () => {
getServiceWidget.mockRejectedValueOnce(new Error("boom"));
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "collections" } };
const res = createMockRes();
await servicesProxy(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Unexpected error" });
});
it("returns 500 when an async proxy handler throws", async () => {
getServiceWidget.mockResolvedValue({ type: "linkwarden" });
handlerFn.handler.mockRejectedValueOnce(new Error("proxy boom"));
const req = { method: "GET", query: { group: "g", service: "s", index: "0" } };
const res = createMockRes();
await servicesProxy(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Unexpected error" });
});
});

View File

@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { getServiceItem, httpProxy, perf, logger } = vi.hoisted(() => ({
getServiceItem: vi.fn(),
httpProxy: vi.fn(),
perf: { now: vi.fn() },
logger: { debug: vi.fn() },
}));
vi.mock("perf_hooks", () => ({
performance: perf,
}));
vi.mock("utils/config/service-helpers", () => ({
getServiceItem,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
import handler from "pages/api/siteMonitor";
describe("pages/api/siteMonitor", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 400 when the service item is missing", async () => {
getServiceItem.mockResolvedValueOnce(null);
const req = { query: { groupName: "g", serviceName: "s" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain("Unable to find service");
});
it("returns 400 when the monitor URL is missing", async () => {
getServiceItem.mockResolvedValueOnce({ siteMonitor: "" });
const req = { query: { groupName: "g", serviceName: "s" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body.error).toBe("No http monitor URL given");
});
it("uses HEAD and returns status + latency when the response is OK", async () => {
getServiceItem.mockResolvedValueOnce({ siteMonitor: "http://example.com" });
perf.now.mockReturnValueOnce(1).mockReturnValueOnce(11);
httpProxy.mockResolvedValueOnce([200]);
const req = { query: { groupName: "g", serviceName: "s" } };
const res = createMockRes();
await handler(req, res);
expect(httpProxy).toHaveBeenCalledWith("http://example.com", { method: "HEAD" });
expect(res.statusCode).toBe(200);
expect(res.body.status).toBe(200);
expect(res.body.latency).toBe(10);
});
it("falls back to GET when HEAD is rejected", async () => {
getServiceItem.mockResolvedValueOnce({ siteMonitor: "http://example.com" });
perf.now.mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValueOnce(5).mockReturnValueOnce(15);
httpProxy.mockResolvedValueOnce([500]).mockResolvedValueOnce([200]);
const req = { query: { groupName: "g", serviceName: "s" } };
const res = createMockRes();
await handler(req, res);
expect(httpProxy).toHaveBeenNthCalledWith(1, "http://example.com", { method: "HEAD" });
expect(httpProxy).toHaveBeenNthCalledWith(2, "http://example.com");
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ status: 200, latency: 10 });
});
it("returns 400 when httpProxy throws", async () => {
getServiceItem.mockResolvedValueOnce({ siteMonitor: "http://example.com" });
httpProxy.mockRejectedValueOnce(new Error("nope"));
const req = { query: { groupName: "g", serviceName: "s" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain("Error attempting http monitor");
});
});

View File

@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { checkAndCopyConfig, getSettings } = vi.hoisted(() => ({
checkAndCopyConfig: vi.fn(),
getSettings: vi.fn(),
}));
vi.mock("utils/config/config", () => ({
default: checkAndCopyConfig,
getSettings,
}));
import handler from "pages/api/theme";
describe("pages/api/theme", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns defaults when settings are missing", () => {
getSettings.mockReturnValueOnce({});
const res = createMockRes();
handler({ res });
expect(checkAndCopyConfig).toHaveBeenCalledWith("settings.yaml");
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ color: "slate", theme: "dark" });
});
it("returns configured color + theme when present", () => {
getSettings.mockReturnValueOnce({ color: "red", theme: "light" });
const res = createMockRes();
handler({ res });
expect(res.body).toEqual({ color: "red", theme: "light" });
});
});

View File

@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { checkAndCopyConfig } = vi.hoisted(() => ({
checkAndCopyConfig: vi.fn(),
}));
vi.mock("utils/config/config", () => ({
default: checkAndCopyConfig,
}));
import handler from "pages/api/validate";
describe("pages/api/validate", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns errors for any configs that don't validate", async () => {
checkAndCopyConfig.mockReturnValueOnce(true).mockReturnValueOnce("settings bad").mockReturnValue(true);
const req = {};
const res = createMockRes();
await handler(req, res);
expect(res.body).toEqual(["settings bad"]);
});
});

View File

@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { getPrivateWidgetOptions, httpProxy, logger } = vi.hoisted(() => ({
getPrivateWidgetOptions: vi.fn(),
httpProxy: vi.fn(),
logger: { error: vi.fn() },
}));
vi.mock("utils/config/widget-helpers", () => ({
getPrivateWidgetOptions,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
import handler from "pages/api/widgets/glances";
describe("pages/api/widgets/glances", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 400 when the widget URL is missing", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({});
const req = { query: { index: "0" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body.error).toBe("Missing Glances URL");
});
it("returns cpu/load/mem and includes optional endpoints when requested", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances", username: "u", password: "p" });
httpProxy
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ total: 1 }))]) // cpu
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ avg: 2 }))]) // load
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ available: 3 }))]) // mem
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify("1 days"))]) // uptime
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify([{ label: "cpu_thermal", value: 50 }]))]) // sensors
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify([{ mnt_point: "/", percent: 1 }]))]); // fs
const req = { query: { index: "0", uptime: "1", cputemp: "1", disk: "1", version: "4" } };
const res = createMockRes();
await handler(req, res);
expect(httpProxy).toHaveBeenCalledWith(
"http://glances/api/4/cpu",
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({ Authorization: expect.any(String) }),
}),
);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({
cpu: { total: 1 },
load: { avg: 2 },
mem: { available: 3 },
uptime: "1 days",
sensors: [{ label: "cpu_thermal", value: 50 }],
fs: [{ mnt_point: "/", percent: 1 }],
});
});
it("does not call optional endpoints unless requested", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances" });
httpProxy
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ total: 1 }))]) // cpu
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ avg: 2 }))]) // load
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ available: 3 }))]); // mem
const req = { query: { index: "0" } };
const res = createMockRes();
await handler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(3);
expect(httpProxy.mock.calls[0][1].headers.Authorization).toBeUndefined();
expect(res.statusCode).toBe(200);
});
it("returns 400 when glances returns 401", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances" });
httpProxy.mockResolvedValueOnce([401, null, Buffer.from("nope")]);
const req = { query: { index: "0" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual(expect.objectContaining({ error: expect.stringContaining("Authorization failure") }));
});
it("returns 400 when glances returns a non-200 status for a downstream call", async () => {
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances" });
httpProxy
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ total: 1 }))]) // cpu
.mockResolvedValueOnce([500, null, Buffer.from("nope")]); // load
const req = { query: { index: "0" } };
const res = createMockRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual(expect.objectContaining({ error: expect.stringContaining("HTTP 500") }));
});
});

Some files were not shown because too many files have changed in this diff Show More