mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-10 06:08:40 -07:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 541b2fe9f1 | |||
| 574ba2c7cb | |||
| f78cab7557 | |||
| 2cd77eae1f | |||
| 6ab83b0b5f | |||
| 9aa5a79c41 | |||
| 1c2cb76702 | |||
| 7de1faa769 | |||
| 1024a382ee | |||
| 438788240b | |||
| df34927198 | |||
| ddc5adc91c | |||
| 22f1317ad3 | |||
| 7c36d09217 | |||
| 378f2d5d6d | |||
| d907ca1d0e | |||
| 81accd8dbe | |||
| 070e1b9cca | |||
| 6324c5ee63 | |||
| 9b475f48d8 | |||
| 35b72b65de | |||
| f6dca0c5ad | |||
| e6ad43a76c | |||
| c46b3e6cff | |||
| e913243149 | |||
| 99e16c6da0 | |||
| c0b6eb3501 | |||
| a17763ca9f | |||
| bd39fb48b9 | |||
| f2dc80e39e | |||
| 7d48b7ea78 | |||
| 4c361422ca | |||
| 7c26cf6d97 | |||
| 5873f8e7d5 | |||
| 6099837373 | |||
| f0bd255adb | |||
| 21bf7ed8e2 | |||
| 728c6fbe18 | |||
| 6a96289717 | |||
| fa47a2d86c | |||
| cc22d04429 | |||
| a414f376b9 | |||
| bdd77342d8 | |||
| 63d2172ef7 | |||
| 85e4e439bc | |||
| 36aa241689 | |||
| 0933ba015a | |||
| c57117daae | |||
| 93763b7c10 | |||
| 6c551ace34 | |||
| 7886742d56 | |||
| a253861061 | |||
| edfb28ab5d | |||
| 3ab6b040b2 | |||
| 9cabb46bb7 | |||
| d7cc2d3f19 | |||
| b5084ba8b3 | |||
| dca0c9ab81 | |||
| d4fd14b724 | |||
| 600c9eee78 | |||
| 198c86fb49 | |||
| f711d290a7 | |||
| 342afa2215 | |||
| 27704f962e |
@@ -10,6 +10,7 @@ version-resolver:
|
||||
- 'breaking-change'
|
||||
minor:
|
||||
labels:
|
||||
- 'minor'
|
||||
- 'enhancement'
|
||||
- 'feature'
|
||||
patch:
|
||||
|
||||
@@ -10,6 +10,10 @@ on:
|
||||
]
|
||||
branches: [ dev ]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
synchronize-with-crowdin:
|
||||
name: Crowdin Sync
|
||||
@@ -19,7 +23,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2
|
||||
uses: crowdin/github-action@e4a6c1338b4063c77d46a81875265f9e8bd76f95 # v3.0.0
|
||||
with:
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
|
||||
@@ -17,6 +17,8 @@ on:
|
||||
env:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
|
||||
|
||||
@@ -27,7 +29,6 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -101,7 +102,7 @@ jobs:
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
|
||||
@@ -10,8 +10,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -25,7 +23,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
- run: sudo apt-get install pngquant
|
||||
- name: Test Docs Build
|
||||
run: uv run --frozen zensical build --clean
|
||||
@@ -33,6 +31,10 @@ jobs:
|
||||
name: Build & Deploy Docs
|
||||
if: github.repository == 'gethomepage/homepage' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
@@ -43,7 +45,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
- run: sudo apt-get install pngquant
|
||||
- name: Build Docs
|
||||
run: uv run --frozen zensical build --clean
|
||||
|
||||
@@ -6,15 +6,15 @@ on:
|
||||
discussion:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
discussions: write
|
||||
issues: write
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
close-needs-discussion:
|
||||
name: Issues Need Discussion
|
||||
if: github.event_name == 'issues' && github.event.label.name == 'needs-discussion'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
@@ -49,6 +49,8 @@ jobs:
|
||||
name: Discussions Need Information
|
||||
if: github.event_name == 'discussion' && github.event.label.name == 'needs-information'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
discussions: write
|
||||
steps:
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
workflow_dispatch:
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Linting Checks
|
||||
|
||||
@@ -2,7 +2,6 @@ name: PR Quality
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
|
||||
@@ -5,7 +5,6 @@ on:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -5,10 +5,7 @@ on:
|
||||
- cron: '0 3 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
discussions: write
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: lock
|
||||
@@ -17,6 +14,9 @@ jobs:
|
||||
stale:
|
||||
name: 'Stale'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10
|
||||
with:
|
||||
@@ -31,6 +31,10 @@ jobs:
|
||||
lock-threads:
|
||||
name: 'Lock Old Threads'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
discussions: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
|
||||
with:
|
||||
@@ -56,6 +60,8 @@ jobs:
|
||||
close-answered-discussions:
|
||||
name: 'Close Answered Discussions'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
discussions: write
|
||||
steps:
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
@@ -112,6 +118,8 @@ jobs:
|
||||
close-outdated-discussions:
|
||||
name: 'Close Outdated Discussions'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
discussions: write
|
||||
steps:
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
@@ -203,6 +211,8 @@ jobs:
|
||||
close-unsupported-feature-requests:
|
||||
name: 'Close Unsupported Feature Requests'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
discussions: write
|
||||
steps:
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
|
||||
@@ -5,6 +5,9 @@ on:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
vitest:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -7,6 +7,10 @@ As of version v0.6.30 homepage supports adding your own custom css & javascript.
|
||||
|
||||
To add custom css simply edit the `custom.css` file under your config directory, similarly for javascript you would edit `custom.js`. You can then target elements in homepage with various classes / ids to customize things to your liking.
|
||||
|
||||
!!! warning
|
||||
|
||||
When Homepage authentication is enabled, `custom.css` remains publicly accessible so it can style the sign-in page. Do not include secrets or sensitive internal URLs in this file. `custom.js` remains protected and is only loaded after authentication.
|
||||
|
||||
You can also set a specific `id` for a service or bookmark to target with your custom css or javascript, e.g.
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -49,6 +49,23 @@ environment:
|
||||
HOMEPAGE_MCP_TOKEN: "generate-with-openssl-rand-base64-32"
|
||||
```
|
||||
|
||||
## Connecting Claude Desktop
|
||||
|
||||
Claude Desktop can use [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) to communicate with the homepage MCP endpoint in `claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"homepage": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-remote", "http://localhost:3000/api/mcp", "--header", "X-Homepage-MCP-Token: your-token"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The **Add custom connector** option will not work: remote connectors authenticate with OAuth and cannot supply `HOMEPAGE_MCP_TOKEN`.
|
||||
|
||||
## Read-only by default
|
||||
|
||||
The MCP endpoint exposes tools for reading and validating supported Homepage config files. File writes are disabled unless you opt in with:
|
||||
|
||||
@@ -69,11 +69,16 @@ For OIDC login (overrides password login):
|
||||
- `HOMEPAGE_OIDC_CLIENT_ID`
|
||||
- `HOMEPAGE_OIDC_CLIENT_SECRET`
|
||||
- Optional: `HOMEPAGE_OIDC_NAME` (display name), `HOMEPAGE_OIDC_SCOPE` (defaults to `openid email profile`)
|
||||
- Optional: `HOMEPAGE_OIDC_AUTO_LOGIN=true` to skip the login page and send unauthenticated visitors straight to the provider
|
||||
|
||||
!!! tip
|
||||
|
||||
With auto-login enabled, visit `/auth/signin?autologin=0` to reach the login page without being redirected, e.g. in case something goes wrong!
|
||||
|
||||
!!! warning
|
||||
|
||||
Homepage grants access to any identity that the configured OIDC provider authorizes for this client. Configure client assignments, groups, or access policies at the identity provider. Homepage does not apply additional claim-based authorization.
|
||||
|
||||
All app pages and `/api` routes except `/api/healthcheck` will require a signed-in session. Static assets remain public.
|
||||
All app pages and `/api` routes except `/api/healthcheck` and `/api/config/custom.css` will require a signed-in session. Static assets remain public.
|
||||
|
||||
Configure your OIDC provider with the a callback URI like `https://homepage.example.com/api/auth/callback/homepage-oidc`.
|
||||
|
||||
@@ -85,6 +85,7 @@ To ensure cohesiveness of various widgets, the following should be used as a gui
|
||||
- Note that we reserve the right to decline widgets for projects that are very young (eg < ~1y) or those with a small reach (eg low GitHub stars). Again, this is in an effort to keep overall widget maintenance under control.
|
||||
- Widgets should be only one row of blocks
|
||||
- Widgets should be no more than 4 blocks wide and generally conform to the styling / design choices of other widgets
|
||||
- Use `withWidgetFields(service, defaultFields)` from `utils/widget-fields` to apply default fields and cap configured fields without mutating the service. The helper limits widgets to 4 fields.
|
||||
- Minimize the number of API calls
|
||||
- Avoid the use of custom proxy unless absolutely necessary
|
||||
- Widgets should be 'read-only', as in they should not make write changes using the relevant tool's API. Homepage widgets are designed to surface information, not to be a (usually worse) replacement for the tool itself.
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Authentik Widget Configuration
|
||||
|
||||
Learn more about [Authentik](https://github.com/goauthentik/authentik).
|
||||
|
||||
This widget reads the number of active users in the system, as well as logins for the last 24 hours.
|
||||
This widget reads the number of active users in the system, as well as logins for the last 24 hours, and authorizations when using widget version 2.
|
||||
|
||||
You will need to generate an API token for an existing user under `Admin Portal` > `Directory` > `Tokens & App passwords`.
|
||||
Make sure to set Intent to "API Token".
|
||||
@@ -15,7 +15,7 @@ The account you made the API token for also needs the following **Assigned globa
|
||||
- authentik Core -> Can view User (Model: User)
|
||||
- authentik Events -> Can view Event (Model: Event)
|
||||
|
||||
Allowed fields: `["users", "loginsLast24H", "failedLoginsLast24H"]`.
|
||||
Allowed fields: `["users", "loginsLast24H", "failedLoginsLast24H", "authorizationsLast24H"]` (`authorizationsLast24H` works with v2 only).
|
||||
|
||||
| Authentik Version | Homepage Widget Version |
|
||||
| ----------------- | ----------------------- |
|
||||
|
||||
@@ -9,7 +9,9 @@ See the [crowdsec docs](https://docs.crowdsec.net/docs/local_api/intro/#machines
|
||||
in most instances you can use the default credentials (`/etc/crowdsec/local_api_credentials.yaml`).
|
||||
|
||||
!!! note
|
||||
Without the `limit24h` option, the widget will fetch all alerts which is limited to 100 by the API to avoid performance issues.
|
||||
|
||||
`alerts` counts alerts raised by your own engine in the last 24 hours. Alerts originating from the
|
||||
CrowdSec community blocklist are excluded.
|
||||
|
||||
Allowed fields: `["alerts", "bans"]`.
|
||||
|
||||
@@ -19,5 +21,4 @@ widget:
|
||||
url: http://crowdsechostorip:port
|
||||
username: localhost # machine_id in crowdsec
|
||||
password: password
|
||||
limit24h: true # optional, limits alerts to last 24h. Default: false
|
||||
```
|
||||
|
||||
@@ -13,8 +13,8 @@ Allowed fields: `["movies", "series", "episodes", "songs", "albums"]`.
|
||||
|
||||
| Jellyfin Version | Homepage Widget Version |
|
||||
| ---------------- | ----------------------- |
|
||||
| < 10.12 | 1 (default) |
|
||||
| >= 10.12 | 2 |
|
||||
| < 12 (10.12) | 1 (default) |
|
||||
| >= 12 (10.12) | 2 |
|
||||
|
||||
```yaml
|
||||
widget:
|
||||
|
||||
@@ -15,5 +15,5 @@ widget:
|
||||
url: https://proxmoxbackupserver.host:port
|
||||
username: api_token_id
|
||||
password: api_token_secret
|
||||
datastore: datastore_name #optional; if ommitted, will display a combination of all datastores used / total
|
||||
datastore: datastore_name #optional; if omitted, will display a combination of all datastores used / total
|
||||
```
|
||||
|
||||
@@ -12,5 +12,6 @@ widget:
|
||||
type: pulse
|
||||
url: http://pulse.host.or.ip:7655
|
||||
key: your-api-token # `monitoring:read` scope is required
|
||||
version: 2 # required for Pulse v6, defaults to 1
|
||||
fields: ["nodes", "vms", "lxcs"] # optional
|
||||
```
|
||||
|
||||
+5
-20
@@ -1,27 +1,12 @@
|
||||
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 nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||
import prettierConfig from "eslint-config-prettier/flat";
|
||||
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([
|
||||
...nextCoreWebVitals,
|
||||
prettierConfig,
|
||||
{
|
||||
extends: fixupConfigRules(compat.extends("next/core-web-vitals", "prettier", "plugin:react-hooks/recommended")),
|
||||
|
||||
plugins: {
|
||||
prettier,
|
||||
},
|
||||
files: ["**/*.{js,mjs,cjs,jsx}"],
|
||||
|
||||
languageOptions: {
|
||||
ecmaVersion: 6,
|
||||
|
||||
+9
-17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "2.0.0",
|
||||
"version": "2.3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.10",
|
||||
"@kubernetes/client-node": "^1.4.0",
|
||||
"@kubernetes/client-node": "^2.0.0",
|
||||
"classnames": "^2.5.1",
|
||||
"compare-versions": "^6.1.1",
|
||||
"dockerode": "^5.0.0",
|
||||
@@ -23,12 +23,12 @@
|
||||
"gamedig": "^5.3.3",
|
||||
"i18next": "^26.3.6",
|
||||
"ical.js": "^2.2.1",
|
||||
"js-yaml": "^4.3.1",
|
||||
"js-yaml": "^5.4.0",
|
||||
"json-rpc-2.0": "^1.7.1",
|
||||
"luxon": "^3.7.2",
|
||||
"memory-cache": "^0.2.0",
|
||||
"minecraftstatuspinger": "^1.2.2",
|
||||
"next": "^16.2.12",
|
||||
"next": "^16.3.3",
|
||||
"next-auth": "^4.24.15",
|
||||
"next-i18next": "^16.0.8",
|
||||
"ping": "^1.0.0",
|
||||
@@ -36,10 +36,10 @@
|
||||
"raw-body": "^4.0.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-i18next": "^17.0.12",
|
||||
"react-icons": "^5.6.0",
|
||||
"recharts": "^3.1.2",
|
||||
"swr": "^2.4.2",
|
||||
"swr": "^2.5.1",
|
||||
"systeminformation": "^5.33.1",
|
||||
"tough-cookie": "^6.0.2",
|
||||
"urbackup-server-api": "^0.92.2",
|
||||
@@ -48,29 +48,21 @@
|
||||
"xml-js": "^1.6.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.1.0",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"eslint": "^9.25.1",
|
||||
"eslint-config-next": "^15.5.11",
|
||||
"eslint-config-next": "^16.3.3",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"jsdom": "^30.0.1",
|
||||
"postcss": "^8.5.26",
|
||||
"prettier": "^3.8.4",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"tailwind-scrollbar": "^4.0.2",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
Generated
+593
-463
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><circle cx="5" cy="5" r="4"/></svg>
|
||||
|
After Width: | Height: | Size: 96 B |
@@ -21,7 +21,7 @@
|
||||
"seconds": "s"
|
||||
},
|
||||
"auth": {
|
||||
"signout": "Sign out"
|
||||
"signout": "Teken uit"
|
||||
},
|
||||
"widget": {
|
||||
"missing_type": "Ontbrekende legstuk-tipe: {{type}}",
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Wag asseblief"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Soek…"
|
||||
"placeholder": "Soek…",
|
||||
"provider": "Soekenjin: {{name}}",
|
||||
"select_provider": "Kies soekenjin"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "SVE",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Ongesond",
|
||||
"not_found": "Nie Gevind Nie",
|
||||
"exited": "Verlaat",
|
||||
"partial": "Gedeeltelik"
|
||||
"partial": "Gedeeltelik",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Fout",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Gebruikers",
|
||||
"loginsLast24H": "Aantekenings (24h)",
|
||||
"failedLoginsLast24H": "Mislukte Aantekenings (24h)"
|
||||
"failedLoginsLast24H": "Mislukte Aantekenings (24h)",
|
||||
"authorizationsLast24H": "Magtigings (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "GEH",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "الرجاء الإنتظار"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "بحث …"
|
||||
"placeholder": "بحث …",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "المعالج",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "غير سليم",
|
||||
"not_found": "غير موجود",
|
||||
"exited": "خرجت",
|
||||
"partial": "جزئي"
|
||||
"partial": "جزئي",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "خطأ",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "المستخدمون",
|
||||
"loginsLast24H": "تسجيلات الدخول (٢٤س)",
|
||||
"failedLoginsLast24H": "فشل تسجيلات الدخول (٢٤س)"
|
||||
"failedLoginsLast24H": "فشل تسجيلات الدخول (٢٤س)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "الذاكرة",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Моля изчакайте"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Търсене…"
|
||||
"placeholder": "Търсене…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "Процесор",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Нездравословен",
|
||||
"not_found": "Не е открит",
|
||||
"exited": "Напуснал",
|
||||
"partial": "Частично"
|
||||
"partial": "Частично",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Грешка",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Потребители",
|
||||
"loginsLast24H": "Logins (24h)",
|
||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
||||
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
+261
-253
@@ -21,7 +21,7 @@
|
||||
"seconds": "s"
|
||||
},
|
||||
"auth": {
|
||||
"signout": "Sign out"
|
||||
"signout": "Tanca la sessió"
|
||||
},
|
||||
"widget": {
|
||||
"missing_type": "Falta el tipus de widget: {{type}}",
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Si us plau, espereu"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Cerca…"
|
||||
"placeholder": "Cerca…",
|
||||
"provider": "Motor de cerca: {{name}}",
|
||||
"select_provider": "Escull motor de cerca"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -70,17 +72,17 @@
|
||||
"empty_data": "Estat del subsistema desconegut"
|
||||
},
|
||||
"unifi_drive": {
|
||||
"healthy": "Healthy",
|
||||
"degraded": "Degraded",
|
||||
"no_data": "No storage data available"
|
||||
"healthy": "Saludable",
|
||||
"degraded": "Degradat",
|
||||
"no_data": "Sense dades d'emmagatzematge disponible"
|
||||
},
|
||||
"duplicati": {
|
||||
"jobs": "Jobs",
|
||||
"stored": "Stored",
|
||||
"lastBackup": "Last Run",
|
||||
"nextRun": "Next Run",
|
||||
"running": "Running",
|
||||
"warnings": "Warnings",
|
||||
"jobs": "Tasques",
|
||||
"stored": "Desat",
|
||||
"lastBackup": "Darrera execució",
|
||||
"nextRun": "Propera execució",
|
||||
"running": "En execució",
|
||||
"warnings": "Avisos",
|
||||
"errors": "Errors"
|
||||
},
|
||||
"docker": {
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "No saludable",
|
||||
"not_found": "No trobat",
|
||||
"exited": "Tancat",
|
||||
"partial": "Parcial"
|
||||
"partial": "Parcial",
|
||||
"created": "Creat",
|
||||
"paused": "Pausat",
|
||||
"restarting": "Reiniciant",
|
||||
"removing": "Eliminant",
|
||||
"dead": "Mort"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Error",
|
||||
@@ -133,7 +140,7 @@
|
||||
"series": "Sèries",
|
||||
"episodes": "Episodis",
|
||||
"songs": "Cançons",
|
||||
"albums": "Albums"
|
||||
"albums": "Àlbums"
|
||||
},
|
||||
"esphome": {
|
||||
"offline": "Desconnectat",
|
||||
@@ -244,10 +251,10 @@
|
||||
"seed": "Sembrat"
|
||||
},
|
||||
"qbittorrent": {
|
||||
"download": "Download",
|
||||
"upload": "Upload",
|
||||
"leech": "Leech",
|
||||
"seed": "Seed"
|
||||
"download": "Baixada",
|
||||
"upload": "Pujada",
|
||||
"leech": "Sangonera",
|
||||
"seed": "Sembrat"
|
||||
},
|
||||
"qnap": {
|
||||
"cpuUsage": "Ús de CPU",
|
||||
@@ -258,44 +265,44 @@
|
||||
"invalid": "No vàlid"
|
||||
},
|
||||
"deluge": {
|
||||
"download": "Download",
|
||||
"upload": "Upload",
|
||||
"leech": "Leech",
|
||||
"seed": "Seed"
|
||||
"download": "Baixada",
|
||||
"upload": "Pujada",
|
||||
"leech": "Sangonera",
|
||||
"seed": "Sembrat"
|
||||
},
|
||||
"develancacheui": {
|
||||
"cachehitbytes": "Bytes trobats a la memòria cau",
|
||||
"cachemissbytes": "Bytes no trobats a la memòria cau"
|
||||
},
|
||||
"downloadstation": {
|
||||
"download": "Download",
|
||||
"upload": "Upload",
|
||||
"leech": "Leech",
|
||||
"seed": "Seed"
|
||||
"download": "Baixada",
|
||||
"upload": "Pujada",
|
||||
"leech": "Sangonera",
|
||||
"seed": "Sembrat"
|
||||
},
|
||||
"sonarr": {
|
||||
"wanted": "Volgut",
|
||||
"queued": "En cua",
|
||||
"series": "Series",
|
||||
"queue": "Queue",
|
||||
"unknown": "Unknown"
|
||||
"series": "Sèries",
|
||||
"queue": "Cua",
|
||||
"unknown": "Desconegut"
|
||||
},
|
||||
"radarr": {
|
||||
"wanted": "Wanted",
|
||||
"wanted": "Volguts",
|
||||
"missing": "Falten",
|
||||
"queued": "Queued",
|
||||
"movies": "Movies",
|
||||
"queue": "Queue",
|
||||
"unknown": "Unknown"
|
||||
"queued": "Encuat",
|
||||
"movies": "Pel·lícules",
|
||||
"queue": "Cua",
|
||||
"unknown": "Desconegut"
|
||||
},
|
||||
"lidarr": {
|
||||
"wanted": "Wanted",
|
||||
"queued": "Queued",
|
||||
"wanted": "Volguts",
|
||||
"queued": "Encuat",
|
||||
"artists": "Artistes"
|
||||
},
|
||||
"readarr": {
|
||||
"wanted": "Wanted",
|
||||
"queued": "Queued",
|
||||
"wanted": "Volguts",
|
||||
"queued": "Encuat",
|
||||
"books": "Llibres"
|
||||
},
|
||||
"bazarr": {
|
||||
@@ -308,16 +315,16 @@
|
||||
"available": "Disponible"
|
||||
},
|
||||
"seerr": {
|
||||
"pending": "Pending",
|
||||
"approved": "Approved",
|
||||
"available": "Available",
|
||||
"completed": "Completed",
|
||||
"processing": "Processing",
|
||||
"issues": "Open Issues"
|
||||
"pending": "Pendent",
|
||||
"approved": "Aprovat",
|
||||
"available": "Disponible",
|
||||
"completed": "Completat",
|
||||
"processing": "Processant",
|
||||
"issues": "Temes oberts"
|
||||
},
|
||||
"netalertx": {
|
||||
"total": "Total",
|
||||
"connected": "Connected",
|
||||
"connected": "Connectat",
|
||||
"new_devices": "Nous dispositius",
|
||||
"down_alerts": "Alertes de caigudes"
|
||||
},
|
||||
@@ -328,18 +335,18 @@
|
||||
"gravity": "Gravetat"
|
||||
},
|
||||
"adguard": {
|
||||
"queries": "Queries",
|
||||
"blocked": "Blocked",
|
||||
"queries": "Consultes",
|
||||
"blocked": "Bloquejat",
|
||||
"filtered": "Filtrat",
|
||||
"latency": "Latència"
|
||||
},
|
||||
"speedtest": {
|
||||
"upload": "Upload",
|
||||
"download": "Download",
|
||||
"ping": "Ping"
|
||||
"upload": "Pujada",
|
||||
"download": "Baixada",
|
||||
"ping": "Latència"
|
||||
},
|
||||
"portainer": {
|
||||
"running": "Running",
|
||||
"running": "En execució",
|
||||
"stopped": "Aturat",
|
||||
"total": "Total"
|
||||
},
|
||||
@@ -357,16 +364,16 @@
|
||||
"address": "Adreça",
|
||||
"expires": "Caduca",
|
||||
"never": "Mai",
|
||||
"user": "User",
|
||||
"hostname": "Hostname",
|
||||
"name": "Name",
|
||||
"client_version": "Client Version",
|
||||
"user": "Usuari",
|
||||
"hostname": "Nom d'hoste",
|
||||
"name": "Nom",
|
||||
"client_version": "Versió de client",
|
||||
"os": "OS",
|
||||
"created": "Created",
|
||||
"authorized": "Authorized",
|
||||
"is_external": "Is External",
|
||||
"update_available": "Update Available",
|
||||
"tags": "Tags",
|
||||
"created": "Creat",
|
||||
"authorized": "Autoritzat",
|
||||
"is_external": "És extern",
|
||||
"update_available": "Actualització disponible",
|
||||
"tags": "Etiquetes",
|
||||
"last_seen": "Vist per darrer cop",
|
||||
"now": "Ara",
|
||||
"years": "{{number}}a",
|
||||
@@ -376,7 +383,7 @@
|
||||
"minutes": "{{number}}m",
|
||||
"seconds": "{{number}}s",
|
||||
"ago": "Fa {{value}}",
|
||||
"true": "Yes",
|
||||
"true": "Sí",
|
||||
"false": "No"
|
||||
},
|
||||
"technitium": {
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Usuaris",
|
||||
"loginsLast24H": "Inicis de sessió (24h)",
|
||||
"failedLoginsLast24H": "Errors d'inici de sessió (24h)"
|
||||
"failedLoginsLast24H": "Errors d'inici de sessió (24h)",
|
||||
"authorizationsLast24H": "Autentificacions (últ. 24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
@@ -539,37 +547,37 @@
|
||||
"61-day": "Pluja lleugera",
|
||||
"61-night": "Pluja lleugera",
|
||||
"63-day": "Pluja",
|
||||
"63-night": "Rain",
|
||||
"63-night": "Pluja",
|
||||
"65-day": "Pluja intensa",
|
||||
"65-night": "Heavy Rain",
|
||||
"65-night": "Pluja forta",
|
||||
"66-day": "Pluja gelada",
|
||||
"66-night": "Freezing Rain",
|
||||
"67-day": "Freezing Rain",
|
||||
"67-night": "Freezing Rain",
|
||||
"66-night": "Pluja gelada",
|
||||
"67-day": "Pluja gelada",
|
||||
"67-night": "Pluja gelada",
|
||||
"71-day": "Neu lleugera",
|
||||
"71-night": "Light Snow",
|
||||
"71-night": "Nevada lleugera",
|
||||
"73-day": "Neu",
|
||||
"73-night": "Snow",
|
||||
"73-night": "Nevada",
|
||||
"75-day": "Neu intensa",
|
||||
"75-night": "Heavy Snow",
|
||||
"75-night": "Nevada forta",
|
||||
"77-day": "Neu lleugera",
|
||||
"77-night": "Snow Grains",
|
||||
"77-night": "Flocs de neu",
|
||||
"80-day": "Plovisqueig",
|
||||
"80-night": "Light Showers",
|
||||
"80-night": "Plugims suaus",
|
||||
"81-day": "Xàfecs",
|
||||
"81-night": "Showers",
|
||||
"81-night": "Plugims",
|
||||
"82-day": "Xàfecs intensos",
|
||||
"82-night": "Heavy Showers",
|
||||
"82-night": "Plugims forts",
|
||||
"85-day": "Xàfecs de neu",
|
||||
"85-night": "Snow Showers",
|
||||
"86-day": "Snow Showers",
|
||||
"86-night": "Snow Showers",
|
||||
"85-night": "Plugims amb neu",
|
||||
"86-day": "Plugims amb neu",
|
||||
"86-night": "Plugims amb neu",
|
||||
"95-day": "Tempesta",
|
||||
"95-night": "Thunderstorm",
|
||||
"95-night": "Tormenta",
|
||||
"96-day": "Tempesta amb calamarsa",
|
||||
"96-night": "Thunderstorm With Hail",
|
||||
"99-day": "Thunderstorm With Hail",
|
||||
"99-night": "Thunderstorm With Hail"
|
||||
"96-night": "Tormenta amb calamarsa",
|
||||
"99-day": "Tormenta amb calamarsa",
|
||||
"99-night": "Tormenta amb calamarsa"
|
||||
},
|
||||
"homebridge": {
|
||||
"available_update": "Sistema",
|
||||
@@ -578,18 +586,18 @@
|
||||
"up_to_date": "Actualitzat",
|
||||
"child_bridges": "Ponts fills",
|
||||
"child_bridges_status": "{{ok}}/{{total}}",
|
||||
"up": "Up",
|
||||
"pending": "Pending",
|
||||
"down": "Down",
|
||||
"up": "En línia",
|
||||
"pending": "Pendent",
|
||||
"down": "Inactiu",
|
||||
"ok": "Ok"
|
||||
},
|
||||
"healthchecks": {
|
||||
"new": "Nou",
|
||||
"up": "Up",
|
||||
"up": "Actiu",
|
||||
"grace": "En Període de gràcia",
|
||||
"down": "Down",
|
||||
"down": "Inactiu",
|
||||
"paused": "En pausa",
|
||||
"status": "Status",
|
||||
"status": "Estat",
|
||||
"last_ping": "Últim ping",
|
||||
"never": "Sense pings"
|
||||
},
|
||||
@@ -599,74 +607,74 @@
|
||||
"containers_failed": "Error"
|
||||
},
|
||||
"autobrr": {
|
||||
"approvedPushes": "Approved",
|
||||
"approvedPushes": "Aprovat",
|
||||
"rejectedPushes": "Rebutjat",
|
||||
"filters": "Filtres",
|
||||
"indexers": "Indexers"
|
||||
"indexers": "Indexadors"
|
||||
},
|
||||
"tubearchivist": {
|
||||
"downloads": "Queue",
|
||||
"downloads": "Cua",
|
||||
"videos": "Vídeos",
|
||||
"channels": "Canals",
|
||||
"playlists": "Llistes de reproducció"
|
||||
},
|
||||
"truenas": {
|
||||
"load": "Càrrega del sistema",
|
||||
"uptime": "Uptime",
|
||||
"alerts": "Alerts"
|
||||
"uptime": "Temps actiu",
|
||||
"alerts": "Alertes"
|
||||
},
|
||||
"pyload": {
|
||||
"speed": "Velocitat",
|
||||
"active": "Active",
|
||||
"queue": "Queue",
|
||||
"active": "Actiu",
|
||||
"queue": "Cua",
|
||||
"total": "Total"
|
||||
},
|
||||
"gluetun": {
|
||||
"public_ip": "IP Pública",
|
||||
"region": "Regió",
|
||||
"country": "País",
|
||||
"port_forwarded": "Port Forwarded"
|
||||
"port_forwarded": "Ports redirigits"
|
||||
},
|
||||
"hdhomerun": {
|
||||
"channels": "Channels",
|
||||
"channels": "Canals",
|
||||
"hd": "HD",
|
||||
"tunerCount": "Sintonitzadors",
|
||||
"channelNumber": "Canal",
|
||||
"channelNetwork": "Xarxa",
|
||||
"signalStrength": "Intensitat",
|
||||
"signalQuality": "Qualitat",
|
||||
"symbolQuality": "Quality",
|
||||
"networkRate": "Bitrate",
|
||||
"symbolQuality": "Qualitat",
|
||||
"networkRate": "Taxa de bits",
|
||||
"clientIP": "Client"
|
||||
},
|
||||
"scrutiny": {
|
||||
"passed": "Aprovat",
|
||||
"failed": "Failed",
|
||||
"unknown": "Unknown"
|
||||
"failed": "Error",
|
||||
"unknown": "Desconegut"
|
||||
},
|
||||
"paperlessngx": {
|
||||
"inbox": "Safata d'entrada",
|
||||
"total": "Total"
|
||||
},
|
||||
"pangolin": {
|
||||
"orgs": "Orgs",
|
||||
"sites": "Sites",
|
||||
"resources": "Resources",
|
||||
"targets": "Targets",
|
||||
"traffic": "Traffic",
|
||||
"in": "In",
|
||||
"out": "Out"
|
||||
"orgs": "Organitzacions",
|
||||
"sites": "Llocs",
|
||||
"resources": "Recursos",
|
||||
"targets": "Objectius",
|
||||
"traffic": "Tràfic",
|
||||
"in": "Dins",
|
||||
"out": "Fora"
|
||||
},
|
||||
"peanut": {
|
||||
"battery_charge": "Càrrega de la bateria",
|
||||
"ups_load": "Càrrega del SAI",
|
||||
"ups_status": "Estat del SAI",
|
||||
"online": "Online",
|
||||
"online": "En línia",
|
||||
"on_battery": "En Bateria",
|
||||
"low_battery": "Bateria Baixa"
|
||||
},
|
||||
"nextdns": {
|
||||
"wait": "Please Wait",
|
||||
"wait": "Espereu si us plau",
|
||||
"no_devices": "No s'han rebut dades del Dispositiu"
|
||||
},
|
||||
"mikrotik": {
|
||||
@@ -854,37 +862,37 @@
|
||||
"jdownloader": {
|
||||
"downloadCount": "Cua",
|
||||
"downloadBytesRemaining": "Restant",
|
||||
"downloadTotalBytes": "Size",
|
||||
"downloadSpeed": "Speed"
|
||||
"downloadTotalBytes": "Mida",
|
||||
"downloadSpeed": "Velocitat"
|
||||
},
|
||||
"kavita": {
|
||||
"seriesCount": "Series",
|
||||
"totalFiles": "Files"
|
||||
"seriesCount": "Sèries",
|
||||
"totalFiles": "Fitxers"
|
||||
},
|
||||
"azuredevops": {
|
||||
"result": "Resultat",
|
||||
"status": "Status",
|
||||
"status": "Estat",
|
||||
"buildId": "Id de compilació",
|
||||
"succeeded": "Amb èxit",
|
||||
"notStarted": "No Iniciat",
|
||||
"failed": "Failed",
|
||||
"failed": "Error",
|
||||
"canceled": "Cancel·lat",
|
||||
"inProgress": "En curs",
|
||||
"totalPrs": "PRs Totals",
|
||||
"myPrs": "Les meves PRs",
|
||||
"approved": "Approved"
|
||||
"approved": "Aprovat"
|
||||
},
|
||||
"gamedig": {
|
||||
"status": "Status",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"status": "Estat",
|
||||
"online": "En línia",
|
||||
"offline": "Desconnectat",
|
||||
"name": "Nom",
|
||||
"map": "Mapa",
|
||||
"currentPlayers": "Jugadors actuals",
|
||||
"players": "Players",
|
||||
"players": "Jugadors",
|
||||
"maxPlayers": "Màxim de jugadors",
|
||||
"bots": "Bots",
|
||||
"ping": "Ping"
|
||||
"ping": "Latència"
|
||||
},
|
||||
"urbackup": {
|
||||
"ok": "Ok",
|
||||
@@ -894,39 +902,39 @@
|
||||
},
|
||||
"mealie": {
|
||||
"recipes": "Receptes",
|
||||
"users": "Users",
|
||||
"users": "Usuaris",
|
||||
"categories": "Categories",
|
||||
"tags": "Etiquetes"
|
||||
},
|
||||
"openmediavault": {
|
||||
"downloading": "Descarregant",
|
||||
"total": "Total",
|
||||
"running": "Running",
|
||||
"stopped": "Stopped",
|
||||
"passed": "Passed",
|
||||
"failed": "Failed"
|
||||
"running": "En execució",
|
||||
"stopped": "Aturat",
|
||||
"passed": "Aprovat",
|
||||
"failed": "Error"
|
||||
},
|
||||
"openwrt": {
|
||||
"uptime": "Uptime",
|
||||
"uptime": "Temps actiu",
|
||||
"cpuLoad": "Càrrega mitjana de CPU (5min)",
|
||||
"up": "Up",
|
||||
"down": "Down",
|
||||
"up": "En línia",
|
||||
"down": "Desconnectat",
|
||||
"bytesTx": "Enviat",
|
||||
"bytesRx": "Received"
|
||||
"bytesRx": "Rebut"
|
||||
},
|
||||
"uptimerobot": {
|
||||
"status": "Status",
|
||||
"uptime": "Uptime",
|
||||
"status": "Estat",
|
||||
"uptime": "Temps actiu",
|
||||
"lastDown": "Darrera Inactivitat",
|
||||
"downDuration": "Duració d'Inactivitat",
|
||||
"sitesUp": "Sites Up",
|
||||
"sitesDown": "Sites Down",
|
||||
"paused": "Paused",
|
||||
"sitesUp": "Pàgines actives",
|
||||
"sitesDown": "Pàgines inactives",
|
||||
"paused": "Pausat",
|
||||
"notyetchecked": "Sense verificar",
|
||||
"up": "Up",
|
||||
"up": "En línia",
|
||||
"seemsdown": "Sembla caigut",
|
||||
"down": "Down",
|
||||
"unknown": "Unknown"
|
||||
"down": "Desconnectat",
|
||||
"unknown": "Desconegut"
|
||||
},
|
||||
"calendar": {
|
||||
"inCinemas": "En cines",
|
||||
@@ -934,7 +942,7 @@
|
||||
"digitalRelease": "Estrena digital",
|
||||
"noEventsToday": "Cap esdeveniment per avui!",
|
||||
"noEventsFound": "No s'han trobat esdeveniments",
|
||||
"errorWhenLoadingData": "Error when loading calendar data"
|
||||
"errorWhenLoadingData": "Error carregant dades del calendari"
|
||||
},
|
||||
"romm": {
|
||||
"platforms": "Plataformes",
|
||||
@@ -945,50 +953,50 @@
|
||||
"totalfilesize": "Tamany total"
|
||||
},
|
||||
"mailcow": {
|
||||
"domains": "Domains",
|
||||
"domains": "Dominis",
|
||||
"mailboxes": "Bústies",
|
||||
"mails": "Correus",
|
||||
"storage": "Storage"
|
||||
"storage": "Emmagatzematge"
|
||||
},
|
||||
"maintainerr": {
|
||||
"itemsHandled": "Items Handled",
|
||||
"episodesHandled": "Episodes Handled",
|
||||
"moviesHandled": "Movies Handled",
|
||||
"showsHandled": "Shows Handled",
|
||||
"seasonsHandled": "Seasons Handled",
|
||||
"reclaimable": "Reclaimable",
|
||||
"movieReclaimable": "Movie Reclaimable",
|
||||
"showReclaimable": "Show Reclaimable",
|
||||
"totalCapacity": "Total Capacity"
|
||||
"itemsHandled": "Items gestionats",
|
||||
"episodesHandled": "Episodis gestionats",
|
||||
"moviesHandled": "Pel·lícules gestionades",
|
||||
"showsHandled": "Sèries gestionades",
|
||||
"seasonsHandled": "Temporades gestionades",
|
||||
"reclaimable": "Reclamable",
|
||||
"movieReclaimable": "Pel·lícula reclamable",
|
||||
"showReclaimable": "Sèrie reclamable",
|
||||
"totalCapacity": "Capacitat total"
|
||||
},
|
||||
"netdata": {
|
||||
"warnings": "Avisos",
|
||||
"criticals": "Crítics"
|
||||
},
|
||||
"ntfy": {
|
||||
"title": "Title",
|
||||
"priority": "Priority",
|
||||
"lastReceived": "Last Received",
|
||||
"message": "Message",
|
||||
"tags": "Tags",
|
||||
"none": "None",
|
||||
"title": "Títol",
|
||||
"priority": "Prioritat",
|
||||
"lastReceived": "Rebut per últim cop",
|
||||
"message": "Missatge",
|
||||
"tags": "Etiquetes",
|
||||
"none": "Cap",
|
||||
"min": "Min",
|
||||
"low": "Low",
|
||||
"default": "Default",
|
||||
"high": "High",
|
||||
"low": "Baix",
|
||||
"default": "Per defecte",
|
||||
"high": "Alt",
|
||||
"urgent": "Urgent"
|
||||
},
|
||||
"plantit": {
|
||||
"events": "Esdeveniments",
|
||||
"plants": "Plantes",
|
||||
"photos": "Photos",
|
||||
"photos": "Fotos",
|
||||
"species": "Espècies"
|
||||
},
|
||||
"gitea": {
|
||||
"notifications": "Notificacions",
|
||||
"issues": "Issues",
|
||||
"issues": "Problemes",
|
||||
"pulls": "Sol·licitud de Canvis",
|
||||
"repositories": "Repositories"
|
||||
"repositories": "Repositoris"
|
||||
},
|
||||
"stash": {
|
||||
"scenes": "Escenes",
|
||||
@@ -1002,13 +1010,13 @@
|
||||
"galleries": "Biblioteques",
|
||||
"performers": "Intèrprets",
|
||||
"studios": "Estudis",
|
||||
"movies": "Movies",
|
||||
"tags": "Tags",
|
||||
"movies": "Pel·lícules",
|
||||
"tags": "Etiquetes",
|
||||
"oCount": "O Count"
|
||||
},
|
||||
"tandoor": {
|
||||
"users": "Users",
|
||||
"recipes": "Recipes",
|
||||
"users": "Usuaris",
|
||||
"recipes": "Receptes",
|
||||
"keywords": "Paraules claus"
|
||||
},
|
||||
"homebox": {
|
||||
@@ -1016,17 +1024,17 @@
|
||||
"totalWithWarranty": "Amb Garantia",
|
||||
"locations": "Ubicacions",
|
||||
"labels": "Etiquetes",
|
||||
"users": "Users",
|
||||
"users": "Usuaris",
|
||||
"totalValue": "Valor total"
|
||||
},
|
||||
"crowdsec": {
|
||||
"alerts": "Alerts",
|
||||
"alerts": "Alertes",
|
||||
"bans": "Prohibicions"
|
||||
},
|
||||
"wgeasy": {
|
||||
"connected": "Connected",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"connected": "Connectat",
|
||||
"enabled": "Habilitat",
|
||||
"disabled": "Deshabilitat",
|
||||
"total": "Total"
|
||||
},
|
||||
"swagdashboard": {
|
||||
@@ -1036,9 +1044,9 @@
|
||||
"banned": "Bloquejat"
|
||||
},
|
||||
"myspeed": {
|
||||
"ping": "Ping",
|
||||
"download": "Download",
|
||||
"upload": "Upload"
|
||||
"ping": "Latència",
|
||||
"download": "Baixada",
|
||||
"upload": "Pujada"
|
||||
},
|
||||
"stocks": {
|
||||
"stocks": "Accions",
|
||||
@@ -1135,120 +1143,120 @@
|
||||
"tags": "Etiquetes"
|
||||
},
|
||||
"slskd": {
|
||||
"slskStatus": "Network",
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected",
|
||||
"updateStatus": "Update",
|
||||
"update_yes": "Available",
|
||||
"update_no": "Up to Date",
|
||||
"downloads": "Downloads",
|
||||
"uploads": "Uploads",
|
||||
"sharedFiles": "Files"
|
||||
"slskStatus": "Xarxa",
|
||||
"connected": "Connectat",
|
||||
"disconnected": "Desconnectat",
|
||||
"updateStatus": "Actualitza",
|
||||
"update_yes": "Disponible",
|
||||
"update_no": "Actualitzat",
|
||||
"downloads": "Descàrregues",
|
||||
"uploads": "Pujades",
|
||||
"sharedFiles": "Fitxers"
|
||||
},
|
||||
"jellystat": {
|
||||
"songs": "Songs",
|
||||
"movies": "Movies",
|
||||
"episodes": "Episodes",
|
||||
"other": "Other"
|
||||
"songs": "Cançons",
|
||||
"movies": "Pel·lícules",
|
||||
"episodes": "Episodis",
|
||||
"other": "Altres"
|
||||
},
|
||||
"checkmk": {
|
||||
"serviceErrors": "Service issues",
|
||||
"hostErrors": "Host issues"
|
||||
"serviceErrors": "Errors de servei",
|
||||
"hostErrors": "Problemes de l'hoste"
|
||||
},
|
||||
"komodo": {
|
||||
"total": "Total",
|
||||
"running": "Running",
|
||||
"stopped": "Stopped",
|
||||
"down": "Down",
|
||||
"unhealthy": "Unhealthy",
|
||||
"unknown": "Unknown",
|
||||
"servers": "Servers",
|
||||
"running": "En execució",
|
||||
"stopped": "Aturat",
|
||||
"down": "Fora de servei",
|
||||
"unhealthy": "No saludable",
|
||||
"unknown": "Desconegut",
|
||||
"servers": "Servidors",
|
||||
"stacks": "Stacks",
|
||||
"containers": "Containers"
|
||||
"containers": "Contenidors"
|
||||
},
|
||||
"filebrowser": {
|
||||
"available": "Available",
|
||||
"used": "Used",
|
||||
"available": "Disponible",
|
||||
"used": "Utilitzat",
|
||||
"total": "Total"
|
||||
},
|
||||
"wallos": {
|
||||
"activeSubscriptions": "Subscriptions",
|
||||
"thisMonthlyCost": "This Month",
|
||||
"nextMonthlyCost": "Next Month",
|
||||
"previousMonthlyCost": "Prev. Month",
|
||||
"nextRenewingSubscription": "Next Payment"
|
||||
"activeSubscriptions": "Subscripcions",
|
||||
"thisMonthlyCost": "Aquest mes",
|
||||
"nextMonthlyCost": "Mes que ve",
|
||||
"previousMonthlyCost": "Mes passat",
|
||||
"nextRenewingSubscription": "Proper pagament"
|
||||
},
|
||||
"unraid": {
|
||||
"STARTED": "Started",
|
||||
"STOPPED": "Stopped",
|
||||
"NEW_ARRAY": "New Array",
|
||||
"RECON_DISK": "Reconstructing Disk",
|
||||
"DISABLE_DISK": "Disk Disabled",
|
||||
"SWAP_DSBL": "Swap Disable",
|
||||
"INVALID_EXPANSION": "Invalid Expansion",
|
||||
"PARITY_NOT_BIGGEST": "Parity Not Biggest",
|
||||
"TOO_MANY_MISSING_DISKS": "Too Many Missing Disks",
|
||||
"NEW_DISK_TOO_SMALL": "New Disk Too Small",
|
||||
"NO_DATA_DISKS": "No Data Disks",
|
||||
"notifications": "Notifications",
|
||||
"status": "Status",
|
||||
"STARTED": "Iniciat",
|
||||
"STOPPED": "Aturat",
|
||||
"NEW_ARRAY": "Nou array",
|
||||
"RECON_DISK": "Reconstruint disc",
|
||||
"DISABLE_DISK": "Disc desactivat",
|
||||
"SWAP_DSBL": "Swap desactivat",
|
||||
"INVALID_EXPANSION": "Expansió invàlida",
|
||||
"PARITY_NOT_BIGGEST": "Paritat no del més gran",
|
||||
"TOO_MANY_MISSING_DISKS": "Falten masses discs",
|
||||
"NEW_DISK_TOO_SMALL": "Nou disc massa petit",
|
||||
"NO_DATA_DISKS": "Sense discs de dades",
|
||||
"notifications": "Notificacions",
|
||||
"status": "Estat",
|
||||
"cpu": "CPU",
|
||||
"memoryUsed": "Memory Used",
|
||||
"memoryAvailable": "Memory Available",
|
||||
"arrayUsed": "Array Used",
|
||||
"arrayFree": "Array Free",
|
||||
"poolUsed": "{{pool}} Used",
|
||||
"poolFree": "{{pool}} Free"
|
||||
"memoryUsed": "Memòria emprada",
|
||||
"memoryAvailable": "Memòria disponible",
|
||||
"arrayUsed": "Array en ús",
|
||||
"arrayFree": "Array lliure",
|
||||
"poolUsed": "{{pool}} en ús",
|
||||
"poolFree": "{{pool}} lliure"
|
||||
},
|
||||
"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_success_30": "Èxits",
|
||||
"num_failure_30": "Fallades",
|
||||
"num_success_latest": "Triomfant",
|
||||
"num_failure_latest": "Fallant",
|
||||
"bytes_added_30": "Bytes afegits"
|
||||
},
|
||||
"yourspotify": {
|
||||
"songs": "Songs",
|
||||
"time": "Time",
|
||||
"artists": "Artists"
|
||||
"songs": "Cançons",
|
||||
"time": "Temps",
|
||||
"artists": "Artistes"
|
||||
},
|
||||
"arcane": {
|
||||
"containers": "Containers",
|
||||
"images": "Images",
|
||||
"image_updates": "Image Updates",
|
||||
"images_unused": "Unused",
|
||||
"environment_required": "Environment ID Required"
|
||||
"containers": "Contenidors",
|
||||
"images": "Imatges",
|
||||
"image_updates": "Actualitzacions d'imatges",
|
||||
"images_unused": "Sense utilitzar",
|
||||
"environment_required": "ID d'entorn requerit"
|
||||
},
|
||||
"dockhand": {
|
||||
"running": "Running",
|
||||
"stopped": "Stopped",
|
||||
"running": "En execució",
|
||||
"stopped": "Aturats",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
"images": "Images",
|
||||
"volumes": "Volumes",
|
||||
"events_today": "Events Today",
|
||||
"pending_updates": "Pending Updates",
|
||||
"memory": "Memòria",
|
||||
"images": "Imatges",
|
||||
"volumes": "Volums",
|
||||
"events_today": "Esdeveniments avui",
|
||||
"pending_updates": "Actualitzacions pendents",
|
||||
"stacks": "Stacks",
|
||||
"paused": "Paused",
|
||||
"paused": "Pausats",
|
||||
"total": "Total",
|
||||
"environment_not_found": "Environment Not Found"
|
||||
"environment_not_found": "Entorn no trobat"
|
||||
},
|
||||
"sparkyfitness": {
|
||||
"eaten": "Eaten",
|
||||
"burned": "Burned",
|
||||
"remaining": "Remaining",
|
||||
"steps": "Steps"
|
||||
"eaten": "Menjat",
|
||||
"burned": "Cremat",
|
||||
"remaining": "Restant",
|
||||
"steps": "Passes"
|
||||
},
|
||||
"syncthing": {
|
||||
"connected": "Connected",
|
||||
"synced": "Synced",
|
||||
"connected": "Connectat",
|
||||
"synced": "Sincronitzat",
|
||||
"errors": "Errors",
|
||||
"storage": "Storage"
|
||||
"storage": "Emmagatzematge"
|
||||
},
|
||||
"sportarr": {
|
||||
"wanted": "Wanted",
|
||||
"queued": "Queued",
|
||||
"leagues": "Leagues"
|
||||
"wanted": "Volgut",
|
||||
"queued": "Encuat",
|
||||
"leagues": "Lligues"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"seconds": "s"
|
||||
},
|
||||
"auth": {
|
||||
"signout": "Sign out"
|
||||
"signout": "Odhlásit se"
|
||||
},
|
||||
"widget": {
|
||||
"missing_type": "Chybí typ widgetu: {{type}}",
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Počkejte prosím"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Hledat…"
|
||||
"placeholder": "Hledat…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "Využití procesoru",
|
||||
@@ -75,13 +77,13 @@
|
||||
"no_data": "Nejsou k dispozici žádná data úložiště"
|
||||
},
|
||||
"duplicati": {
|
||||
"jobs": "Jobs",
|
||||
"stored": "Stored",
|
||||
"lastBackup": "Last Run",
|
||||
"nextRun": "Next Run",
|
||||
"running": "Running",
|
||||
"warnings": "Warnings",
|
||||
"errors": "Errors"
|
||||
"jobs": "Úlohy",
|
||||
"stored": "Uloženo",
|
||||
"lastBackup": "Naposledy spuštěno",
|
||||
"nextRun": "Další spuštění",
|
||||
"running": "Běží",
|
||||
"warnings": "Upozornění",
|
||||
"errors": "Chyby"
|
||||
},
|
||||
"docker": {
|
||||
"rx": "RX",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Nezdravý",
|
||||
"not_found": "Nenalezen",
|
||||
"exited": "Ukončen",
|
||||
"partial": "Částečný"
|
||||
"partial": "Částečný",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Chyba",
|
||||
@@ -133,7 +140,7 @@
|
||||
"series": "Seriály",
|
||||
"episodes": "Epizody",
|
||||
"songs": "Skladby",
|
||||
"albums": "Albums"
|
||||
"albums": "Alba"
|
||||
},
|
||||
"esphome": {
|
||||
"offline": "Offline",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Uživatelé",
|
||||
"loginsLast24H": "Příhlášení (24h)",
|
||||
"failedLoginsLast24H": "Neúspěšná přihlášení (24h)"
|
||||
"failedLoginsLast24H": "Neúspěšná přihlášení (24h)",
|
||||
"authorizationsLast24H": "Autorizace (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "Využití paměti",
|
||||
@@ -951,15 +959,15 @@
|
||||
"storage": "Úložiště"
|
||||
},
|
||||
"maintainerr": {
|
||||
"itemsHandled": "Items Handled",
|
||||
"episodesHandled": "Episodes Handled",
|
||||
"moviesHandled": "Movies Handled",
|
||||
"showsHandled": "Shows Handled",
|
||||
"seasonsHandled": "Seasons Handled",
|
||||
"reclaimable": "Reclaimable",
|
||||
"movieReclaimable": "Movie Reclaimable",
|
||||
"showReclaimable": "Show Reclaimable",
|
||||
"totalCapacity": "Total Capacity"
|
||||
"itemsHandled": "Zpracované položky",
|
||||
"episodesHandled": "Zpracované epizody",
|
||||
"moviesHandled": "Zpracované filmy",
|
||||
"showsHandled": "Zpracované seriály",
|
||||
"seasonsHandled": "Zpracované série",
|
||||
"reclaimable": "Uvolnitelné",
|
||||
"movieReclaimable": "Uvolnitelné filmy",
|
||||
"showReclaimable": "Uvolnitelné seriály",
|
||||
"totalCapacity": "Celková kapacita"
|
||||
},
|
||||
"netdata": {
|
||||
"warnings": "Upozornění",
|
||||
@@ -1241,14 +1249,14 @@
|
||||
"steps": "Kroky"
|
||||
},
|
||||
"syncthing": {
|
||||
"connected": "Connected",
|
||||
"synced": "Synced",
|
||||
"errors": "Errors",
|
||||
"storage": "Storage"
|
||||
"connected": "Připojeno",
|
||||
"synced": "Synchronizováno",
|
||||
"errors": "Chyby",
|
||||
"storage": "Úložiště"
|
||||
},
|
||||
"sportarr": {
|
||||
"wanted": "Wanted",
|
||||
"queued": "Queued",
|
||||
"leagues": "Leagues"
|
||||
"wanted": "Požadované",
|
||||
"queued": "Ve frontě",
|
||||
"leagues": "Ligy"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Vent venligst"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Søg…"
|
||||
"placeholder": "Søg…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Usund",
|
||||
"not_found": "Ikke Fundet",
|
||||
"exited": "Forladt",
|
||||
"partial": "Delvis"
|
||||
"partial": "Delvis",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Error",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Login (24 timer)",
|
||||
"failedLoginsLast24H": "Mislykkede logins (24 timer)"
|
||||
"failedLoginsLast24H": "Mislykkede logins (24 timer)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Bitte warten"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Suche…"
|
||||
"placeholder": "Suche…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Fehlerhaft",
|
||||
"not_found": "Nicht gefunden",
|
||||
"exited": "Beendet",
|
||||
"partial": "Teilweise"
|
||||
"partial": "Teilweise",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Fehler",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Benutzer",
|
||||
"loginsLast24H": "Anmeldungen (24 h)",
|
||||
"failedLoginsLast24H": "Fehlversuche (24 h)"
|
||||
"failedLoginsLast24H": "Fehlversuche (24 h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "RAM",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"seconds": "s"
|
||||
},
|
||||
"auth": {
|
||||
"signout": "Sign out"
|
||||
"signout": "Αποσύνδεση"
|
||||
},
|
||||
"widget": {
|
||||
"missing_type": "Λείπει ο τύπος widget: {{type}}",
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Παρακαλώ περιμένετε"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Αναζήτηση…"
|
||||
"placeholder": "Αναζήτηση…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Επιλέξτε τις μηχανές αναζήτησης"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "Επεξεργαστής",
|
||||
@@ -64,24 +66,24 @@
|
||||
"wlan_devices": "WLAN Συσκευές",
|
||||
"lan_users": "LAN Χρήστες",
|
||||
"wlan_users": "WLAN Χρήστες",
|
||||
"up": "UP",
|
||||
"up": "ΕΠΑΝΩ",
|
||||
"down": "ΚΑΤΩ",
|
||||
"wait": "Please wait",
|
||||
"wait": "Παρακαλώ περιμένετε",
|
||||
"empty_data": "Άγνωστη κατάσταση υποσυστήματος"
|
||||
},
|
||||
"unifi_drive": {
|
||||
"healthy": "Healthy",
|
||||
"healthy": "Υγειές",
|
||||
"degraded": "Degraded",
|
||||
"no_data": "No storage data available"
|
||||
},
|
||||
"duplicati": {
|
||||
"jobs": "Jobs",
|
||||
"stored": "Stored",
|
||||
"lastBackup": "Last Run",
|
||||
"nextRun": "Next Run",
|
||||
"running": "Running",
|
||||
"warnings": "Warnings",
|
||||
"errors": "Errors"
|
||||
"jobs": "Εργασίες",
|
||||
"stored": "Αποθηκευμένα",
|
||||
"lastBackup": "Τελευταία εκτέλεση",
|
||||
"nextRun": "Επόμενη εκτέλεση",
|
||||
"running": "Εκτελείται",
|
||||
"warnings": "Προειδοποιήσεις",
|
||||
"errors": "Σφάλματα"
|
||||
},
|
||||
"docker": {
|
||||
"rx": "RX",
|
||||
@@ -97,10 +99,15 @@
|
||||
"unhealthy": "Άρρωστο",
|
||||
"not_found": "Δεν βρέθηκε",
|
||||
"exited": "Έκλεισε",
|
||||
"partial": "Μερικό"
|
||||
"partial": "Μερικό",
|
||||
"created": "Δημιουργήθηκε",
|
||||
"paused": "Σε παύση",
|
||||
"restarting": "Γίνεται επανεκκίνηση",
|
||||
"removing": "Αφαιρείται",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Error",
|
||||
"error": "Σφάλμα",
|
||||
"ping": "Ping",
|
||||
"down": "Ping down",
|
||||
"up": "Ping up",
|
||||
@@ -108,11 +115,11 @@
|
||||
},
|
||||
"siteMonitor": {
|
||||
"http_status": "Κατάσταση HTTP",
|
||||
"error": "Error",
|
||||
"error": "Σφάλμα",
|
||||
"response": "Απόκριση",
|
||||
"down": "Down",
|
||||
"up": "Up",
|
||||
"not_available": "Not Available"
|
||||
"not_available": "Μη Διαθέσιμο"
|
||||
},
|
||||
"emby": {
|
||||
"playing": "Παίζει",
|
||||
@@ -125,7 +132,7 @@
|
||||
"songs": "Τραγούδια"
|
||||
},
|
||||
"jellyfin": {
|
||||
"playing": "Playing",
|
||||
"playing": "Γίνεται αναπαραγωγή",
|
||||
"transcoding": "Transcoding",
|
||||
"bitrate": "Bitrate",
|
||||
"no_active": "No Active Streams",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Συνδέσεις (24h)",
|
||||
"failedLoginsLast24H": "Αποτυχημένες Συνδέσεις (24h)"
|
||||
"failedLoginsLast24H": "Αποτυχημένες Συνδέσεις (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Please wait"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search…"
|
||||
"placeholder": "Search…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Unhealthy",
|
||||
"not_found": "Not Found",
|
||||
"exited": "Exited",
|
||||
"partial": "Partial"
|
||||
"partial": "Partial",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Error",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Logins (24h)",
|
||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
||||
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Bonvolu atendi"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Serĉi…"
|
||||
"placeholder": "Serĉi…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "Ĉefprocesoro",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Malsana",
|
||||
"not_found": "Ne trovita",
|
||||
"exited": "Eliris",
|
||||
"partial": "Parta"
|
||||
"partial": "Parta",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Error",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Logins (24h)",
|
||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
||||
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Espera, por favor"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Buscar…"
|
||||
"placeholder": "Buscar…",
|
||||
"provider": "Motor de búsqueda: {{name}}",
|
||||
"select_provider": "Seleccionar motores de búsqueda"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "No saludable",
|
||||
"not_found": "No encontrado",
|
||||
"exited": "Terminado",
|
||||
"partial": "Parcial"
|
||||
"partial": "Parcial",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Error",
|
||||
@@ -133,7 +140,7 @@
|
||||
"series": "Series",
|
||||
"episodes": "Episodios",
|
||||
"songs": "Canciones",
|
||||
"albums": "Albums"
|
||||
"albums": "Álbumes"
|
||||
},
|
||||
"esphome": {
|
||||
"offline": "Fuera de línea",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Usuarios",
|
||||
"loginsLast24H": "Inicios de sesión (24h)",
|
||||
"failedLoginsLast24H": "Inicios de sesión fallidos (24h)"
|
||||
"failedLoginsLast24H": "Inicios de sesión fallidos (24h)",
|
||||
"authorizationsLast24H": "Autenticaciones (24 h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Itxaron mesedez"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Bilatu…"
|
||||
"placeholder": "Bilatu…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Unhealthy",
|
||||
"not_found": "Not Found",
|
||||
"exited": "Exited",
|
||||
"partial": "Partial"
|
||||
"partial": "Partial",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Error",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Logins (24h)",
|
||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
||||
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Odota, ole hyvä"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Hae…"
|
||||
"placeholder": "Hae…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Unhealthy",
|
||||
"not_found": "Not Found",
|
||||
"exited": "Exited",
|
||||
"partial": "Partial"
|
||||
"partial": "Partial",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Error",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Kirjautumisia (24h)",
|
||||
"failedLoginsLast24H": "Epäonnistuneita kirjautumisia (24h)"
|
||||
"failedLoginsLast24H": "Epäonnistuneita kirjautumisia (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"seconds": "s"
|
||||
},
|
||||
"auth": {
|
||||
"signout": "Sign out"
|
||||
"signout": "Se déconnecter"
|
||||
},
|
||||
"widget": {
|
||||
"missing_type": "Type de widget manquant : {{type}}",
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Veuillez patienter"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Recherche…"
|
||||
"placeholder": "Recherche…",
|
||||
"provider": "Moteur de recherche : {{name}}",
|
||||
"select_provider": "Sélectionnez un moteur de recherche"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Mauvaise santé",
|
||||
"not_found": "Inconnu",
|
||||
"exited": "Arrêté",
|
||||
"partial": "Partiel"
|
||||
"partial": "Partiel",
|
||||
"created": "Créé",
|
||||
"paused": "En pause",
|
||||
"restarting": "Redémarrage",
|
||||
"removing": "Suppression",
|
||||
"dead": "Mort"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Erreur",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Utilisateurs",
|
||||
"loginsLast24H": "Connexions (24 h)",
|
||||
"failedLoginsLast24H": "Connexions échouées (24 h)"
|
||||
"failedLoginsLast24H": "Connexions échouées (24 h)",
|
||||
"authorizationsLast24H": "Authentifications (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "RAM",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"seconds": "שניות"
|
||||
},
|
||||
"auth": {
|
||||
"signout": "Sign out"
|
||||
"signout": "התנתק"
|
||||
},
|
||||
"widget": {
|
||||
"missing_type": "סוג ווידג'ט חסר: {{type}}",
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "המתן בבקשה"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "חיפוש…"
|
||||
"placeholder": "חיפוש…",
|
||||
"provider": "מנוע חיפוש: {{name}}",
|
||||
"select_provider": "בחר מנוע חיפוש"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "מעבד",
|
||||
@@ -70,18 +72,18 @@
|
||||
"empty_data": "מצב תת-מערכת לא ידוע"
|
||||
},
|
||||
"unifi_drive": {
|
||||
"healthy": "Healthy",
|
||||
"degraded": "Degraded",
|
||||
"no_data": "No storage data available"
|
||||
"healthy": "בריא",
|
||||
"degraded": "ירוד",
|
||||
"no_data": "אין אחסון זמין"
|
||||
},
|
||||
"duplicati": {
|
||||
"jobs": "Jobs",
|
||||
"stored": "Stored",
|
||||
"jobs": "משימות",
|
||||
"stored": "שמור",
|
||||
"lastBackup": "Last Run",
|
||||
"nextRun": "Next Run",
|
||||
"running": "Running",
|
||||
"warnings": "Warnings",
|
||||
"errors": "Errors"
|
||||
"warnings": "הזהרות",
|
||||
"errors": "שגיאות"
|
||||
},
|
||||
"docker": {
|
||||
"rx": "RX",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "לא בריא",
|
||||
"not_found": "לא נמצא",
|
||||
"exited": "יצא",
|
||||
"partial": "חלקי"
|
||||
"partial": "חלקי",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "שגיאה",
|
||||
@@ -125,15 +132,15 @@
|
||||
"songs": "שירים"
|
||||
},
|
||||
"jellyfin": {
|
||||
"playing": "Playing",
|
||||
"playing": "מנגן",
|
||||
"transcoding": "Transcoding",
|
||||
"bitrate": "Bitrate",
|
||||
"no_active": "No Active Streams",
|
||||
"movies": "Movies",
|
||||
"series": "Series",
|
||||
"episodes": "Episodes",
|
||||
"songs": "Songs",
|
||||
"albums": "Albums"
|
||||
"no_active": "אין מדיה פעילה",
|
||||
"movies": "סרטים",
|
||||
"series": "סדרות",
|
||||
"episodes": "פרקים",
|
||||
"songs": "שירים",
|
||||
"albums": "אלבומים"
|
||||
},
|
||||
"esphome": {
|
||||
"offline": "מכובה",
|
||||
@@ -308,11 +315,11 @@
|
||||
"available": "זמין"
|
||||
},
|
||||
"seerr": {
|
||||
"pending": "Pending",
|
||||
"approved": "Approved",
|
||||
"available": "Available",
|
||||
"completed": "Completed",
|
||||
"processing": "Processing",
|
||||
"pending": "ממתין",
|
||||
"approved": "מאושר",
|
||||
"available": "זמין",
|
||||
"completed": "הסתיים",
|
||||
"processing": "מעבד",
|
||||
"issues": "Open Issues"
|
||||
},
|
||||
"netalertx": {
|
||||
@@ -357,16 +364,16 @@
|
||||
"address": "כתובת",
|
||||
"expires": "תאריך תפוגה",
|
||||
"never": "אף פעם",
|
||||
"user": "User",
|
||||
"user": "משתמש",
|
||||
"hostname": "Hostname",
|
||||
"name": "Name",
|
||||
"name": "שם",
|
||||
"client_version": "Client Version",
|
||||
"os": "OS",
|
||||
"created": "Created",
|
||||
"authorized": "Authorized",
|
||||
"is_external": "Is External",
|
||||
"update_available": "Update Available",
|
||||
"tags": "Tags",
|
||||
"os": "מערכת הפעלה",
|
||||
"created": "יוצר",
|
||||
"authorized": "מורשה",
|
||||
"is_external": "האם חיצוני",
|
||||
"update_available": "עדכון זמין",
|
||||
"tags": "תגיות",
|
||||
"last_seen": "נראה לאחרונה",
|
||||
"now": "כעת",
|
||||
"years": "{{number}}y",
|
||||
@@ -376,8 +383,8 @@
|
||||
"minutes": "{{number}}m",
|
||||
"seconds": "{{number}}s",
|
||||
"ago": "{{value}} Ago",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
"true": "כן",
|
||||
"false": "לא"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "שאילתות",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "משתמשים",
|
||||
"loginsLast24H": "כניסות (24h)",
|
||||
"failedLoginsLast24H": "כניסות שנכשלו (24h)"
|
||||
"failedLoginsLast24H": "כניסות שנכשלו (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "זיכרון",
|
||||
@@ -481,7 +489,7 @@
|
||||
},
|
||||
"pulse": {
|
||||
"nodes": "Nodes",
|
||||
"vms": "VMs",
|
||||
"vms": "מכונות וירטואליות",
|
||||
"lxcs": "LXCs"
|
||||
},
|
||||
"glances": {
|
||||
@@ -581,7 +589,7 @@
|
||||
"up": "למעלה",
|
||||
"pending": "ממתין",
|
||||
"down": "למטה",
|
||||
"ok": "Ok"
|
||||
"ok": "אוקיי"
|
||||
},
|
||||
"healthchecks": {
|
||||
"new": "חדש",
|
||||
@@ -650,9 +658,9 @@
|
||||
},
|
||||
"pangolin": {
|
||||
"orgs": "Orgs",
|
||||
"sites": "Sites",
|
||||
"resources": "Resources",
|
||||
"targets": "Targets",
|
||||
"sites": "אתרים",
|
||||
"resources": "משאבים",
|
||||
"targets": "מטרות",
|
||||
"traffic": "Traffic",
|
||||
"in": "In",
|
||||
"out": "Out"
|
||||
@@ -754,7 +762,7 @@
|
||||
"volumeAvailable": "זמין"
|
||||
},
|
||||
"dispatcharr": {
|
||||
"channels": "Channels",
|
||||
"channels": "ערוצים",
|
||||
"streams": "Streams"
|
||||
},
|
||||
"mylar": {
|
||||
@@ -1179,9 +1187,9 @@
|
||||
"nextRenewingSubscription": "תשלום הבא"
|
||||
},
|
||||
"unraid": {
|
||||
"STARTED": "Started",
|
||||
"STOPPED": "Stopped",
|
||||
"NEW_ARRAY": "New Array",
|
||||
"STARTED": "התחיל",
|
||||
"STOPPED": "נעצר",
|
||||
"NEW_ARRAY": "מערך חדש",
|
||||
"RECON_DISK": "Reconstructing Disk",
|
||||
"DISABLE_DISK": "Disk Disabled",
|
||||
"SWAP_DSBL": "Swap Disable",
|
||||
@@ -1193,31 +1201,31 @@
|
||||
"notifications": "Notifications",
|
||||
"status": "Status",
|
||||
"cpu": "CPU",
|
||||
"memoryUsed": "Memory Used",
|
||||
"memoryAvailable": "Memory Available",
|
||||
"memoryUsed": "זיכרון בשימוש",
|
||||
"memoryAvailable": "זיכרון זמין",
|
||||
"arrayUsed": "Array Used",
|
||||
"arrayFree": "Array Free",
|
||||
"poolUsed": "{{pool}} Used",
|
||||
"poolFree": "{{pool}} Free"
|
||||
},
|
||||
"backrest": {
|
||||
"num_plans": "Plans",
|
||||
"num_success_30": "Successes",
|
||||
"num_failure_30": "Failures",
|
||||
"num_plans": "תוכניות",
|
||||
"num_success_30": "הצלחות",
|
||||
"num_failure_30": "כשלונות",
|
||||
"num_success_latest": "Succeeding",
|
||||
"num_failure_latest": "Failing",
|
||||
"bytes_added_30": "Bytes Added"
|
||||
},
|
||||
"yourspotify": {
|
||||
"songs": "Songs",
|
||||
"time": "Time",
|
||||
"artists": "Artists"
|
||||
"songs": "שירים",
|
||||
"time": "זמן",
|
||||
"artists": "אומנים"
|
||||
},
|
||||
"arcane": {
|
||||
"containers": "Containers",
|
||||
"images": "Images",
|
||||
"image_updates": "Image Updates",
|
||||
"images_unused": "Unused",
|
||||
"images_unused": "לא בשימוש",
|
||||
"environment_required": "Environment ID Required"
|
||||
},
|
||||
"dockhand": {
|
||||
@@ -1227,7 +1235,7 @@
|
||||
"memory": "Memory",
|
||||
"images": "Images",
|
||||
"volumes": "Volumes",
|
||||
"events_today": "Events Today",
|
||||
"events_today": "אירועים היום",
|
||||
"pending_updates": "Pending Updates",
|
||||
"stacks": "Stacks",
|
||||
"paused": "Paused",
|
||||
@@ -1235,16 +1243,16 @@
|
||||
"environment_not_found": "Environment Not Found"
|
||||
},
|
||||
"sparkyfitness": {
|
||||
"eaten": "Eaten",
|
||||
"burned": "Burned",
|
||||
"remaining": "Remaining",
|
||||
"steps": "Steps"
|
||||
"eaten": "נאכלו",
|
||||
"burned": "נשרפו",
|
||||
"remaining": "נותרו",
|
||||
"steps": "צעדים"
|
||||
},
|
||||
"syncthing": {
|
||||
"connected": "Connected",
|
||||
"synced": "Synced",
|
||||
"errors": "Errors",
|
||||
"storage": "Storage"
|
||||
"synced": "מסונכרן",
|
||||
"errors": "שגיאות",
|
||||
"storage": "אחסון"
|
||||
},
|
||||
"sportarr": {
|
||||
"wanted": "Wanted",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Please wait"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search…"
|
||||
"placeholder": "Search…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Unhealthy",
|
||||
"not_found": "Not Found",
|
||||
"exited": "Exited",
|
||||
"partial": "Partial"
|
||||
"partial": "Partial",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Error",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Logins (24h)",
|
||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
||||
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"seconds": "s"
|
||||
},
|
||||
"auth": {
|
||||
"signout": "Sign out"
|
||||
"signout": "Odjavi se"
|
||||
},
|
||||
"widget": {
|
||||
"missing_type": "Nedostajuća vrsta widgeta: {{type}}",
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Pričekaj"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Traži …"
|
||||
"placeholder": "Traži …",
|
||||
"provider": "Tražilica: {{name}}",
|
||||
"select_provider": "Odaberi tražilicu"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Nefunkcionalno",
|
||||
"not_found": "Nepronađeno",
|
||||
"exited": "Zatoreno",
|
||||
"partial": "Djelomično"
|
||||
"partial": "Djelomično",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Greška",
|
||||
@@ -133,7 +140,7 @@
|
||||
"series": "Serije",
|
||||
"episodes": "Epizode",
|
||||
"songs": "Pjesme",
|
||||
"albums": "Albums"
|
||||
"albums": "Albumi"
|
||||
},
|
||||
"esphome": {
|
||||
"offline": "Offline",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Korisnici",
|
||||
"loginsLast24H": "Prijave (24 h)",
|
||||
"failedLoginsLast24H": "Neuspjele prijave (24 h)"
|
||||
"failedLoginsLast24H": "Neuspjele prijave (24 h)",
|
||||
"authorizationsLast24H": "Autorizacije (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
@@ -951,15 +959,15 @@
|
||||
"storage": "Spremište"
|
||||
},
|
||||
"maintainerr": {
|
||||
"itemsHandled": "Items Handled",
|
||||
"episodesHandled": "Episodes Handled",
|
||||
"moviesHandled": "Movies Handled",
|
||||
"showsHandled": "Shows Handled",
|
||||
"seasonsHandled": "Seasons Handled",
|
||||
"itemsHandled": "Obrađene stavke",
|
||||
"episodesHandled": "Obrađene epizode",
|
||||
"moviesHandled": "Obrađeni filmovi",
|
||||
"showsHandled": "Obrađene emisije",
|
||||
"seasonsHandled": "Obrađene sezone",
|
||||
"reclaimable": "Reclaimable",
|
||||
"movieReclaimable": "Movie Reclaimable",
|
||||
"showReclaimable": "Show Reclaimable",
|
||||
"totalCapacity": "Total Capacity"
|
||||
"totalCapacity": "Ukupni kapacitet"
|
||||
},
|
||||
"netdata": {
|
||||
"warnings": "Upozorenja",
|
||||
@@ -1247,8 +1255,8 @@
|
||||
"storage": "Spremište"
|
||||
},
|
||||
"sportarr": {
|
||||
"wanted": "Wanted",
|
||||
"queued": "Queued",
|
||||
"leagues": "Leagues"
|
||||
"wanted": "Željeno",
|
||||
"queued": "U redu čekanja",
|
||||
"leagues": "Lige"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Kérjük várjon"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Keresés…"
|
||||
"placeholder": "Keresés…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "Processzor",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Egészségtelen",
|
||||
"not_found": "Nem található",
|
||||
"exited": "Kilépett",
|
||||
"partial": "Részleges"
|
||||
"partial": "Részleges",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Hiba",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Felhasználók",
|
||||
"loginsLast24H": "Bejelentkezések (24 óra)",
|
||||
"failedLoginsLast24H": "Sikertelen bejelentkezések (24h)"
|
||||
"failedLoginsLast24H": "Sikertelen bejelentkezések (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Harap tunggu"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Telusuri…"
|
||||
"placeholder": "Telusuri…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Tidak Lancar",
|
||||
"not_found": "Tidak Ditemukan",
|
||||
"exited": "Terkeluar",
|
||||
"partial": "Sebagian"
|
||||
"partial": "Sebagian",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Error",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Login (24j)",
|
||||
"failedLoginsLast24H": "Login Gagal (24j)"
|
||||
"failedLoginsLast24H": "Login Gagal (24j)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Attendi per favore"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Cerca…"
|
||||
"placeholder": "Cerca…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Non sano",
|
||||
"not_found": "Non trovato",
|
||||
"exited": "Uscito",
|
||||
"partial": "Parziale"
|
||||
"partial": "Parziale",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Errore",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Utenti",
|
||||
"loginsLast24H": "Accessi (24h)",
|
||||
"failedLoginsLast24H": "Accessi Falliti (24h)"
|
||||
"failedLoginsLast24H": "Accessi Falliti (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "お待ちください"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "検索…"
|
||||
"placeholder": "検索…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "非健全",
|
||||
"not_found": "不明",
|
||||
"exited": "停止しました",
|
||||
"partial": "部分的"
|
||||
"partial": "部分的",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "エラー",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "ログイン (24時間)",
|
||||
"failedLoginsLast24H": "ログイン失敗(24時間)"
|
||||
"failedLoginsLast24H": "ログイン失敗(24時間)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "잠시만 기다리세요"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "검색…"
|
||||
"placeholder": "검색…",
|
||||
"provider": "검색 엔진: {{name}}",
|
||||
"select_provider": "검색 엔진 선택"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "비정상",
|
||||
"not_found": "찾을 수 없음",
|
||||
"exited": "종료됨",
|
||||
"partial": "부분적"
|
||||
"partial": "부분적",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "오류",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "사용자",
|
||||
"loginsLast24H": "로그인 (24시간)",
|
||||
"failedLoginsLast24H": "실패한 로그인 (24시간)"
|
||||
"failedLoginsLast24H": "실패한 로그인 (24시간)",
|
||||
"authorizationsLast24H": "인증(24시간)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "메모리",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Lūdzu, uzgaidiet"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Meklēt…"
|
||||
"placeholder": "Meklēt…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Unhealthy",
|
||||
"not_found": "Not Found",
|
||||
"exited": "Exited",
|
||||
"partial": "Partial"
|
||||
"partial": "Partial",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Error",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Logins (24h)",
|
||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
||||
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Sila tunggu"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Carian…"
|
||||
"placeholder": "Carian…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Kurang sihat",
|
||||
"not_found": "Tidak dijumpai",
|
||||
"exited": "Dimatikan",
|
||||
"partial": "Sebahagian"
|
||||
"partial": "Sebahagian",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Error",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Logmasuk (24j)",
|
||||
"failedLoginsLast24H": "Logmasuk Gagal (24j)"
|
||||
"failedLoginsLast24H": "Logmasuk Gagal (24j)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Even geduld"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Zoeken…"
|
||||
"placeholder": "Zoeken…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Ongezond",
|
||||
"not_found": "Niet Gevonden",
|
||||
"exited": "Gestopt",
|
||||
"partial": "Gedeeltelijk"
|
||||
"partial": "Gedeeltelijk",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Fout",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Gebruikers",
|
||||
"loginsLast24H": "Logins (24u)",
|
||||
"failedLoginsLast24H": "Mislukte Logins (24u)"
|
||||
"failedLoginsLast24H": "Mislukte Logins (24u)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "GEH",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Vennligst vent"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Søk…"
|
||||
"placeholder": "Søk…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Usunn",
|
||||
"not_found": "Not Found",
|
||||
"exited": "Exited",
|
||||
"partial": "Delvis"
|
||||
"partial": "Delvis",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Error",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Logins (24h)",
|
||||
"failedLoginsLast24H": "Mislykket innlogginger (24t)"
|
||||
"failedLoginsLast24H": "Mislykket innlogginger (24t)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"seconds": "s"
|
||||
},
|
||||
"auth": {
|
||||
"signout": "Sign out"
|
||||
"signout": "Wyloguj się"
|
||||
},
|
||||
"widget": {
|
||||
"missing_type": "Brakujący typ widżetu: {{type}}",
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Proszę czekać"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Szukaj…"
|
||||
"placeholder": "Szukaj…",
|
||||
"provider": "Wyszukiwarka: {{name}}",
|
||||
"select_provider": "Wybierz wyszukiwarkę"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "Procesor",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Niezdrowy",
|
||||
"not_found": "Nie znaleziono",
|
||||
"exited": "Zakończony",
|
||||
"partial": "Częściowy"
|
||||
"partial": "Częściowy",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Błąd",
|
||||
@@ -133,7 +140,7 @@
|
||||
"series": "Seriale",
|
||||
"episodes": "Odcinki",
|
||||
"songs": "Piosenki",
|
||||
"albums": "Albums"
|
||||
"albums": "Albumy"
|
||||
},
|
||||
"esphome": {
|
||||
"offline": "Offline",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Użytkownicy",
|
||||
"loginsLast24H": "Logowania (24h)",
|
||||
"failedLoginsLast24H": "Nieudane logowania (24h)"
|
||||
"failedLoginsLast24H": "Nieudane logowania (24h)",
|
||||
"authorizationsLast24H": "Uwierzytelnienia (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "RAM",
|
||||
|
||||
+437
-429
File diff suppressed because it is too large
Load Diff
@@ -39,7 +39,9 @@
|
||||
"wait": "Por favor, aguarde"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Pesquisar…"
|
||||
"placeholder": "Pesquisar…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Não-saudável",
|
||||
"not_found": "Não Encontrado",
|
||||
"exited": "Encerrado",
|
||||
"partial": "Parcial"
|
||||
"partial": "Parcial",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Erro",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Usuários",
|
||||
"loginsLast24H": "Inícios de sessão (24h)",
|
||||
"failedLoginsLast24H": "Inícios de sessão falhados (24h)"
|
||||
"failedLoginsLast24H": "Inícios de sessão falhados (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Va rugăm așteptați"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Caută…"
|
||||
"placeholder": "Caută…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "Procesor",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Nesănătos",
|
||||
"not_found": "Negăsit",
|
||||
"exited": "Ieşit",
|
||||
"partial": "Parțial"
|
||||
"partial": "Parțial",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Error",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Autentificări (24h)",
|
||||
"failedLoginsLast24H": "Conectări eșuate (24h)"
|
||||
"failedLoginsLast24H": "Conectări eșuate (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"seconds": "сек"
|
||||
},
|
||||
"auth": {
|
||||
"signout": "Sign out"
|
||||
"signout": "Выход"
|
||||
},
|
||||
"widget": {
|
||||
"missing_type": "Отсутствует тип виджета: {{type}}",
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Пожалуйста, подождите"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Поиск…"
|
||||
"placeholder": "Поиск…",
|
||||
"provider": "Поисковая система: {{name}}",
|
||||
"select_provider": "Выберите поисковую систему"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "ЦП",
|
||||
@@ -75,13 +77,13 @@
|
||||
"no_data": "Нет доступных данных о хранилище"
|
||||
},
|
||||
"duplicati": {
|
||||
"jobs": "Jobs",
|
||||
"stored": "Stored",
|
||||
"lastBackup": "Last Run",
|
||||
"nextRun": "Next Run",
|
||||
"running": "Running",
|
||||
"warnings": "Warnings",
|
||||
"errors": "Errors"
|
||||
"jobs": "Работы",
|
||||
"stored": "Сохранено",
|
||||
"lastBackup": "Последний запуск",
|
||||
"nextRun": "Следующий запуск",
|
||||
"running": "Запущено",
|
||||
"warnings": "Предупреждения",
|
||||
"errors": "Ошибки"
|
||||
},
|
||||
"docker": {
|
||||
"rx": "RX",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Нездоровый",
|
||||
"not_found": "Не найдено",
|
||||
"exited": "Вышел",
|
||||
"partial": "Частичный"
|
||||
"partial": "Частичный",
|
||||
"created": "Создан",
|
||||
"paused": "Приостановлен",
|
||||
"restarting": "Перезапуск",
|
||||
"removing": "Удаление",
|
||||
"dead": "Мёртв"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Ошибка",
|
||||
@@ -133,7 +140,7 @@
|
||||
"series": "Сериалы",
|
||||
"episodes": "Эпизоды",
|
||||
"songs": "Песни",
|
||||
"albums": "Albums"
|
||||
"albums": "Альбомы"
|
||||
},
|
||||
"esphome": {
|
||||
"offline": "Не в сети",
|
||||
@@ -235,17 +242,17 @@
|
||||
"rutorrent": {
|
||||
"active": "Активно",
|
||||
"upload": "Отдача",
|
||||
"download": "Скачивание"
|
||||
"download": "Загрузка"
|
||||
},
|
||||
"transmission": {
|
||||
"download": "Скачивание",
|
||||
"download": "Загрузка",
|
||||
"upload": "Отдача",
|
||||
"leech": "Лич",
|
||||
"seed": "Сид"
|
||||
},
|
||||
"qbittorrent": {
|
||||
"download": "Скачивание",
|
||||
"upload": "Загрузка",
|
||||
"download": "Загрузка",
|
||||
"upload": "Отдача",
|
||||
"leech": "Лич",
|
||||
"seed": "Сид"
|
||||
},
|
||||
@@ -258,8 +265,8 @@
|
||||
"invalid": "Некорректный"
|
||||
},
|
||||
"deluge": {
|
||||
"download": "Скачивание",
|
||||
"upload": "Загрузка",
|
||||
"download": "Загрузка",
|
||||
"upload": "Отдача",
|
||||
"leech": "Лич",
|
||||
"seed": "Сид"
|
||||
},
|
||||
@@ -268,8 +275,8 @@
|
||||
"cachemissbytes": "Мисс байты кэша"
|
||||
},
|
||||
"downloadstation": {
|
||||
"download": "Скачивание",
|
||||
"upload": "Загрузка",
|
||||
"download": "Загрузка",
|
||||
"upload": "Отдача",
|
||||
"leech": "Лич",
|
||||
"seed": "Сид"
|
||||
},
|
||||
@@ -334,8 +341,8 @@
|
||||
"latency": "Задержка"
|
||||
},
|
||||
"speedtest": {
|
||||
"upload": "Загрузка",
|
||||
"download": "Скачивание",
|
||||
"upload": "Отдача",
|
||||
"download": "Загрузка",
|
||||
"ping": "Пинг"
|
||||
},
|
||||
"portainer": {
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Пользователи",
|
||||
"loginsLast24H": "Входы (24ч)",
|
||||
"failedLoginsLast24H": "Неудачные входы (24ч)"
|
||||
"failedLoginsLast24H": "Неудачные входы (24ч)",
|
||||
"authorizationsLast24H": "Входы (24ч)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "Память",
|
||||
@@ -480,9 +488,9 @@
|
||||
"vms": "Виртуальные машины"
|
||||
},
|
||||
"pulse": {
|
||||
"nodes": "Nodes",
|
||||
"vms": "VMs",
|
||||
"lxcs": "LXCs"
|
||||
"nodes": "Узлы",
|
||||
"vms": "Виртуальные машины",
|
||||
"lxcs": "Контейнеры LXC"
|
||||
},
|
||||
"glances": {
|
||||
"cpu": "ЦП",
|
||||
@@ -581,7 +589,7 @@
|
||||
"up": "В сети",
|
||||
"pending": "Ожидают",
|
||||
"down": "Не в сети",
|
||||
"ok": "Ok"
|
||||
"ok": "OK"
|
||||
},
|
||||
"healthchecks": {
|
||||
"new": "Новый",
|
||||
@@ -649,7 +657,7 @@
|
||||
"total": "Всего"
|
||||
},
|
||||
"pangolin": {
|
||||
"orgs": "Orgs",
|
||||
"orgs": "Организации",
|
||||
"sites": "Сайты",
|
||||
"resources": "Ресурсы",
|
||||
"targets": "Цели",
|
||||
@@ -687,7 +695,7 @@
|
||||
"limit": "Лимит"
|
||||
},
|
||||
"opnsense": {
|
||||
"cpu": "ЦП",
|
||||
"cpu": "Нагрузка ЦП",
|
||||
"memory": "Активно ОЗУ",
|
||||
"wanUpload": "WAN Загрузка",
|
||||
"wanDownload": "WAN скачивание"
|
||||
@@ -822,7 +830,7 @@
|
||||
"gross_percent_today": "Сегодня",
|
||||
"gross_percent_1y": "Один год",
|
||||
"gross_percent_max": "Все время",
|
||||
"net_worth": "Net Worth"
|
||||
"net_worth": "Общая стоимость"
|
||||
},
|
||||
"audiobookshelf": {
|
||||
"podcasts": "Подкасты",
|
||||
@@ -951,15 +959,15 @@
|
||||
"storage": "Хранилище"
|
||||
},
|
||||
"maintainerr": {
|
||||
"itemsHandled": "Items Handled",
|
||||
"episodesHandled": "Episodes Handled",
|
||||
"moviesHandled": "Movies Handled",
|
||||
"showsHandled": "Shows Handled",
|
||||
"seasonsHandled": "Seasons Handled",
|
||||
"reclaimable": "Reclaimable",
|
||||
"movieReclaimable": "Movie Reclaimable",
|
||||
"showReclaimable": "Show Reclaimable",
|
||||
"totalCapacity": "Total Capacity"
|
||||
"itemsHandled": "Элементы обрабатываются",
|
||||
"episodesHandled": "Серии обрабатываются",
|
||||
"moviesHandled": "Фильмы обрабатываются",
|
||||
"showsHandled": "Шоу обрабатываются",
|
||||
"seasonsHandled": "Сезоны обрабатываются",
|
||||
"reclaimable": "Восстановленные",
|
||||
"movieReclaimable": "Восстановленный фильм",
|
||||
"showReclaimable": "Восстановленное шоу",
|
||||
"totalCapacity": "Общая ёмкость"
|
||||
},
|
||||
"netdata": {
|
||||
"warnings": "Предупреждения",
|
||||
@@ -968,7 +976,7 @@
|
||||
"ntfy": {
|
||||
"title": "Название",
|
||||
"priority": "Приоритет",
|
||||
"lastReceived": "Last Received",
|
||||
"lastReceived": "Последние полученные",
|
||||
"message": "Сообщение",
|
||||
"tags": "Теги",
|
||||
"none": "Отсутствует",
|
||||
@@ -1105,7 +1113,7 @@
|
||||
"apps": "Приложения",
|
||||
"synced": "Синхронизированные",
|
||||
"outOfSync": "Не синхронизированные",
|
||||
"healthy": "Healthy",
|
||||
"healthy": "Здоров",
|
||||
"degraded": "Деградированные",
|
||||
"progressing": "Выполняются",
|
||||
"missing": "Отсутствует",
|
||||
@@ -1152,18 +1160,18 @@
|
||||
"other": "Другое"
|
||||
},
|
||||
"checkmk": {
|
||||
"serviceErrors": "Service issues",
|
||||
"hostErrors": "Host issues"
|
||||
"serviceErrors": "Проблемы сервиса",
|
||||
"hostErrors": "Проблемы узла"
|
||||
},
|
||||
"komodo": {
|
||||
"total": "Всего",
|
||||
"running": "Запущено",
|
||||
"stopped": "Остановлено",
|
||||
"down": "Не в сети",
|
||||
"unhealthy": "Unhealthy",
|
||||
"unhealthy": "Неисправен",
|
||||
"unknown": "Неизвестно",
|
||||
"servers": "Серверы",
|
||||
"stacks": "Stacks",
|
||||
"stacks": "Стеки",
|
||||
"containers": "Контейнеры"
|
||||
},
|
||||
"filebrowser": {
|
||||
@@ -1186,7 +1194,7 @@
|
||||
"DISABLE_DISK": "Диск отключен",
|
||||
"SWAP_DSBL": "Swap отключён",
|
||||
"INVALID_EXPANSION": "Неверное Расширение",
|
||||
"PARITY_NOT_BIGGEST": "Parity Not Biggest",
|
||||
"PARITY_NOT_BIGGEST": "Четность не наибольшая",
|
||||
"TOO_MANY_MISSING_DISKS": "Слишком много отсутствующих дисков",
|
||||
"NEW_DISK_TOO_SMALL": "Новый диск слишком мал",
|
||||
"NO_DATA_DISKS": "Нет дисков данных",
|
||||
@@ -1195,18 +1203,18 @@
|
||||
"cpu": "ЦП",
|
||||
"memoryUsed": "Использовано ОЗУ",
|
||||
"memoryAvailable": "Доступная память",
|
||||
"arrayUsed": "Array Used",
|
||||
"arrayFree": "Array Free",
|
||||
"arrayUsed": "Используемый массив",
|
||||
"arrayFree": "Свободный массив",
|
||||
"poolUsed": "{{pool}} Использовано",
|
||||
"poolFree": "{{pool}} Свободно"
|
||||
},
|
||||
"backrest": {
|
||||
"num_plans": "Plans",
|
||||
"num_success_30": "Successes",
|
||||
"num_plans": "Планы",
|
||||
"num_success_30": "Успешные",
|
||||
"num_failure_30": "Ошибки",
|
||||
"num_success_latest": "Succeeding",
|
||||
"num_failure_latest": "Failing",
|
||||
"bytes_added_30": "Bytes Added"
|
||||
"num_success_latest": "Успешно",
|
||||
"num_failure_latest": "Неудачные",
|
||||
"bytes_added_30": "Байты добавлены"
|
||||
},
|
||||
"yourspotify": {
|
||||
"songs": "Треков",
|
||||
@@ -1235,20 +1243,20 @@
|
||||
"environment_not_found": "Среда не найдена"
|
||||
},
|
||||
"sparkyfitness": {
|
||||
"eaten": "Eaten",
|
||||
"burned": "Burned",
|
||||
"remaining": "Remaining",
|
||||
"steps": "Steps"
|
||||
"eaten": "Съедено",
|
||||
"burned": "Сожжено",
|
||||
"remaining": "Осталось",
|
||||
"steps": "Шаги"
|
||||
},
|
||||
"syncthing": {
|
||||
"connected": "Connected",
|
||||
"synced": "Synced",
|
||||
"errors": "Errors",
|
||||
"storage": "Storage"
|
||||
"connected": "Подключено",
|
||||
"synced": "Синхронизировано",
|
||||
"errors": "Ошибки",
|
||||
"storage": "Хранилище"
|
||||
},
|
||||
"sportarr": {
|
||||
"wanted": "Wanted",
|
||||
"queued": "Queued",
|
||||
"leagues": "Leagues"
|
||||
"wanted": "Требуется",
|
||||
"queued": "В очереди",
|
||||
"leagues": "Лиги"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"seconds": "s"
|
||||
},
|
||||
"auth": {
|
||||
"signout": "Sign out"
|
||||
"signout": "Odhlásiť sa"
|
||||
},
|
||||
"widget": {
|
||||
"missing_type": "Chýba typ widgetu: {{type}}",
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Počkajte prosím"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Hľadať…"
|
||||
"placeholder": "Hľadať…",
|
||||
"provider": "Vyhľadávač: {{name}}",
|
||||
"select_provider": "Vyberte vyhľadávač"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Nezdravý",
|
||||
"not_found": "Nenájdené",
|
||||
"exited": "Ukončené",
|
||||
"partial": "Čiastočný"
|
||||
"partial": "Čiastočný",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Chyba",
|
||||
@@ -133,7 +140,7 @@
|
||||
"series": "Seriálov",
|
||||
"episodes": "Epizód",
|
||||
"songs": "Skladieb",
|
||||
"albums": "Albums"
|
||||
"albums": "Albumov"
|
||||
},
|
||||
"esphome": {
|
||||
"offline": "Nedostupné",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Používateľov",
|
||||
"loginsLast24H": "Prihlás. (24 hod.)",
|
||||
"failedLoginsLast24H": "Neúspešné prihlás. (24 hod.)"
|
||||
"failedLoginsLast24H": "Neúspešné prihlás. (24 hod.)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "RAM",
|
||||
@@ -959,7 +967,7 @@
|
||||
"reclaimable": "Reclaimable",
|
||||
"movieReclaimable": "Movie Reclaimable",
|
||||
"showReclaimable": "Show Reclaimable",
|
||||
"totalCapacity": "Total Capacity"
|
||||
"totalCapacity": "Celková kapacita"
|
||||
},
|
||||
"netdata": {
|
||||
"warnings": "Upozornenia",
|
||||
@@ -1248,7 +1256,7 @@
|
||||
},
|
||||
"sportarr": {
|
||||
"wanted": "Wanted",
|
||||
"queued": "Queued",
|
||||
"queued": "V poradí",
|
||||
"leagues": "Leagues"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Prosimo počakajte"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Iskanje…"
|
||||
"placeholder": "Iskanje…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Ni zdrav",
|
||||
"not_found": "Ni najden",
|
||||
"exited": "V izhodu",
|
||||
"partial": "Delni"
|
||||
"partial": "Delni",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Error",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Prijave (24h)",
|
||||
"failedLoginsLast24H": "Neveljavne prijave (24h)"
|
||||
"failedLoginsLast24H": "Neveljavne prijave (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Молим сачекајте"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Претражи…"
|
||||
"placeholder": "Претражи…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "Процесор",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Нездравих",
|
||||
"not_found": "Није пронађено",
|
||||
"exited": "Напуштених",
|
||||
"partial": "Делимично"
|
||||
"partial": "Делимично",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Грешка",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Корисника",
|
||||
"loginsLast24H": "Пријаве (24ч)",
|
||||
"failedLoginsLast24H": "Неуспешне пријаве (24ч)"
|
||||
"failedLoginsLast24H": "Неуспешне пријаве (24ч)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "Меморија",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Vänligen vänta"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Sök…"
|
||||
"placeholder": "Sök…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Unhealthy",
|
||||
"not_found": "Not Found",
|
||||
"exited": "Exited",
|
||||
"partial": "Partial"
|
||||
"partial": "Partial",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Error",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Inloggningar (24h)",
|
||||
"failedLoginsLast24H": "Misslyckade inloggningar (24h)"
|
||||
"failedLoginsLast24H": "Misslyckade inloggningar (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "దయచేసి వేచి ఉండండి"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "వెతకండి…"
|
||||
"placeholder": "వెతకండి…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "సీపియూ",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Unhealthy",
|
||||
"not_found": "Not Found",
|
||||
"exited": "Exited",
|
||||
"partial": "Partial"
|
||||
"partial": "Partial",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Error",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "లాగిన్లు (24గం)",
|
||||
"failedLoginsLast24H": "విఫలమైన లాగిన్లు (24గం)"
|
||||
"failedLoginsLast24H": "విఫలమైన లాగిన్లు (24గం)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "โปรดรอ"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "ค้นหา…"
|
||||
"placeholder": "ค้นหา…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "ซีพียู",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Unhealthy",
|
||||
"not_found": "Not Found",
|
||||
"exited": "Exited",
|
||||
"partial": "Partial"
|
||||
"partial": "Partial",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Error",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Logins (24h)",
|
||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
||||
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Lütfen bekleyin"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Ara…"
|
||||
"placeholder": "Ara…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "İşlemci",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Sağlıksız",
|
||||
"not_found": "Bulunamadı",
|
||||
"exited": "Kapandı",
|
||||
"partial": "Kısmi"
|
||||
"partial": "Kısmi",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Hata",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Kullanıcılar",
|
||||
"loginsLast24H": "Girişler (24 Saat)",
|
||||
"failedLoginsLast24H": "Başarısız Girişler (24 Saat)"
|
||||
"failedLoginsLast24H": "Başarısız Girişler (24 Saat)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "Bellek",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Будь ласка, зачекайте"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Пошук…"
|
||||
"placeholder": "Пошук…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "ЦП",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "Нездоровий",
|
||||
"not_found": "Не знайдено",
|
||||
"exited": "Вийшов",
|
||||
"partial": "Частковий"
|
||||
"partial": "Частковий",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Помилка",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Користувачі",
|
||||
"loginsLast24H": "Вхід (протягом доби)",
|
||||
"failedLoginsLast24H": "Невдалі входи (протягом доби)"
|
||||
"failedLoginsLast24H": "Невдалі входи (протягом доби)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "Пам'ять",
|
||||
|
||||
+208
-200
@@ -21,7 +21,7 @@
|
||||
"seconds": "giây"
|
||||
},
|
||||
"auth": {
|
||||
"signout": "Sign out"
|
||||
"signout": "Đăng xuất"
|
||||
},
|
||||
"widget": {
|
||||
"missing_type": "Thiếu loại Widget: {{type}}",
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "Vui lòng chờ"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Tìm kiếm…"
|
||||
"placeholder": "Tìm kiếm…",
|
||||
"provider": "Công cụ tìm kiếm: {{name}}",
|
||||
"select_provider": "Chọn công cụ tìm kiếm"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -48,7 +50,7 @@
|
||||
"free": "Dư",
|
||||
"used": "Đã dùng",
|
||||
"load": "\n",
|
||||
"temp": "TEMP",
|
||||
"temp": "Tạm thời",
|
||||
"max": "Tối đa",
|
||||
"uptime": "UP"
|
||||
},
|
||||
@@ -64,24 +66,24 @@
|
||||
"wlan_devices": "Thiết bị trong mạng WLAN",
|
||||
"lan_users": "Người dùng mạng LAN",
|
||||
"wlan_users": "Người dùng mạng WLAN",
|
||||
"up": "UP",
|
||||
"down": "DOWN",
|
||||
"up": "Hoạt động",
|
||||
"down": "Không hoạt động",
|
||||
"wait": "Vui lòng chờ",
|
||||
"empty_data": "Trạng thái hệ thống phụ không xác định"
|
||||
},
|
||||
"unifi_drive": {
|
||||
"healthy": "Healthy",
|
||||
"degraded": "Degraded",
|
||||
"no_data": "No storage data available"
|
||||
"healthy": "Bình thường",
|
||||
"degraded": "Suy giảm",
|
||||
"no_data": "Không có dữ liệu"
|
||||
},
|
||||
"duplicati": {
|
||||
"jobs": "Jobs",
|
||||
"stored": "Stored",
|
||||
"lastBackup": "Last Run",
|
||||
"nextRun": "Next Run",
|
||||
"running": "Running",
|
||||
"warnings": "Warnings",
|
||||
"errors": "Errors"
|
||||
"jobs": "Tác vụ",
|
||||
"stored": "Đã lưu",
|
||||
"lastBackup": "Lần chạy cuối",
|
||||
"nextRun": "Lần chạy tiếp theo",
|
||||
"running": "Đang chạy",
|
||||
"warnings": "Cảnh báo",
|
||||
"errors": "Lỗi"
|
||||
},
|
||||
"docker": {
|
||||
"rx": "RX",
|
||||
@@ -96,8 +98,13 @@
|
||||
"starting": "Đang bắt đầu",
|
||||
"unhealthy": "Bất thường",
|
||||
"not_found": "Không tìm thấy",
|
||||
"exited": "Exited",
|
||||
"partial": "Partial"
|
||||
"exited": "Đã thoát",
|
||||
"partial": "Một phần",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Lỗi",
|
||||
@@ -118,35 +125,35 @@
|
||||
"playing": "Đang chơi",
|
||||
"transcoding": "Chuyển định dạng",
|
||||
"bitrate": "Bitrate",
|
||||
"no_active": "No Active Streams",
|
||||
"no_active": "Không có luồng đang hoạt động",
|
||||
"movies": "Phim ảnh",
|
||||
"series": "Series",
|
||||
"episodes": "Episodes",
|
||||
"episodes": "Tập",
|
||||
"songs": "Bài hát"
|
||||
},
|
||||
"jellyfin": {
|
||||
"playing": "Playing",
|
||||
"transcoding": "Transcoding",
|
||||
"bitrate": "Bitrate",
|
||||
"no_active": "No Active Streams",
|
||||
"movies": "Movies",
|
||||
"playing": "Đang phát",
|
||||
"transcoding": "Đang chuyển mã",
|
||||
"bitrate": "Tốc độ bit",
|
||||
"no_active": "Không có luồng đang hoạt động",
|
||||
"movies": "Phim",
|
||||
"series": "Series",
|
||||
"episodes": "Episodes",
|
||||
"songs": "Songs",
|
||||
"albums": "Albums"
|
||||
"episodes": "Tập",
|
||||
"songs": "Bài hát",
|
||||
"albums": "Album"
|
||||
},
|
||||
"esphome": {
|
||||
"offline": "Offline",
|
||||
"offline_alt": "Offline",
|
||||
"online": "Online",
|
||||
"total": "Total",
|
||||
"total": "Tổng cộng",
|
||||
"unknown": "Không xác định"
|
||||
},
|
||||
"evcc": {
|
||||
"pv_power": "Production",
|
||||
"pv_power": "Công suất phát",
|
||||
"battery_soc": "Pin",
|
||||
"grid_power": "Lưới",
|
||||
"home_power": "Consumption",
|
||||
"home_power": "Công suất tiêu thụ",
|
||||
"charge_power": "Bộ sạc",
|
||||
"kilowatt": "kW"
|
||||
},
|
||||
@@ -154,7 +161,7 @@
|
||||
"download": "Tải xuống",
|
||||
"upload": "Tải lên",
|
||||
"leech": "",
|
||||
"seed": "Seed"
|
||||
"seed": "Seeder"
|
||||
},
|
||||
"freshrss": {
|
||||
"subscriptions": "Đăng ký",
|
||||
@@ -173,115 +180,115 @@
|
||||
"maxDown": "Tải xuống tối đa",
|
||||
"maxUp": "Tải lên tối đa",
|
||||
"down": "Down",
|
||||
"up": "Up",
|
||||
"received": "Received",
|
||||
"sent": "Sent",
|
||||
"externalIPAddress": "Ext. IP",
|
||||
"externalIPv6Address": "Ext. IPv6",
|
||||
"externalIPv6Prefix": "Ext. IPv6-Prefix"
|
||||
"up": "Tải lên",
|
||||
"received": "Đã nhận",
|
||||
"sent": "Đã gửi",
|
||||
"externalIPAddress": "Địa chỉ IP bên ngoài",
|
||||
"externalIPv6Address": "Địa chỉ IPv6 bên ngoài",
|
||||
"externalIPv6Prefix": "Tiền tố IPv6 bên ngoài"
|
||||
},
|
||||
"caddy": {
|
||||
"upstreams": "Upstreams",
|
||||
"requests": "Current requests",
|
||||
"requests_failed": "Failed requests"
|
||||
"upstreams": "Máy chủ backend",
|
||||
"requests": "Yêu cầu hiện tại",
|
||||
"requests_failed": "Yêu cầu thất bại"
|
||||
},
|
||||
"changedetectionio": {
|
||||
"totalObserved": "Total Observed",
|
||||
"diffsDetected": "Diffs Detected"
|
||||
"totalObserved": "Tổng số đã theo dõi",
|
||||
"diffsDetected": "Số thay đổi đã phát hiện"
|
||||
},
|
||||
"channelsdvrserver": {
|
||||
"shows": "Shows",
|
||||
"recordings": "Recordings",
|
||||
"scheduled": "Scheduled",
|
||||
"passes": "Passes"
|
||||
"shows": "Chương trình",
|
||||
"recordings": "Bản ghi",
|
||||
"scheduled": "Đã lên lịch",
|
||||
"passes": "Quy tắc ghi"
|
||||
},
|
||||
"tautulli": {
|
||||
"playing": "Playing",
|
||||
"transcoding": "Transcoding",
|
||||
"bitrate": "Bitrate",
|
||||
"no_active": "No Active Streams",
|
||||
"plex_connection_error": "Check Plex Connection"
|
||||
"playing": "Đang phát",
|
||||
"transcoding": "Đang chuyển mã",
|
||||
"bitrate": "Tốc độ bit",
|
||||
"no_active": "Không có luồng đang hoạt động",
|
||||
"plex_connection_error": "Kiểm tra kết nối Plex"
|
||||
},
|
||||
"tracearr": {
|
||||
"no_active": "No Active Streams",
|
||||
"streams": "Streams",
|
||||
"transcodes": "Transcodes",
|
||||
"directplay": "Direct Play",
|
||||
"bitrate": "Bitrate"
|
||||
"no_active": "Không có luồng đang hoạt động",
|
||||
"streams": "Luồng",
|
||||
"transcodes": "Luồng chuyển mã",
|
||||
"directplay": "Phát trực tiếp",
|
||||
"bitrate": "Tốc độ bit"
|
||||
},
|
||||
"omada": {
|
||||
"connectedAp": "Connected APs",
|
||||
"activeUser": "Active devices",
|
||||
"alerts": "Alerts",
|
||||
"connectedGateways": "Connected gateways",
|
||||
"connectedSwitches": "Connected switches"
|
||||
"connectedAp": "AP đã kết nối",
|
||||
"activeUser": "Thiết bị đang hoạt động",
|
||||
"alerts": "Cảnh báo",
|
||||
"connectedGateways": "Gateway đã kết nối",
|
||||
"connectedSwitches": "Switch đã kết nối"
|
||||
},
|
||||
"nzbget": {
|
||||
"rate": "Rate",
|
||||
"remaining": "Remaining",
|
||||
"rate": "Tốc độ",
|
||||
"remaining": "Còn lại",
|
||||
"downloaded": "Đã tải"
|
||||
},
|
||||
"plex": {
|
||||
"streams": "Active Streams",
|
||||
"albums": "Albums",
|
||||
"movies": "Movies",
|
||||
"tv": "TV Shows"
|
||||
"streams": "Luồng đang hoạt động",
|
||||
"albums": "Album",
|
||||
"movies": "Phim",
|
||||
"tv": "Chương trình TV"
|
||||
},
|
||||
"sabnzbd": {
|
||||
"rate": "Rate",
|
||||
"rate": "Tốc độ",
|
||||
"queue": "Hàng chờ",
|
||||
"timeleft": "Thời gian còn lại"
|
||||
},
|
||||
"rutorrent": {
|
||||
"active": "Hoạt động",
|
||||
"upload": "Upload",
|
||||
"download": "Download"
|
||||
"upload": "Tải lên",
|
||||
"download": "Tải xuống"
|
||||
},
|
||||
"transmission": {
|
||||
"download": "Download",
|
||||
"upload": "Upload",
|
||||
"leech": "Leech",
|
||||
"seed": "Seed"
|
||||
"download": "Tải xuống",
|
||||
"upload": "Tải lên",
|
||||
"leech": "Leecher",
|
||||
"seed": "Seeder"
|
||||
},
|
||||
"qbittorrent": {
|
||||
"download": "Download",
|
||||
"upload": "Upload",
|
||||
"leech": "Leech",
|
||||
"seed": "Seed"
|
||||
"download": "Tải xuống",
|
||||
"upload": "Tải lên",
|
||||
"leech": "Leecher",
|
||||
"seed": "Seeder"
|
||||
},
|
||||
"qnap": {
|
||||
"cpuUsage": "CPU Usage",
|
||||
"memUsage": "MEM Usage",
|
||||
"systemTempC": "System Temp",
|
||||
"poolUsage": "Pool Usage",
|
||||
"volumeUsage": "Volume Usage",
|
||||
"invalid": "Invalid"
|
||||
"cpuUsage": "Mức sử dụng CPU",
|
||||
"memUsage": "Mức sử dụng bộ nhớ",
|
||||
"systemTempC": "Nhiệt độ hệ thống",
|
||||
"poolUsage": "Mức sử dụng pool",
|
||||
"volumeUsage": "Mức sử dụng volume",
|
||||
"invalid": "Không hợp lệ"
|
||||
},
|
||||
"deluge": {
|
||||
"download": "Download",
|
||||
"upload": "Upload",
|
||||
"leech": "Leech",
|
||||
"seed": "Seed"
|
||||
"download": "Tải xuống",
|
||||
"upload": "Tải lên",
|
||||
"leech": "Leecher",
|
||||
"seed": "Seeder"
|
||||
},
|
||||
"develancacheui": {
|
||||
"cachehitbytes": "Cache Hit Bytes",
|
||||
"cachemissbytes": "Cache Miss Bytes"
|
||||
"cachehitbytes": "Dung lượng cache hit",
|
||||
"cachemissbytes": "Dung lượng cache miss"
|
||||
},
|
||||
"downloadstation": {
|
||||
"download": "Download",
|
||||
"upload": "Upload",
|
||||
"leech": "Leech",
|
||||
"seed": "Seed"
|
||||
"download": "Tải xuống",
|
||||
"upload": "Tải lên",
|
||||
"leech": "Leecher",
|
||||
"seed": "Seeder"
|
||||
},
|
||||
"sonarr": {
|
||||
"wanted": "Wanted",
|
||||
"wanted": "Cần tải",
|
||||
"queued": "Queued",
|
||||
"series": "Series",
|
||||
"series": "Phim bộ",
|
||||
"queue": "Queue",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"radarr": {
|
||||
"wanted": "Wanted",
|
||||
"wanted": "Cần tải",
|
||||
"missing": "Missing",
|
||||
"queued": "Queued",
|
||||
"movies": "Movies",
|
||||
@@ -289,12 +296,12 @@
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"lidarr": {
|
||||
"wanted": "Wanted",
|
||||
"wanted": "Cần tải",
|
||||
"queued": "Queued",
|
||||
"artists": "Artists"
|
||||
},
|
||||
"readarr": {
|
||||
"wanted": "Wanted",
|
||||
"wanted": "Cần tải",
|
||||
"queued": "Queued",
|
||||
"books": "Sách"
|
||||
},
|
||||
@@ -334,66 +341,66 @@
|
||||
"latency": "Latency"
|
||||
},
|
||||
"speedtest": {
|
||||
"upload": "Upload",
|
||||
"download": "Download",
|
||||
"upload": "Tải lên",
|
||||
"download": "Tải xuống",
|
||||
"ping": "Ping"
|
||||
},
|
||||
"portainer": {
|
||||
"running": "Running",
|
||||
"stopped": "Stopped",
|
||||
"total": "Total"
|
||||
"running": "Đang chạy",
|
||||
"stopped": "Đã dừng",
|
||||
"total": "Tổng cộng"
|
||||
},
|
||||
"suwayomi": {
|
||||
"download": "Downloaded",
|
||||
"nondownload": "Non-Downloaded",
|
||||
"read": "Read",
|
||||
"unread": "Unread",
|
||||
"downloadedread": "Downloaded & Read",
|
||||
"downloadedunread": "Downloaded & Unread",
|
||||
"nondownloadedread": "Non-Downloaded & Read",
|
||||
"nondownloadedunread": "Non-Downloaded & Unread"
|
||||
"download": "Đã tải xuống",
|
||||
"nondownload": "Chưa tải xuống",
|
||||
"read": "Đã đọc",
|
||||
"unread": "Chưa đọc",
|
||||
"downloadedread": "Đã tải xuống & Đã đọc",
|
||||
"downloadedunread": "Đã tải xuống & Chưa đọc",
|
||||
"nondownloadedread": "Chưa tải xuống & Đã đọc",
|
||||
"nondownloadedunread": "Chưa tải xuống & Chưa đọc"
|
||||
},
|
||||
"tailscale": {
|
||||
"address": "Address",
|
||||
"expires": "Expires",
|
||||
"never": "Never",
|
||||
"user": "User",
|
||||
"hostname": "Hostname",
|
||||
"name": "Name",
|
||||
"client_version": "Client Version",
|
||||
"os": "OS",
|
||||
"created": "Created",
|
||||
"authorized": "Authorized",
|
||||
"is_external": "Is External",
|
||||
"update_available": "Update Available",
|
||||
"tags": "Tags",
|
||||
"last_seen": "Last Seen",
|
||||
"now": "Now",
|
||||
"years": "{{number}}y",
|
||||
"weeks": "{{number}}w",
|
||||
"days": "{{number}}d",
|
||||
"hours": "{{number}}h",
|
||||
"minutes": "{{number}}m",
|
||||
"seconds": "{{number}}s",
|
||||
"ago": "{{value}} Ago",
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
"address": "Địa chỉ",
|
||||
"expires": "Hết hạn",
|
||||
"never": "Không bao giờ",
|
||||
"user": "Người dùng",
|
||||
"hostname": "Tên máy chủ",
|
||||
"name": "Tên",
|
||||
"client_version": "Phiên bản máy khách",
|
||||
"os": "Hệ điều hành",
|
||||
"created": "Đã tạo",
|
||||
"authorized": "Đã cấp quyền",
|
||||
"is_external": "Là bên ngoài",
|
||||
"update_available": "Có bản cập nhật",
|
||||
"tags": "Thẻ",
|
||||
"last_seen": "Lần cuối nhìn thấy",
|
||||
"now": "Bây giờ",
|
||||
"years": "{{number}} năm",
|
||||
"weeks": "{{number}} tuần",
|
||||
"days": "{{number}} ngày",
|
||||
"hours": "{{number}} giờ",
|
||||
"minutes": "{{number}} phút",
|
||||
"seconds": "{{number}} giây",
|
||||
"ago": "{{value}} trước",
|
||||
"true": "Có",
|
||||
"false": "Không"
|
||||
},
|
||||
"technitium": {
|
||||
"totalQueries": "Queries",
|
||||
"totalNoError": "Success",
|
||||
"totalServerFailure": "Failures",
|
||||
"totalNxDomain": "NX Domains",
|
||||
"totalRefused": "Refused",
|
||||
"totalQueries": "Tổng số truy vấn",
|
||||
"totalNoError": "Thành công",
|
||||
"totalServerFailure": "Lỗi máy chủ",
|
||||
"totalNxDomain": "NX Domain",
|
||||
"totalRefused": "Bị từ chối",
|
||||
"totalAuthoritative": "Authoritative",
|
||||
"totalRecursive": "Recursive",
|
||||
"totalCached": "Cached",
|
||||
"totalBlocked": "Blocked",
|
||||
"totalDropped": "Dropped",
|
||||
"totalClients": "Clients"
|
||||
"totalCached": "Được lưu trong bộ nhớ đệm",
|
||||
"totalBlocked": "Bị chặn",
|
||||
"totalDropped": "Bị loại bỏ",
|
||||
"totalClients": "Số máy khách"
|
||||
},
|
||||
"tdarr": {
|
||||
"queue": "Queue",
|
||||
"queue": "Hàng đợi",
|
||||
"processed": "Processed",
|
||||
"errored": "Errored",
|
||||
"saved": "Saved"
|
||||
@@ -410,7 +417,7 @@
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"navidrome": {
|
||||
"nothing_streaming": "No Active Streams",
|
||||
"nothing_streaming": "Không có luồng đang hoạt động",
|
||||
"please_wait": "Please Wait"
|
||||
},
|
||||
"npm": {
|
||||
@@ -453,9 +460,9 @@
|
||||
"domain_count": "Domains"
|
||||
},
|
||||
"medusa": {
|
||||
"wanted": "Wanted",
|
||||
"wanted": "Cần tải",
|
||||
"queued": "Queued",
|
||||
"series": "Series"
|
||||
"series": "Phim bộ"
|
||||
},
|
||||
"minecraft": {
|
||||
"players": "Players",
|
||||
@@ -471,66 +478,67 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Logins (24h)",
|
||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
||||
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||
"authorizationsLast24H": "Lượt ủy quyền (24 giờ)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
"mem": "Bộ nhớ",
|
||||
"cpu": "CPU",
|
||||
"lxc": "LXC",
|
||||
"vms": "VMs"
|
||||
"vms": "VM"
|
||||
},
|
||||
"pulse": {
|
||||
"nodes": "Nodes",
|
||||
"vms": "VMs",
|
||||
"lxcs": "LXCs"
|
||||
"nodes": "Node",
|
||||
"vms": "VM",
|
||||
"lxcs": "LXC"
|
||||
},
|
||||
"glances": {
|
||||
"cpu": "CPU",
|
||||
"load": "Load",
|
||||
"wait": "Please wait",
|
||||
"temp": "TEMP",
|
||||
"_temp": "Temp",
|
||||
"warn": "Warn",
|
||||
"uptime": "UP",
|
||||
"total": "Total",
|
||||
"free": "Free",
|
||||
"used": "Used",
|
||||
"days": "d",
|
||||
"hours": "h",
|
||||
"crit": "Crit",
|
||||
"read": "Read",
|
||||
"write": "Write",
|
||||
"load": "Tải hệ thống",
|
||||
"wait": "Vui lòng đợi",
|
||||
"temp": "Nhiệt độ",
|
||||
"_temp": "Nhiệt độ",
|
||||
"warn": "Cảnh báo",
|
||||
"uptime": "Thời gian hoạt động",
|
||||
"total": "Tổng cộng",
|
||||
"free": "Còn trống",
|
||||
"used": "Đã sử dụng",
|
||||
"days": "ngày",
|
||||
"hours": "giờ",
|
||||
"crit": "Nghiêm trọng",
|
||||
"read": "Đọc",
|
||||
"write": "Ghi",
|
||||
"gpu": "GPU",
|
||||
"mem": "Mem",
|
||||
"mem": "Bộ nhớ",
|
||||
"swap": "Swap"
|
||||
},
|
||||
"quicklaunch": {
|
||||
"bookmark": "Bookmark",
|
||||
"service": "Service",
|
||||
"search": "Search",
|
||||
"custom": "Custom",
|
||||
"visit": "Visit",
|
||||
"bookmark": "Dấu trang",
|
||||
"service": "Dịch vụ",
|
||||
"search": "Tìm kiếm",
|
||||
"custom": "Tùy chỉnh",
|
||||
"visit": "Truy cập",
|
||||
"url": "URL",
|
||||
"searchsuggestion": "Suggestion"
|
||||
"searchsuggestion": "Gợi ý"
|
||||
},
|
||||
"wmo": {
|
||||
"0-day": "Sunny",
|
||||
"0-night": "Clear",
|
||||
"1-day": "Mainly Sunny",
|
||||
"1-night": "Mainly Clear",
|
||||
"2-day": "Partly Cloudy",
|
||||
"2-night": "Partly Cloudy",
|
||||
"3-day": "Cloudy",
|
||||
"3-night": "Cloudy",
|
||||
"45-day": "Foggy",
|
||||
"45-night": "Foggy",
|
||||
"48-day": "Foggy",
|
||||
"48-night": "Foggy",
|
||||
"51-day": "Light Drizzle",
|
||||
"51-night": "Light Drizzle",
|
||||
"53-day": "Drizzle",
|
||||
"53-night": "Drizzle",
|
||||
"55-day": "Heavy Drizzle",
|
||||
"0-day": "Trời nắng",
|
||||
"0-night": "Trời quang",
|
||||
"1-day": "Chủ yếu có nắng",
|
||||
"1-night": "Chủ yếu trời quang",
|
||||
"2-day": "Có mây từng phần",
|
||||
"2-night": "Có mây từng phần",
|
||||
"3-day": "Nhiều mây",
|
||||
"3-night": "Nhiều mây",
|
||||
"45-day": "Có sương mù",
|
||||
"45-night": "Có sương mù",
|
||||
"48-day": "Có sương mù",
|
||||
"48-night": "Có sương mù",
|
||||
"51-day": "Mưa phùn nhẹ",
|
||||
"51-night": "Mưa phùn nhẹ",
|
||||
"53-day": "Mưa phùn",
|
||||
"53-night": "Mưa phùn",
|
||||
"55-day": "Mưa phùn lớn",
|
||||
"55-night": "Heavy Drizzle",
|
||||
"56-day": "Light Freezing Drizzle",
|
||||
"56-night": "Light Freezing Drizzle",
|
||||
@@ -738,14 +746,14 @@
|
||||
"m": "m"
|
||||
},
|
||||
"atsumeru": {
|
||||
"series": "Series",
|
||||
"series": "Bộ truyện",
|
||||
"archives": "Archives",
|
||||
"chapters": "Chapters",
|
||||
"categories": "Categories"
|
||||
},
|
||||
"komga": {
|
||||
"libraries": "Libraries",
|
||||
"series": "Series",
|
||||
"series": "Bộ truyện",
|
||||
"books": "Books"
|
||||
},
|
||||
"diskstation": {
|
||||
@@ -758,9 +766,9 @@
|
||||
"streams": "Streams"
|
||||
},
|
||||
"mylar": {
|
||||
"series": "Series",
|
||||
"series": "Phim bộ",
|
||||
"issues": "Issues",
|
||||
"wanted": "Wanted"
|
||||
"wanted": "Cần tải"
|
||||
},
|
||||
"photoprism": {
|
||||
"albums": "Albums",
|
||||
@@ -843,7 +851,7 @@
|
||||
"books": "Books",
|
||||
"authors": "Authors",
|
||||
"categories": "Categories",
|
||||
"series": "Series"
|
||||
"series": "Bộ sách"
|
||||
},
|
||||
"booklore": {
|
||||
"libraries": "Libraries",
|
||||
@@ -858,7 +866,7 @@
|
||||
"downloadSpeed": "Speed"
|
||||
},
|
||||
"kavita": {
|
||||
"seriesCount": "Series",
|
||||
"seriesCount": "Số bộ truyện",
|
||||
"totalFiles": "Files"
|
||||
},
|
||||
"azuredevops": {
|
||||
@@ -1247,7 +1255,7 @@
|
||||
"storage": "Storage"
|
||||
},
|
||||
"sportarr": {
|
||||
"wanted": "Wanted",
|
||||
"wanted": "Cần tải",
|
||||
"queued": "Queued",
|
||||
"leagues": "Leagues"
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "請稍後"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜索緊…"
|
||||
"placeholder": "搜索緊…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "不健康的",
|
||||
"not_found": "未找到",
|
||||
"exited": "已退出",
|
||||
"partial": "部分"
|
||||
"partial": "部分",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Error",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "登錄( 24小时)",
|
||||
"failedLoginsLast24H": "登錄失敗( 24鐘頭)"
|
||||
"failedLoginsLast24H": "登錄失敗( 24鐘頭)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "请稍候"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜索…"
|
||||
"placeholder": "搜索…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "不健康",
|
||||
"not_found": "未找到",
|
||||
"exited": "已退出",
|
||||
"partial": "部分"
|
||||
"partial": "部分",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "错误",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "登录 (24h)",
|
||||
"failedLoginsLast24H": "登录失败 (24h)"
|
||||
"failedLoginsLast24H": "登录失败 (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "内存",
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"wait": "請稍候"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜尋…"
|
||||
"placeholder": "搜尋…",
|
||||
"provider": "Search engine: {{name}}",
|
||||
"select_provider": "Select search engine"
|
||||
},
|
||||
"resources": {
|
||||
"cpu": "CPU",
|
||||
@@ -97,7 +99,12 @@
|
||||
"unhealthy": "不健康的",
|
||||
"not_found": "找不到",
|
||||
"exited": "已退出",
|
||||
"partial": "部分"
|
||||
"partial": "部分",
|
||||
"created": "Created",
|
||||
"paused": "Paused",
|
||||
"restarting": "Restarting",
|
||||
"removing": "Removing",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"ping": {
|
||||
"error": "Error",
|
||||
@@ -471,7 +478,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "登入 (過去 24 小時)",
|
||||
"failedLoginsLast24H": "登入失敗 (過去 24 小時)"
|
||||
"failedLoginsLast24H": "登入失敗 (過去 24 小時)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -73,6 +73,7 @@ describe("pages/api/auth/[...nextauth]", () => {
|
||||
it("routes sanitized NextAuth logs through the Homepage logger", async () => {
|
||||
const mod = await import("pages/api/auth/[...nextauth]");
|
||||
const sensitiveMetadata = {
|
||||
error: Object.assign(new Error("State cookie was missing."), { access_token: "sensitive-access-token" }),
|
||||
clientSecret: "sensitive-client-secret",
|
||||
access_token: "sensitive-access-token",
|
||||
id_token: "sensitive-id-token",
|
||||
@@ -82,7 +83,7 @@ describe("pages/api/auth/[...nextauth]", () => {
|
||||
mod.authOptions.logger.warn("NEXTAUTH_URL", sensitiveMetadata);
|
||||
mod.authOptions.logger.debug("OAUTH_CALLBACK_RESPONSE", sensitiveMetadata);
|
||||
|
||||
expect(errorMock).toHaveBeenCalledWith("%s", "OAUTH_CALLBACK_ERROR");
|
||||
expect(errorMock).toHaveBeenCalledWith("%s: %s", "OAUTH_CALLBACK_ERROR", "State cookie was missing.");
|
||||
expect(warnMock).toHaveBeenCalledWith("%s", "NEXTAUTH_URL");
|
||||
expect(debugMock).toHaveBeenCalledWith("%s", "OAUTH_CALLBACK_RESPONSE");
|
||||
expect(JSON.stringify([...errorMock.mock.calls, ...warnMock.mock.calls, ...debugMock.mock.calls])).not.toContain(
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { getDockerStats, logger } = vi.hoisted(() => ({
|
||||
getDockerStats: vi.fn(),
|
||||
logger: { error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("utils/docker/stats", () => ({ getDockerStats }));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/docker/stats";
|
||||
|
||||
describe("pages/api/docker/stats", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns the stats map for the requested server", async () => {
|
||||
getDockerStats.mockResolvedValue({ stats: { app: { cpu: 20, mem: 900, rx: 4, tx: 6 } } });
|
||||
|
||||
const req = { query: { server: "local" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(getDockerStats).toHaveBeenCalledWith("local");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ stats: { app: { cpu: 20, mem: 900, rx: 4, tx: 6 } } });
|
||||
});
|
||||
|
||||
it("returns 500 when the stats lookup reports an error", async () => {
|
||||
getDockerStats.mockResolvedValue({ error: "query failed" });
|
||||
|
||||
const req = { query: { server: "local" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: "query failed" });
|
||||
});
|
||||
|
||||
it("logs and returns 500 when the stats lookup throws", async () => {
|
||||
getDockerStats.mockRejectedValue(new Error("boom"));
|
||||
|
||||
const req = { query: { server: "local" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: { message: "boom" } });
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,211 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { state, DockerCtor, getDockerArguments, containersFromConfig, hasHomepageLabels, getSettings, logger } =
|
||||
vi.hoisted(() => {
|
||||
const state = {
|
||||
docker: null,
|
||||
containers: [],
|
||||
health: {},
|
||||
containers: [],
|
||||
health: {},
|
||||
dockerArgs: { conn: { socketPath: "/var/run/docker.sock" }, swarm: false },
|
||||
};
|
||||
|
||||
function DockerCtor() {
|
||||
return state.docker;
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
DockerCtor,
|
||||
getDockerArguments: vi.fn(() => state.dockerArgs),
|
||||
containersFromConfig: vi.fn(async () => new Set()),
|
||||
hasHomepageLabels: vi.fn(() => false),
|
||||
getSettings: vi.fn(() => ({ instanceName: undefined })),
|
||||
logger: { error: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("dockerode", () => ({
|
||||
default: DockerCtor,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/docker", () => ({
|
||||
default: getDockerArguments,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
containersFromConfig,
|
||||
hasHomepageLabels,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
getSettings,
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/docker/statuses";
|
||||
|
||||
describe("pages/api/docker/statuses", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.dockerArgs = { conn: { socketPath: "/var/run/docker.sock" }, swarm: false };
|
||||
state.containers = [];
|
||||
state.health = {};
|
||||
state.docker = {
|
||||
listContainers: vi.fn(async (options) => {
|
||||
const health = options?.filters?.health?.[0];
|
||||
return health ? (state.health[health] ?? []) : state.containers;
|
||||
}),
|
||||
listServices: vi.fn(),
|
||||
listTasks: vi.fn(),
|
||||
};
|
||||
containersFromConfig.mockResolvedValue(new Set());
|
||||
hasHomepageLabels.mockReturnValue(false);
|
||||
getSettings.mockReturnValue({ instanceName: undefined });
|
||||
});
|
||||
|
||||
it("returns configured container statuses with health from the docker health filters", async () => {
|
||||
containersFromConfig.mockResolvedValue(new Set(["glance", "share"]));
|
||||
state.containers = [
|
||||
{ Names: ["/glance"], Id: "cid-glance", State: "running" },
|
||||
{ Names: ["/share"], Id: "cid-share", State: "exited" },
|
||||
];
|
||||
state.health = { healthy: [{ Id: "cid-glance" }] };
|
||||
|
||||
const req = { query: { server: "local" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(getDockerArguments).toHaveBeenCalledWith("local");
|
||||
expect(containersFromConfig).toHaveBeenCalledWith("local");
|
||||
// one list plus one per health state, constant regardless of container count
|
||||
expect(state.docker.listContainers).toHaveBeenCalledTimes(4);
|
||||
expect(state.docker.listServices).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
statuses: {
|
||||
glance: { status: "running", health: "healthy" },
|
||||
share: { status: "exited" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("takes health from the filter results rather than the status string", async () => {
|
||||
containersFromConfig.mockResolvedValue(new Set(["app"]));
|
||||
state.containers = [{ Names: ["/app"], Id: "cid1", State: "running", Status: "Up 2 hours (healthy)" }];
|
||||
state.health = { unhealthy: [{ Id: "cid1" }] };
|
||||
|
||||
const req = { query: { server: "local" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.body).toEqual({ statuses: { app: { status: "running", health: "unhealthy" } } });
|
||||
});
|
||||
|
||||
it("omits health when the docker daemon rejects the health filter", async () => {
|
||||
containersFromConfig.mockResolvedValue(new Set(["app"]));
|
||||
state.containers = [{ Names: ["/app"], Id: "cid1", State: "running", Status: "Up 2 hours (healthy)" }];
|
||||
state.docker.listContainers.mockImplementation(async (options) => {
|
||||
if (options?.filters?.health) throw new Error("filter unsupported");
|
||||
return state.containers;
|
||||
});
|
||||
|
||||
const req = { query: { server: "local" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.body).toEqual({ statuses: { app: { status: "running" } } });
|
||||
});
|
||||
|
||||
it("omits containers that are neither configured nor labelled for homepage", async () => {
|
||||
containersFromConfig.mockResolvedValue(new Set(["glance"]));
|
||||
state.containers = [
|
||||
{ Names: ["/glance"], State: "running", Status: "Up" },
|
||||
{ Names: ["/secret-db"], State: "running", Status: "Up" },
|
||||
];
|
||||
|
||||
const req = { query: { server: "local" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.body).toEqual({ statuses: { glance: { status: "running" } } });
|
||||
expect(res.body.statuses["secret-db"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes containers discovered through homepage labels", async () => {
|
||||
hasHomepageLabels.mockImplementation((labels) => labels?.["homepage.name"] !== undefined);
|
||||
state.containers = [
|
||||
{ Names: ["/labelled"], State: "running", Status: "Up", Labels: { "homepage.name": "App" } },
|
||||
{ Names: ["/unlabelled"], State: "running", Status: "Up", Labels: {} },
|
||||
];
|
||||
|
||||
const req = { query: { server: "local" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.body).toEqual({ statuses: { labelled: { status: "running" } } });
|
||||
});
|
||||
|
||||
it("includes swarm services when the server is in swarm mode", async () => {
|
||||
state.dockerArgs.swarm = true;
|
||||
containersFromConfig.mockResolvedValue(new Set(["web", "api"]));
|
||||
state.containers = [{ Names: ["/web"], Id: "cid1", State: "running", Status: "Up" }];
|
||||
state.docker.listServices.mockResolvedValue([
|
||||
{ ID: "sid", Spec: { Name: "api", Mode: { Replicated: { Replicas: "2" } } } },
|
||||
]);
|
||||
state.docker.listTasks.mockResolvedValue([
|
||||
{ ServiceID: "sid", Status: {} },
|
||||
{ ServiceID: "sid", Status: {} },
|
||||
]);
|
||||
|
||||
const req = { query: { server: "swarm" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
statuses: {
|
||||
web: { status: "running" },
|
||||
api: { status: "running 2/2" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("omits swarm services that are neither configured nor labelled", async () => {
|
||||
state.dockerArgs.swarm = true;
|
||||
containersFromConfig.mockResolvedValue(new Set());
|
||||
state.containers = [];
|
||||
state.docker.listServices.mockResolvedValue([
|
||||
{ ID: "sid", Spec: { Name: "internal", Mode: { Replicated: { Replicas: "1" } } } },
|
||||
]);
|
||||
state.docker.listTasks.mockResolvedValue([{ ServiceID: "sid", Status: {} }]);
|
||||
|
||||
const req = { query: { server: "swarm" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.body).toEqual({ statuses: {} });
|
||||
});
|
||||
|
||||
it("includes swarm services discovered through homepage labels", async () => {
|
||||
state.dockerArgs.swarm = true;
|
||||
hasHomepageLabels.mockImplementation((labels) => labels?.["homepage.name"] !== undefined);
|
||||
state.containers = [];
|
||||
state.docker.listServices.mockResolvedValue([
|
||||
{
|
||||
ID: "sid",
|
||||
Spec: { Name: "api", Labels: { "homepage.name": "Api" }, Mode: { Replicated: { Replicas: "1" } } },
|
||||
},
|
||||
]);
|
||||
state.docker.listTasks.mockResolvedValue([{ ServiceID: "sid", Status: {} }]);
|
||||
|
||||
const req = { query: { server: "swarm" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.body).toEqual({ statuses: { api: { status: "running 1/1" } } });
|
||||
});
|
||||
|
||||
it("returns only listed containers when swarm queries fail", async () => {
|
||||
state.dockerArgs.swarm = true;
|
||||
containersFromConfig.mockResolvedValue(new Set(["web"]));
|
||||
state.containers = [{ Names: ["/web"], Id: "cid1", State: "running", Status: "Up" }];
|
||||
state.docker.listServices.mockRejectedValue(new Error("no services"));
|
||||
state.docker.listTasks.mockRejectedValue(new Error("no tasks"));
|
||||
|
||||
const req = { query: { server: "swarm" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ statuses: { web: { status: "running" } } });
|
||||
});
|
||||
|
||||
it("returns 500 when docker returns a non-array containers payload", async () => {
|
||||
state.containers = Buffer.from("bad");
|
||||
|
||||
const req = { query: { server: "local" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: "query failed" });
|
||||
});
|
||||
|
||||
it("logs and returns 500 when the docker query throws", async () => {
|
||||
getDockerArguments.mockImplementationOnce(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
|
||||
const req = { query: { server: "local" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: { message: "boom" } });
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,48 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { StrictMode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { getSettingsMock } = vi.hoisted(() => ({
|
||||
const { getSettingsMock, authOptionsMock, signInMock, replaceMock, routerQuery } = vi.hoisted(() => ({
|
||||
getSettingsMock: vi.fn(),
|
||||
authOptionsMock: vi.fn(),
|
||||
signInMock: vi.fn(),
|
||||
replaceMock: vi.fn(),
|
||||
routerQuery: {},
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
getSettings: getSettingsMock,
|
||||
}));
|
||||
|
||||
vi.mock("pages/api/auth/[...nextauth]", () => ({
|
||||
get authOptions() {
|
||||
return authOptionsMock();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/router", () => ({
|
||||
useRouter: () => ({
|
||||
query: {},
|
||||
query: routerQuery,
|
||||
replace: replaceMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { getProviders } from "next-auth/react";
|
||||
vi.mock("next-auth/react", () => ({ signIn: signInMock }));
|
||||
|
||||
import SignInPage, { getServerSideProps } from "pages/auth/signin";
|
||||
|
||||
const OIDC_PROVIDERS = { "homepage-oidc": { id: "homepage-oidc", name: "Homepage OIDC", type: "oauth" } };
|
||||
const SETTINGS = { theme: "dark", color: "slate", title: "Homepage" };
|
||||
|
||||
describe("pages/auth/signin", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.keys(routerQuery).forEach((key) => delete routerQuery[key]);
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
it("renders an error state when no providers are configured", async () => {
|
||||
render(
|
||||
<SignInPage
|
||||
@@ -33,7 +55,7 @@ describe("pages/auth/signin", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Authentication not configured")).toBeInTheDocument();
|
||||
expect(screen.getByText("Authentication error")).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
@@ -60,8 +82,69 @@ describe("pages/auth/signin", () => {
|
||||
expect(screen.getByRole("button", { name: /login via oidc/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("redirects to the provider when auto-login is enabled", () => {
|
||||
routerQuery.callbackUrl = "/some/page";
|
||||
|
||||
render(<SignInPage providers={OIDC_PROVIDERS} settings={SETTINGS} autoLogin />);
|
||||
|
||||
expect(signInMock).toHaveBeenCalledWith("homepage-oidc", { callbackUrl: "/some/page" });
|
||||
expect(screen.getByText(/redirecting to homepage oidc/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /login via/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not auto-login when the provider returned an error", () => {
|
||||
routerQuery.error = "OAuthCallback";
|
||||
|
||||
render(<SignInPage providers={OIDC_PROVIDERS} settings={SETTINGS} autoLogin />);
|
||||
|
||||
expect(signInMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: /login via homepage oidc/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not auto-login when it is explicitly disabled in the url", () => {
|
||||
routerQuery.autologin = "0";
|
||||
|
||||
render(<SignInPage providers={OIDC_PROVIDERS} settings={SETTINGS} autoLogin />);
|
||||
|
||||
expect(signInMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: /login via homepage oidc/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("redirects once under strict mode, rather than tripping its own loop guard", () => {
|
||||
render(
|
||||
<StrictMode>
|
||||
<SignInPage providers={OIDC_PROVIDERS} settings={SETTINGS} autoLogin />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
expect(signInMock).toHaveBeenCalledTimes(1);
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops auto-login and hands back the page when the session never sticks", () => {
|
||||
routerQuery.callbackUrl = "/some/page";
|
||||
|
||||
render(<SignInPage providers={OIDC_PROVIDERS} settings={SETTINGS} autoLogin />);
|
||||
render(<SignInPage providers={OIDC_PROVIDERS} settings={SETTINGS} autoLogin />);
|
||||
|
||||
expect(signInMock).toHaveBeenCalledTimes(1);
|
||||
expect(replaceMock).toHaveBeenCalledWith("/auth/signin?autologin=0&callbackUrl=%2Fsome%2Fpage");
|
||||
});
|
||||
|
||||
it("does not auto-login the password provider", () => {
|
||||
render(
|
||||
<SignInPage
|
||||
providers={{ credentials: { id: "credentials", name: "Password", type: "credentials" } }}
|
||||
settings={SETTINGS}
|
||||
autoLogin
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(signInMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("getServerSideProps returns providers and only public sign-in settings", async () => {
|
||||
getProviders.mockResolvedValueOnce({ foo: { id: "foo", name: "Foo" } });
|
||||
authOptionsMock.mockReturnValueOnce({ providers: [{ id: "foo", name: "Foo", type: "oauth" }] });
|
||||
getSettingsMock.mockReturnValueOnce({
|
||||
theme: "dark",
|
||||
color: "slate",
|
||||
@@ -79,11 +162,11 @@ describe("pages/auth/signin", () => {
|
||||
|
||||
const res = await getServerSideProps({});
|
||||
|
||||
expect(getProviders).toHaveBeenCalled();
|
||||
expect(getSettingsMock).toHaveBeenCalled();
|
||||
expect(res).toEqual({
|
||||
props: {
|
||||
providers: { foo: { id: "foo", name: "Foo" } },
|
||||
autoLogin: false,
|
||||
providers: { foo: { id: "foo", name: "Foo", type: "oauth" } },
|
||||
settings: {
|
||||
theme: "dark",
|
||||
color: "slate",
|
||||
@@ -96,4 +179,64 @@ describe("pages/auth/signin", () => {
|
||||
expect(res.props.settings).not.toHaveProperty("providers");
|
||||
expect(res.props.settings).not.toHaveProperty("layout");
|
||||
});
|
||||
|
||||
it("getServerSideProps enables auto-login from the environment", async () => {
|
||||
authOptionsMock.mockReturnValueOnce({ providers: [] });
|
||||
getSettingsMock.mockReturnValueOnce({ theme: "dark" });
|
||||
vi.stubEnv("HOMEPAGE_OIDC_AUTO_LOGIN", "true");
|
||||
|
||||
const res = await getServerSideProps({});
|
||||
|
||||
expect(res.props.autoLogin).toBe(true);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("getServerSideProps falls back to no providers when auth options fail to load", async () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
authOptionsMock.mockImplementationOnce(() => {
|
||||
throw new Error("Homepage auth is enabled but HOMEPAGE_EXTERNAL_URL (or NEXTAUTH_URL) is missing.");
|
||||
});
|
||||
getSettingsMock.mockReturnValueOnce({ theme: "dark" });
|
||||
|
||||
const res = await getServerSideProps({});
|
||||
|
||||
expect(res.props.providers).toEqual({});
|
||||
expect(consoleError).toHaveBeenCalled();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("getServerSideProps passes only id, name and type from each provider", async () => {
|
||||
authOptionsMock.mockReturnValueOnce({
|
||||
providers: [
|
||||
{
|
||||
id: "homepage-oidc",
|
||||
name: "Homepage OIDC",
|
||||
type: "oauth",
|
||||
issuer: "https://oidc.example",
|
||||
clientId: "canary-client-id",
|
||||
clientSecret: "canary-client-secret",
|
||||
wellKnown: "https://oidc.example/.well-known/openid-configuration",
|
||||
authorization: { params: { scope: "openid email profile" } },
|
||||
profile: () => ({}),
|
||||
},
|
||||
{
|
||||
id: "credentials",
|
||||
name: "Password",
|
||||
type: "credentials",
|
||||
credentials: { password: { label: "Password", type: "password" } },
|
||||
authorize: () => null,
|
||||
},
|
||||
],
|
||||
});
|
||||
getSettingsMock.mockReturnValueOnce({ theme: "dark" });
|
||||
|
||||
const res = await getServerSideProps({});
|
||||
|
||||
expect(res.props.providers).toEqual({
|
||||
"homepage-oidc": { id: "homepage-oidc", name: "Homepage OIDC", type: "oauth" },
|
||||
credentials: { id: "credentials", name: "Password", type: "credentials" },
|
||||
});
|
||||
// These props get serialized into the sign-in page, which is unauthenticated
|
||||
expect(JSON.stringify(res.props)).not.toMatch(/canary-client-secret|canary-client-id|oidc\.example/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ColorContext } from "utils/contexts/color";
|
||||
@@ -25,6 +25,7 @@ const {
|
||||
throwIn: null,
|
||||
validateData: [],
|
||||
hashData: null,
|
||||
hashConfig: null,
|
||||
mutateHash: vi.fn(),
|
||||
servicesData: [],
|
||||
bookmarksData: [],
|
||||
@@ -59,9 +60,12 @@ const {
|
||||
const serverSideTranslations = vi.fn(async (language) => ({ _translations: language }));
|
||||
const logger = { error: vi.fn() };
|
||||
|
||||
const useSWR = vi.fn((key) => {
|
||||
const useSWR = vi.fn((key, config) => {
|
||||
if (key === "/api/validate") return { data: state.validateData };
|
||||
if (key === "/api/hash") return { data: state.hashData, mutate: state.mutateHash };
|
||||
if (key === "/api/hash") {
|
||||
state.hashConfig = config;
|
||||
return { data: state.hashData, mutate: state.mutateHash };
|
||||
}
|
||||
if (key === "/api/services") return { data: state.servicesData };
|
||||
if (key === "/api/bookmarks") return { data: state.bookmarksData };
|
||||
if (key === "/api/widgets") return { data: state.widgetsData };
|
||||
@@ -172,6 +176,7 @@ describe("pages/index getStaticProps", () => {
|
||||
state.throwIn = null;
|
||||
state.validateData = [];
|
||||
state.hashData = null;
|
||||
state.hashConfig = null;
|
||||
state.servicesData = [];
|
||||
state.bookmarksData = [];
|
||||
state.widgetsData = [];
|
||||
@@ -351,6 +356,7 @@ describe("pages/index Index routing + SWR branches", () => {
|
||||
}
|
||||
|
||||
await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } });
|
||||
act(() => state.hashConfig.onSuccess(state.hashData));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchSpy).toHaveBeenCalledWith("/api/revalidate");
|
||||
@@ -379,11 +385,24 @@ describe("pages/index Index routing + SWR branches", () => {
|
||||
localStorage.removeItem("hash");
|
||||
|
||||
await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } });
|
||||
act(() => state.hashConfig.onSuccess(state.hashData));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("hash")).toBe("first-hash");
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a hash response without a hash", async () => {
|
||||
state.validateData = [];
|
||||
localStorage.setItem("hash", "old-hash");
|
||||
|
||||
await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } });
|
||||
|
||||
expect(() => act(() => state.hashConfig.onSuccess(null))).not.toThrow();
|
||||
expect(() => act(() => state.hashConfig.onSuccess({}))).not.toThrow();
|
||||
expect(localStorage.getItem("hash")).toBe("old-hash");
|
||||
expect(document.querySelector(".animate-spin")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pages/index Home behavior", () => {
|
||||
@@ -419,11 +438,27 @@ describe("pages/index Home behavior", () => {
|
||||
|
||||
fireEvent.keyDown(document.body, { key: "a" });
|
||||
expect(screen.getByTestId("quicklaunch")).toHaveTextContent("open:3");
|
||||
expect(state.quickLaunchProps.searchString).toBe("a");
|
||||
|
||||
fireEvent.keyDown(document.body, { key: "Escape" });
|
||||
expect(screen.getByTestId("quicklaunch")).toHaveTextContent("closed:3");
|
||||
});
|
||||
|
||||
it("opens search on space without seeding the query with whitespace", async () => {
|
||||
await renderIndex({
|
||||
initialSettings: { title: "Homepage", layout: {} },
|
||||
settings: { title: "Homepage", layout: {}, language: "en" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(state.quickLaunchProps).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document.body, { key: " " });
|
||||
expect(screen.getByTestId("quicklaunch")).toHaveTextContent("open:3");
|
||||
expect(state.quickLaunchProps.searchString).toBe("");
|
||||
});
|
||||
|
||||
it("renders services and bookmark groups when present", async () => {
|
||||
await renderIndex({
|
||||
initialSettings: { title: "Homepage", layout: {} },
|
||||
@@ -489,7 +524,7 @@ describe("pages/index Home behavior", () => {
|
||||
state.widgetsData = [];
|
||||
|
||||
const { setTheme, setColor, setSettings } = await renderIndex({
|
||||
initialSettings: { title: "Homepage", layout: {} },
|
||||
initialSettings: { title: "Homepage", layout: {}, favicon: "/x.ico" },
|
||||
settings: {
|
||||
title: "Homepage",
|
||||
layout: {},
|
||||
@@ -498,7 +533,6 @@ describe("pages/index Home behavior", () => {
|
||||
color: "emerald",
|
||||
disableIndexing: true,
|
||||
base: "/base/",
|
||||
favicon: "/x.ico",
|
||||
},
|
||||
theme: "dark",
|
||||
color: "slate",
|
||||
@@ -516,6 +550,20 @@ describe("pages/index Home behavior", () => {
|
||||
expect(document.querySelector('link[rel="icon"]')?.getAttribute("href")).toBe("/x.ico");
|
||||
});
|
||||
|
||||
// Safari reads the head without running JS, so the favicon has to be there before the
|
||||
// settings context is populated, and mask-icon must not be
|
||||
it("renders a custom favicon before the settings context is populated", async () => {
|
||||
await renderIndex({
|
||||
initialSettings: { title: "Homepage", layout: {}, favicon: "/x.ico" },
|
||||
settings: {},
|
||||
});
|
||||
|
||||
expect(document.querySelector('link[rel="icon"]')?.getAttribute("href")).toBe("/x.ico");
|
||||
expect(document.querySelector('link[rel="apple-touch-icon"]')?.getAttribute("href")).toBe("/x.ico");
|
||||
expect(document.querySelector('link[rel="mask-icon"]')).toBeNull();
|
||||
expect(document.querySelector('link[rel="shortcut icon"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("marks information widgets as right-aligned for known widget types", async () => {
|
||||
await renderIndex({
|
||||
initialSettings: { title: "Homepage", layout: {} },
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { MdKeyboardArrowDown } from "react-icons/md";
|
||||
|
||||
import List from "components/bookmarks/list";
|
||||
import ErrorBoundary from "components/errorboundry";
|
||||
import ResolvedIcon from "components/resolvedicon";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { MdKeyboardArrowDown } from "react-icons/md";
|
||||
|
||||
export default function BookmarksGroup({
|
||||
bookmarks,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import classNames from "classnames";
|
||||
import ResolvedIcon from "components/resolvedicon";
|
||||
import { useContext } from "react";
|
||||
|
||||
import ResolvedIcon from "components/resolvedicon";
|
||||
import { SettingsContext } from "utils/contexts/settings";
|
||||
|
||||
export default function Item({ bookmark, iconOnly = false }) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import classNames from "classnames";
|
||||
import Item from "components/bookmarks/item";
|
||||
|
||||
import { columnMap } from "../../utils/layout/columns";
|
||||
|
||||
import Item from "components/bookmarks/item";
|
||||
|
||||
export default function List({ bookmarks, layout, bookmarksStyle }) {
|
||||
let classes = layout?.style === "row" ? `grid ${columnMap[layout?.columns]} gap-x-2` : "flex flex-col bookmark-list";
|
||||
const style = {};
|
||||
|
||||
@@ -15,7 +15,6 @@ export default class ErrorBoundary extends React.Component {
|
||||
|
||||
// You can also log error messages to an error reporting service here
|
||||
if (error || errorInfo) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("component error: %s, info: %s", error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
/* eslint-disable jsx-a11y/alt-text */
|
||||
import { useContext, useEffect, useRef } from "react";
|
||||
import { ColorContext } from "utils/contexts/color";
|
||||
|
||||
import { ColorContext } from "utils/contexts/color";
|
||||
import themes from "utils/styles/themes";
|
||||
|
||||
export function Svg({ svgRef = null }) {
|
||||
|
||||
+134
-114
@@ -3,11 +3,12 @@ import { useTranslation } from "next-i18next/pages";
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { FiSearch } from "react-icons/fi";
|
||||
import useSWR from "swr";
|
||||
import { SettingsContext } from "utils/contexts/settings";
|
||||
|
||||
import ResolvedIcon from "./resolvedicon";
|
||||
import { getStoredProvider, searchProviders } from "./widgets/search/search";
|
||||
|
||||
import { SettingsContext } from "utils/contexts/settings";
|
||||
|
||||
const MOBILE_BUTTON_POSITIONS = {
|
||||
"top-left": "top-4 left-4",
|
||||
"top-right": "top-4 right-4",
|
||||
@@ -15,6 +16,69 @@ const MOBILE_BUTTON_POSITIONS = {
|
||||
"bottom-right": "bottom-4 right-4",
|
||||
};
|
||||
|
||||
function parseUrl(searchString) {
|
||||
if (!/.+[.:].+/.test(searchString)) return null; // basic test for probably a url
|
||||
|
||||
try {
|
||||
return new URL(searchString.toLowerCase().startsWith("http") ? searchString : `https://${searchString}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSearchResults({
|
||||
hideVisitURL,
|
||||
searchDescriptions,
|
||||
searchProvider,
|
||||
searchString,
|
||||
searchSuggestions,
|
||||
servicesAndBookmarks,
|
||||
t,
|
||||
url,
|
||||
}) {
|
||||
if (searchString.trim().length === 0) return [];
|
||||
|
||||
const results = servicesAndBookmarks.flatMap((result) => {
|
||||
const nameMatch = result.name.toLowerCase().includes(searchString);
|
||||
const descriptionMatch = searchDescriptions && result.description?.toLowerCase().includes(searchString);
|
||||
|
||||
if (!nameMatch && !descriptionMatch) return [];
|
||||
return [{ ...result, ...(searchDescriptions && { priority: nameMatch ? 2 : +descriptionMatch }) }];
|
||||
});
|
||||
|
||||
if (searchDescriptions) {
|
||||
results.sort((a, b) => b.priority - a.priority);
|
||||
}
|
||||
|
||||
if (searchProvider) {
|
||||
results.push({
|
||||
href: searchProvider.url + encodeURIComponent(searchString),
|
||||
name: `${searchProvider.name ?? t("quicklaunch.custom")} ${t("quicklaunch.search")}`,
|
||||
type: "search",
|
||||
});
|
||||
|
||||
if (searchProvider.showSearchSuggestions && searchProvider.suggestionUrl && searchSuggestions[1]) {
|
||||
results.push(
|
||||
...searchSuggestions[1].map((suggestion) => ({
|
||||
href: searchProvider.url + encodeURIComponent(suggestion),
|
||||
name: suggestion,
|
||||
type: "searchSuggestion",
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hideVisitURL && url) {
|
||||
results.unshift({
|
||||
href: url.toString(),
|
||||
name: `${t("quicklaunch.visit")} URL`,
|
||||
type: "url",
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export default function QuickLaunch({ servicesAndBookmarks, searchString, setSearchString, isOpen, setSearching }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -23,9 +87,8 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
|
||||
|
||||
const searchField = useRef();
|
||||
|
||||
const [results, setResults] = useState([]);
|
||||
const [currentItemIndex, setCurrentItemIndex] = useState(null);
|
||||
const [url, setUrl] = useState(null);
|
||||
const url = parseUrl(searchString);
|
||||
const [searchSuggestions, setSearchSuggestions] = useState([]);
|
||||
|
||||
const { data: widgets } = useSWR("/api/widgets");
|
||||
@@ -61,8 +124,8 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
|
||||
? MOBILE_BUTTON_POSITIONS[settings.quicklaunch.mobileButtonPosition]
|
||||
: null;
|
||||
|
||||
function openCurrentItem(newWindow) {
|
||||
const result = results[currentItemIndex];
|
||||
function openCurrentItem(newWindow, index = activeItemIndex) {
|
||||
const result = results[index];
|
||||
window.open(
|
||||
result.href,
|
||||
newWindow ? "_blank" : (result.target ?? searchProvider?.target ?? settings.target ?? "_blank"),
|
||||
@@ -81,17 +144,9 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
|
||||
|
||||
function handleSearchChange(event) {
|
||||
const rawSearchString = event.target.value;
|
||||
try {
|
||||
if (!/.+[.:].+/g.test(rawSearchString)) throw new Error(); // basic test for probably a url
|
||||
let urlString = rawSearchString;
|
||||
if (urlString.toLowerCase().indexOf("http") !== 0) urlString = `https://${rawSearchString}`;
|
||||
setUrl(new URL(urlString)); // basic validation
|
||||
setSearchString(rawSearchString);
|
||||
return;
|
||||
} catch (e) {
|
||||
setUrl(null);
|
||||
}
|
||||
setSearchString(rawSearchString.toLowerCase());
|
||||
setCurrentItemIndex(null);
|
||||
// urls keep their casing, everything else is lowercased for matching
|
||||
setSearchString(parseUrl(rawSearchString) ? rawSearchString : rawSearchString.toLowerCase());
|
||||
}
|
||||
|
||||
function handleSearchKeyDown(event) {
|
||||
@@ -103,18 +158,19 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
|
||||
} else if (event.key === "Enter" && results.length) {
|
||||
closeAndReset();
|
||||
openCurrentItem(event.metaKey);
|
||||
} else if (event.key === "ArrowDown" && results[currentItemIndex + 1]) {
|
||||
setCurrentItemIndex(currentItemIndex + 1);
|
||||
} else if (event.key === "ArrowDown" && results[activeItemIndex + 1]) {
|
||||
setCurrentItemIndex(activeItemIndex + 1);
|
||||
event.preventDefault();
|
||||
} else if (event.key === "ArrowUp" && currentItemIndex > 0) {
|
||||
setCurrentItemIndex(currentItemIndex - 1);
|
||||
} else if (event.key === "ArrowUp" && activeItemIndex > 0) {
|
||||
setCurrentItemIndex(activeItemIndex - 1);
|
||||
event.preventDefault();
|
||||
} else if (
|
||||
event.key === "ArrowRight" &&
|
||||
results[currentItemIndex] &&
|
||||
results[currentItemIndex].type === "searchSuggestion"
|
||||
results[activeItemIndex] &&
|
||||
results[activeItemIndex].type === "searchSuggestion"
|
||||
) {
|
||||
setSearchString(results[currentItemIndex].name);
|
||||
setCurrentItemIndex(null);
|
||||
setSearchString(results[activeItemIndex].name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +180,8 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
|
||||
|
||||
function handleItemClick(event) {
|
||||
closeAndReset();
|
||||
openCurrentItem(event.metaKey);
|
||||
// in case hover doesnt fire, use the clicked item, not the highlighted one
|
||||
openCurrentItem(event.metaKey, parseInt(event.currentTarget.dataset.index, 10));
|
||||
}
|
||||
|
||||
function handleItemKeyDown(event) {
|
||||
@@ -138,88 +195,51 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
if (searchString.trim().length === 0) setResults([]);
|
||||
else {
|
||||
let newResults = servicesAndBookmarks.filter((r) => {
|
||||
const nameMatch = r.name.toLowerCase().includes(searchString);
|
||||
let descriptionMatch;
|
||||
if (searchDescriptions) {
|
||||
descriptionMatch = r.description?.toLowerCase().includes(searchString);
|
||||
r.priority = nameMatch ? 2 * +nameMatch : +descriptionMatch; // eslint-disable-line no-param-reassign
|
||||
}
|
||||
return nameMatch || descriptionMatch;
|
||||
});
|
||||
|
||||
if (searchDescriptions) {
|
||||
newResults = newResults.sort((a, b) => b.priority - a.priority);
|
||||
}
|
||||
|
||||
if (searchProvider) {
|
||||
newResults.push({
|
||||
href: searchProvider.url + encodeURIComponent(searchString),
|
||||
name: `${searchProvider.name ?? t("quicklaunch.custom")} ${t("quicklaunch.search")}`,
|
||||
type: "search",
|
||||
});
|
||||
|
||||
if (searchProvider.showSearchSuggestions && searchProvider.suggestionUrl) {
|
||||
if (searchString.trim() !== searchSuggestions[0]?.trim()) {
|
||||
fetch(
|
||||
`/api/search/searchSuggestion?query=${encodeURIComponent(searchString)}&providerName=${
|
||||
searchProvider.name ?? "Custom"
|
||||
}`,
|
||||
{ signal: abortController.signal },
|
||||
)
|
||||
.then(async (searchSuggestionResult) => {
|
||||
const newSearchSuggestions = await searchSuggestionResult.json();
|
||||
|
||||
if (newSearchSuggestions) {
|
||||
if (newSearchSuggestions[1].length > 4) {
|
||||
newSearchSuggestions[1] = newSearchSuggestions[1].splice(0, 4);
|
||||
}
|
||||
setSearchSuggestions(newSearchSuggestions);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// If there is an error, just ignore it. There just will be no search suggestions.
|
||||
});
|
||||
}
|
||||
|
||||
if (searchSuggestions[1]) {
|
||||
newResults = newResults.concat(
|
||||
searchSuggestions[1].map((suggestion) => ({
|
||||
href: searchProvider.url + encodeURIComponent(suggestion),
|
||||
name: suggestion,
|
||||
type: "searchSuggestion",
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hideVisitURL && url) {
|
||||
newResults.unshift({
|
||||
href: url.toString(),
|
||||
name: `${t("quicklaunch.visit")} URL`,
|
||||
type: "url",
|
||||
});
|
||||
}
|
||||
|
||||
setResults(newResults);
|
||||
|
||||
if (newResults.length) {
|
||||
setCurrentItemIndex(0);
|
||||
}
|
||||
if (
|
||||
searchString.trim().length === 0 ||
|
||||
!searchProvider?.showSearchSuggestions ||
|
||||
!searchProvider.suggestionUrl ||
|
||||
searchString.trim() === searchSuggestions[0]?.trim()
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchString, servicesAndBookmarks, searchDescriptions, hideVisitURL, searchSuggestions, searchProvider, url]);
|
||||
const abortController = new AbortController();
|
||||
|
||||
fetch(
|
||||
`/api/search/searchSuggestion?query=${encodeURIComponent(searchString)}&providerName=${
|
||||
searchProvider.name ?? "Custom"
|
||||
}`,
|
||||
{ signal: abortController.signal },
|
||||
)
|
||||
.then(async (searchSuggestionResult) => {
|
||||
const newSearchSuggestions = await searchSuggestionResult.json();
|
||||
|
||||
if (newSearchSuggestions) {
|
||||
setCurrentItemIndex(null);
|
||||
setSearchSuggestions([newSearchSuggestions[0], newSearchSuggestions[1].slice(0, 4)]);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// If there is an error, just ignore it. There just will be no search suggestions.
|
||||
});
|
||||
|
||||
return () => abortController.abort();
|
||||
}, [searchProvider, searchString, searchSuggestions]);
|
||||
|
||||
const results = getSearchResults({
|
||||
hideVisitURL,
|
||||
searchDescriptions,
|
||||
searchProvider,
|
||||
searchString,
|
||||
searchSuggestions,
|
||||
servicesAndBookmarks,
|
||||
t,
|
||||
url,
|
||||
});
|
||||
|
||||
const activeItemIndex = currentItemIndex ?? (results.length ? 0 : null);
|
||||
|
||||
const [hidden, setHidden] = useState(true);
|
||||
useEffect(() => {
|
||||
function handleBackdropClick(event) {
|
||||
if (event.target?.tagName === "DIV") closeAndReset();
|
||||
@@ -228,14 +248,11 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
|
||||
if (isOpen) {
|
||||
searchField.current.focus();
|
||||
document.body.addEventListener("click", handleBackdropClick);
|
||||
setHidden(false);
|
||||
} else {
|
||||
document.body.removeEventListener("click", handleBackdropClick);
|
||||
searchField.current.blur();
|
||||
setTimeout(() => {
|
||||
setHidden(true);
|
||||
}, 300); // disable on close
|
||||
}
|
||||
|
||||
return () => document.body.removeEventListener("click", handleBackdropClick);
|
||||
}, [isOpen, closeAndReset]);
|
||||
|
||||
function highlightText(text) {
|
||||
@@ -244,7 +261,6 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
|
||||
<span>
|
||||
{parts.map((part, i) =>
|
||||
part.toLowerCase() === searchString.toLowerCase() ? (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<span key={`${searchString}_${i}`} className="bg-theme-300/10">
|
||||
{part}
|
||||
</span>
|
||||
@@ -260,13 +276,17 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
|
||||
<>
|
||||
<div
|
||||
className={classNames(
|
||||
"relative z-40 ease-in-out duration-300 transition-opacity",
|
||||
hidden && !isOpen && "hidden",
|
||||
!hidden && isOpen && "opacity-100",
|
||||
!isOpen && "opacity-0",
|
||||
"relative z-40 ease-in-out",
|
||||
isOpen ? "visible opacity-100" : "invisible opacity-0 pointer-events-none",
|
||||
)}
|
||||
style={{
|
||||
transitionProperty: "opacity, visibility",
|
||||
transitionDuration: "300ms, 0s",
|
||||
transitionDelay: isOpen ? "0s, 0s" : "0s, 300ms",
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-hidden={!isOpen}
|
||||
>
|
||||
<div className="fixed inset-0 bg-gray-500 opacity-50" />
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
@@ -298,7 +318,7 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
|
||||
onKeyDown={handleItemKeyDown}
|
||||
className={classNames(
|
||||
"flex flex-row w-full items-center justify-between rounded-md text-sm md:text-xl py-2 px-4 cursor-pointer text-theme-700 dark:text-theme-200",
|
||||
i === currentItemIndex && "bg-theme-300/50 dark:bg-theme-700/50",
|
||||
i === activeItemIndex && "bg-theme-300/50 dark:bg-theme-700/50",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row items-center mr-4 pointer-events-none">
|
||||
|
||||
@@ -46,13 +46,25 @@ function Wrapper({ servicesAndBookmarks = [], initialOpen = true } = {}) {
|
||||
const [isOpen, setSearching] = useState(initialOpen);
|
||||
|
||||
return (
|
||||
<QuickLaunch
|
||||
servicesAndBookmarks={servicesAndBookmarks}
|
||||
searchString={searchString}
|
||||
setSearchString={setSearchString}
|
||||
isOpen={isOpen}
|
||||
setSearching={setSearching}
|
||||
/>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="seed-key"
|
||||
onClick={() => {
|
||||
setSearchString((current) => `${current}g`);
|
||||
setSearching(true);
|
||||
}}
|
||||
>
|
||||
seed
|
||||
</button>
|
||||
<QuickLaunch
|
||||
servicesAndBookmarks={servicesAndBookmarks}
|
||||
searchString={searchString}
|
||||
setSearchString={setSearchString}
|
||||
isOpen={isOpen}
|
||||
setSearching={setSearching}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,6 +163,35 @@ describe("components/quicklaunch", () => {
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not carry a previous url result into a search seeded by a keypress", async () => {
|
||||
renderWithProviders(<Wrapper />, {
|
||||
settings: {
|
||||
target: "_self",
|
||||
quicklaunch: {
|
||||
provider: "duckduckgo",
|
||||
showSearchSuggestions: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText("Search");
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
|
||||
fireEvent.change(input, { target: { value: "example.com" } });
|
||||
expect(await screen.findByText("quicklaunch.visit URL")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
});
|
||||
|
||||
// reopen by "typing" a character, the way pages/index does
|
||||
fireEvent.click(screen.getByTestId("seed-key"));
|
||||
|
||||
expect(input).toHaveValue("g");
|
||||
expect(screen.queryByText("quicklaunch.visit URL")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes on Escape and clears the search string after the timeout", async () => {
|
||||
renderWithProviders(<Wrapper />, {
|
||||
settings: {
|
||||
@@ -222,6 +263,37 @@ describe("components/quicklaunch", () => {
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("opens the clicked result even without a preceding hover event", async () => {
|
||||
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
|
||||
renderWithProviders(
|
||||
<Wrapper
|
||||
servicesAndBookmarks={[
|
||||
{ name: "Alpha", href: "https://alpha.example" },
|
||||
{ name: "Alpine", href: "https://alpine.example" },
|
||||
{ name: "Almond", href: "https://almond.example" },
|
||||
]}
|
||||
/>,
|
||||
{ settings: { target: "_self", quicklaunch: { showSearchSuggestions: false } } },
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("Search");
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
|
||||
fireEvent.change(input, { target: { value: "al" } });
|
||||
await waitFor(() => expect(document.querySelector('button[data-index="2"]')).toBeTruthy());
|
||||
|
||||
// touch devices don't fire mouseEnter, so the highlighted item is still the first one
|
||||
fireEvent.click(document.querySelector('button[data-index="2"]'));
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
});
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith("https://almond.example", "_self", "noreferrer");
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("handles Escape on a result button (not just the input)", async () => {
|
||||
renderWithProviders(<Wrapper servicesAndBookmarks={[{ name: "Alpha", href: "https://alpha.example" }]} />, {
|
||||
settings: { quicklaunch: { showSearchSuggestions: false } },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Image from "next/image";
|
||||
import { useContext } from "react";
|
||||
|
||||
import { SettingsContext } from "utils/contexts/settings";
|
||||
import { ThemeContext } from "utils/contexts/theme";
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
import classNames from "classnames";
|
||||
import ResolvedIcon from "components/resolvedicon";
|
||||
import List from "components/services/list";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { MdKeyboardArrowDown } from "react-icons/md";
|
||||
|
||||
import { columnMap } from "../../utils/layout/columns";
|
||||
|
||||
import ResolvedIcon from "components/resolvedicon";
|
||||
import List from "components/services/list";
|
||||
|
||||
export default function ServicesGroup({
|
||||
group,
|
||||
layout,
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import classNames from "classnames";
|
||||
import ResolvedIcon from "components/resolvedicon";
|
||||
import { useContext, useState } from "react";
|
||||
import { SettingsContext } from "utils/contexts/settings";
|
||||
import Docker from "widgets/docker/component";
|
||||
import Kubernetes from "widgets/kubernetes/component";
|
||||
import ProxmoxVM from "widgets/proxmoxvm/component";
|
||||
|
||||
import KubernetesStatus from "./kubernetes-status";
|
||||
import Ping from "./ping";
|
||||
@@ -13,6 +8,12 @@ import SiteMonitor from "./site-monitor";
|
||||
import Status from "./status";
|
||||
import Widget from "./widget";
|
||||
|
||||
import ResolvedIcon from "components/resolvedicon";
|
||||
import { SettingsContext } from "utils/contexts/settings";
|
||||
import Docker from "widgets/docker/component";
|
||||
import Kubernetes from "widgets/kubernetes/component";
|
||||
import ProxmoxVM from "widgets/proxmoxvm/component";
|
||||
|
||||
export default function Item({ service, groupName, useEqualHeights }) {
|
||||
const hasLink = service.href && service.href !== "#";
|
||||
const { settings } = useContext(SettingsContext);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import classNames from "classnames";
|
||||
import Item from "components/services/item";
|
||||
|
||||
import { columnMap } from "../../utils/layout/columns";
|
||||
|
||||
import Item from "components/services/item";
|
||||
|
||||
export default function List({ groupName, services, layout, useEqualHeights, header }) {
|
||||
return (
|
||||
<ul
|
||||
|
||||
@@ -1,45 +1,61 @@
|
||||
import { useTranslation } from "next-i18next/pages";
|
||||
import useSWR from "swr";
|
||||
|
||||
const WARNING_CLASS = "text-orange-400/50 dark:text-orange-400/80";
|
||||
|
||||
// docker container states other than running
|
||||
const STATE_LABELS = {
|
||||
created: "docker.created",
|
||||
dead: "docker.dead",
|
||||
exited: "docker.exited",
|
||||
"not found": "docker.not_found",
|
||||
paused: "docker.paused",
|
||||
removing: "docker.removing",
|
||||
restarting: "docker.restarting",
|
||||
};
|
||||
|
||||
// states that mean something is wrong rather than merely not started
|
||||
const WARNING_STATES = new Set(["dead", "exited", "not found", "restarting"]);
|
||||
|
||||
export default function Status({ service, style }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, error } = useSWR(`/api/docker/status/${service.container}/${service.server || ""}`);
|
||||
const { data: response, error } = useSWR(`/api/docker/statuses?server=${encodeURIComponent(service.server || "")}`);
|
||||
const statusError = error ?? response?.error;
|
||||
const { statuses } = response ?? {};
|
||||
const data = statuses ? (statuses[service.container] ?? { status: "not found" }) : undefined;
|
||||
|
||||
let statusLabel = t("docker.unknown");
|
||||
let backgroundClass = "px-1.5 py-0.5 bg-theme-500/10 dark:bg-theme-900/50";
|
||||
let colorClass = "text-black/20 dark:text-white/40 ";
|
||||
|
||||
if (error) {
|
||||
if (statusError) {
|
||||
statusLabel = t("docker.error");
|
||||
colorClass = "text-rose-500/80";
|
||||
} else if (data) {
|
||||
if (data.status?.includes("running")) {
|
||||
colorClass = "text-emerald-500/80";
|
||||
} else if (data?.status?.includes("running")) {
|
||||
colorClass = "text-emerald-500/80";
|
||||
|
||||
if (!data.health) {
|
||||
statusLabel = data.status.replace("running", t("docker.running"));
|
||||
} else {
|
||||
statusLabel = data.health === "healthy" ? t("docker.healthy") : data.health;
|
||||
if (!data.health) {
|
||||
statusLabel = data.status.replace("running", t("docker.running"));
|
||||
} else {
|
||||
statusLabel = data.health === "healthy" ? t("docker.healthy") : data.health;
|
||||
|
||||
if (data.health === "starting") {
|
||||
statusLabel = t("docker.starting");
|
||||
colorClass = "text-blue-500/80";
|
||||
}
|
||||
if (data.health === "starting") {
|
||||
statusLabel = t("docker.starting");
|
||||
colorClass = "text-blue-500/80";
|
||||
}
|
||||
|
||||
if (data.health === "unhealthy") {
|
||||
statusLabel = t("docker.unhealthy");
|
||||
colorClass = "text-orange-400/50 dark:text-orange-400/80";
|
||||
}
|
||||
if (data.health === "unhealthy") {
|
||||
statusLabel = t("docker.unhealthy");
|
||||
colorClass = WARNING_CLASS;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.status === "not found" || data.status === "exited" || data.status?.startsWith("partial")) {
|
||||
if (data.status === "not found") statusLabel = t("docker.not_found");
|
||||
else if (data.status === "exited") statusLabel = t("docker.exited");
|
||||
else statusLabel = data.status.replace("partial", t("docker.partial"));
|
||||
colorClass = "text-orange-400/50 dark:text-orange-400/80";
|
||||
}
|
||||
} else if (data?.status?.startsWith("partial")) {
|
||||
statusLabel = data.status.replace("partial", t("docker.partial"));
|
||||
colorClass = WARNING_CLASS;
|
||||
} else if (data && STATE_LABELS[data.status]) {
|
||||
statusLabel = t(STATE_LABELS[data.status]);
|
||||
if (WARNING_STATES.has(data.status)) colorClass = WARNING_CLASS;
|
||||
}
|
||||
|
||||
if (style === "dot") {
|
||||
|
||||
@@ -21,8 +21,11 @@ describe("components/services/status", () => {
|
||||
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
|
||||
expect(useSWR).toHaveBeenCalledWith("/api/docker/status/c/s");
|
||||
expect(useSWR).toHaveBeenCalledWith("/api/docker/statuses?server=s");
|
||||
expect(screen.getByText("docker.unknown")).toBeInTheDocument();
|
||||
|
||||
render(<Status service={{ container: "c" }} />);
|
||||
expect(useSWR).toHaveBeenCalledWith("/api/docker/statuses?server=");
|
||||
});
|
||||
|
||||
it("renders error when SWR fails", () => {
|
||||
@@ -33,38 +36,87 @@ describe("components/services/status", () => {
|
||||
expect(screen.getByText("docker.error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error when the api returns an error payload with a 200-parsed body", () => {
|
||||
useSWR.mockReturnValue({ data: { error: "query failed" }, error: undefined });
|
||||
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
|
||||
expect(screen.getByText("docker.error")).toBeInTheDocument();
|
||||
expect(screen.queryByText("docker.not_found")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders healthy/unhealthy and partial/exited/not found statuses", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "running", health: "healthy" }, error: undefined });
|
||||
useSWR.mockReturnValue({ data: { statuses: { c: { status: "running", health: "healthy" } } }, error: undefined });
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
expect(screen.getByText("docker.healthy")).toBeInTheDocument();
|
||||
|
||||
useSWR.mockReturnValue({ data: { status: "running", health: "unhealthy" }, error: undefined });
|
||||
useSWR.mockReturnValue({ data: { statuses: { c: { status: "running", health: "unhealthy" } } }, error: undefined });
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
expect(screen.getByText("docker.unhealthy")).toBeInTheDocument();
|
||||
|
||||
useSWR.mockReturnValue({ data: { status: "partial 1/2" }, error: undefined });
|
||||
useSWR.mockReturnValue({ data: { statuses: { c: { status: "partial 1/2" } } }, error: undefined });
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
expect(screen.getByText("docker.partial 1/2")).toBeInTheDocument();
|
||||
|
||||
useSWR.mockReturnValue({ data: { status: "exited" }, error: undefined });
|
||||
useSWR.mockReturnValue({ data: { statuses: { c: { status: "exited" } } }, error: undefined });
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
expect(screen.getByText("docker.exited")).toBeInTheDocument();
|
||||
|
||||
useSWR.mockReturnValue({ data: { status: "not found" }, error: undefined });
|
||||
useSWR.mockReturnValue({ data: { statuses: {} }, error: undefined });
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
expect(screen.getByText("docker.not_found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders starting health when container is running and starting", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "running", health: "starting" }, error: undefined });
|
||||
useSWR.mockReturnValue({ data: { statuses: { c: { status: "running", health: "starting" } } }, error: undefined });
|
||||
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
|
||||
expect(screen.getByText("docker.starting")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the remaining container states instead of falling through to unknown", () => {
|
||||
const states = {
|
||||
created: "docker.created",
|
||||
paused: "docker.paused",
|
||||
restarting: "docker.restarting",
|
||||
removing: "docker.removing",
|
||||
dead: "docker.dead",
|
||||
};
|
||||
|
||||
Object.entries(states).forEach(([status, label]) => {
|
||||
useSWR.mockReturnValue({ data: { statuses: { c: { status } } }, error: undefined });
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText("docker.unknown")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("colors problem states as warnings and merely stopped states neutrally", () => {
|
||||
useSWR.mockReturnValue({ data: { statuses: { c: { status: "restarting" } } }, error: undefined });
|
||||
const { container: restarting } = render(<Status service={{ container: "c", server: "s" }} style="dot" />);
|
||||
expect(restarting.querySelector(".bg-orange-400")).toBeInTheDocument();
|
||||
|
||||
useSWR.mockReturnValue({ data: { statuses: { c: { status: "paused" } } }, error: undefined });
|
||||
const { container: paused } = render(<Status service={{ container: "c", server: "s" }} style="dot" />);
|
||||
expect(paused.querySelector(".bg-orange-400")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not surface health for containers that are not running", () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: { statuses: { c: { status: "paused", health: "unhealthy" } } },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
|
||||
expect(screen.getByText("docker.paused")).toBeInTheDocument();
|
||||
expect(screen.queryByText("docker.unhealthy")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a dot when style is dot", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "running" }, error: undefined });
|
||||
useSWR.mockReturnValue({ data: { statuses: { c: { status: "running" } } }, error: undefined });
|
||||
|
||||
const { container } = render(<Status service={{ container: "c", server: "s" }} style="dot" />);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ErrorBoundary from "components/errorboundry";
|
||||
import { useTranslation } from "next-i18next/pages";
|
||||
|
||||
import ErrorBoundary from "components/errorboundry";
|
||||
import components from "widgets/components";
|
||||
|
||||
export default function Widget({ widget, service }) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useContext, useMemo } from "react";
|
||||
import { SettingsContext } from "utils/contexts/settings";
|
||||
|
||||
import Error from "./error";
|
||||
import { BlockHighlightContext } from "./highlight-context";
|
||||
|
||||
import { SettingsContext } from "utils/contexts/settings";
|
||||
import { buildHighlightConfig } from "utils/highlights";
|
||||
|
||||
const ALIASED_WIDGETS = {
|
||||
|
||||
@@ -13,13 +13,13 @@ export default function Error({ error }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (typeof error === "string") {
|
||||
error = { message: error }; // eslint-disable-line no-param-reassign
|
||||
error = { message: error };
|
||||
} else if (typeof error === "number") {
|
||||
error = { message: `Error ${error}` }; // eslint-disable-line no-param-reassign
|
||||
error = { message: `Error ${error}` };
|
||||
}
|
||||
|
||||
if (error?.data?.error) {
|
||||
error = error.data.error; // eslint-disable-line no-param-reassign
|
||||
error = error.data.error;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import classNames from "classnames";
|
||||
import { useContext } from "react";
|
||||
|
||||
import { TabContext } from "utils/contexts/tab";
|
||||
|
||||
function slugify(tabName) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Popover, Transition } from "@headlessui/react";
|
||||
import classNames from "classnames";
|
||||
import { Fragment, useContext } from "react";
|
||||
import { IoColorPalette } from "react-icons/io5";
|
||||
|
||||
import { ColorContext } from "utils/contexts/color";
|
||||
|
||||
const colors = [
|
||||
|
||||
@@ -12,7 +12,11 @@ export default function SignOut() {
|
||||
|
||||
return (
|
||||
<div id="signout" className="rounded-full flex align-middle self-center mr-3">
|
||||
<button type="button" onClick={() => signOut({ callbackUrl: "/" })} className="outline-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signOut({ callbackUrl: "/auth/signin?autologin=0" })}
|
||||
className="outline-hidden"
|
||||
>
|
||||
<MdLogout className="text-theme-800 dark:text-theme-200 w-6 h-6 cursor-pointer" aria-hidden="true" />
|
||||
<span className="sr-only">{t("auth.signout")}</span>
|
||||
</button>
|
||||
|
||||
@@ -32,6 +32,7 @@ describe("components/toggles/signout", () => {
|
||||
const { getByRole } = render(<SignOut />);
|
||||
fireEvent.click(getByRole("button"));
|
||||
|
||||
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/" });
|
||||
// Not "/", which would bounce straight back into the provider when auto-login is on
|
||||
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/signin?autologin=0" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useContext } from "react";
|
||||
import { MdDarkMode, MdLightMode, MdToggleOff, MdToggleOn } from "react-icons/md";
|
||||
|
||||
import { ThemeContext } from "utils/contexts/theme";
|
||||
|
||||
export default function ThemeToggle() {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useTranslation } from "next-i18next/pages";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import Container from "../widget/container";
|
||||
import Raw from "../widget/raw";
|
||||
|
||||
import useCurrentTime from "utils/hooks/use-current-time";
|
||||
|
||||
const textSizes = {
|
||||
"4xl": "text-4xl",
|
||||
"3xl": "text-3xl",
|
||||
@@ -18,17 +20,10 @@ const textSizes = {
|
||||
export default function DateTime({ options }) {
|
||||
const { text_size: textSize, locale, format } = options;
|
||||
const { i18n } = useTranslation();
|
||||
const [date, setDate] = useState("");
|
||||
const dateLocale = locale ?? i18n.language;
|
||||
|
||||
useEffect(() => {
|
||||
const dateFormat = new Intl.DateTimeFormat(dateLocale, { ...format });
|
||||
setDate(dateFormat.format(new Date()));
|
||||
const interval = setInterval(() => {
|
||||
setDate(dateFormat.format(new Date()));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [date, setDate, dateLocale, format]);
|
||||
const currentTime = useCurrentTime(1000);
|
||||
const dateFormatter = useMemo(() => new Intl.DateTimeFormat(dateLocale, { ...format }), [dateLocale, format]);
|
||||
const date = currentTime === null ? "" : dateFormatter.format(new Date(currentTime));
|
||||
|
||||
return (
|
||||
<Container options={options} additionalClassNames="information-widget-datetime">
|
||||
|
||||
@@ -4,13 +4,14 @@ import { useContext } from "react";
|
||||
import { FaMemory, FaRegClock, FaThermometerHalf } from "react-icons/fa";
|
||||
import { FiCpu, FiHardDrive } from "react-icons/fi";
|
||||
import useSWR from "swr";
|
||||
import { SettingsContext } from "utils/contexts/settings";
|
||||
|
||||
import Error from "../widget/error";
|
||||
import Resource from "../widget/resource";
|
||||
import Resources from "../widget/resources";
|
||||
import WidgetLabel from "../widget/widget_label";
|
||||
|
||||
import { SettingsContext } from "utils/contexts/settings";
|
||||
|
||||
const defaultCpuSensorLabels = ["cpu_thermal", "Core", "Tctl", "Temperature"];
|
||||
|
||||
function convertToFahrenheit(t) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import ResolvedIcon from "components/resolvedicon";
|
||||
|
||||
import Container from "../widget/container";
|
||||
import Raw from "../widget/raw";
|
||||
|
||||
import ResolvedIcon from "components/resolvedicon";
|
||||
|
||||
export default function Logo({ options }) {
|
||||
return (
|
||||
<Container
|
||||
|
||||
@@ -7,10 +7,9 @@ import Resource from "../widget/resource";
|
||||
|
||||
export default function Network({ options, refresh = 1500 }) {
|
||||
const { t } = useTranslation();
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (options.network === true) options.network = "default";
|
||||
const network = options.network === true ? "default" : options.network;
|
||||
|
||||
const { data, error } = useSWR(`/api/widgets/resources?type=network&interfaceName=${options.network}`, {
|
||||
const { data, error } = useSWR(`/api/widgets/resources?type=network&interfaceName=${network}`, {
|
||||
refreshInterval: refresh,
|
||||
});
|
||||
|
||||
|
||||
@@ -22,10 +22,12 @@ describe("components/widgets/resources/network", () => {
|
||||
|
||||
it("normalizes options.network=true to default interfaceName in the request", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
const options = Object.freeze({ network: true });
|
||||
|
||||
render(<Network options={{ network: true }} />);
|
||||
render(<Network options={options} />);
|
||||
|
||||
expect(useSWR).toHaveBeenCalledWith(expect.stringContaining("interfaceName=default"), expect.any(Object));
|
||||
expect(options.network).toBe(true);
|
||||
});
|
||||
|
||||
it("renders rates and usage percentage when data is present", () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@headlessui/react";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "next-i18next/pages";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { Fragment, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { BiLogoBing } from "react-icons/bi";
|
||||
import { FiSearch } from "react-icons/fi";
|
||||
import { SiBaidu, SiBrave, SiDuckduckgo, SiGoogle } from "react-icons/si";
|
||||
@@ -57,12 +57,12 @@ export const searchProviders = {
|
||||
},
|
||||
};
|
||||
|
||||
function getAvailableProviderIds(options) {
|
||||
if (options.provider && Array.isArray(options.provider)) {
|
||||
return options.provider.filter((value) => searchProviders.hasOwnProperty(value));
|
||||
function getAvailableProviderIds(provider) {
|
||||
if (provider && Array.isArray(provider)) {
|
||||
return provider.filter((value) => searchProviders.hasOwnProperty(value));
|
||||
}
|
||||
if (options.provider && searchProviders[options.provider]) {
|
||||
return [options.provider];
|
||||
if (provider && searchProviders[provider]) {
|
||||
return [provider];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -79,23 +79,32 @@ export function getStoredProvider() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function subscribeToStoredProvider(onStoreChange) {
|
||||
const handleStorage = (event) => {
|
||||
if (event.key === localStorageKey) onStoreChange();
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}
|
||||
|
||||
export default function Search({ options }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const availableProviderIds = getAvailableProviderIds(options) ?? [];
|
||||
// options is a fresh object each render, so memo on provider itself
|
||||
const availableProviderIds = useMemo(() => getAvailableProviderIds(options.provider) ?? [], [options.provider]);
|
||||
const storedProvider = useSyncExternalStore(subscribeToStoredProvider, getStoredProvider, () => null);
|
||||
const storedProviderId = Object.keys(searchProviders).find(
|
||||
(providerId) => searchProviders[providerId] === storedProvider,
|
||||
);
|
||||
const initialProvider = availableProviderIds.includes(storedProviderId)
|
||||
? storedProvider
|
||||
: searchProviders[availableProviderIds[0] ?? "google"];
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedProvider, setSelectedProvider] = useState(searchProviders[availableProviderIds[0] ?? "google"]);
|
||||
const [providerOverride, setProviderOverride] = useState(null);
|
||||
const [searchSuggestions, setSearchSuggestions] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const storedProvider = getStoredProvider();
|
||||
let storedProviderKey = null;
|
||||
storedProviderKey = Object.keys(searchProviders).find((pkey) => searchProviders[pkey] === storedProvider);
|
||||
if (storedProvider && availableProviderIds.includes(storedProviderKey)) {
|
||||
setSelectedProvider(storedProvider);
|
||||
}
|
||||
}, [availableProviderIds]);
|
||||
const selectedProvider = providerOverride ?? initialProvider;
|
||||
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
@@ -129,9 +138,9 @@ export default function Search({ options }) {
|
||||
};
|
||||
}, [selectedProvider, options, query, searchSuggestions]);
|
||||
|
||||
let currentSuggestion;
|
||||
|
||||
function doSearch(value) {
|
||||
if (!value) return;
|
||||
|
||||
const q = encodeURIComponent(value);
|
||||
const { url } = selectedProvider;
|
||||
if (url) {
|
||||
@@ -141,13 +150,11 @@ export default function Search({ options }) {
|
||||
}
|
||||
|
||||
setQuery("");
|
||||
currentSuggestion = null;
|
||||
}
|
||||
|
||||
const handleSearchKeyDown = (event) => {
|
||||
const useSuggestion = searchSuggestions.length && currentSuggestion;
|
||||
if (event.key === "Enter") {
|
||||
doSearch(useSuggestion ? currentSuggestion : event.target.value);
|
||||
if (event.key === "Enter" && !searchSuggestions[1]?.length) {
|
||||
doSearch(event.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -156,7 +163,7 @@ export default function Search({ options }) {
|
||||
}
|
||||
|
||||
const onChangeProvider = (provider) => {
|
||||
setSelectedProvider(provider);
|
||||
setProviderOverride(provider);
|
||||
localStorage.setItem(localStorageKey, provider.name);
|
||||
};
|
||||
|
||||
@@ -165,7 +172,7 @@ export default function Search({ options }) {
|
||||
<Raw>
|
||||
<div className="flex-col relative h-8 my-4 min-w-fit z-20">
|
||||
<div className="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none w-full text-theme-800 dark:text-white" />
|
||||
<Combobox value={query}>
|
||||
<Combobox value={query} onChange={doSearch}>
|
||||
<ComboboxInput
|
||||
type="text"
|
||||
className="
|
||||
@@ -177,6 +184,7 @@ export default function Search({ options }) {
|
||||
focus:border-theme-500 dark:focus:border-white/50
|
||||
border border-theme-300 dark:border-theme-200/50"
|
||||
placeholder={t("search.placeholder")}
|
||||
aria-label={t("search.placeholder")}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
}}
|
||||
@@ -184,7 +192,6 @@ export default function Search({ options }) {
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={options.focus}
|
||||
onBlur={(e) => e.preventDefault()}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
@@ -204,8 +211,8 @@ export default function Search({ options }) {
|
||||
bg-theme-600/40 dark:bg-white/10
|
||||
focus:ring-theme-500 dark:focus:ring-white/50"
|
||||
>
|
||||
<selectedProvider.icon className="text-white w-3 h-3" />
|
||||
<span className="sr-only">{t("search.search")}</span>
|
||||
<selectedProvider.icon className="text-white w-3 h-3" aria-hidden="true" />
|
||||
<span className="sr-only">{t("search.provider", { name: selectedProvider.name })}</span>
|
||||
</ListboxButton>
|
||||
</div>
|
||||
<Transition
|
||||
@@ -218,6 +225,7 @@ export default function Search({ options }) {
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<ListboxOptions
|
||||
aria-label={t("search.select_provider")}
|
||||
className="absolute right-0 z-10 mt-1 origin-top-right rounded-md
|
||||
bg-theme-100 dark:bg-theme-600 shadow-lg
|
||||
ring-1 ring-black ring-opacity-5 focus:outline-hidden"
|
||||
@@ -234,7 +242,8 @@ export default function Search({ options }) {
|
||||
active ? "bg-theme-600/10 dark:bg-white/10 dark:text-gray-900" : "dark:text-gray-100",
|
||||
)}
|
||||
>
|
||||
<p.icon className="h-4 w-4 mx-4 my-2" />
|
||||
<p.icon className="h-4 w-4 mx-4 my-2" aria-hidden="true" />
|
||||
<span className="sr-only">{p.name}</span>
|
||||
</li>
|
||||
)}
|
||||
</ListboxOption>
|
||||
@@ -248,32 +257,24 @@ export default function Search({ options }) {
|
||||
{searchSuggestions[1]?.length > 0 && (
|
||||
<ComboboxOptions className="mt-1 rounded-md bg-theme-50 dark:bg-theme-800 border border-theme-300 dark:border-theme-200/30 cursor-pointer shadow-lg">
|
||||
<div className="p-1 bg-white/50 dark:bg-white/10 text-theme-900/90 dark:text-white/90 text-xs">
|
||||
<ComboboxOption key={query} value={query} />
|
||||
<ComboboxOption key={query} value={query}>
|
||||
<span className="sr-only">{query}</span>
|
||||
</ComboboxOption>
|
||||
{searchSuggestions[1].map((suggestion) => (
|
||||
<ComboboxOption
|
||||
key={suggestion}
|
||||
value={suggestion}
|
||||
onMouseDown={() => {
|
||||
doSearch(suggestion);
|
||||
}}
|
||||
className="flex w-full"
|
||||
>
|
||||
{({ active }) => {
|
||||
if (active) currentSuggestion = suggestion;
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"px-2 py-1 rounded-md w-full flex-nowrap",
|
||||
active ? "bg-theme-300/20 dark:bg-white/10" : "",
|
||||
)}
|
||||
>
|
||||
<span className="whitespace-pre">{suggestion.indexOf(query) === 0 ? query : ""}</span>
|
||||
<span className="mr-4 whitespace-pre opacity-50">
|
||||
{suggestion.indexOf(query) === 0 ? suggestion.substring(query.length) : suggestion}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
<ComboboxOption key={suggestion} value={suggestion} className="flex w-full">
|
||||
{({ active }) => (
|
||||
<div
|
||||
className={classNames(
|
||||
"px-2 py-1 rounded-md w-full flex-nowrap",
|
||||
active ? "bg-theme-300/20 dark:bg-white/10" : "",
|
||||
)}
|
||||
>
|
||||
<span className="whitespace-pre">{suggestion.indexOf(query) === 0 ? query : ""}</span>
|
||||
<span className="mr-4 whitespace-pre opacity-50">
|
||||
{suggestion.indexOf(query) === 0 ? suggestion.substring(query.length) : suggestion}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</ComboboxOption>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user