mirror of
https://github.com/gethomepage/homepage.git
synced 2026-02-08 08:50:52 +08:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e96e406560 | ||
|
|
f4f54cea60 | ||
|
|
06595ef107 | ||
|
|
91b9aa479a | ||
|
|
08cde2f597 | ||
|
|
0ce175cda5 | ||
|
|
7f1de58e71 | ||
|
|
f729290e96 | ||
|
|
4974cd96b6 | ||
|
|
4450a6e1d0 | ||
|
|
ac11efc5c7 | ||
|
|
3c005d239e | ||
|
|
c4e77d4b1d | ||
|
|
9d415ac45d | ||
|
|
8b9720ca93 | ||
|
|
ad4ac465ae | ||
|
|
872a3600aa | ||
|
|
7d019185a3 | ||
|
|
99f1540d8c | ||
|
|
97e909ebf4 | ||
|
|
4d4fab391c | ||
|
|
1233b5e803 | ||
|
|
7e3fa97679 | ||
|
|
64c81615ec | ||
|
|
5c15466ac4 | ||
|
|
9cdb70527b | ||
|
|
062b1bcfbb | ||
|
|
4ebc24a1b4 | ||
|
|
79b63e4099 | ||
|
|
c86a007ed0 | ||
|
|
ca9506e485 | ||
|
|
1aec61811f | ||
|
|
6c945d6573 | ||
|
|
09893343a9 | ||
|
|
6b6090e303 | ||
|
|
d3f1832f70 | ||
|
|
f524531a13 | ||
|
|
d6dde5fc41 |
21
.codecov.yml
Normal file
21
.codecov.yml
Normal 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: 25%
|
||||
patch:
|
||||
default:
|
||||
target: 100%
|
||||
threshold: 25%
|
||||
|
||||
comment:
|
||||
layout: "reach,diff,flags,files"
|
||||
behavior: default
|
||||
require_changes: false
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
5
.github/PULL_REQUEST_TEMPLATE.md
vendored
5
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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.
|
||||
|
||||
50
.github/workflows/docs-publish.yml
vendored
50
.github/workflows/docs-publish.yml
vendored
@@ -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
37
.github/workflows/test.yml
vendored
Normal 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
2
.gitignore
vendored
@@ -46,7 +46,7 @@ next-env.d.ts
|
||||
# IDEs
|
||||
/.idea/
|
||||
|
||||
# MkDocs documentation
|
||||
# Zensical documentation
|
||||
site*/
|
||||
.cache/
|
||||
|
||||
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.13
|
||||
@@ -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>
|
||||
|
||||
<a href="https://codecov.io/gh/gethomepage/homepage"><img src="https://codecov.io/gh/gethomepage/homepage/graph/badge.svg?token=7SKFL4D9K7"/></a>
|
||||
|
||||
<a href="https://crowdin.com/project/gethomepage" target="_blank"><img src="https://badges.crowdin.net/gethomepage/localized.svg"></a>
|
||||
|
||||
<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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
18
docs/widgets/services/arcane.md
Normal file
18
docs/widgets/services/arcane.md
Normal 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
|
||||
```
|
||||
17
docs/widgets/services/dispatcharr.md
Normal file
17
docs/widgets/services/dispatcharr.md
Normal 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
|
||||
```
|
||||
20
docs/widgets/services/dockhand.md
Normal file
20
docs/widgets/services/dockhand.md
Normal 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
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
78
eslint.config.mjs
Normal 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/"]),
|
||||
]);
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
29
package.json
29
package.json
@@ -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
1763
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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": "Houers",
|
||||
"images": "Beelde",
|
||||
"image_updates": "Beeldopdaterings",
|
||||
"images_unused": "Ongebruik",
|
||||
"environment_required": "Omgewings-ID Vereis"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,16 @@
|
||||
"episodes": "Odcinki",
|
||||
"songs": "Piosenki"
|
||||
},
|
||||
"jellyfin": {
|
||||
"playing": "Odtwarza",
|
||||
"transcoding": "Transkoduje",
|
||||
"bitrate": "Bitrate",
|
||||
"no_active": "Brak aktywnych strumieni",
|
||||
"movies": "Filmy",
|
||||
"series": "Seriale",
|
||||
"episodes": "Odcinki",
|
||||
"songs": "Piosenki"
|
||||
},
|
||||
"esphome": {
|
||||
"offline": "Offline",
|
||||
"offline_alt": "Offline",
|
||||
@@ -705,6 +715,10 @@
|
||||
"uptime": "Czas działania",
|
||||
"volumeAvailable": "Dostępne"
|
||||
},
|
||||
"dispatcharr": {
|
||||
"channels": "Kanały",
|
||||
"streams": "Strumienie"
|
||||
},
|
||||
"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": "Kontenery",
|
||||
"images": "Obrazy",
|
||||
"image_updates": "Aktualizacje obrazów",
|
||||
"images_unused": "Nieużywane",
|
||||
"environment_required": "Wymagane ID środowiska"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
"http_status": "HTTP stavový kód",
|
||||
"error": "Chyba",
|
||||
"response": "Odpoveď",
|
||||
"down": "Down",
|
||||
"down": "Nedostupné",
|
||||
"up": "Beží",
|
||||
"not_available": "Nedostupné"
|
||||
},
|
||||
@@ -107,9 +107,19 @@
|
||||
"episodes": "Epizódy",
|
||||
"songs": "Skladby"
|
||||
},
|
||||
"jellyfin": {
|
||||
"playing": "Prehráva sa",
|
||||
"transcoding": "Prebieha prekódovanie",
|
||||
"bitrate": "Prenosová rýchlosť",
|
||||
"no_active": "Žiadne aktívne vysielania",
|
||||
"movies": "Filmov",
|
||||
"series": "Seriálov",
|
||||
"episodes": "Epizód",
|
||||
"songs": "Skladieb"
|
||||
},
|
||||
"esphome": {
|
||||
"offline": "Offline",
|
||||
"offline_alt": "Offline",
|
||||
"offline": "Nedostupné",
|
||||
"offline_alt": "Nedostupné",
|
||||
"online": "Online",
|
||||
"total": "Celkom",
|
||||
"unknown": "Neznáme"
|
||||
@@ -144,7 +154,7 @@
|
||||
"uptime": "Dostupnosť",
|
||||
"maxDown": "Max. sťahovanie",
|
||||
"maxUp": "Max. nahrávanie",
|
||||
"down": "Down",
|
||||
"down": "Nedostupné",
|
||||
"up": "Beží",
|
||||
"received": "Prijaté",
|
||||
"sent": "Odoslané",
|
||||
@@ -170,7 +180,7 @@
|
||||
"tautulli": {
|
||||
"playing": "Playing",
|
||||
"transcoding": "Transcoding",
|
||||
"bitrate": "Bitrate",
|
||||
"bitrate": "Prenosová rýchlosť",
|
||||
"no_active": "No Active Streams",
|
||||
"plex_connection_error": "Skontroluj spojenie s Plex"
|
||||
},
|
||||
@@ -419,7 +429,7 @@
|
||||
"version": "Verzia",
|
||||
"status": "Stav",
|
||||
"up": "Online",
|
||||
"down": "Offline"
|
||||
"down": "Nedostupné"
|
||||
},
|
||||
"miniflux": {
|
||||
"read": "Prečítané",
|
||||
@@ -440,7 +450,7 @@
|
||||
"cpu": "CPU",
|
||||
"load": "Záťaž",
|
||||
"wait": "Čakajte, prosím",
|
||||
"temp": "TEMP",
|
||||
"temp": "TEPL",
|
||||
"_temp": "Teplota",
|
||||
"warn": "Upozornení",
|
||||
"uptime": "BEŽÍ",
|
||||
@@ -481,13 +491,13 @@
|
||||
"51-day": "Mierne mrholenie",
|
||||
"51-night": "Slabé mrholenie",
|
||||
"53-day": "Mrholenie",
|
||||
"53-night": "Drizzle",
|
||||
"53-night": "Mrholenie",
|
||||
"55-day": "Silné mrholenie",
|
||||
"55-night": "Silné mrholenie",
|
||||
"56-day": "Mierne mrazivé mrholenie",
|
||||
"56-night": "Light Freezing Drizzle",
|
||||
"56-night": "Jemné mrznúce mrholenie",
|
||||
"57-day": "Mrazivé mrholenie",
|
||||
"57-night": "Freezing Drizzle",
|
||||
"57-night": "Mrznúce mrholenie",
|
||||
"61-day": "Slabý dážď",
|
||||
"61-night": "Slabý dážď",
|
||||
"63-day": "Dážď",
|
||||
@@ -532,14 +542,14 @@
|
||||
"child_bridges_status": "{{ok}}/{{total}}",
|
||||
"up": "Beží",
|
||||
"pending": "Čakajúce",
|
||||
"down": "Down",
|
||||
"down": "Nedostupné",
|
||||
"ok": "Ok"
|
||||
},
|
||||
"healthchecks": {
|
||||
"new": "Nový",
|
||||
"up": "Beží",
|
||||
"grace": "V dodatočnej lehote",
|
||||
"down": "Down",
|
||||
"down": "Nedostupné",
|
||||
"paused": "Pozastavené",
|
||||
"status": "Stav",
|
||||
"last_ping": "Poslendný ping",
|
||||
@@ -665,7 +675,7 @@
|
||||
"memory": "Využitie pamäte",
|
||||
"wanStatus": "Stav WAN",
|
||||
"up": "Beží",
|
||||
"down": "Down",
|
||||
"down": "Nedostupné",
|
||||
"temp": "Temp",
|
||||
"disk": "Využitie disku",
|
||||
"wanIP": "IP adresa WAN"
|
||||
@@ -705,6 +715,10 @@
|
||||
"uptime": "Dostupnosť",
|
||||
"volumeAvailable": "Dostupné"
|
||||
},
|
||||
"dispatcharr": {
|
||||
"channels": "Channels",
|
||||
"streams": "Streams"
|
||||
},
|
||||
"mylar": {
|
||||
"series": "Series",
|
||||
"issues": "Problémy",
|
||||
@@ -762,8 +776,8 @@
|
||||
"targets_total": "Cieľov spolu"
|
||||
},
|
||||
"gatus": {
|
||||
"up": "Sites Up",
|
||||
"down": "Sites Down",
|
||||
"up": "Dostupné stránky",
|
||||
"down": "Nedostupné stránky",
|
||||
"uptime": "Dostupnosť"
|
||||
},
|
||||
"ghostfolio": {
|
||||
@@ -785,7 +799,7 @@
|
||||
},
|
||||
"whatsupdocker": {
|
||||
"monitoring": "Monitoring",
|
||||
"updates": "Updates"
|
||||
"updates": "Aktualizácie"
|
||||
},
|
||||
"calibreweb": {
|
||||
"books": "Books",
|
||||
@@ -858,7 +872,7 @@
|
||||
"uptime": "Dostupnosť",
|
||||
"cpuLoad": "Záťaž CPU priem. (5m)",
|
||||
"up": "Beží",
|
||||
"down": "Down",
|
||||
"down": "Nedostupné",
|
||||
"bytesTx": "Prenesených",
|
||||
"bytesRx": "Prijaté"
|
||||
},
|
||||
@@ -867,13 +881,13 @@
|
||||
"uptime": "Dostupnosť",
|
||||
"lastDown": "Posledný čas nedostupnosti",
|
||||
"downDuration": "Trvanie nedostupnosti",
|
||||
"sitesUp": "Sites Up",
|
||||
"sitesDown": "Sites Down",
|
||||
"sitesUp": "Dostupné stránky",
|
||||
"sitesDown": "Nedostupné stránky",
|
||||
"paused": "Pozastavené",
|
||||
"notyetchecked": "Neskontrolované",
|
||||
"up": "Beží",
|
||||
"seemsdown": "Javí sa nedostupný",
|
||||
"down": "Down",
|
||||
"down": "Nedostupné",
|
||||
"unknown": "Neznáme"
|
||||
},
|
||||
"calendar": {
|
||||
@@ -1009,17 +1023,17 @@
|
||||
"last_seen": "Last Seen",
|
||||
"status": "Stav",
|
||||
"online": "Online",
|
||||
"offline": "Offline"
|
||||
"offline": "Nedostupné"
|
||||
},
|
||||
"beszel": {
|
||||
"name": "Name",
|
||||
"systems": "Systems",
|
||||
"up": "Beží",
|
||||
"down": "Down",
|
||||
"down": "Nedostupné",
|
||||
"paused": "Pozastavené",
|
||||
"pending": "Čakajúce",
|
||||
"status": "Stav",
|
||||
"updated": "Updated",
|
||||
"updated": "Aktualizované",
|
||||
"cpu": "CPU",
|
||||
"memory": "RAM",
|
||||
"disk": "Disk",
|
||||
@@ -1064,7 +1078,7 @@
|
||||
"disconnected": "Odpojené",
|
||||
"updateStatus": "Update",
|
||||
"update_yes": "Dostupné",
|
||||
"update_no": "Up to Date",
|
||||
"update_no": "Aktuálne",
|
||||
"downloads": "Downloads",
|
||||
"uploads": "Uploads",
|
||||
"sharedFiles": "Files"
|
||||
@@ -1083,7 +1097,7 @@
|
||||
"total": "Celkom",
|
||||
"running": "Beží",
|
||||
"stopped": "Zastavené",
|
||||
"down": "Down",
|
||||
"down": "Nedostupné",
|
||||
"unhealthy": "Nezdravý",
|
||||
"unknown": "Neznáme",
|
||||
"servers": "Servery",
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "İşlemci",
|
||||
"mem": "MEM",
|
||||
"mem": "Bellek",
|
||||
"total": "Toplam",
|
||||
"free": "Boş",
|
||||
"used": "Kullanımda",
|
||||
@@ -80,7 +80,7 @@
|
||||
"unhealthy": "Sağlıksız",
|
||||
"not_found": "Bulunamadı",
|
||||
"exited": "Kapandı",
|
||||
"partial": "Parçalı"
|
||||
"partial": "Kısmi"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Hata",
|
||||
@@ -93,20 +93,30 @@
|
||||
"http_status": "HTTPS durumu",
|
||||
"error": "Hata",
|
||||
"response": "Yanıt",
|
||||
"down": "Çalışmayan",
|
||||
"down": "İndirme",
|
||||
"up": "Çalışıyor",
|
||||
"not_available": "Uygun değil"
|
||||
},
|
||||
"emby": {
|
||||
"playing": "Oynatılıyor",
|
||||
"transcoding": "Dönüştürülüyor",
|
||||
"bitrate": "Bit Oranı",
|
||||
"bitrate": "Bit Hızı",
|
||||
"no_active": "Etkin akış yok",
|
||||
"movies": "Filmler",
|
||||
"series": "Diziler",
|
||||
"episodes": "Bölümler",
|
||||
"songs": "Şarkılar"
|
||||
},
|
||||
"jellyfin": {
|
||||
"playing": "Oynatılıyor",
|
||||
"transcoding": "Dönüştürülüyor",
|
||||
"bitrate": "Bit Hızı",
|
||||
"no_active": "Aktif Yayın Yok",
|
||||
"movies": "Filmler",
|
||||
"series": "Diziler",
|
||||
"episodes": "Bölümler",
|
||||
"songs": "Şarkılar"
|
||||
},
|
||||
"esphome": {
|
||||
"offline": "Çevrimdışı",
|
||||
"offline_alt": "Çevrimdışı",
|
||||
@@ -125,8 +135,8 @@
|
||||
"flood": {
|
||||
"download": "İndirme",
|
||||
"upload": "Yükleme",
|
||||
"leech": "Tüketici",
|
||||
"seed": "Sağlayıcı"
|
||||
"leech": "İndirilen",
|
||||
"seed": "Gönderilen"
|
||||
},
|
||||
"freshrss": {
|
||||
"subscriptions": "Abonelikler",
|
||||
@@ -142,10 +152,10 @@
|
||||
"connectionStatusDisconnected": "Bağlı değil",
|
||||
"connectionStatusConnected": "Bağlı",
|
||||
"uptime": "Çalışma Süresi",
|
||||
"maxDown": "Max. Indirme",
|
||||
"maxUp": "Max. Gönderme",
|
||||
"down": "Çalışmayan",
|
||||
"up": "Çalışıyor",
|
||||
"maxDown": "Maks. İndirme",
|
||||
"maxUp": "Maks. Gönderme",
|
||||
"down": "İndirme",
|
||||
"up": "Yükleme",
|
||||
"received": "Alınan",
|
||||
"sent": "Gönderilen",
|
||||
"externalIPAddress": "Harici IP",
|
||||
@@ -170,7 +180,7 @@
|
||||
"tautulli": {
|
||||
"playing": "Oynatılıyor",
|
||||
"transcoding": "Dönüştürülüyor",
|
||||
"bitrate": "Bit Oranı",
|
||||
"bitrate": "Bit Hızı",
|
||||
"no_active": "Etkin akış yok",
|
||||
"plex_connection_error": "Plex Bağlantısı Kontrol Ediliyor"
|
||||
},
|
||||
@@ -199,20 +209,20 @@
|
||||
},
|
||||
"rutorrent": {
|
||||
"active": "Etkin",
|
||||
"upload": "Yükleme",
|
||||
"upload": "Gönderme",
|
||||
"download": "İndirme"
|
||||
},
|
||||
"transmission": {
|
||||
"download": "İndirme",
|
||||
"upload": "Yükleme",
|
||||
"leech": "Tüketici",
|
||||
"seed": "Sağlayıcı"
|
||||
"upload": "Gönderme",
|
||||
"leech": "İndirilen",
|
||||
"seed": "Gönderilen"
|
||||
},
|
||||
"qbittorrent": {
|
||||
"download": "İndirme",
|
||||
"upload": "Yükleme",
|
||||
"leech": "Tüketici",
|
||||
"seed": "Sağlayıcı"
|
||||
"upload": "Gönderme",
|
||||
"leech": "İndirilen",
|
||||
"seed": "Gönderilen"
|
||||
},
|
||||
"qnap": {
|
||||
"cpuUsage": "İşlemci Kullanımı",
|
||||
@@ -224,9 +234,9 @@
|
||||
},
|
||||
"deluge": {
|
||||
"download": "İndirme",
|
||||
"upload": "Yükleme",
|
||||
"leech": "Leech",
|
||||
"seed": "Seed"
|
||||
"upload": "Gönderme",
|
||||
"leech": "İndirilen",
|
||||
"seed": "Gönderilen"
|
||||
},
|
||||
"develancacheui": {
|
||||
"cachehitbytes": "Önbellek İsabetli Byte",
|
||||
@@ -234,14 +244,14 @@
|
||||
},
|
||||
"downloadstation": {
|
||||
"download": "İndirme",
|
||||
"upload": "Yükleme",
|
||||
"leech": "Tüketici",
|
||||
"seed": "Sağlayıcı"
|
||||
"upload": "Gönderme",
|
||||
"leech": "İndirilen",
|
||||
"seed": "Gönderilen"
|
||||
},
|
||||
"sonarr": {
|
||||
"wanted": "İstendi",
|
||||
"queued": "Kuyrukta",
|
||||
"series": "Seriler",
|
||||
"series": "Diziler",
|
||||
"queue": "Kuyruk",
|
||||
"unknown": "Bilinmeyen"
|
||||
},
|
||||
@@ -276,10 +286,10 @@
|
||||
"pending": "Bekleyen",
|
||||
"approved": "Onaylı",
|
||||
"available": "Uygun",
|
||||
"issues": "Open Issues"
|
||||
"issues": "Açık Sorunlar"
|
||||
},
|
||||
"overseerr": {
|
||||
"pending": "Pending",
|
||||
"pending": "Beklemede",
|
||||
"processing": "İşleniyor",
|
||||
"approved": "Onaylı",
|
||||
"available": "Uygun"
|
||||
@@ -297,7 +307,7 @@
|
||||
"gravity": "Gravity"
|
||||
},
|
||||
"adguard": {
|
||||
"queries": "Queries",
|
||||
"queries": "Sorgular",
|
||||
"blocked": "Engellenen",
|
||||
"filtered": "Filtrelendi",
|
||||
"latency": "Gecikme"
|
||||
@@ -438,9 +448,9 @@
|
||||
},
|
||||
"glances": {
|
||||
"cpu": "İşlemci",
|
||||
"load": "Load",
|
||||
"load": "Yük",
|
||||
"wait": "Lütfen bekleyin",
|
||||
"temp": "TEMP",
|
||||
"temp": "Sıcaklık",
|
||||
"_temp": "Sıcaklık",
|
||||
"warn": "Uyarı",
|
||||
"uptime": "ÇALIŞIYOR",
|
||||
@@ -453,7 +463,7 @@
|
||||
"read": "Okundu",
|
||||
"write": "Yazma",
|
||||
"gpu": "GPU",
|
||||
"mem": "Hafıza",
|
||||
"mem": "Bellek",
|
||||
"swap": "Swap"
|
||||
},
|
||||
"quicklaunch": {
|
||||
@@ -533,7 +543,7 @@
|
||||
"up": "Çalışıyor",
|
||||
"pending": "Bekleyen",
|
||||
"down": "Çalışmayan",
|
||||
"ok": "Ok"
|
||||
"ok": "Tamam"
|
||||
},
|
||||
"healthchecks": {
|
||||
"new": "Yeni",
|
||||
@@ -588,7 +598,7 @@
|
||||
"signalStrength": "Sağlamlık",
|
||||
"signalQuality": "Kalite",
|
||||
"symbolQuality": "Kalite",
|
||||
"networkRate": "Bit Oranı",
|
||||
"networkRate": "Bit Hızı",
|
||||
"clientIP": "Alıcı"
|
||||
},
|
||||
"scrutiny": {
|
||||
@@ -601,13 +611,13 @@
|
||||
"total": "Toplam"
|
||||
},
|
||||
"pangolin": {
|
||||
"orgs": "Orgs",
|
||||
"sites": "Sites",
|
||||
"resources": "Resources",
|
||||
"targets": "Targets",
|
||||
"traffic": "Traffic",
|
||||
"in": "In",
|
||||
"out": "Out"
|
||||
"orgs": "Kuruluşlar",
|
||||
"sites": "Siteler",
|
||||
"resources": "Kaynaklar",
|
||||
"targets": "Hedefler",
|
||||
"traffic": "Trafik",
|
||||
"in": "Gelen",
|
||||
"out": "Giden"
|
||||
},
|
||||
"peanut": {
|
||||
"battery_charge": "Pil Yüzdesi",
|
||||
@@ -666,7 +676,7 @@
|
||||
"wanStatus": "WAN Durumu",
|
||||
"up": "Çalışıyor",
|
||||
"down": "Çalışmayan",
|
||||
"temp": "Temp",
|
||||
"temp": "Sıcaklık",
|
||||
"disk": "Disk Kullanımı",
|
||||
"wanIP": "WAN IP"
|
||||
},
|
||||
@@ -687,7 +697,7 @@
|
||||
"down": "Çalışmayan site",
|
||||
"uptime": "Çalışma süresi",
|
||||
"incident": "Olay",
|
||||
"m": "m"
|
||||
"m": "dk"
|
||||
},
|
||||
"atsumeru": {
|
||||
"series": "Diziler",
|
||||
@@ -705,6 +715,10 @@
|
||||
"uptime": "Çalışma süresi",
|
||||
"volumeAvailable": "Uygun"
|
||||
},
|
||||
"dispatcharr": {
|
||||
"channels": "Kanallar",
|
||||
"streams": "Akışlar"
|
||||
},
|
||||
"mylar": {
|
||||
"series": "Diziler",
|
||||
"issues": "Sorunlar",
|
||||
@@ -757,12 +771,12 @@
|
||||
"nodes": "Düğümler"
|
||||
},
|
||||
"prometheus": {
|
||||
"targets_up": "Hedef Çalışıyor",
|
||||
"targets_up": "Çalışan Hedef",
|
||||
"targets_down": "Çalışmayan hedef",
|
||||
"targets_total": "Toplam Hedef"
|
||||
},
|
||||
"gatus": {
|
||||
"up": "Sites Up",
|
||||
"up": "Çalışan Siteler",
|
||||
"down": "Çalışmayan site",
|
||||
"uptime": "Çalışma süresi"
|
||||
},
|
||||
@@ -770,7 +784,7 @@
|
||||
"gross_percent_today": "Bugün",
|
||||
"gross_percent_1y": "Bir yıl",
|
||||
"gross_percent_max": "Tüm zaman",
|
||||
"net_worth": "Net Worth"
|
||||
"net_worth": "Net Değer"
|
||||
},
|
||||
"audiobookshelf": {
|
||||
"podcasts": "Podcast",
|
||||
@@ -791,13 +805,13 @@
|
||||
"books": "Kitaplar",
|
||||
"authors": "Yazarlar",
|
||||
"categories": "Kategoriler",
|
||||
"series": "Seriler"
|
||||
"series": "Diziler"
|
||||
},
|
||||
"booklore": {
|
||||
"libraries": "Libraries",
|
||||
"books": "Books",
|
||||
"reading": "Reading",
|
||||
"finished": "Finished"
|
||||
"libraries": "Kütüphaneler",
|
||||
"books": "Kitaplar",
|
||||
"reading": "Okunuyor",
|
||||
"finished": "Bitti"
|
||||
},
|
||||
"jdownloader": {
|
||||
"downloadCount": "Kuyruk",
|
||||
@@ -806,7 +820,7 @@
|
||||
"downloadSpeed": "Hız"
|
||||
},
|
||||
"kavita": {
|
||||
"seriesCount": "Seriler",
|
||||
"seriesCount": "Diziler",
|
||||
"totalFiles": "Dosyalar"
|
||||
},
|
||||
"azuredevops": {
|
||||
@@ -851,7 +865,7 @@
|
||||
"total": "Toplam",
|
||||
"running": "Çalışıyor",
|
||||
"stopped": "Durdu",
|
||||
"passed": "Passed",
|
||||
"passed": "Başarılı",
|
||||
"failed": "Başarısız"
|
||||
},
|
||||
"openwrt": {
|
||||
@@ -860,7 +874,7 @@
|
||||
"up": "Çalışıyor",
|
||||
"down": "Çalışmayan",
|
||||
"bytesTx": "İletilen",
|
||||
"bytesRx": "Received"
|
||||
"bytesRx": "Alınan"
|
||||
},
|
||||
"uptimerobot": {
|
||||
"status": "Durum",
|
||||
@@ -910,7 +924,7 @@
|
||||
},
|
||||
"gitea": {
|
||||
"notifications": "Bildirimler",
|
||||
"issues": "Issues",
|
||||
"issues": "Sorunlar",
|
||||
"pulls": "Değişiklik İstekleri",
|
||||
"repositories": "Depolar"
|
||||
},
|
||||
@@ -992,21 +1006,21 @@
|
||||
"lubelogger": {
|
||||
"vehicle": "Taşıt",
|
||||
"vehicles": "Taşıtlar",
|
||||
"serviceRecords": "Service Records",
|
||||
"serviceRecords": "Servis Kayıtları",
|
||||
"reminders": "Hatırlatıcılar",
|
||||
"nextReminder": "Sonraki hatırlatıcı",
|
||||
"none": "None"
|
||||
"none": "Hiçbiri"
|
||||
},
|
||||
"vikunja": {
|
||||
"projects": "Etkin projeler",
|
||||
"tasks7d": "Bitişi Bu Hafta Olan Görevler",
|
||||
"tasksOverdue": "Overdue Tasks",
|
||||
"tasksInProgress": "Tasks In Progress"
|
||||
"tasksOverdue": "Gecikmiş Görevler",
|
||||
"tasksInProgress": "Devam Eden Görevler"
|
||||
},
|
||||
"headscale": {
|
||||
"name": "Ad",
|
||||
"address": "Adres",
|
||||
"last_seen": "Last Seen",
|
||||
"last_seen": "Son Görülme",
|
||||
"status": "Durum",
|
||||
"online": "Çevrimiçi",
|
||||
"offline": "Çevrimdışı"
|
||||
@@ -1017,21 +1031,21 @@
|
||||
"up": "Çalışıyor",
|
||||
"down": "Çalışmayan",
|
||||
"paused": "Durduruldu",
|
||||
"pending": "Pending",
|
||||
"pending": "Beklemede",
|
||||
"status": "Durum",
|
||||
"updated": "Güncellendi",
|
||||
"cpu": "İşlemci",
|
||||
"memory": "Bellek",
|
||||
"disk": "Disk",
|
||||
"disk": "Depolama",
|
||||
"network": "NET"
|
||||
},
|
||||
"argocd": {
|
||||
"apps": "Uygulamalar",
|
||||
"synced": "Synced",
|
||||
"outOfSync": "Out Of Sync",
|
||||
"synced": "Senkron",
|
||||
"outOfSync": "Senkron Değil",
|
||||
"healthy": "Sağlıklı",
|
||||
"degraded": "Degraded",
|
||||
"progressing": "Progressing",
|
||||
"degraded": "Sorunlu",
|
||||
"progressing": "Uygulanıyor",
|
||||
"missing": "Eksik",
|
||||
"suspended": "Askıya Alındı"
|
||||
},
|
||||
@@ -1039,22 +1053,22 @@
|
||||
"loading": "Yükleniyor"
|
||||
},
|
||||
"gitlab": {
|
||||
"groups": "Groups",
|
||||
"issues": "Issues",
|
||||
"merges": "Merge Requests",
|
||||
"projects": "Projects"
|
||||
"groups": "Gruplar",
|
||||
"issues": "Sorunlar",
|
||||
"merges": "Birleştirme İstekleri",
|
||||
"projects": "Projeler"
|
||||
},
|
||||
"apcups": {
|
||||
"status": "Durum",
|
||||
"load": "Load",
|
||||
"bcharge": "Battery Charge",
|
||||
"load": "Yük",
|
||||
"bcharge": "Pil Yüzdesi",
|
||||
"timeleft": "Kalan zaman"
|
||||
},
|
||||
"karakeep": {
|
||||
"bookmarks": "Yer imleri",
|
||||
"favorites": "Gözdeler",
|
||||
"archived": "Archived",
|
||||
"highlights": "Highlights",
|
||||
"archived": "Arşivlenen",
|
||||
"highlights": "Öne Çıkanlar",
|
||||
"lists": "Listeler",
|
||||
"tags": "Etiketler"
|
||||
},
|
||||
@@ -1076,8 +1090,8 @@
|
||||
"other": "Diğer"
|
||||
},
|
||||
"checkmk": {
|
||||
"serviceErrors": "Service issues",
|
||||
"hostErrors": "Host issues"
|
||||
"serviceErrors": "Hizmet Sorunları",
|
||||
"hostErrors": "Sunucu Sorunları"
|
||||
},
|
||||
"komodo": {
|
||||
"total": "Toplam",
|
||||
@@ -1087,8 +1101,8 @@
|
||||
"unhealthy": "Sağlıksız",
|
||||
"unknown": "Bilinmeyen",
|
||||
"servers": "Sunucular",
|
||||
"stacks": "Stacks",
|
||||
"containers": "Containers"
|
||||
"stacks": "Yığınlar",
|
||||
"containers": "Konteynerler"
|
||||
},
|
||||
"filebrowser": {
|
||||
"available": "Uygun",
|
||||
@@ -1106,11 +1120,11 @@
|
||||
"STARTED": "Başladı",
|
||||
"STOPPED": "Durdu",
|
||||
"NEW_ARRAY": "Yeni dizi",
|
||||
"RECON_DISK": "Reconstructing Disk",
|
||||
"RECON_DISK": "Disk Yeniden Oluşturuluyor",
|
||||
"DISABLE_DISK": "Disk devre dışı",
|
||||
"SWAP_DSBL": "Swap devre dışı",
|
||||
"INVALID_EXPANSION": "Invalid Expansion",
|
||||
"PARITY_NOT_BIGGEST": "Parity Not Biggest",
|
||||
"INVALID_EXPANSION": "Geçersiz Genişletme",
|
||||
"PARITY_NOT_BIGGEST": "Parity En Büyük Disk Değil",
|
||||
"TOO_MANY_MISSING_DISKS": "Çok fazla disk eksik",
|
||||
"NEW_DISK_TOO_SMALL": "Yeni disk çok küçük",
|
||||
"NO_DATA_DISKS": "Veri diski yok",
|
||||
@@ -1125,16 +1139,37 @@
|
||||
"poolFree": "{{pool}} boş"
|
||||
},
|
||||
"backrest": {
|
||||
"num_plans": "Plans",
|
||||
"num_success_30": "Successes",
|
||||
"num_failure_30": "Failures",
|
||||
"num_success_latest": "Succeeding",
|
||||
"num_failure_latest": "Failing",
|
||||
"bytes_added_30": "Bytes Added"
|
||||
"num_plans": "Planlar",
|
||||
"num_success_30": "Başarılılar",
|
||||
"num_failure_30": "Başarısızlıklar",
|
||||
"num_success_latest": "Başarılı",
|
||||
"num_failure_latest": "Başarısız",
|
||||
"bytes_added_30": "Eklenen Veri"
|
||||
},
|
||||
"yourspotify": {
|
||||
"songs": "Songs",
|
||||
"time": "Time",
|
||||
"artists": "Artists"
|
||||
"songs": "Şarkılar",
|
||||
"time": "Zaman",
|
||||
"artists": "Sanatçılar"
|
||||
},
|
||||
"arcane": {
|
||||
"containers": "Konteynerler",
|
||||
"images": "İmajlar",
|
||||
"image_updates": "İmaj Güncellemeleri",
|
||||
"images_unused": "Kullanılmayan İmajlar",
|
||||
"environment_required": "Ortam Kimliği Gerekli"
|
||||
},
|
||||
"dockhand": {
|
||||
"running": "Çalışan",
|
||||
"stopped": "Durdurulan",
|
||||
"cpu": "İşlemci",
|
||||
"memory": "Bellek",
|
||||
"images": "İmajlar",
|
||||
"volumes": "Birimler",
|
||||
"events_today": "Bugünkü Olaylar",
|
||||
"pending_updates": "Bekleyen Güncellemeler",
|
||||
"stacks": "Yığınlar",
|
||||
"paused": "Duraklatılan",
|
||||
"total": "Toplam",
|
||||
"environment_not_found": "Ortam Bulunamadı"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "问题",
|
||||
@@ -1045,10 +1059,10 @@
|
||||
"projects": "项目"
|
||||
},
|
||||
"apcups": {
|
||||
"status": "Status",
|
||||
"load": "Load",
|
||||
"bcharge": "Battery Charge",
|
||||
"timeleft": "Time Left"
|
||||
"status": "状态",
|
||||
"load": "负载",
|
||||
"bcharge": "电池电量",
|
||||
"timeleft": "剩余供电时间"
|
||||
},
|
||||
"karakeep": {
|
||||
"bookmarks": "书签",
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
8
pyproject.toml
Normal 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",
|
||||
]
|
||||
@@ -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
|
||||
37
src/__tests__/pages/_app.test.jsx
Normal file
37
src/__tests__/pages/_app.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
24
src/__tests__/pages/_document.test.jsx
Normal file
24
src/__tests__/pages/_document.test.jsx
Normal 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"');
|
||||
});
|
||||
});
|
||||
30
src/__tests__/pages/api/bookmarks.test.js
Normal file
30
src/__tests__/pages/api/bookmarks.test.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
87
src/__tests__/pages/api/config/[path].test.js
Normal file
87
src/__tests__/pages/api/config/[path].test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
153
src/__tests__/pages/api/docker/stats/[...service].test.js
Normal file
153
src/__tests__/pages/api/docker/stats/[...service].test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
211
src/__tests__/pages/api/docker/status/[...service].test.js
Normal file
211
src/__tests__/pages/api/docker/status/[...service].test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
64
src/__tests__/pages/api/hash.test.js
Normal file
64
src/__tests__/pages/api/hash.test.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
16
src/__tests__/pages/api/healthcheck.test.js
Normal file
16
src/__tests__/pages/api/healthcheck.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
210
src/__tests__/pages/api/kubernetes/stats/[...service].test.js
Normal file
210
src/__tests__/pages/api/kubernetes/stats/[...service].test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
121
src/__tests__/pages/api/kubernetes/status/[...service].test.js
Normal file
121
src/__tests__/pages/api/kubernetes/status/[...service].test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
80
src/__tests__/pages/api/ping.test.js
Normal file
80
src/__tests__/pages/api/ping.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
148
src/__tests__/pages/api/proxmox/stats/[...service].test.js
Normal file
148
src/__tests__/pages/api/proxmox/stats/[...service].test.js
Normal 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" });
|
||||
});
|
||||
});
|
||||
46
src/__tests__/pages/api/releases.test.js
Normal file
46
src/__tests__/pages/api/releases.test.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
29
src/__tests__/pages/api/revalidate.test.js
Normal file
29
src/__tests__/pages/api/revalidate.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
106
src/__tests__/pages/api/search/searchSuggestion.test.js
Normal file
106
src/__tests__/pages/api/search/searchSuggestion.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
30
src/__tests__/pages/api/services/index.test.js
Normal file
30
src/__tests__/pages/api/services/index.test.js
Normal 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: [] });
|
||||
});
|
||||
});
|
||||
347
src/__tests__/pages/api/services/proxy.test.js
Normal file
347
src/__tests__/pages/api/services/proxy.test.js
Normal file
@@ -0,0 +1,347 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
103
src/__tests__/pages/api/siteMonitor.test.js
Normal file
103
src/__tests__/pages/api/siteMonitor.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
41
src/__tests__/pages/api/theme.test.js
Normal file
41
src/__tests__/pages/api/theme.test.js
Normal 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" });
|
||||
});
|
||||
});
|
||||
30
src/__tests__/pages/api/validate.test.js
Normal file
30
src/__tests__/pages/api/validate.test.js
Normal 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"]);
|
||||
});
|
||||
});
|
||||
123
src/__tests__/pages/api/widgets/glances.test.js
Normal file
123
src/__tests__/pages/api/widgets/glances.test.js
Normal 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") }));
|
||||
});
|
||||
});
|
||||
30
src/__tests__/pages/api/widgets/index.test.js
Normal file
30
src/__tests__/pages/api/widgets/index.test.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { widgetsResponse } = vi.hoisted(() => ({
|
||||
widgetsResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/api-response", () => ({
|
||||
widgetsResponse,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/widgets/index";
|
||||
|
||||
describe("pages/api/widgets/index", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns widgetsResponse()", async () => {
|
||||
widgetsResponse.mockResolvedValueOnce([{ type: "logo", options: {} }]);
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.body).toEqual([{ type: "logo", options: {} }]);
|
||||
});
|
||||
});
|
||||
204
src/__tests__/pages/api/widgets/kubernetes.test.js
Normal file
204
src/__tests__/pages/api/widgets/kubernetes.test.js
Normal file
@@ -0,0 +1,204 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { kc, coreApi, metricsApi, getKubeConfig, parseCpu, parseMemory, logger } = vi.hoisted(() => {
|
||||
const coreApi = { listNode: vi.fn() };
|
||||
const metricsApi = { getNodeMetrics: vi.fn() };
|
||||
|
||||
const kc = {
|
||||
makeApiClient: vi.fn(() => coreApi),
|
||||
};
|
||||
|
||||
return {
|
||||
kc,
|
||||
coreApi,
|
||||
metricsApi,
|
||||
getKubeConfig: vi.fn(),
|
||||
parseCpu: vi.fn(),
|
||||
parseMemory: vi.fn(),
|
||||
logger: { error: vi.fn(), debug: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@kubernetes/client-node", () => ({
|
||||
CoreV1Api: class CoreV1Api {},
|
||||
Metrics: class Metrics {
|
||||
constructor() {
|
||||
return metricsApi;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/kubernetes", () => ({
|
||||
getKubeConfig,
|
||||
}));
|
||||
|
||||
vi.mock("utils/kubernetes/utils", () => ({
|
||||
parseCpu,
|
||||
parseMemory,
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/widgets/kubernetes";
|
||||
|
||||
describe("pages/api/widgets/kubernetes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 500 when no kube config is available", async () => {
|
||||
getKubeConfig.mockReturnValueOnce(null);
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body.error).toBe("No kubernetes configuration");
|
||||
});
|
||||
|
||||
it("returns 500 when listing nodes fails", async () => {
|
||||
getKubeConfig.mockReturnValueOnce(kc);
|
||||
coreApi.listNode.mockResolvedValueOnce(null);
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body.error).toContain("fetching nodes");
|
||||
});
|
||||
|
||||
it("logs and returns 500 when listing nodes throws", async () => {
|
||||
getKubeConfig.mockReturnValueOnce(kc);
|
||||
coreApi.listNode.mockRejectedValueOnce({ statusCode: 500, body: "nope", response: "nope" });
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
expect(logger.debug).toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body.error).toContain("fetching nodes");
|
||||
});
|
||||
|
||||
it("returns 500 when metrics lookup fails", async () => {
|
||||
getKubeConfig.mockReturnValueOnce(kc);
|
||||
parseMemory.mockReturnValue(100);
|
||||
coreApi.listNode.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
metadata: { name: "n1" },
|
||||
status: { capacity: { cpu: "1", memory: "100" }, conditions: [{ type: "Ready", status: "True" }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
metricsApi.getNodeMetrics.mockRejectedValueOnce(new Error("nope"));
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body.error).toContain("Error getting metrics");
|
||||
});
|
||||
|
||||
it("returns cluster totals and per-node usage", async () => {
|
||||
getKubeConfig.mockReturnValueOnce(kc);
|
||||
|
||||
parseMemory.mockImplementation((value) => {
|
||||
if (value === "100") return 100;
|
||||
if (value === "50") return 50;
|
||||
if (value === "30") return 30;
|
||||
return 0;
|
||||
});
|
||||
parseCpu.mockImplementation((value) => {
|
||||
if (value === "100m") return 0.1;
|
||||
if (value === "200m") return 0.2;
|
||||
return 0;
|
||||
});
|
||||
|
||||
coreApi.listNode.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
metadata: { name: "n1" },
|
||||
status: { capacity: { cpu: "1", memory: "100" }, conditions: [{ type: "Ready", status: "True" }] },
|
||||
},
|
||||
{
|
||||
metadata: { name: "n2" },
|
||||
status: { capacity: { cpu: "2", memory: "50" }, conditions: [{ type: "Ready", status: "False" }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
metricsApi.getNodeMetrics.mockResolvedValueOnce({
|
||||
items: [
|
||||
{ metadata: { name: "n1" }, usage: { cpu: "100m", memory: "30" } },
|
||||
{ metadata: { name: "n2" }, usage: { cpu: "200m", memory: "50" } },
|
||||
],
|
||||
});
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.cluster.cpu.total).toBe(3);
|
||||
expect(res.body.cluster.cpu.load).toBeCloseTo(0.3);
|
||||
expect(res.body.cluster.memory.total).toBe(150);
|
||||
expect(res.body.nodes).toHaveLength(2);
|
||||
expect(res.body.nodes.find((n) => n.name === "n1").cpu.percent).toBeCloseTo(10);
|
||||
});
|
||||
|
||||
it("returns a metrics error when metrics contain an unexpected node name", async () => {
|
||||
getKubeConfig.mockReturnValueOnce(kc);
|
||||
parseMemory.mockReturnValue(100);
|
||||
parseCpu.mockReturnValue(0.1);
|
||||
|
||||
coreApi.listNode.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
metadata: { name: "n1" },
|
||||
status: { capacity: { cpu: "1", memory: "100" }, conditions: [{ type: "Ready", status: "True" }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
metricsApi.getNodeMetrics.mockResolvedValueOnce({
|
||||
items: [{ metadata: { name: "n2" }, usage: { cpu: "100m", memory: "30" } }],
|
||||
});
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body.error).toContain("Error getting metrics");
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 500 when an unexpected error is thrown", async () => {
|
||||
getKubeConfig.mockImplementationOnce(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: "unknown error" });
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
126
src/__tests__/pages/api/widgets/longhorn.test.js
Normal file
126
src/__tests__/pages/api/widgets/longhorn.test.js
Normal file
@@ -0,0 +1,126 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { getSettings, httpProxy, logger } = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
httpProxy: vi.fn(),
|
||||
logger: { error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
getSettings,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/widgets/longhorn";
|
||||
|
||||
describe("pages/api/widgets/longhorn", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 400 when the longhorn URL isn't configured", async () => {
|
||||
getSettings.mockReturnValueOnce({ providers: { longhorn: {} } });
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe("Missing Longhorn URL");
|
||||
});
|
||||
|
||||
it("parses and aggregates node disk totals, including a total node", async () => {
|
||||
getSettings.mockReturnValueOnce({
|
||||
providers: { longhorn: { url: "http://lh", username: "u", password: "p" } },
|
||||
});
|
||||
|
||||
const payload = {
|
||||
data: [
|
||||
{
|
||||
id: "n1",
|
||||
disks: {
|
||||
d1: { storageAvailable: 1, storageMaximum: 10, storageReserved: 2, storageScheduled: 3 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "n2",
|
||||
disks: {
|
||||
d1: { storageAvailable: 4, storageMaximum: 20, storageReserved: 5, storageScheduled: 6 },
|
||||
d2: { storageAvailable: 1, storageMaximum: 1, storageReserved: 1, storageScheduled: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", JSON.stringify(payload)]);
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledWith(
|
||||
"http://lh/v1/nodes",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({ Authorization: expect.any(String) }),
|
||||
}),
|
||||
);
|
||||
expect(res.headers["Content-Type"]).toBe("application/json");
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const nodes = res.body.nodes;
|
||||
expect(nodes.map((n) => n.id)).toEqual(["n1", "n2", "total"]);
|
||||
expect(nodes.find((n) => n.id === "total")).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "total",
|
||||
available: 6,
|
||||
maximum: 31,
|
||||
reserved: 8,
|
||||
scheduled: 10,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("handles nodes without disks and logs non-200 responses", async () => {
|
||||
getSettings.mockReturnValueOnce({ providers: { longhorn: { url: "http://lh" } } });
|
||||
|
||||
const payload = { data: [{ id: "n1" }] };
|
||||
httpProxy.mockResolvedValueOnce([401, "application/json", JSON.stringify(payload)]);
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.nodes).toEqual([
|
||||
{ id: "n1", available: 0, maximum: 0, reserved: 0, scheduled: 0 },
|
||||
{ id: "total", available: 0, maximum: 0, reserved: 0, scheduled: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns nodes=null when the API returns a null payload", async () => {
|
||||
getSettings.mockReturnValueOnce({ providers: { longhorn: { url: "http://lh" } } });
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", "null"]);
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ nodes: null });
|
||||
});
|
||||
});
|
||||
52
src/__tests__/pages/api/widgets/openmeteo.test.js
Normal file
52
src/__tests__/pages/api/widgets/openmeteo.test.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { cachedRequest } = vi.hoisted(() => ({
|
||||
cachedRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
cachedRequest,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/widgets/openmeteo";
|
||||
|
||||
describe("pages/api/widgets/openmeteo", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("builds the open-meteo URL with units + timezone and calls cachedRequest", async () => {
|
||||
cachedRequest.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const req = {
|
||||
query: { latitude: "1", longitude: "2", units: "metric", cache: "5" },
|
||||
};
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(cachedRequest).toHaveBeenCalledWith(
|
||||
"https://api.open-meteo.com/v1/forecast?latitude=1&longitude=2&daily=sunrise,sunset¤t_weather=true&temperature_unit=celsius&timezone=auto",
|
||||
"5",
|
||||
);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("uses the provided timezone and fahrenheit for non-metric units", async () => {
|
||||
cachedRequest.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const req = {
|
||||
query: { latitude: "1", longitude: "2", units: "imperial", cache: 1, timezone: "UTC" },
|
||||
};
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(cachedRequest).toHaveBeenCalledWith(
|
||||
"https://api.open-meteo.com/v1/forecast?latitude=1&longitude=2&daily=sunrise,sunset¤t_weather=true&temperature_unit=fahrenheit&timezone=UTC",
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
122
src/__tests__/pages/api/widgets/openweathermap.test.js
Normal file
122
src/__tests__/pages/api/widgets/openweathermap.test.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { getSettings, getPrivateWidgetOptions, cachedRequest } = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
getPrivateWidgetOptions: vi.fn(),
|
||||
cachedRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
getSettings,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/widget-helpers", () => ({
|
||||
getPrivateWidgetOptions,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
cachedRequest,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/widgets/openweathermap";
|
||||
|
||||
describe("pages/api/widgets/openweathermap", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 400 when no API key and no provider are supplied", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({});
|
||||
|
||||
const req = { query: { latitude: "1", longitude: "2", units: "metric", lang: "en", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Missing API key or provider" });
|
||||
});
|
||||
|
||||
it("returns 400 when provider doesn't match endpoint and no per-widget key exists", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({});
|
||||
|
||||
const req = { query: { latitude: "1", longitude: "2", units: "metric", lang: "en", provider: "weatherapi" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Invalid provider for endpoint" });
|
||||
});
|
||||
|
||||
it("uses key from widget options when present", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({ apiKey: "from-widget" });
|
||||
cachedRequest.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const req = {
|
||||
query: { latitude: "1", longitude: "2", units: "metric", lang: "en", cache: "1", index: "2" },
|
||||
};
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(getPrivateWidgetOptions).toHaveBeenCalledWith("openweathermap", "2");
|
||||
expect(cachedRequest).toHaveBeenCalledWith(
|
||||
"https://api.openweathermap.org/data/2.5/weather?lat=1&lon=2&appid=from-widget&units=metric&lang=en",
|
||||
"1",
|
||||
);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("falls back to settings provider key when provider=openweathermap", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({});
|
||||
getSettings.mockReturnValueOnce({ providers: { openweathermap: "from-settings" } });
|
||||
cachedRequest.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const req = {
|
||||
query: {
|
||||
latitude: "1",
|
||||
longitude: "2",
|
||||
units: "imperial",
|
||||
lang: "en",
|
||||
provider: "openweathermap",
|
||||
cache: 2,
|
||||
index: "0",
|
||||
},
|
||||
};
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(cachedRequest).toHaveBeenCalledWith(
|
||||
"https://api.openweathermap.org/data/2.5/weather?lat=1&lon=2&appid=from-settings&units=imperial&lang=en",
|
||||
2,
|
||||
);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("returns 400 when provider=openweathermap but settings do not provide an api key", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({});
|
||||
getSettings.mockReturnValueOnce({ providers: {} });
|
||||
|
||||
const req = {
|
||||
query: {
|
||||
latitude: "1",
|
||||
longitude: "2",
|
||||
units: "metric",
|
||||
lang: "en",
|
||||
provider: "openweathermap",
|
||||
cache: 1,
|
||||
index: "0",
|
||||
},
|
||||
};
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Missing API key" });
|
||||
});
|
||||
});
|
||||
140
src/__tests__/pages/api/widgets/resources.test.js
Normal file
140
src/__tests__/pages/api/widgets/resources.test.js
Normal file
@@ -0,0 +1,140 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { si, logger } = vi.hoisted(() => ({
|
||||
si: {
|
||||
currentLoad: vi.fn(),
|
||||
fsSize: vi.fn(),
|
||||
mem: vi.fn(),
|
||||
cpuTemperature: vi.fn(),
|
||||
time: vi.fn(),
|
||||
networkStats: vi.fn(),
|
||||
networkInterfaceDefault: vi.fn(),
|
||||
},
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
vi.mock("systeminformation", () => ({ default: si }));
|
||||
|
||||
import handler from "pages/api/widgets/resources";
|
||||
|
||||
describe("pages/api/widgets/resources", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns CPU load data", async () => {
|
||||
si.currentLoad.mockResolvedValueOnce({ currentLoad: 12.34, avgLoad: 1.23 });
|
||||
|
||||
const req = { query: { type: "cpu" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.cpu).toEqual({ usage: 12.34, load: 1.23 });
|
||||
});
|
||||
|
||||
it("returns 404 when requested disk target does not exist", async () => {
|
||||
si.fsSize.mockResolvedValueOnce([{ mount: "/" }]);
|
||||
|
||||
const req = { query: { type: "disk", target: "/missing" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toEqual({ error: "Resource not available." });
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns disk info for the requested mount", async () => {
|
||||
si.fsSize.mockResolvedValueOnce([{ mount: "/data", size: 1 }]);
|
||||
|
||||
const req = { query: { type: "disk", target: "/data" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.drive).toEqual({ mount: "/data", size: 1 });
|
||||
});
|
||||
|
||||
it("returns memory, cpu temp and uptime", async () => {
|
||||
si.mem.mockResolvedValueOnce({ total: 10 });
|
||||
si.cpuTemperature.mockResolvedValueOnce({ main: 50 });
|
||||
si.time.mockResolvedValueOnce({ uptime: 123 });
|
||||
|
||||
const resMem = createMockRes();
|
||||
await handler({ query: { type: "memory" } }, resMem);
|
||||
expect(resMem.statusCode).toBe(200);
|
||||
expect(resMem.body.memory).toEqual({ total: 10 });
|
||||
|
||||
const resTemp = createMockRes();
|
||||
await handler({ query: { type: "cputemp" } }, resTemp);
|
||||
expect(resTemp.statusCode).toBe(200);
|
||||
expect(resTemp.body.cputemp).toEqual({ main: 50 });
|
||||
|
||||
const resUptime = createMockRes();
|
||||
await handler({ query: { type: "uptime" } }, resUptime);
|
||||
expect(resUptime.statusCode).toBe(200);
|
||||
expect(resUptime.body.uptime).toBe(123);
|
||||
});
|
||||
|
||||
it("returns 404 when requested network interface does not exist", async () => {
|
||||
si.networkStats.mockResolvedValueOnce([{ iface: "en0" }]);
|
||||
|
||||
const req = { query: { type: "network", interfaceName: "missing" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toEqual({ error: "Interface not found" });
|
||||
});
|
||||
|
||||
it("returns default interface network stats", async () => {
|
||||
si.networkStats.mockResolvedValueOnce([{ iface: "en0", rx_bytes: 1 }]);
|
||||
si.networkInterfaceDefault.mockResolvedValueOnce("en0");
|
||||
|
||||
const req = { query: { type: "network" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.interface).toBe("en0");
|
||||
expect(res.body.network).toEqual({ iface: "en0", rx_bytes: 1 });
|
||||
});
|
||||
|
||||
it("returns 404 when the default interface cannot be found in networkStats", async () => {
|
||||
si.networkStats.mockResolvedValueOnce([{ iface: "en0", rx_bytes: 1 }]);
|
||||
si.networkInterfaceDefault.mockResolvedValueOnce("en1");
|
||||
|
||||
const req = { query: { type: "network" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toEqual({ error: "Default interface not found" });
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid type", async () => {
|
||||
const req = { query: { type: "nope" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "invalid type" });
|
||||
});
|
||||
});
|
||||
117
src/__tests__/pages/api/widgets/stocks.test.js
Normal file
117
src/__tests__/pages/api/widgets/stocks.test.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { getSettings, cachedRequest, logger } = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
cachedRequest: vi.fn(),
|
||||
logger: { debug: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
getSettings,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
cachedRequest,
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/widgets/stocks";
|
||||
|
||||
describe("pages/api/widgets/stocks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("validates watchlist and provider", async () => {
|
||||
const res1 = createMockRes();
|
||||
await handler({ query: {} }, res1);
|
||||
expect(res1.statusCode).toBe(400);
|
||||
|
||||
const res2 = createMockRes();
|
||||
await handler({ query: { watchlist: "null", provider: "finnhub" } }, res2);
|
||||
expect(res2.statusCode).toBe(400);
|
||||
|
||||
const res3 = createMockRes();
|
||||
await handler({ query: { watchlist: "AAPL,AAPL", provider: "finnhub" } }, res3);
|
||||
expect(res3.statusCode).toBe(400);
|
||||
expect(res3.body.error).toContain("duplicates");
|
||||
|
||||
const res4 = createMockRes();
|
||||
await handler({ query: { watchlist: "AAPL", provider: "nope" } }, res4);
|
||||
expect(res4.statusCode).toBe(400);
|
||||
expect(res4.body.error).toContain("Invalid provider");
|
||||
|
||||
const res5 = createMockRes();
|
||||
await handler({ query: { watchlist: "AAPL" } }, res5);
|
||||
expect(res5.statusCode).toBe(400);
|
||||
expect(res5.body.error).toContain("Missing provider");
|
||||
|
||||
const res6 = createMockRes();
|
||||
await handler({ query: { watchlist: "A,B,C,D,E,F,G,H,I", provider: "finnhub" } }, res6);
|
||||
expect(res6.statusCode).toBe(400);
|
||||
expect(res6.body.error).toContain("Max items");
|
||||
});
|
||||
|
||||
it("returns 400 when API key isn't configured for provider", async () => {
|
||||
getSettings.mockReturnValueOnce({ providers: {} });
|
||||
|
||||
const req = { query: { watchlist: "AAPL", provider: "finnhub" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toContain("API Key");
|
||||
});
|
||||
|
||||
it("tolerates missing providers config and returns a helpful error", async () => {
|
||||
getSettings.mockReturnValueOnce({});
|
||||
|
||||
const req = { query: { watchlist: "AAPL", provider: "finnhub" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toContain("API Key");
|
||||
});
|
||||
|
||||
it("returns a normalized stocks response and rounds values", async () => {
|
||||
getSettings.mockReturnValueOnce({ providers: { finnhub: "k" } });
|
||||
|
||||
cachedRequest
|
||||
.mockResolvedValueOnce({ c: 10.123, dp: -1.234 }) // AAPL
|
||||
.mockResolvedValueOnce({ c: null, dp: null }); // MSFT
|
||||
|
||||
const req = { query: { watchlist: "AAPL,MSFT", provider: "finnhub", cache: "1" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(cachedRequest).toHaveBeenCalledWith("https://finnhub.io/api/v1/quote?symbol=AAPL&token=k", "1");
|
||||
expect(res.body).toEqual({
|
||||
stocks: [
|
||||
{ ticker: "AAPL", currentPrice: "10.12", percentChange: -1.23 },
|
||||
{ ticker: "MSFT", currentPrice: null, percentChange: null },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null entries when the watchlist includes empty tickers", async () => {
|
||||
getSettings.mockReturnValueOnce({ providers: { finnhub: "k" } });
|
||||
cachedRequest.mockResolvedValueOnce({ c: 1, dp: 1 });
|
||||
|
||||
const req = { query: { watchlist: "AAPL,", provider: "finnhub" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.body.stocks[0]).toEqual({ ticker: "AAPL", currentPrice: "1.00", percentChange: 1 });
|
||||
expect(res.body.stocks[1]).toEqual({ ticker: null, currentPrice: null, percentChange: null });
|
||||
});
|
||||
});
|
||||
98
src/__tests__/pages/api/widgets/weather.test.js
Normal file
98
src/__tests__/pages/api/widgets/weather.test.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { getSettings, getPrivateWidgetOptions, cachedRequest } = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
getPrivateWidgetOptions: vi.fn(),
|
||||
cachedRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
getSettings,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/widget-helpers", () => ({
|
||||
getPrivateWidgetOptions,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
cachedRequest,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/widgets/weather";
|
||||
|
||||
describe("pages/api/widgets/weatherapi", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 400 when no API key and no provider are supplied", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({});
|
||||
|
||||
const req = { query: { latitude: "1", longitude: "2", lang: "en", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Missing API key or provider" });
|
||||
});
|
||||
|
||||
it("uses key from widget options when present", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({ apiKey: "from-widget" });
|
||||
cachedRequest.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const req = { query: { latitude: "1", longitude: "2", lang: "en", cache: 1, index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(cachedRequest).toHaveBeenCalledWith(
|
||||
"http://api.weatherapi.com/v1/current.json?q=1,2&key=from-widget&lang=en",
|
||||
1,
|
||||
);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("falls back to settings provider key when provider=weatherapi", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({});
|
||||
getSettings.mockReturnValueOnce({ providers: { weatherapi: "from-settings" } });
|
||||
cachedRequest.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const req = { query: { latitude: "1", longitude: "2", lang: "en", provider: "weatherapi", cache: "2" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(cachedRequest).toHaveBeenCalledWith(
|
||||
"http://api.weatherapi.com/v1/current.json?q=1,2&key=from-settings&lang=en",
|
||||
"2",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsupported providers", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({});
|
||||
|
||||
const req = { query: { latitude: "1", longitude: "2", lang: "en", provider: "nope" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Invalid provider for endpoint" });
|
||||
});
|
||||
|
||||
it("returns 400 when a provider is set but no API key can be resolved", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({});
|
||||
getSettings.mockReturnValueOnce({ providers: {} });
|
||||
|
||||
const req = { query: { latitude: "1", longitude: "2", lang: "en", provider: "weatherapi" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Missing API key" });
|
||||
});
|
||||
});
|
||||
42
src/__tests__/pages/browserconfig.xml.test.js
Normal file
42
src/__tests__/pages/browserconfig.xml.test.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import themes from "utils/styles/themes";
|
||||
|
||||
const { getSettings } = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
getSettings,
|
||||
}));
|
||||
|
||||
import BrowserConfig, { getServerSideProps } from "pages/browserconfig.xml.jsx";
|
||||
|
||||
function createMockRes() {
|
||||
return {
|
||||
setHeader: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("pages/browserconfig.xml", () => {
|
||||
it("writes a browserconfig xml response using the selected theme color", async () => {
|
||||
getSettings.mockReturnValueOnce({ color: "slate", theme: "dark" });
|
||||
const res = createMockRes();
|
||||
|
||||
await getServerSideProps({ res });
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "text/xml");
|
||||
expect(res.end).toHaveBeenCalled();
|
||||
|
||||
const xml = res.write.mock.calls[0][0];
|
||||
expect(xml).toContain('<?xml version="1.0" encoding="utf-8"?>');
|
||||
expect(xml).toContain('<square150x150logo src="/mstile-150x150.png?v=2"/>');
|
||||
expect(xml).toContain(`<TileColor>${themes.slate.dark}</TileColor>`);
|
||||
});
|
||||
|
||||
it("exports a placeholder component", () => {
|
||||
expect(BrowserConfig()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user