Compare commits

...
Author SHA1 Message Date
Thomas 86478adf7a Merge branch 'dev' into beszel-system-grid-layout-regression 2026-07-28 14:57:30 +02:00
homarr-releases[bot] 51a6cbe6d4 chore: update bug report template (#6464) 2026-07-28 14:56:56 +02:00
homarr-update-contributors[bot] d08eb2afa6 chore: update contributors (#6461) 2026-07-28 14:56:42 +02:00
Tim MooreandTim Moore 91a6b19ba1 fix(beszel): expire stored sessions so the integration recovers from token expiry (#6476)
Co-authored-by: Tim Moore <tim.moore@ingenuity.com.au>
2026-07-28 14:55:33 +02:00
Thomas e66979bab6 feat(auth): link credentials users with OIDC (#6445) 2026-07-28 14:49:51 +02:00
homarr-renovate[bot] 4ef7641cb2 chore(deps): update pnpm to v11.15.1 (#6466)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-25 02:43:47 +00:00
Releases Homarr f0019fa213 chore(release): 1.72.0 [skip ci] 2026-07-24 18:11:58 +00:00
homarr-releases[bot] ba8536b36d chore(release): automatic release v1.72.0 2026-07-24 18:10:37 +00:00
homarr-renovate[bot] 965aec5155 chore(deps): update pnpm to v11.15.0 (#6455)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-23 16:29:37 +00:00
homarr-crowdin[bot] 387db030cd chore(lang): updated translations from crowdin (#6283) 2026-07-23 09:14:14 +02:00
homarr-renovate[bot] 8e557c9924 chore(deps): update pnpm to v11.14.0 (#6449)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-23 05:58:19 +00:00
Thomas f26e60edf0 fix(old-import): map new clock weather options (#6452) 2026-07-23 02:08:50 +02:00
Thomas 7bbfd74454 Merge pull request #6384 from homarr-labs/docs/update-helm-docs
docs(helm): update chart installation page
2026-07-23 02:04:11 +02:00
homarr-releases[bot] 018b1ea4da chore: update bug report template (#6382) 2026-07-23 02:03:43 +02:00
Thomas 14c3fa2e76 fix(beszel): prevent live subscription memory growth (#6448)
* fix(beszel): bound live stats subscriptions

* fix(beszel): avoid blocking subscription teardown

* fix(import): map new clock options
2026-07-23 02:03:08 +02:00
Thomas 9168f8114b fix(manage): hide sidebar nav items from unprivileged users (#6236) 2026-07-23 02:01:10 +02:00
Thomas 9cabb42edb fix(widgets): add 350ms hover delay before opening calendar day popover (#6431) 2026-07-23 01:54:23 +02:00
Thomas bfba3c9215 fix(releases): avoid unnecessary GitHub pagination for LinuxServer updates (#6444) 2026-07-23 00:20:54 +02:00
Bergasha 7184bc09c1 feat(clock widget): Adds animated weather and temp (#6372) 2026-07-23 00:04:32 +02:00
Thomas 9bb0c0f811 fix(media-requests): cache statistics responses (#6443) 2026-07-22 17:38:28 +02:00
Thomas cf0e469465 feat: restrict apps management to board modifiers (#6425) 2026-07-21 16:56:33 +02:00
Thomas 8e7fa782b3 feat(weather): add humidity display (#6429) 2026-07-21 16:36:03 +02:00
Thomas 97652802f0 feat(immich): show random photos without album (#6424) 2026-07-21 16:10:45 +02:00
Thomas 1706289124 fix(media-server): guard against undefined playback from stale cached data (#6433) 2026-07-21 16:01:37 +02:00
homarr-renovate[bot] dd23de7aaa chore(deps): update pnpm to v11.13.1 (#6422)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-21 05:54:52 +00:00
Thomas f1602f67d4 fix: stop auto-seeding ArchiveTeam Warrior integration and widget on default dashboards (#6413) 2026-07-20 17:01:07 +02:00
Thomas c03cd97d3a fix(test): use Home Assistant long-lived token JWT
* fix(test): use Home Assistant long-lived token

* fix(test): revert to correct Home Assistant long-lived token JWT
2026-07-20 15:27:50 +02:00
Thomas a8e9a074d3 Merge pull request #6411 from homarr-labs/mysql-migration-test-timeout
fix(db): increase mysql migration test timeout to 120s
2026-07-20 15:19:12 +02:00
Thomas Camlong 1e9d3e0656 fix(db): increase mysql migration test timeout to 120s 2026-07-20 15:18:50 +02:00
Thomas 01e0f3612d docs(immich): list Asset View permission (#6408) 2026-07-20 14:57:07 +02:00
Thomas 0015a6d88d fix(immich): use thumbnail endpoint instead of original for album carousel (#6396)
* fix(immich): use thumbnail endpoint instead of original for album carousel

Switches from /api/assets/:id/original to /api/assets/:id/thumbnail to fix
HEIC image incompatibility (thumbnails are always JPEG), reduce bandwidth,
and improve loading speed.

Closes #6388

* working on immich

* Improve image proxy error handling

* fix(image-proxy): use trusted fetch response type

* docs(immich): require Asset View permission
2026-07-20 14:53:27 +02:00
Thomas 77dc317da2 chore(ci): remove memory regression tests (#6395) 2026-07-18 22:55:38 +02:00
homarr-update-contributors[bot] 6aeda3faaf chore: update contributors (#6378) 2026-07-18 22:43:01 +02:00
ajnart dafe1fdb75 fix(beszel): restore balanced grid layout for system grid widget
Reverts getColCount to the minimize-empty-cells heuristic from v1.68.0
after the aspect-ratio rewrite in 576856d63 broke 2×2 layout for 4
systems at common widget widths (240–359px).

Lowered MIN_CELL_WIDTH from 180 to 140 — a compromise between the
original 120 (v1.68.0) and the current 180, leaving room for the
DiskUsage component while keeping the 2×2 threshold at 280px.

Fixes #6385
2026-07-18 14:35:19 +02:00
github-actions[bot] ac6a0c9254 docs(helm): update chart installation page 2026-07-17 19:24:43 +00:00
Releases Homarr ca358d9f8b chore(release): 1.71.0 [skip ci] 2026-07-17 17:58:17 +00:00
homarr-releases[bot] 03e0683892 chore(release): automatic release v1.71.0 2026-07-17 17:57:04 +00:00
Junker der Provinz 6ac4c6a7b9 fix(search): let public dashboards use generic custom search engines (#6375) 2026-07-17 14:53:52 +02:00
Junker der Provinz a0e0bf65ff fix(releases): let LinuxServer.io version filters find older releases via GitHub (#6376) 2026-07-17 14:53:01 +02:00
Junker der Provinz bc018e1fe0 fix(docker): disable the broken full-screen toggle on the container table (#6374) 2026-07-17 14:43:37 +02:00
Thomas 5e39c8813a feat(preferences): add enableRightClickOnWidgets user preference (#6298) 2026-07-17 14:42:19 +02:00
Thomas 06719cdef0 fix(ci): remove coverage report, scope format to changed files, fix oxlint config (#6359) 2026-07-16 10:21:07 +02:00
Bergasha 5144e14da4 fix(media-server): populate playback progress, transcode status, and network location for streams (#6315) 2026-07-15 16:27:58 +02:00
Thomas 8528d5068b feat(media-requests): filter requests list by status and time window (#6320) 2026-07-15 16:27:24 +02:00
Thomas c6c4428f7d Revert "fix(docker): report current CPU usage instead of the lifetime average" (#6357) 2026-07-15 14:55:18 +02:00
Thomas 7e4cb0201b fix(widgets): add refetchInterval to WidgetDefinition for per-widget polling (#6352) 2026-07-15 11:52:35 +02:00
Thomas 7887ce12c0 fix(integrations): resolve intermittent Seerr/Overseerr crashes and add query status to context menu (#6347) 2026-07-15 11:41:18 +02:00
Thomas 2d450239ac fix(releases): add required octokit throttling rate limit handlers (#6338) 2026-07-15 11:27:32 +02:00
Thomas bf4ccd8cb3 fix(ci): remove persist-credentials: false to fix git-auto-commit-action push (#6346) 2026-07-15 10:33:32 +02:00
homarr-renovate[bot] c473e80243 chore(deps): update pnpm to v11.11.0 (#6342)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-15 02:31:21 +00:00
homarr-renovate[bot] bf55bcce92 fix(deps): update dependency swiper to ^14.0.5 (#6341)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 23:55:02 +00:00
homarr-renovate[bot] 49248cedaf chore(deps): update dependency posthog-js to ^1.399.1 (#6340)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-14 22:09:52 +00:00
178 changed files with 21060 additions and 1115 deletions

No files matched your search

+2
View File
@@ -33,6 +33,8 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.72.0
- 1.71.0
- 1.70.0
- 1.69.2
- 1.69.0
-1
View File
@@ -23,7 +23,6 @@ jobs:
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
persist-credentials: false
- name: Setup
uses: ./tooling/github/setup
+7 -90
View File
@@ -60,7 +60,7 @@ jobs:
TURBO_PLATFORM: linux/amd64
steps:
- uses: actions/checkout@v7
if: '!env.ACT'
if: "!env.ACT"
with:
fetch-depth: 0
persist-credentials: false
@@ -81,9 +81,6 @@ jobs:
- name: Copy environment defaults
run: cp .env.example .env
- name: Check formatting in affected workspaces
run: pnpm turbo run format --affected --continue -- --check
- name: Lint, typecheck, and build affected workspaces
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful
@@ -93,12 +90,8 @@ jobs:
- name: Run unit tests
run: pnpm test
- name: Report coverage
if: always() && !env.ACT
uses: davelosert/vitest-coverage-report-action@v2
container:
name: Container, E2E, and memory
name: Container and E2E
needs: fast-gate
if: |
github.event_name == 'merge_group' ||
@@ -111,7 +104,7 @@ jobs:
packages: write
steps:
- uses: actions/checkout@v7
if: '!env.ACT'
if: "!env.ACT"
with:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
@@ -120,11 +113,11 @@ jobs:
uses: ./tooling/github/setup
- name: Set up Docker Buildx
if: '!env.ACT'
if: "!env.ACT"
uses: docker/setup-buildx-action@v4
- name: Build container once
if: '!env.ACT'
if: "!env.ACT"
uses: docker/build-push-action@v7
with:
context: .
@@ -165,7 +158,7 @@ jobs:
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
- name: Cache Playwright browsers
if: '!env.ACT'
if: "!env.ACT"
id: playwright-cache
uses: actions/cache@v6
with:
@@ -191,82 +184,6 @@ jobs:
path: e2e/test-results/
retention-days: 7
- name: Run memory regression test
env:
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
SECRET_ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000
run: |
docker run -d --name "$CONTAINER_NAME" -p 127.0.0.1::7575 \
-e NO_EXTERNAL_CONNECTION=true \
-e SECRET_ENCRYPTION_KEY="$SECRET_ENCRYPTION_KEY" \
homarr-ci
PORT=$(docker port "$CONTAINER_NAME" 7575/tcp | sed 's/.*://')
[[ "$PORT" =~ ^[0-9]+$ ]] || { echo "ERROR: invalid port mapping: '$PORT'"; exit 1; }
URL="http://127.0.0.1:${PORT}"
healthy=false
for i in $(seq 1 30); do
if curl -sf "$URL/api/health/ready" >/dev/null 2>&1; then
echo "Container healthy after $((i * 2))s"
healthy=true
break
fi
sleep 2
done
if [ "$healthy" != "true" ]; then
echo "ERROR: Container never became healthy"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
fi
warmup_ok=0
for _ in $(seq 1 5); do
curl -sf "$URL/" >/dev/null 2>&1 && warmup_ok=$((warmup_ok + 1))
sleep 2
done
if [ "$warmup_ok" -eq 0 ]; then
echo "ERROR: All warmup requests failed"
docker logs "$CONTAINER_NAME" --tail 50
exit 1
fi
docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" | grep -qx true || {
echo "ERROR: Container died before memory measurement"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
}
RAW_MEMORY=$(docker stats --no-stream --format '{{.MemUsage}}' "$CONTAINER_NAME" | sed 's| /.*||')
MEMORY_MB=$(echo "$RAW_MEMORY" | awk '
/GiB/ { gsub(/GiB/, ""); print $1 * 1024; next }
/MiB/ { gsub(/MiB/, ""); print $1; next }
/KiB/ { gsub(/KiB/, ""); print $1 / 1024; next }
{ print $1 / 1048576 }
')
echo "Memory usage: ${MEMORY_MB} MB (${RAW_MEMORY})"
[[ "$MEMORY_MB" =~ ^[0-9]+(\.[0-9]+)?$ ]] || {
echo "ERROR: non-numeric memory value: '$MEMORY_MB' (raw: '$RAW_MEMORY')"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
}
if awk "BEGIN { exit !($MEMORY_MB < 50) }"; then
echo "ERROR: Memory suspiciously low (${MEMORY_MB} MB) — container may have crashed"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
elif awk "BEGIN { exit !($MEMORY_MB > 700) }"; then
echo "ERROR: Memory usage ${MEMORY_MB} MB exceeds 700 MB"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
fi
- name: Clean up memory test container
if: always()
env:
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
run: docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
- name: Determine preview tag
id: preview
if: |
@@ -316,7 +233,7 @@ jobs:
run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; }
- name: Delete GHCR package version
if: '!env.ACT'
if: "!env.ACT"
uses: actions/github-script@v8
with:
script: |
+4
View File
@@ -1,3 +1,7 @@
## [1.72.0](https://github.com/homarr-labs/homarr/compare/v1.71.0...v1.72.0) (2026-07-24)
## [1.71.0](https://github.com/homarr-labs/homarr/compare/v1.70.0...v1.71.0) (2026-07-17)
## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10)
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
-3
View File
@@ -23,9 +23,6 @@
"react-hooks/exhaustive-deps": "warn",
"eslint/no-unused-expressions": "warn"
},
"options": {
"reportUnusedDisableDirectives": "off"
},
"ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", ".docusaurus/**", "build/**"],
"settings": {
"react": { "version": "19" }
@@ -127,6 +127,8 @@ Homarr supports multiple authentication options, from internal userbase (credent
If you'd rather manage group memberships for OIDC users locally (instead of via the IdP), set `AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT=true`. While enabled, Homarr stops syncing OIDC users' group memberships from the groups claim, and admins can add/remove OIDC users on a group's members page. If you turn this back to `false`, the next login of each user resumes the regular sync and reconciles their groups with the IdP's claim again, which may remove memberships that were added manually while local management was enabled.
To let an existing credentials user also sign in with OIDC, set `AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING=true`. On the first OIDC sign-in, Homarr links a credentials user with the same email address instead of creating a second user. The user keeps their password login, local username and profile, groups, permissions, boards, settings, and API keys. OIDC profile and group claims do not overwrite the credentials-owned data. Homarr requires the OIDC profile to contain `email_verified: true` while this option is enabled. Only enable this when your OIDC provider verifies email addresses, because a matching email could otherwise be used to take over an existing account.
<details>
<summary>
Example Setup
@@ -204,6 +206,7 @@ Homarr supports multiple authentication options, from internal userbase (credent
| ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- |
| ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false |
| ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false |
| ``AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING`` | Link an OIDC identity to an existing credentials user with the same email. The credentials login and existing user data are preserved. Requires the OIDC profile to contain `email_verified: true`. | false |
| ``AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD`` | Override method for the token endpoint authentication. Supported values are `client_secret_basic`, `client_secret_post`, `client_secret_jwt` and `none`. | client_secret_basic |
### Permission System
@@ -2,9 +2,9 @@
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
![Version: 8.22.0](https://img.shields.io/badge/Version-8.22.0-informational?style=flat)
![Version: 8.23.0](https://img.shields.io/badge/Version-8.23.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.70.0](https://img.shields.io/badge/AppVersion-v1.70.0-informational?style=flat)
![AppVersion: v1.71.0](https://img.shields.io/badge/AppVersion-v1.71.0-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes
@@ -259,7 +259,7 @@ httproute:
value: /
backendRefs:
- name: homarr
port: 8080
port: 7575
````
</details>
@@ -419,17 +419,17 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| envSecrets.dbEncryption.key | string | `"db-encryption-key"` | Secret key for SECRET_ENCRYPTION_KEY can be generated with `openssl rand -hex 32` |
| fullnameOverride | string | `""` | Overrides chart's fullname |
| hostAliases | list | `[]` | Add static entries to /etc/hosts in the Pod. This is useful in the following cases: - You are running in a dual-stack cluster (IPv4 + IPv6) and want to force usage of IPv4 for specific hostnames - Your application is having DNS resolution issues or IPv6 preference issues - You need to override or simulate DNS entries without changing global DNS - You are running in an air-gapped or isolated environment without external DNS Example: hostAliases: - ip: "192.168.1.10" hostnames: - "example.com" - "example.internal" |
| httproute | object | `{"enabled":false,"hostnames":["chart-example.local"],"parentRefs":[{"name":"my-gateway","namespace":"default"}],"rules":[{"backendRefs":[{"name":"homarr","port":8080}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]}` | Gateway API HTTPRoute configuration |
| httproute | object | `{"enabled":false,"hostnames":["chart-example.local"],"parentRefs":[{"name":"my-gateway","namespace":"default"}],"rules":[{"backendRefs":[{"name":"homarr","port":7575}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]}` | Gateway API HTTPRoute configuration |
| httproute.enabled | bool | `false` | Enable HTTPRoute |
| httproute.hostnames | list | `["chart-example.local"]` | Hostnames this route matches (similar to ingress.hosts.host) |
| httproute.parentRefs | list | `[{"name":"my-gateway","namespace":"default"}]` | References to the parent Gateway(s) this route attaches to. Each item must include at least a `name`, and optionally a `namespace`. |
| httproute.rules | list | `[{"backendRefs":[{"name":"homarr","port":8080}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]` | List of routing rules. Each rule can include: - matches: path/header/query matching - filters: optional transformations (redirects, header modifications, etc.) - backendRefs: one or more Kubernetes Services to forward traffic to |
| httproute.rules | list | `[{"backendRefs":[{"name":"homarr","port":7575}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]` | List of routing rules. Each rule can include: - matches: path/header/query matching - filters: optional transformations (redirects, header modifications, etc.) - backendRefs: one or more Kubernetes Services to forward traffic to |
| httproute.rules[0].filters | list | `[]` | Optional filters for this rule (default: empty) |
| httproute.rules[0].matches[0].path.type | string | `"PathPrefix"` | Path match type. One of: Exact, PathPrefix, RegularExpression |
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
| image.tag | string | `"v1.70.0"` | Overrides the image tag whose default is the chart appVersion |
| image.tag | string | `"v1.71.0"` | Overrides the image tag whose default is the chart appVersion |
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
| ingress.annotations | object | `{}` | Ingress annotations |
| ingress.enabled | bool | `false` | Enable ingress |
+1 -2
View File
@@ -50,12 +50,11 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats';
<code>user.read</code>,{" "}
<code>user.update</code>,{" "}
<code>asset.read</code>,{" "}
<code>asset.view</code>,{" "}
<code>asset.download</code>,{" "}
<code>asset.statistics</code>,{" "}
<code>server.statistics</code>,{" "}
<code>server.about</code>.
<br />
Additional permissions may be required depending on your Immich version. If you encounter authorization errors, grant any missing permissions requested by the server.
</span>
),
@@ -15,7 +15,7 @@ import beszelSystemStats from "./img/beszel-system-stats.png";
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. Live view keeps a one-minute window and pauses while the browser tab is hidden. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
### Screenshots
@@ -1,6 +1,6 @@
---
title: 'Immich Album Carousel'
description: 'Shows a slideshow of your album pictures from Immich'
description: 'Shows a slideshow of pictures from an Immich album or your full library'
hide_title: true
---
@@ -37,4 +37,3 @@ import screen from './img/screen.png';
### Configuration
<WidgetConfig configuration={immichAlbumCarouselWidget.configuration} />
@@ -4,15 +4,16 @@ import { IconPhoto } from "@tabler/icons-react";
export const immichAlbumCarouselWidget: WidgetDefinition = {
icon: IconPhoto,
name: "Immich Album",
description: "Shows a slideshow of your album pictures from Immich",
description: "Shows a slideshow of pictures from an Immich album or your full library",
path: "../../widgets/immich-album-carousel",
configuration: {
items: [
{
name: "Album",
description: "Select the Immich album to show in the slideshow.",
description:
"Select an Immich album or All photos. An empty selection also shows random photos from your full library.",
values: { type: "string" },
defaultValue: "",
defaultValue: "All photos",
},
{
name: "Interval in seconds",
@@ -1,41 +1,44 @@
---
title: 'Media Request List'
description: 'See a list of all media requests from your integration'
title: "Media Request List"
description: "See a list of all media requests from your integration"
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { IconAd } from "@tabler/icons-react";
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import { mediaRequestListWidget } from '.';
import { jellyseerrIntegration } from '@site/docs/integrations/jellyseerr';
import { overseerrIntegration } from '@site/docs/integrations/overseerr';
import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem";
import { mediaRequestListWidget } from ".";
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
<WidgetHeader
widget={mediaRequestListWidget}
categories={['Media requests']}
/>
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests.
You can limit the list to specific request statuses (for example, only show `pending`) and restrict it to requests created within the last N days.
### Screenshots
import mediaRequestList from './img/list.png';
import mediaRequestList from "./img/list.png";
<img src={mediaRequestList} alt="media-request-list" width={256} height={256} />
### Supported Integrations
<WidgetIntegrations items={[{
integration: jellyseerrIntegration,
}, {
integration: overseerrIntegration,
}]} />
<WidgetIntegrations
items={[
{
integration: jellyseerrIntegration,
},
{
integration: overseerrIntegration,
},
]}
/>
### Interactions
@@ -47,4 +50,5 @@ import mediaRequestList from './img/list.png';
<AddingWidget />
### Configuration
<WidgetConfig configuration={mediaRequestListWidget.configuration} />
@@ -14,6 +14,18 @@ export const mediaRequestListWidget: WidgetDefinition = {
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Statuses to show",
description: "Only requests with these statuses will be shown. Defaults to all statuses.",
values: "List of: 'pending', 'approved', 'declined', 'failed', 'completed'",
defaultValue: "All statuses",
},
{
name: "Show only recent requests",
description: "Limit to requests created within this many days. Set to 0 to disable the time filter.",
values: "0-365",
defaultValue: "0",
},
],
},
};
+1 -1
View File
@@ -43,7 +43,7 @@
"random-word-slugs": "^0.1.7",
"react": "catalog:",
"react-dom": "catalog:",
"swiper": "^14.0.2",
"swiper": "^14.0.5",
"tailwindcss": "^4.3.2"
},
"devDependencies": {
@@ -2,6 +2,7 @@
import type { PropsWithChildren } from "react";
import { useState } from "react";
import type { QueryKey } from "@tanstack/react-query";
import { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
@@ -33,6 +34,7 @@ import {
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
import { widgetImports } from "@homarr/widgets";
import { createWidgetQueryPersister } from "./query-cache-persister";
@@ -90,6 +92,13 @@ export function TRPCReactProvider(props: PropsWithChildren) {
},
});
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
for (const { definition } of Object.values(widgetImports)) {
const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string };
if (def.refetchInterval === undefined) continue;
const key = def.queryKey ?? [["widget", def.kind]];
const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000;
client.setQueryDefaults(key, { refetchInterval: interval });
}
return client;
});
@@ -16,7 +16,10 @@ export default async function AppEditPage(props: AppEditPageProps) {
const params = await props.params;
const session = await auth();
if (!session?.user.permissions.includes("app-modify-all")) {
if (
!session?.user.permissions.includes("board-modify-all") ||
!session?.user.permissions.includes("app-modify-all")
) {
notFound();
}
const app = await api.app.byId({ id: params.id });
@@ -10,7 +10,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
export default async function AppNewPage() {
const session = await auth();
if (!session?.user.permissions.includes("app-create")) {
if (!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create")) {
notFound();
}
@@ -1,5 +1,5 @@
import { Fragment } from "react";
import { redirect } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
import { IconBox, IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4";
@@ -33,6 +33,9 @@ export default async function AppsPage(props: AppsPageProps) {
if (!session) {
redirect("/auth/login");
}
if (!session.user.permissions.includes("board-modify-all")) {
notFound();
}
const searchParams = searchParamsSchema.parse(await props.searchParams);
@@ -61,7 +61,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconBox,
href: "/manage/apps",
label: t("items.apps"),
hidden: !session,
hidden: !session?.user.permissions.includes("app-create"),
iconProps: {
strokeWidth: 2.5,
},
@@ -71,20 +71,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconAffiliateFilled,
href: "/manage/integrations",
label: t("items.integrations"),
hidden: !session,
hidden: !session?.user.permissions.includes("integration-create"),
"data-onboarding-tour-id": "manage-integrations",
},
{
icon: IconApi,
href: "/manage/custom-widgets",
label: t("items.customWidgets"),
hidden: !session,
hidden: !session?.user.permissions.includes("admin"),
},
{
icon: IconSearch,
href: "/manage/search-engines",
label: t("items.searchEngies"),
hidden: !session,
hidden: !session?.user.permissions.includes("search-engine-create"),
iconProps: {
strokeWidth: 2.5,
},
@@ -94,7 +94,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconPhotoFilled,
href: "/manage/medias",
label: t("items.medias"),
hidden: !session,
hidden: !session?.user.permissions.includes("media-upload"),
"data-onboarding-tour-id": "manage-medias",
},
{
@@ -193,6 +193,10 @@ export function DockerTable({ initialData }: DockerTableProps) {
positionToolbarAlertBanner: "top",
enableTableFooter: false,
enableBottomToolbar: false,
// MRT's built-in full-screen mode does not stretch the table to fill the
// viewport, so with few containers it renders as a thin strip (#6230). Disable
// it, matching the other management tables (users, invites).
enableFullScreenToggle: false,
positionGlobalFilter: "right",
mantineSearchTextInputProps: {
placeholder: tDocker("table.search", { count: String(containers.length) }),
@@ -31,6 +31,7 @@ import {
userChangeHomeBoardsSchema,
userChangeSearchPreferencesSchema,
userEditProfileSchema,
userEnableRightClickOnWidgetsSchema,
userFirstDayOfWeekSchema,
userPingIconsEnabledSchema,
} from "@homarr/validation/user";
@@ -51,6 +52,7 @@ const userGeneralSettingsSchema = z.object({
ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled,
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets,
});
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
@@ -78,6 +80,7 @@ const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues
ddgBangsEnabled: user.ddgBangs,
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
pingIconsEnabled: user.pingIconsEnabled,
enableRightClickOnWidgets: user.enableRightClickOnWidgets,
});
export const UserGeneralSettingsForm = ({
@@ -95,6 +98,7 @@ export const UserGeneralSettingsForm = ({
const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation();
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation();
const initialValues = buildInitialValues(user);
const initialValuesRef = useRef(initialValues);
@@ -114,6 +118,7 @@ export const UserGeneralSettingsForm = ({
changeSearchPreferencesMutation,
changeFirstDayOfWeekMutation,
changePingIconsEnabledMutation,
changeEnableRightClickOnWidgetsMutation,
];
const isPending = mutations.some((m) => m.isPending);
@@ -169,6 +174,14 @@ export const UserGeneralSettingsForm = ({
action: () =>
changePingIconsEnabledMutation.mutateAsync({ id: user.id, pingIconsEnabled: parsed.data.pingIconsEnabled }),
},
{
when: changed("enableRightClickOnWidgets"),
action: () =>
changeEnableRightClickOnWidgetsMutation.mutateAsync({
id: user.id,
enableRightClickOnWidgets: parsed.data.enableRightClickOnWidgets,
}),
},
];
const promises = saveActions.filter((s) => s.when).map((s) => s.action());
@@ -305,6 +318,11 @@ export const UserGeneralSettingsForm = ({
label={t("user.field.pingIconsEnabled.label")}
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
/>
<Switch
label={t("user.field.enableRightClickOnWidgets.label")}
description={t("user.field.enableRightClickOnWidgets.description")}
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
/>
</Stack>
</Card>
</SimpleGrid>
@@ -1,19 +1,25 @@
import { notFound } from "next/navigation";
import { ImageProxy } from "@homarr/image-proxy";
export const GET = async (_request: Request, props: { params: Promise<{ id: string }> }) => {
const { id } = await props.params;
const imageProxy = new ImageProxy();
const image = await imageProxy.forwardImageAsync(id);
if (!image) {
notFound();
const result = await imageProxy.forwardImageAsync(id);
if ("error" in result) {
const status = result.error === "upstream-error" ? result.statusCode : result.error === "fetch-error" ? 502 : 404;
return new Response(null, {
status,
headers: {
"X-Homarr-Image-Proxy-Error": result.error,
},
});
}
return new Response(image, {
return new Response(result.image, {
headers: {
"Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour
...(result.contentType ? { "Content-Type": result.contentType } : {}),
},
});
};
@@ -1,9 +1,17 @@
"use client";
import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
import { useCallback, useMemo } from "react";
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
import {
IconAlertTriangle,
IconCircleCheck,
IconCopy,
IconLayoutKanban,
IconRefresh,
IconSettings,
IconTrash,
} from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
@@ -11,10 +19,12 @@ import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useTimeAgo } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TranslationFunction } from "@homarr/translation";
import type { WidgetContextMenuAction } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
@@ -38,6 +48,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const tMenu = useScopedI18n("item.menu.label");
const t = useI18n();
const settings = useSettings();
const isRightClickEnabled = settings.enableRightClickOnWidgets;
const { openModal } = useModalAction(WidgetEditModal);
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
const { openConfirmModal } = useConfirmModal();
@@ -158,6 +169,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
);
if (!session) return <>{children}</>;
if (!isRightClickEnabled) return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
@@ -232,11 +244,14 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
onClick={handleRefetch}
disabled={isWidgetFetching}
>
<Group justify="space-between" wrap="nowrap">
<Group justify="space-between" wrap="nowrap" gap="sm">
{tMenu("refresh")}
<Text size="xs" c="dimmed">
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} />
</Text>
<WidgetQueryStatus
queryClient={queryClient}
queryKey={widgetQueryKey}
isFetching={isWidgetFetching}
t={t}
/>
</Group>
</Menu.Item>
<Menu.Item
@@ -273,34 +288,65 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
);
};
const WidgetCacheAge = ({
queryClient,
queryKey,
isFetching,
}: {
interface WidgetQueryStatusProps {
queryClient: QueryClient;
queryKey: QueryKey;
isFetching: boolean;
}) => {
// 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity
const [, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((n) => n + 1), 1000);
return () => clearInterval(id);
}, []);
t: TranslationFunction;
}
if (isFetching) return <Loader size={12} />;
const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => {
const queries = queryClient.getQueryCache().findAll({ queryKey });
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
const ageLabel = useTimeAgo(new Date(latest));
const timestamps = queryClient
.getQueryCache()
.findAll({ queryKey })
.map((q) => q.state.dataUpdatedAt)
.filter(Boolean);
if (timestamps.length === 0) return null;
if (isFetching) {
return (
<Group gap={4} wrap="nowrap">
<Loader size={12} />
<Text size="xs" c="dimmed">
{t("item.menu.status.loading")}
</Text>
</Group>
);
}
const latest = Math.max(...timestamps);
const seconds = Math.floor((Date.now() - latest) / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
return `${Math.floor(seconds / 60)}m ago`;
if (queries.length === 0) {
return (
<Text size="xs" c="dimmed">
{t("item.menu.status.idle")}
</Text>
);
}
const hasError = queries.some((q) => q.state.status === "error");
if (hasError) {
const errorQuery = queries.find((q) => q.state.status === "error");
const errorMessage =
errorQuery?.state.error instanceof Error
? errorQuery.state.error.message
: String(errorQuery?.state.error ?? "Unknown error");
return (
<Tooltip label={errorMessage} multiline position="left" w={250}>
<Group gap={4} wrap="nowrap">
<IconAlertTriangle size={12} color="var(--mantine-color-red-6)" />
<Text size="xs" c="red.6">
{t("item.menu.status.error")} · {ageLabel}
</Text>
</Group>
</Tooltip>
);
}
return (
<Group gap={4} wrap="nowrap">
<IconCircleCheck size={12} color="var(--mantine-color-green-6)" />
<Text size="xs" c="dimmed">
{t("item.menu.status.success")} · {ageLabel}
</Text>
</Group>
);
};
+2 -2
View File
@@ -6,7 +6,7 @@
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.10.0"
"pnpm": ">=11.15.1"
},
"packageManager": "pnpm@11.10.0"
"packageManager": "pnpm@11.15.1"
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.70.0",
"version": "1.72.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -69,7 +69,7 @@
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.10.0"
"pnpm": ">=11.15.1"
},
"packageManager": "pnpm@11.10.0"
"packageManager": "pnpm@11.15.1"
}
+2 -1
View File
@@ -18,7 +18,8 @@ import { AppAccessControl } from "./app/app-access-control";
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
export const appRouter = createTRPCRouter({
getPaginated: protectedProcedure
getPaginated: permissionRequiredProcedure
.requiresPermission("board-modify-all")
.input(paginatedSchema)
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
.meta({
+1 -1
View File
@@ -101,7 +101,7 @@ export const homeRouter = createTRPCRouter({
});
}
if (ctx.session?.user) {
if (ctx.session?.user.permissions.includes("board-modify-all")) {
statistics.push({
titleKey: "app",
subtitleKey: "resources",
@@ -3,7 +3,7 @@ import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { asc, eq, like } from "@homarr/db";
import { and, asc, eq, like } from "@homarr/db";
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import { searchEngines, users } from "@homarr/db/schema";
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
@@ -130,9 +130,15 @@ export const searchEngineRouter = createTRPCRouter({
return null;
}),
search: protectedProcedure.input(searchSchema).query(async ({ ctx, input }) => {
search: publicProcedure.input(searchSchema).query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines.findMany({
where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
// Public dashboards have no session: restrict anonymous users to generic
// (non-integration) engines so custom search engines work there too (#4132),
// while integration-backed engines stay available only when signed in.
where: and(
like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
ctx.session?.user ? undefined : eq(searchEngines.type, "generic"),
),
with: {
integration: {
columns: {
+27
View File
@@ -76,6 +76,33 @@ describe("all should return all apps", () => {
});
});
describe("getPaginated should require board modification access", () => {
test("should throw FORBIDDEN without board-modify-all", async () => {
const caller = appRouter.createCaller({
db: createDb(),
deviceType: undefined,
session: createDefaultSession(),
});
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("Permission denied");
});
test("should return apps with board-modify-all", async () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["board-modify-all"]),
});
await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" });
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).resolves.toMatchObject({
totalCount: 1,
items: [{ id: "1" }],
});
});
});
describe("byId should return an app by id", () => {
test("should return an app by id when canUserSeeAppAsync returns true", async () => {
// Arrange
+77
View File
@@ -357,6 +357,83 @@ describe("delete should delete user", () => {
});
});
describe("changeEnableRightClickOnWidgets should toggle the right-click preference", () => {
test("non-admin can toggle their own preference", async () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await db.insert(users).values({
id: defaultOwnerId,
name: "owner",
});
await caller.changeEnableRightClickOnWidgets({ id: defaultOwnerId, enableRightClickOnWidgets: false });
const updated = await db.query.users.findFirst({
where: eq(users.id, defaultOwnerId),
columns: { enableRightClickOnWidgets: true },
});
expect(updated?.enableRightClickOnWidgets).toBe(false);
await caller.changeEnableRightClickOnWidgets({ id: defaultOwnerId, enableRightClickOnWidgets: true });
const restored = await db.query.users.findFirst({
where: eq(users.id, defaultOwnerId),
columns: { enableRightClickOnWidgets: true },
});
expect(restored?.enableRightClickOnWidgets).toBe(true);
});
test("non-admin cannot toggle another user's preference", async () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const otherUserId = createId();
await db.insert(users).values({ id: defaultOwnerId });
await db.insert(users).values({ id: otherUserId, name: "other" });
await expect(
caller.changeEnableRightClickOnWidgets({ id: otherUserId, enableRightClickOnWidgets: false }),
).rejects.toThrow("User not found");
const other = await db.query.users.findFirst({
where: eq(users.id, otherUserId),
columns: { enableRightClickOnWidgets: true },
});
expect(other?.enableRightClickOnWidgets).toBe(true);
});
test("admin can toggle another user's preference", async () => {
const db = createDb();
const adminSession = createSession(["admin"]);
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: adminSession,
});
const targetUserId = createId();
await db.insert(users).values({ id: defaultOwnerId });
await db.insert(users).values({ id: targetUserId, name: "target" });
await caller.changeEnableRightClickOnWidgets({ id: targetUserId, enableRightClickOnWidgets: false });
const target = await db.query.users.findFirst({
where: eq(users.id, targetUserId),
columns: { enableRightClickOnWidgets: true },
});
expect(target?.enableRightClickOnWidgets).toBe(false);
});
});
const createOnboardingStepAsync = async (db: Database, step: OnboardingStep) => {
await db.insert(onboarding).values({
id: createId(),
+29
View File
@@ -23,6 +23,7 @@ import {
userCreateSchema,
userDdgBangsSchema,
userEditProfileSchema,
userEnableRightClickOnWidgetsSchema,
userFirstDayOfWeekSchema,
userInitSchema,
userPingIconsEnabledSchema,
@@ -345,6 +346,7 @@ export const userRouter = createTRPCRouter({
mobileHomeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
@@ -381,6 +383,7 @@ export const userRouter = createTRPCRouter({
mobileHomeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
@@ -651,6 +654,32 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, ctx.session.user.id));
}),
changeEnableRightClickOnWidgets: protectedProcedure
.meta({
openapi: {
method: "PATCH",
path: "/api/users/right-click-widgets",
tags: ["users"],
protect: true,
},
})
.input(convertIntersectionToZodObject(userEnableRightClickOnWidgetsSchema.and(byIdSchema)))
.output(z.void())
.mutation(async ({ input, ctx }) => {
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
enableRightClickOnWidgets: input.enableRightClickOnWidgets,
})
.where(eq(users.id, input.id));
}),
changePingIconsEnabled: protectedProcedure
.input(userPingIconsEnabledSchema.and(byIdSchema))
.mutation(async ({ input, ctx }) => {
@@ -0,0 +1,45 @@
import { describe, expect, test } from "vitest";
import { BoundedAsyncQueue } from "../bounded-async-queue";
describe("BoundedAsyncQueue", () => {
test("keeps only the newest values when the queue is full", async () => {
const queue = new BoundedAsyncQueue<number>(2);
queue.push(1);
queue.push(2);
queue.push(3);
queue.close();
await expect(queue.next()).resolves.toEqual({ value: 2, done: false });
await expect(queue.next()).resolves.toEqual({ value: 3, done: false });
await expect(queue.next()).resolves.toEqual({ value: undefined, done: true });
});
test("resolves a pending read as soon as a value arrives", async () => {
const queue = new BoundedAsyncQueue<string>(1);
const next = queue.next();
queue.push("latest");
await expect(next).resolves.toEqual({ value: "latest", done: false });
});
test("resolves a pending read when the producer closes", async () => {
const queue = new BoundedAsyncQueue<number>(1);
const next = queue.next();
queue.close();
await expect(next).resolves.toEqual({ value: undefined, done: true });
});
test("rejects a pending read when the producer fails", async () => {
const queue = new BoundedAsyncQueue<number>(1);
const next = queue.next();
const error = new Error("upstream failed");
queue.fail(error);
await expect(next).rejects.toBe(error);
});
});
+76 -79
View File
@@ -1,5 +1,4 @@
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -14,8 +13,10 @@ import {
import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { BoundedAsyncQueue } from "./bounded-async-queue";
const logger = createLogger({ module: "beszelRouter" });
const MAX_PENDING_LIVE_EVENTS = 4;
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
@@ -203,98 +204,94 @@ export const beszelRouter = createTRPCRouter({
systemId: z.string(),
}),
)
.subscription(({ ctx, input }) => {
return observable<LiveStatsEvent>((emit) => {
const controller = new AbortController();
let isActive = true;
let emittedEventCount = 0;
.subscription(async function* ({ ctx, input, signal }) {
const integration = ctx.integrations[0];
if (!integration) {
throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" });
}
logger.debug("Beszel realtime subscription started", {
userId: ctx.session?.user?.id,
integrationIds: ctx.integrations.map((integration) => integration.id),
systemId: input.systemId,
});
const queue = new BoundedAsyncQueue<LiveStatsEvent>(MAX_PENDING_LIVE_EVENTS);
const controller = new AbortController();
let emittedEventCount = 0;
const stop = () => {
controller.abort();
void queue.return();
};
if (signal?.aborted) return;
signal?.addEventListener("abort", stop, { once: true });
logger.debug("Beszel realtime subscription started", {
userId: ctx.session?.user?.id,
integrationIds: ctx.integrations.map((candidate) => candidate.id),
systemId: input.systemId,
});
try {
const instance = await createIntegrationAsync(integration);
if (controller.signal.aborted) return;
void (async () => {
const integration = ctx.integrations[0];
if (!integration) {
emit.error(
new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" }),
);
return;
}
try {
const instance = await createIntegrationAsync(integration);
if (typeof instance.subscribeRealtimeMetrics === "function") {
await instance.subscribeRealtimeMetrics(
input.systemId,
(event) => {
if (!isActive) return;
emittedEventCount += 1;
if (emittedEventCount <= 2 || emittedEventCount % 60 === 0) {
logger.debug("Forwarding Beszel realtime events", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
eventType: event.type,
emittedEventCount,
statsCount: Array.isArray(event.record.stats) ? event.record.stats.length : undefined,
});
}
emit.next(event);
},
controller.signal,
);
} else {
emit.error(
new TRPCError({
code: "METHOD_NOT_SUPPORTED",
message: `Integration ${integration.kind} does not support realtime metrics`,
}),
);
}
} catch (error) {
if (isActive) {
logger.warn("Beszel realtime subscription failed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
emittedEventCount,
error: errorMessage(error),
if (typeof instance.subscribeRealtimeMetrics !== "function") {
throw new TRPCError({
code: "METHOD_NOT_SUPPORTED",
message: `Integration ${integration.kind} does not support realtime metrics`,
});
emit.error(
error instanceof TRPCError
? error
: new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: error instanceof Error ? error.message : String(error),
}),
);
}
}
if (isActive) {
logger.debug("Beszel realtime subscription completed", {
await instance.subscribeRealtimeMetrics(
input.systemId,
(event) => {
emittedEventCount += 1;
if (emittedEventCount <= 2 || emittedEventCount % 60 === 0) {
logger.debug("Forwarding Beszel realtime events", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
eventType: event.type,
emittedEventCount,
statsCount: Array.isArray(event.record.stats) ? event.record.stats.length : undefined,
});
}
queue.push(event);
},
controller.signal,
);
queue.close();
} catch (error) {
if (controller.signal.aborted) {
queue.close();
return;
}
logger.warn("Beszel realtime subscription failed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
emittedEventCount,
error: errorMessage(error),
});
emit.complete();
queue.fail(
error instanceof TRPCError
? error
: new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: error instanceof Error ? error.message : String(error),
}),
);
}
})();
return () => {
isActive = false;
logger.debug("Beszel realtime subscription cancelled", {
userId: ctx.session?.user?.id,
integrationIds: ctx.integrations.map((integration) => integration.id),
systemId: input.systemId,
emittedEventCount,
});
controller.abort();
};
});
for await (const event of queue) yield event;
} finally {
stop();
signal?.removeEventListener("abort", stop);
logger.debug("Beszel realtime subscription stopped", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
emittedEventCount,
});
}
}),
});
@@ -0,0 +1,88 @@
type QueueResult<T> = IteratorResult<T, undefined>;
type PendingRead<T> = {
resolve: (result: QueueResult<T>) => void;
reject: (reason?: unknown) => void;
};
/**
* A small async queue for live streams.
*
* Live metrics are snapshots, so retaining every snapshot while a client is
* slow is both unnecessary and dangerous. When the queue is full, discard
* the oldest pending snapshot and keep the newest one.
*/
export class BoundedAsyncQueue<T> implements AsyncIterableIterator<T> {
private readonly values: T[] = [];
private pendingRead: PendingRead<T> | undefined;
private closed = false;
private failed = false;
private failure: unknown;
public constructor(private readonly maxSize: number) {
if (!Number.isInteger(maxSize) || maxSize < 1) {
throw new Error("A bounded async queue requires a positive integer max size");
}
}
public push(value: T): void {
if (this.closed) return;
if (this.pendingRead) {
const pendingRead = this.pendingRead;
this.pendingRead = undefined;
pendingRead.resolve({ value, done: false });
return;
}
if (this.values.length >= this.maxSize) this.values.shift();
this.values.push(value);
}
public close(): void {
if (this.closed) return;
this.closed = true;
if (this.pendingRead) {
const pendingRead = this.pendingRead;
this.pendingRead = undefined;
pendingRead.resolve({ value: undefined, done: true });
}
}
public fail(error: unknown): void {
if (this.closed) return;
this.closed = true;
this.failed = true;
this.failure = error;
this.values.length = 0;
if (this.pendingRead) {
const pendingRead = this.pendingRead;
this.pendingRead = undefined;
pendingRead.reject(error);
}
}
public next(): Promise<QueueResult<T>> {
if (this.values.length > 0) {
return Promise.resolve({ value: this.values.shift() as T, done: false });
}
if (this.failed) return Promise.reject(this.failure);
if (this.closed) return Promise.resolve({ value: undefined, done: true });
return new Promise<QueueResult<T>>((resolve, reject) => {
this.pendingRead = { resolve, reject };
});
}
public return(): Promise<QueueResult<T>> {
this.values.length = 0;
this.close();
return Promise.resolve({ value: undefined, done: true });
}
public [Symbol.asyncIterator](): AsyncIterableIterator<T> {
return this;
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ export const immichRouter = createTRPCRouter({
.concat(createImmichIntegrationMiddleware("query"))
.input(
z.object({
albumId: z.string(),
albumId: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
@@ -3,7 +3,10 @@ import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
import {
mediaRequestListInputSchema,
mediaRequestListRequestHandler,
} from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -16,13 +19,14 @@ export const mediaRequestsRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, available). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)",
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => {
.input(mediaRequestListInputSchema)
.query(async ({ ctx, input }) => {
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaRequestListRequestHandler.handler(integration, {}).getDataAsync();
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
});
return results
+7 -4
View File
@@ -3,6 +3,7 @@ import { escapeForRegEx } from "@tiptap/react";
import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq } from "@homarr/db";
import { boards, items, widgetSecrets } from "@homarr/db/schema";
import { releaseProviderKinds } from "@homarr/definitions";
@@ -11,6 +12,8 @@ import { releasesRequestHandler } from "@homarr/request-handler/releases";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
const logger = createLogger({ module: "releasesRouter" });
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
if (!versionFilter) return undefined;
@@ -71,13 +74,13 @@ export const releasesRouter = createTRPCRouter({
for (const secret of secrets) {
try {
tokensByProvider.set(secret.kind, decryptSecret(secret.value));
} catch {
// Skip corrupt secrets
} catch (error) {
logger.warn("Failed to decrypt widget secret", { itemId: input.itemId, kind: secret.kind, error });
}
}
}
} catch {
// Access denied or table not yet migrated -- proceed without tokens
} catch (error) {
logger.warn("Failed to load release widget tokens", { itemId: input.itemId, error });
}
}
+24 -4
View File
@@ -6,20 +6,24 @@ import { and, eq } from "@homarr/db";
import { accounts, sessions, users } from "@homarr/db/schema";
import type { SupportedAuthProvider } from "@homarr/definitions";
export const createAdapter = (db: Database, provider: SupportedAuthProvider | "unknown"): Adapter => {
export const createAdapter = (
db: Database,
provider: SupportedAuthProvider | "unknown",
enableDangerousCredentialsLinking = false,
): Adapter => {
const drizzleAdapter = DrizzleAdapter(db, { usersTable: users, sessionsTable: sessions, accountsTable: accounts });
return {
...drizzleAdapter,
// We override the default implementation as we want to have a provider
// flag in the user instead of the account to not intermingle users from different providers
// Keep users isolated by their primary provider. The explicit credentials-linking
// opt-in is the only exception and keeps credentials as the primary provider.
// eslint-disable-next-line no-restricted-syntax
getUserByEmail: async (email) => {
if (provider === "unknown") {
throw new Error("Unable to get user by email for unknown provider");
}
const user = await db.query.users.findFirst({
let user = await db.query.users.findFirst({
where: and(eq(users.email, email), eq(users.provider, provider)),
columns: {
id: true,
@@ -30,6 +34,22 @@ export const createAdapter = (db: Database, provider: SupportedAuthProvider | "u
},
});
if (!user && provider === "oidc" && enableDangerousCredentialsLinking) {
// Credentials accounts do not have an email-verification flow, so their
// Auth.js emailVerified field is always null. The OIDC profile's strict
// email_verified check is the security boundary for this opt-in.
user = await db.query.users.findFirst({
where: and(eq(users.email, email), eq(users.provider, "credentials")),
columns: {
id: true,
name: true,
email: true,
emailVerified: true,
image: true,
},
});
}
if (!user) {
return null;
}
+1 -1
View File
@@ -44,7 +44,7 @@ export const createConfiguration = (
headers: ReadonlyHeaders | null,
useSecureCookies: boolean,
) => {
const adapter = createAdapter(db, provider);
const adapter = createAdapter(db, provider, env.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING);
return NextAuth({
logger: {
error: (error) => {
+1
View File
@@ -46,6 +46,7 @@ export const env = createEnv({
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),
AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false),
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: createBooleanSchema(false),
AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING: createBooleanSchema(false),
AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD: z
.enum(["client_secret_basic", "client_secret_post", "client_secret_jwt", "none"])
.default("client_secret_basic"),
+3 -1
View File
@@ -24,6 +24,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
name: true,
image: true,
colorScheme: true,
provider: true,
},
});
@@ -33,6 +34,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
// Groups from oidc provider are provided from the profile, it's not typed.
if (
!env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT &&
dbUser.provider === "oidc" &&
profile &&
groupsKey in profile &&
Array.isArray(profile[groupsKey])
@@ -57,7 +59,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
});
}
if (profile) {
if (profile && dbUser.provider === "oidc") {
const profileUsername = extractProfileName(profile);
if (!profileUsername) {
throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`);
@@ -7,6 +7,7 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
import { env } from "../../env";
import { createRedirectUri } from "../../redirect";
import { assertVerifiedEmailForCredentialsLinking } from "./verified-email";
export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profile> => ({
id: "oidc",
@@ -18,7 +19,8 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
token_endpoint_auth_method: env.AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD,
},
issuer: env.AUTH_OIDC_ISSUER,
allowDangerousEmailAccountLinking: env.AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING,
allowDangerousEmailAccountLinking:
env.AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING || env.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING,
authorization: {
params: {
scope: env.AUTH_OIDC_SCOPE_OVERWRITE,
@@ -55,6 +57,7 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
if (!profile.sub) {
throw new Error(`OIDC provider did not return a sub property='${Object.keys(profile).join(",")}'`);
}
assertVerifiedEmailForCredentialsLinking(profile, env.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING);
const name = extractProfileName(profile);
if (!name) {
throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`);
@@ -0,0 +1,11 @@
export const assertVerifiedEmailForCredentialsLinking = (
profile: { email?: unknown; email_verified?: unknown },
isCredentialsLinkingEnabled: boolean,
) => {
if (
isCredentialsLinkingEnabled &&
(profile.email_verified !== true || typeof profile.email !== "string" || profile.email.trim().length === 0)
) {
throw new Error("OIDC provider did not return a verified email while credentials linking is enabled");
}
};
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { OidcProvider } from "../oidc/oidc-provider";
import { assertVerifiedEmailForCredentialsLinking } from "../oidc/verified-email";
const mockEnv = vi.hoisted(() => ({
AUTH_OIDC_CLIENT_ID: "client-id",
AUTH_OIDC_CLIENT_NAME: "OIDC",
AUTH_OIDC_CLIENT_SECRET: "client-secret",
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: false,
AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING: false,
AUTH_OIDC_FORCE_USERINFO: false,
AUTH_OIDC_ISSUER: "https://example.com",
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: undefined,
AUTH_OIDC_SCOPE_OVERWRITE: "openid email profile",
AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD: "client_secret_basic" as const,
}));
vi.mock("../../env", () => ({ env: mockEnv }));
vi.mock("next-auth", () => ({ customFetch: Symbol("customFetch") }));
afterEach(() => {
mockEnv.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING = false;
});
describe("assertVerifiedEmailForCredentialsLinking", () => {
test("allows a strictly verified OIDC email when credentials linking is enabled", () => {
expect(() =>
assertVerifiedEmailForCredentialsLinking({ email: "test@example.com", email_verified: true }, true),
).not.toThrow();
});
test.each([
{ email: "test@example.com", email_verified: false },
{ email: "test@example.com", email_verified: "true" },
{ email_verified: true },
{ email: null, email_verified: true },
{ email: " ", email_verified: true },
{},
])("rejects an OIDC profile without a strictly verified email when credentials linking is enabled", (profile) => {
expect(() => assertVerifiedEmailForCredentialsLinking(profile, true)).toThrow(
"OIDC provider did not return a verified email while credentials linking is enabled",
);
});
test("does not require the claim when credentials linking is disabled", () => {
expect(() => assertVerifiedEmailForCredentialsLinking({}, false)).not.toThrow();
});
test("OIDC provider rejects an unverified profile when credentials linking is enabled", () => {
mockEnv.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING = true;
const provider = OidcProvider(null);
expect(provider.allowDangerousEmailAccountLinking).toBe(true);
const mapProfile = provider.profile;
if (!mapProfile) throw new Error("Expected OIDC profile mapper to be defined");
expect(() =>
mapProfile(
{
sub: "subject",
preferred_username: "test",
email: "test@example.com",
email_verified: false,
},
{},
),
).toThrow("OIDC provider did not return a verified email while credentials linking is enabled");
});
test("OIDC provider accepts an unverified profile when credentials linking is disabled", () => {
const provider = OidcProvider(null);
const mapProfile = provider.profile;
if (!mapProfile) throw new Error("Expected OIDC profile mapper to be defined");
expect(() =>
mapProfile(
{
sub: "subject",
preferred_username: "test",
email: "test@example.com",
email_verified: false,
},
{},
),
).not.toThrow();
});
});
+89 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import { users } from "@homarr/db/schema";
import { eq } from "@homarr/db";
import { accounts, apiKeys, groupMembers, groups, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { createAdapter } from "../adapter";
@@ -64,4 +65,91 @@ describe("createAdapter should create drizzle adapter", () => {
// Assert
await expect(actAsync()).rejects.toThrow("Unable to get user by email for unknown provider");
});
test("getUserByEmail should return a credentials user for oidc when credentials linking is enabled", async () => {
const db = createDb();
const adapter = createAdapter(db, "oidc", true);
const email = "test@example.com";
await db.insert(users).values({ id: "credentials-user", name: "test", email, provider: "credentials" });
const user = await adapter.getUserByEmail?.(email);
expect(user?.id).toBe("credentials-user");
});
test("getUserByEmail should not return credentials users for non-oidc providers", async () => {
const db = createDb();
const adapter = createAdapter(db, "ldap", true);
const email = "test@example.com";
await db.insert(users).values({ id: "credentials-user", name: "test", email, provider: "credentials" });
const user = await adapter.getUserByEmail?.(email);
expect(user).toBeNull();
});
test("getUserByEmail should prefer an existing oidc user over a credentials user", async () => {
const db = createDb();
const adapter = createAdapter(db, "oidc", true);
const email = "test@example.com";
await db.insert(users).values([
{ id: "credentials-user", name: "credentials", email, provider: "credentials" },
{ id: "oidc-user", name: "oidc", email, provider: "oidc" },
]);
const user = await adapter.getUserByEmail?.(email);
expect(user?.id).toBe("oidc-user");
});
test("linkAccount should preserve the credentials user and their data", async () => {
const db = createDb();
const adapter = createAdapter(db, "oidc", true);
const email = "test@example.com";
await db.insert(users).values({
id: "credentials-user",
name: "test",
email,
password: "hashed-password",
provider: "credentials",
colorScheme: "light",
completedBoardTour: true,
});
await db.insert(groups).values({ id: "group", name: "Test group", position: 1 });
await db.insert(groupMembers).values({ groupId: "group", userId: "credentials-user" });
await db.insert(apiKeys).values({ id: "api-key", apiKey: "secret", userId: "credentials-user" });
const user = await adapter.getUserByEmail?.(email);
if (!user) throw new Error("Expected credentials user to be found for OIDC linking");
await adapter.linkAccount?.({
userId: user.id,
type: "oidc",
provider: "oidc",
providerAccountId: "oidc-subject",
});
const linkedUser = await db.query.users.findFirst({ where: eq(users.id, "credentials-user") });
const account = await db.query.accounts.findFirst({ where: eq(accounts.providerAccountId, "oidc-subject") });
const membership = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "credentials-user"),
});
const apiKey = await db.query.apiKeys.findFirst({ where: eq(apiKeys.userId, "credentials-user") });
const userByAccount = await adapter.getUserByAccount?.({
provider: "oidc",
providerAccountId: "oidc-subject",
});
const allUsers = await db.query.users.findMany();
expect(linkedUser).toMatchObject({
id: "credentials-user",
password: "hashed-password",
provider: "credentials",
colorScheme: "light",
completedBoardTour: true,
});
expect(account?.userId).toBe("credentials-user");
expect(membership?.groupId).toBe("group");
expect(apiKey?.id).toBe("api-key");
expect(userByAccount?.id).toBe("credentials-user");
expect(allUsers).toHaveLength(1);
});
});
+39 -7
View File
@@ -135,7 +135,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
test("should add missing group membership", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createUserAsync(db, "oidc");
await createGroupAsync(db);
const eventHandler = createSignInEventHandler(db);
@@ -155,7 +155,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
test("should remove group membership", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createUserAsync(db, "oidc");
await createGroupAsync(db);
await db.insert(groupMembers).values({
userId: "1",
@@ -179,7 +179,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
test("should not remove group membership for everyone group", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createUserAsync(db, "oidc");
await createGroupAsync(db, everyoneGroup);
await db.insert(groupMembers).values({
userId: "1",
@@ -204,7 +204,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
// Arrange
mockEnv.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT = true;
const db = createDb();
await createUserAsync(db);
await createUserAsync(db, "oidc");
await createGroupAsync(db);
const eventHandler = createSignInEventHandler(db);
@@ -229,10 +229,10 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
["ldap" as const, { name: "test-new" }, undefined],
["oidc" as const, { name: "test" }, { preferred_username: "test-new" }],
["oidc" as const, { name: "test" }, { preferred_username: "test@example.com", name: "test-new" }],
])("signInEventHandler should update username for %s provider", async (_provider, user, profile) => {
])("signInEventHandler should update username for %s provider", async (provider, user, profile) => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createUserAsync(db, provider);
const eventHandler = createSignInEventHandler(db);
// Act
@@ -251,6 +251,32 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
});
expect(dbUser?.name).toBe("test-new");
});
test("signInEventHandler should preserve credentials-owned data when the user signs in with linked oidc", async () => {
const db = createDb();
await createUserAsync(db, "credentials", "local-avatar");
await createGroupAsync(db);
await db.insert(groupMembers).values({ userId: "1", groupId: "1" });
const eventHandler = createSignInEventHandler(db);
await eventHandler?.({
user: { id: "1", name: "test" },
profile: {
preferred_username: "oidc-name",
picture: "oidc-avatar",
someRandomGroupsKey: [],
},
account: null,
});
const dbUser = await db.query.users.findFirst({ where: eq(users.id, "1") });
const membership = await db.query.groupMembers.findFirst({ where: eq(groupMembers.userId, "1") });
expect(dbUser).toMatchObject({
name: "test",
image: "local-avatar",
provider: "credentials",
});
expect(membership?.groupId).toBe("1");
});
test("signInEventHandler should set color-scheme cookie", async () => {
// Arrange
const db = createDb();
@@ -275,11 +301,17 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
});
});
const createUserAsync = async (db: Database) =>
const createUserAsync = async (
db: Database,
provider: "credentials" | "ldap" | "oidc" = "credentials",
image?: string,
) =>
await db.insert(users).values({
id: "1",
name: "test",
colorScheme: "dark",
image,
provider,
});
const createGroupAsync = async (db: Database, name = "test") =>
@@ -67,12 +67,14 @@ export const fetchWithTrustedCertificatesAsync = async (
url: RequestInfo,
options?: RequestInit & { timeout?: number; bodyTimeout?: number },
): Promise<Response> => {
const agent = await createCertificateAgentAsync(
undefined,
options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined,
);
const agent =
options?.dispatcher ??
(await createCertificateAgentAsync(
undefined,
options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined,
));
if (options?.timeout) {
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options;
const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options;
return await withTimeoutAsync(
async (signal) =>
fetch(url, {
@@ -84,7 +86,7 @@ export const fetchWithTrustedCertificatesAsync = async (
);
}
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options ?? {};
const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options ?? {};
return fetch(url, {
...fetchOptions,
dispatcher: agent,
@@ -0,0 +1 @@
ALTER TABLE `user` ADD `enable_right_click_on_widgets` boolean DEFAULT true NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -302,6 +302,13 @@
"when": 1783176100870,
"tag": "0042_add_widget_secret",
"breakpoints": true
},
{
"idx": 43,
"version": "5",
"when": 1783852800000,
"tag": "0043_add_right_click_preference",
"breakpoints": true
}
]
}
}
@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "enable_right_click_on_widgets" boolean DEFAULT true NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -64,6 +64,13 @@
"when": 1783176100870,
"tag": "0008_add_widget_secret",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1783786677702,
"tag": "0009_add_right_click_preference",
"breakpoints": true
}
]
}
}
@@ -0,0 +1 @@
ALTER TABLE `user` ADD `enable_right_click_on_widgets` integer DEFAULT true NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -288,6 +288,13 @@
"when": 1783176100870,
"tag": "0040_add_widget_secret",
"breakpoints": true
},
{
"idx": 41,
"version": "6",
"when": 1783786658943,
"tag": "0041_add_right_click_preference",
"breakpoints": true
}
]
}
}
+1
View File
@@ -86,6 +86,7 @@ export const users = mysqlTable("user", {
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(),
enableRightClickOnWidgets: boolean().default(true).notNull(),
completedManageTour: boolean().default(false).notNull(),
completedBoardTour: boolean().default(false).notNull(),
});
+1
View File
@@ -85,6 +85,7 @@ export const users = pgTable("user", {
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(),
enableRightClickOnWidgets: boolean().default(true).notNull(),
completedManageTour: boolean().default(false).notNull(),
completedBoardTour: boolean().default(false).notNull(),
});
+1
View File
@@ -68,6 +68,7 @@ export const users = sqliteTable("user", {
colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
enableRightClickOnWidgets: int({ mode: "boolean" }).default(true).notNull(),
completedManageTour: int({ mode: "boolean" }).default(false).notNull(),
completedBoardTour: int({ mode: "boolean" }).default(false).notNull(),
});
+1 -1
View File
@@ -37,5 +37,5 @@ describe("Mysql Migration", () => {
connection.end();
await mysqlContainer.stop();
}, 40_000);
}, 120_000);
});
-1
View File
@@ -467,7 +467,6 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
category: ["archiving"],
documentationUrl: null,
defaultUrl: "http://localhost:8001",
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
+31 -8
View File
@@ -19,6 +19,12 @@ const createUrlByIdChannel = (id: string) =>
headers: `${string}.${string}`;
}>(`image-proxy:url:${id}`);
export type ForwardImageResult =
| { image: ArrayBuffer; contentType: string | null }
| { error: "not-found" }
| { error: "upstream-error"; statusCode: number }
| { error: "fetch-error" };
export class ImageProxy {
private static hmacKey: Buffer<ArrayBuffer> | null = null;
@@ -47,17 +53,34 @@ export class ImageProxy {
return this.createImageUrl(id);
}
public async forwardImageAsync(id: string): Promise<ArrayBuffer | null> {
public async forwardImageAsync(id: string): Promise<ForwardImageResult> {
const urlAndHeaders = await this.getImageUrlAndHeadersAsync(id);
if (!urlAndHeaders) {
return null;
return { error: "not-found" };
}
const response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, {
headers: urlAndHeaders.headers ?? {},
});
const proxyUrl = this.createImageUrl(id);
let response: Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
try {
response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, {
headers: urlAndHeaders.headers ?? {},
});
} catch (error) {
logger.error(
new ErrorWithMetadata(
"Failed to connect to image source",
{
id,
url: this.redactUrl(urlAndHeaders.url),
headers: this.redactHeaders(urlAndHeaders.headers),
proxyUrl,
},
{ cause: error },
),
);
return { error: "fetch-error" };
}
if (!response.ok) {
logger.error(
new ErrorWithMetadata("Failed to fetch image", {
@@ -68,7 +91,7 @@ export class ImageProxy {
statusCode: response.status,
}),
);
return null;
return { error: "upstream-error", statusCode: response.status };
}
const arrayBuffer = await response.arrayBuffer();
@@ -80,7 +103,7 @@ export class ImageProxy {
size: `${(arrayBuffer.byteLength / 1024).toFixed(1)}KB`,
});
return arrayBuffer;
return { image: arrayBuffer, contentType: response.headers.get("content-type") };
}
private createImageUrl(id: string): string {
@@ -23,10 +23,15 @@ export const createSessionStore = <TValue>(integration: { id: string }) => {
return null;
}
},
async setAsync(value: TValue) {
logger.debug("Updating session in store", { store: channelName });
/**
* @param value session to store
* @param options pass `ttlSeconds` when the credential has a known lifetime, so a stored
* session can never outlive the token it holds
*/
async setAsync(value: TValue, options?: { ttlSeconds?: number }) {
logger.debug("Updating session in store", { store: channelName, ttlSeconds: options?.ttlSeconds });
try {
await channel.setAsync(encryptSecret(superjson.stringify(value)));
await channel.setAsync(encryptSecret(superjson.stringify(value)), options);
} catch (error) {
logger.error(new ErrorWithMetadata("Failed to save session", { store: channelName }, { cause: error }));
}
@@ -1,6 +1,8 @@
import { ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { Response as UndiciResponse } from "undici";
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
@@ -80,11 +82,47 @@ export const normalizeRealtimeSnapshot = (
return events;
};
interface BeszelSession {
export interface BeszelSession {
token: string;
userId: string;
/** Epoch milliseconds, taken from the token's `exp` claim. Undefined when it could not be read. */
expiresAt?: number;
}
/**
* Re-authenticate this long before the token actually lapses, so a request can never be sent
* with a token that expires while it is in flight.
*/
const sessionExpiryLeewayMs = 60_000;
/**
* Reads the expiration out of a PocketBase auth token.
*
* @returns expiry as epoch milliseconds, or null when the token is malformed or carries no
* numeric `exp` claim — in which case we fall back to the existing 401 retry path.
*/
export const parseTokenExpiration = (token: string): number | null => {
const payload = token.split(".")[1];
if (!payload) return null;
try {
const decoded = Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
const { exp } = JSON.parse(decoded) as { exp?: unknown };
return typeof exp === "number" && Number.isFinite(exp) ? exp * 1000 : null;
} catch {
return null;
}
};
/**
* Beszel answers requests carrying an expired token with `200` and an empty list rather than `401`,
* because its collection rules filter on `@request.auth.id` instead of rejecting. The 401 retry in
* `fetchWithAuthAsync` therefore never fires, so a stored session would otherwise be trusted
* forever. Sessions have to expire themselves.
*/
export const isSessionExpired = (session: BeszelSession, now = Date.now()) =>
session.expiresAt !== undefined && session.expiresAt - sessionExpiryLeewayMs <= now;
export class BeszelIntegration extends Integration {
private readonly sessionStore: SessionStore<BeszelSession>;
@@ -95,11 +133,19 @@ export class BeszelIntegration extends Integration {
private async authenticateAsync(): Promise<BeszelSession> {
const existingSession = await this.sessionStore.getAsync();
if (existingSession) {
if (existingSession && !isSessionExpired(existingSession)) {
logger.debug("Using stored Beszel session", { integrationId: this.integration.id });
return existingSession;
}
if (existingSession) {
logger.info("Stored Beszel session expired, re-authenticating", {
integrationId: this.integration.id,
expiresAt: existingSession.expiresAt,
});
await this.sessionStore.clearAsync();
}
const authUrl = this.url("/api/collections/users/auth-with-password");
logger.debug("Authenticating with Beszel", { integrationId: this.integration.id, url: authUrl.pathname });
const response = await fetchWithTrustedCertificatesAsync(authUrl, {
@@ -118,9 +164,30 @@ export class BeszelIntegration extends Integration {
}
const data = (await response.json()) as BeszelAuthResponse;
const session: BeszelSession = { token: data.token, userId: data.record.id };
await this.sessionStore.setAsync(session);
logger.debug("Saved Beszel session", { integrationId: this.integration.id, userId: session.userId });
const expiresAt = parseTokenExpiration(data.token) ?? undefined;
// A token that has already lapsed by the time it reaches us means our clock and Beszel's
// disagree by more than the token lifetime. Re-authenticating would only mint another dead
// token, and using this one would silently produce the empty responses this check exists to
// prevent — so fail loudly instead.
if (expiresAt !== undefined && expiresAt <= Date.now()) {
logger.warn("Beszel issued an already-expired token", { integrationId: this.integration.id, expiresAt });
throw new ErrorWithMetadata("Beszel issued an already-expired authentication token, check for clock skew", {
integrationId: this.integration.id,
expiresAt,
});
}
const session: BeszelSession = { token: data.token, userId: data.record.id, expiresAt };
// Give the stored entry the token's own lifetime, so the cache cannot outlive the credential
// even if the session is never read again before it lapses.
const ttlSeconds = expiresAt === undefined ? undefined : Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000));
await this.sessionStore.setAsync(session, ttlSeconds === undefined ? undefined : { ttlSeconds });
logger.debug("Saved Beszel session", {
integrationId: this.integration.id,
userId: session.userId,
expiresAt,
});
return session;
}
@@ -357,19 +424,41 @@ export class BeszelIntegration extends Integration {
let emittedSnapshotCount = 0;
while (!signal.aborted) {
let streamResponse: UndiciResponse | undefined;
let streamAgent: Awaited<ReturnType<typeof createCertificateAgentAsync>> | undefined;
const closeStreamAsync = async () => {
const response = streamResponse;
streamResponse = undefined;
const agent = streamAgent;
streamAgent = undefined;
if (response?.body) await response.body.cancel().catch(() => {});
if (agent) await agent.close().catch(() => {});
};
try {
let session = await this.authenticateAsync();
const openStream = async () =>
await fetchWithTrustedCertificatesAsync(realtimeUrl, {
headers: {
Accept: "text/event-stream",
Authorization: session.token,
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
signal,
bodyTimeout: 0,
});
const openStream = async () => {
streamAgent = await createCertificateAgentAsync(undefined, { bodyTimeout: 0 });
try {
const response = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
headers: {
Accept: "text/event-stream",
Authorization: session.token,
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
signal,
bodyTimeout: 0,
dispatcher: streamAgent,
});
streamResponse = response;
return response;
} catch (error) {
await closeStreamAsync();
throw error;
}
};
logger.debug("Opening Beszel rt_metrics SSE connection", {
integrationId: this.integration.id,
@@ -378,6 +467,7 @@ export class BeszelIntegration extends Integration {
});
let response = await openStream();
if (response.status === 401) {
await closeStreamAsync();
await this.sessionStore.clearAsync();
session = await this.authenticateAsync();
response = await openStream();
@@ -402,14 +492,21 @@ export class BeszelIntegration extends Integration {
clientId = frame.id || fallbackClientId;
if (!clientId) throw new Error("PocketBase PB_CONNECT event did not include a client ID");
if (!streamAgent) throw new Error("Beszel realtime stream agent is not available");
const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
method: "POST",
headers: { Authorization: session.token, "Content-Type": "application/json" },
body: JSON.stringify({ clientId, subscriptions: [topic] }),
signal,
dispatcher: streamAgent,
});
if (subscribeResponse.status === 401) await this.sessionStore.clearAsync();
if (!subscribeResponse.ok) throw new ResponseError(subscribeResponse);
try {
if (subscribeResponse.status === 401) await this.sessionStore.clearAsync();
if (!subscribeResponse.ok) throw new ResponseError(subscribeResponse);
await subscribeResponse.arrayBuffer();
} finally {
if (subscribeResponse.body) await subscribeResponse.body.cancel().catch(() => {});
}
logger.debug("Subscribed to Beszel rt_metrics topic", {
integrationId: this.integration.id,
systemId,
@@ -461,10 +558,12 @@ export class BeszelIntegration extends Integration {
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
await closeStreamAsync();
}
if (!signal.aborted) throw new Error("Beszel rt_metrics SSE stream ended unexpectedly");
} catch (error) {
await closeStreamAsync();
if (signal.aborted) break;
if (error instanceof ResponseError && error.statusCode === 401 && retryAttempt === 0) {
retryAttempt += 1;
@@ -10,6 +10,7 @@ vi.hoisted(() => {
const BESZEL_URL = process.env.BESZEL_TEST_URL ?? "http://localhost:8090";
const BESZEL_EMAIL = process.env.BESZEL_TEST_EMAIL ?? "";
const BESZEL_PASSWORD = process.env.BESZEL_TEST_PASSWORD ?? "";
const closeAgent = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock("@homarr/redis", () => ({
createGetSetChannel: () => ({
@@ -32,7 +33,7 @@ vi.mock("@homarr/core/infrastructure/logs", () => ({
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: (url: URL | string, init?: RequestInit) => fetch(url, init),
createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}),
createCertificateAgentAsync: vi.fn().mockResolvedValue(undefined),
createCertificateAgentAsync: vi.fn().mockResolvedValue({ close: closeAgent }),
}));
vi.mock("@homarr/core/infrastructure/certificates", () => ({
@@ -153,6 +154,7 @@ describe("subscribeRealtimeMetrics", () => {
expect(subscriptionBodies).toEqual([{ clientId: "client-1", subscriptions: [topic] }]);
expect(events.map((event) => event.type)).toEqual(["system_stats", "container_stats"]);
expect(closeAgent).toHaveBeenCalled();
} finally {
globalThis.fetch = originalFetch;
}
@@ -0,0 +1,213 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
process.env.ENABLE_DNS_CACHING = "false";
});
// A stateful stand-in for redis, so a session actually survives between calls and the
// expiry logic can be exercised end to end.
const store = vi.hoisted(() => new Map<string, { value: string; ttlSeconds?: number }>());
vi.mock("@homarr/redis", () => ({
createGetSetChannel: (name: string) => ({
getAsync: () => Promise.resolve(store.get(name)?.value ?? null),
setAsync: (value: string, options?: { ttlSeconds?: number }) => {
store.set(name, { value, ttlSeconds: options?.ttlSeconds });
return Promise.resolve();
},
removeAsync: () => {
store.delete(name);
return Promise.resolve();
},
}),
}));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
ErrorWithMetadata: class extends Error {},
}));
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: (url: URL | string, init?: RequestInit) => fetch(url, init),
createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}),
createCertificateAgentAsync: vi.fn().mockResolvedValue({ close: vi.fn() }),
}));
vi.mock("@homarr/core/infrastructure/certificates", () => ({
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
}));
import { BeszelIntegration, isSessionExpired, parseTokenExpiration } from "../beszel-integration";
const base64Url = (value: object) => Buffer.from(JSON.stringify(value)).toString("base64url");
const createToken = (expiresAtSeconds: number) => `header.${base64Url({ exp: expiresAtSeconds })}.signature`;
describe("parseTokenExpiration", () => {
test("reads the exp claim as epoch milliseconds", () => {
expect(parseTokenExpiration(createToken(1_800_000_000))).toBe(1_800_000_000_000);
});
test("decodes base64url payloads containing - and _", () => {
// A payload that base64-encodes with both + and /, which must be url-decoded first.
const token = `header.${Buffer.from(JSON.stringify({ sub: "a>b?c>>", exp: 1_700_000_000 })).toString("base64url")}.sig`;
expect(parseTokenExpiration(token)).toBe(1_700_000_000_000);
});
test.each([
["not-a-jwt", "no segments"],
["header.%%%not-base64%%%.signature", "undecodable payload"],
[`header.${base64Url({ id: "user-1" })}.signature`, "no exp claim"],
[`header.${base64Url({ exp: "soon" })}.signature`, "non-numeric exp"],
])("returns null for %s (%s)", (token) => {
expect(parseTokenExpiration(token)).toBeNull();
});
});
const session = (expiresAt?: number) => ({ token: "t", userId: "u", expiresAt });
describe("isSessionExpired", () => {
const now = Date.UTC(2026, 6, 26, 12, 0, 0);
test("treats a session with an unknown expiry as valid, falling back to the 401 retry", () => {
expect(isSessionExpired(session(undefined), now)).toBe(false);
});
test("keeps a session that is comfortably in the future", () => {
expect(isSessionExpired(session(now + 60 * 60 * 1000), now)).toBe(false);
});
test("expires a session that lapses within the leeway window", () => {
expect(isSessionExpired(session(now + 30_000), now)).toBe(true);
});
test("expires a session that has already lapsed", () => {
expect(isSessionExpired(session(now - 1), now)).toBe(true);
});
});
const createIntegration = () =>
new BeszelIntegration({
id: "test-beszel-session",
name: "Test Beszel",
url: "http://localhost:8090",
externalUrl: null,
decryptedSecrets: [
{ kind: "username", value: "user@example.com" },
{ kind: "password", value: "password" },
],
});
describe("BeszelIntegration session lifecycle", () => {
const originalFetch = globalThis.fetch;
let authCount = 0;
let issuedToken = "";
beforeEach(() => {
store.clear();
authCount = 0;
globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/collections/users/auth-with-password")) {
authCount++;
return Promise.resolve(Response.json({ token: issuedToken, record: { id: "user-1" } }));
}
if (url.includes("/api/collections/systems/records")) {
return Promise.resolve(Response.json({ page: 1, perPage: 500, totalItems: 0, totalPages: 0, items: [] }));
}
throw new Error(`Unexpected request: ${url}`);
}) as typeof fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
test("reuses a stored session while its token is still valid", async () => {
issuedToken = createToken(Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60);
const integration = createIntegration();
await integration.getSystemsAsync();
await integration.getSystemsAsync();
expect(authCount).toBe(1);
});
test("persists the session with a ttl matching the token lifetime", async () => {
const lifetimeSeconds = 7 * 24 * 60 * 60;
issuedToken = createToken(Math.floor(Date.now() / 1000) + lifetimeSeconds);
await createIntegration().getSystemsAsync();
const stored = store.get("session-store:test-beszel-session");
expect(stored?.ttlSeconds).toBeGreaterThan(lifetimeSeconds - 60);
expect(stored?.ttlSeconds).toBeLessThanOrEqual(lifetimeSeconds);
});
test("re-authenticates once the stored token has expired", async () => {
// Beszel replies 200 + empty list to an expired token instead of 401, so without an expiry
// check the stale session would be reused forever. See homarr-labs/homarr#6470.
vi.useFakeTimers();
try {
const lifetimeSeconds = 7 * 24 * 60 * 60;
const issuedAt = new Date("2026-07-26T00:00:00.000Z");
vi.setSystemTime(issuedAt);
issuedToken = createToken(Math.floor(issuedAt.getTime() / 1000) + lifetimeSeconds);
const integration = createIntegration();
await integration.getSystemsAsync();
expect(authCount).toBe(1);
// Step past the token's lifetime, exactly as an idle Homarr instance would.
vi.setSystemTime(new Date(issuedAt.getTime() + (lifetimeSeconds + 1) * 1000));
issuedToken = createToken(Math.floor(Date.now() / 1000) + lifetimeSeconds);
await integration.getSystemsAsync();
expect(authCount).toBe(2);
} finally {
vi.useRealTimers();
}
});
test("uses a short-lived token that is valid now but lapses inside the leeway window", async () => {
// The leeway exists to stop a token expiring mid-request; it must not reject a token that is
// still valid, or an instance with a short authToken.duration could never authenticate.
issuedToken = createToken(Math.floor(Date.now() / 1000) + 30);
await expect(createIntegration().getSystemsAsync()).resolves.toEqual([]);
expect(authCount).toBe(1);
expect(store.get("session-store:test-beszel-session")?.ttlSeconds).toBeGreaterThan(0);
});
test("fails loudly when Beszel issues an already-expired token instead of returning empty data", async () => {
issuedToken = createToken(Math.floor(Date.now() / 1000) - 600);
const caught = await createIntegration()
.getSystemsAsync()
.then(
() => null,
(error: unknown) => error,
);
// The integration base class wraps thrown errors, so assert against the cause chain.
const messages: string[] = [];
for (let error: unknown = caught; error instanceof Error; error = error.cause) messages.push(error.message);
expect(messages.join(" | ")).toMatch(/clock skew/i);
// The dead token must not be persisted, or it would be served to the next caller.
expect(store.has("session-store:test-beszel-session")).toBe(false);
expect(authCount).toBe(1);
});
test("does not store a ttl when the token carries no readable expiry", async () => {
issuedToken = "opaque-token-without-claims";
await createIntegration().getSystemsAsync();
expect(store.get("session-store:test-beszel-session")?.ttlSeconds).toBeUndefined();
});
});
@@ -117,6 +117,12 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: {
state: null,
positionMs: null,
durationMs: null,
},
location: null,
metadata: null,
};
}
@@ -1,10 +1,14 @@
import {
AssetMediaSize,
AssetTypeEnum,
AssetVisibility,
getAlbumInfo,
getAllAlbums,
getMyUser,
getServerStatistics,
init,
searchAssets,
searchRandom,
searchUsers,
} from "@immich/sdk";
import type { AssetResponseDto, MetadataSearchDto } from "@immich/sdk";
@@ -42,6 +46,7 @@ export interface ImmichAsset {
}
const logger = createLogger({ module: "immich-integration" });
const randomPhotoCount = 50;
export class ImmichIntegration extends Integration {
constructor(integration: IntegrationInput) {
@@ -62,36 +67,36 @@ export class ImmichIntegration extends Integration {
};
}
public async getAlbumAsync(albumId: string): Promise<ImmichAlbum> {
public async getAlbumAsync(albumId?: string): Promise<ImmichAlbum> {
this.initClient();
const requestOptions = this.getRequestOptions();
if (!albumId) {
const assets = await searchRandom(
{
randomSearchDto: {
size: randomPhotoCount,
type: AssetTypeEnum.Image,
visibility: AssetVisibility.Timeline,
},
},
requestOptions,
);
return {
id: "",
albumName: "",
assets: await this.createAssetsAsync(assets),
};
}
const [album, albumAssets] = await Promise.all([
getAlbumInfo({ id: albumId }, requestOptions),
this.searchAlbumAssetsAsync(albumId),
]);
const imageProxy = new ImageProxy();
return {
albumName: album.albumName,
assets: await Promise.all(
albumAssets.map(async (asset) => {
const publicLink = await imageProxy.createImageAsync(
this.url(`/api/assets/${asset.id}/original`).toString(),
{
"x-api-key": this.getSecretValue("apiKey"),
},
);
return {
id: asset.id,
type: asset.type,
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
originalPath: asset.originalPath,
updatedAt: asset.updatedAt,
publicLink,
};
}),
),
assets: await this.createAssetsAsync(albumAssets),
id: album.id,
};
}
@@ -133,6 +138,32 @@ export class ImmichIntegration extends Integration {
return assets;
}
private async createAssetsAsync(assets: AssetResponseDto[]): Promise<ImmichAsset[]> {
const imageProxy = new ImageProxy();
return await Promise.all(
assets.map(async (asset) => {
const publicLink = await imageProxy.createImageAsync(
// This is the URL generated by the SDK's viewAsset call. Preview images are
// browser-compatible while avoiding the large original asset download.
this.url(`/api/assets/${asset.id}/thumbnail`, { size: AssetMediaSize.Preview }).toString(),
{
"x-api-key": this.getSecretValue("apiKey"),
},
);
return {
id: asset.id,
type: asset.type,
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
originalPath: asset.originalPath,
updatedAt: asset.updatedAt,
publicLink,
};
}),
);
}
private getRequestOptions(fetchAsync = fetchWithTrustedCertificatesAsync) {
return {
// Undici and node types are not the same
@@ -13,6 +13,12 @@ export interface StreamSession {
episodeName?: string | null;
albumName?: string | null;
episodeCount?: number | null;
playback: {
state: "playing" | "paused" | "buffering" | null;
positionMs: number | null;
durationMs: number | null;
};
location: "lan" | "wan" | null;
metadata: {
video: {
resolution: {
@@ -17,6 +17,10 @@ import type { IMediaServerIntegration } from "../interfaces/media-server/media-s
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
import type { IMediaReleasesIntegration, MediaRelease, MediaType } from "../types";
function ticksToMs(ticks: number | null | undefined): number | null {
return ticks ? Math.round(ticks / 10_000) : null;
}
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
private readonly jellyfin: Jellyfin = new Jellyfin({
@@ -50,6 +54,9 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
if (sessionInfo.NowPlayingItem) {
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
currentlyPlaying = {
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
@@ -57,6 +64,12 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: {
state: sessionInfo.PlayState?.IsPaused ? "paused" : "playing",
positionMs,
durationMs,
},
location: null,
metadata: {
video: {
resolution:
@@ -13,6 +13,8 @@ const sessions: StreamSession[] = [
episodeName: null,
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -27,6 +29,8 @@ const sessions: StreamSession[] = [
episodeName: "Ozymandias",
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -41,6 +45,8 @@ const sessions: StreamSession[] = [
episodeName: null,
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -55,6 +61,8 @@ const sessions: StreamSession[] = [
episodeName: "The We We Are",
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -69,6 +77,8 @@ const sessions: StreamSession[] = [
episodeName: null,
albumName: "Random Access Memories",
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -83,6 +93,8 @@ const sessions: StreamSession[] = [
episodeName: null,
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -153,9 +153,9 @@ export class OverseerrIntegration
);
} else if (pendingResults.length > 0) requests = pendingResults;
else if (allResults.length > 0) requests = allResults;
else return Promise.all([]);
else return [];
return await Promise.all(
const settled = await Promise.allSettled(
requests.map(async (request): Promise<MediaRequest> => {
const information = await this.getItemInformationAsync(request.media.tmdbId, request.type);
@@ -184,6 +184,10 @@ export class OverseerrIntegration
};
}),
);
return settled
.filter((result): result is PromiseFulfilledResult<MediaRequest> => result.status === "fulfilled")
.map((result) => result.value);
}
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
@@ -301,6 +305,10 @@ export class OverseerrIntegration
},
});
if (!response.ok) {
throw new Error(`Failed to fetch ${type} information for id ${id}: ${response.status} ${response.statusText}`);
}
if (type === "tv") {
const series = (await response.json()) as TvInformation;
return {
@@ -3,6 +3,54 @@ interface MediaContainer {
Track?: Session[];
}
interface Stream {
$: {
streamType: string;
codec?: string;
language?: string;
selected?: string;
width?: string;
height?: string;
frameRate?: string;
channels?: string;
};
}
interface Part {
Stream?: Stream[];
$: {
container?: string;
};
}
interface Media {
Part?: Part[];
$: {
videoResolution?: string;
videoCodec?: string;
audioCodec?: string;
bitrate?: string;
container?: string;
};
}
interface TranscodeSession {
$: {
throttled?: string;
progress?: string;
speed?: string;
videoDecision?: string;
audioDecision?: string;
sourceVideoCodec?: string;
sourceAudioCodec?: string;
videoCodec?: string;
audioCodec?: string;
container?: string;
width?: string;
height?: string;
};
}
interface Session {
User?: {
$: {
@@ -15,13 +63,20 @@ interface Session {
$: {
product: string;
title: string;
address?: string;
state?: string;
local?: string;
};
}[];
Session?: {
$: {
id: string;
bandwidth?: string;
location?: string;
};
}[];
TranscodeSession?: TranscodeSession[];
Media?: Media[];
$: {
grandparentTitle?: string;
parentTitle?: string;
@@ -29,6 +84,8 @@ interface Session {
index?: number;
type: string;
live?: string;
viewOffset?: string;
duration?: string;
};
}
@@ -17,6 +17,32 @@ import type { PlexResponse } from "./interface";
const logger = createLogger({ module: "plexIntegration" });
function parseOptionalNumber(value: string | undefined): number | null {
if (!value) {
return null;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? null : parsed;
}
function parseLocation(value: string | undefined): "lan" | "wan" | null {
return value === "lan" || value === "wan" ? value : null;
}
function parsePlaybackState(value: string | undefined): "playing" | "paused" | "buffering" | null {
return value === "playing" || value === "paused" || value === "buffering" ? value : null;
}
function parseResolution(
width: string | undefined,
height: string | undefined,
): { width: number; height: number } | null {
const parsedWidth = parseOptionalNumber(width);
const parsedHeight = parseOptionalNumber(height);
return parsedWidth !== null && parsedHeight !== null ? { width: parsedWidth, height: parsedHeight } : null;
}
export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
const token = super.getSecretValue("apiKey");
@@ -43,11 +69,24 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
const userElement = mediaElement.User ? mediaElement.User[0] : undefined;
const playerElement = mediaElement.Player ? mediaElement.Player[0] : undefined;
const sessionElement = mediaElement.Session ? mediaElement.Session[0] : undefined;
const transcodeElement = mediaElement.TranscodeSession ? mediaElement.TranscodeSession[0] : undefined;
const mediaInfoElement = mediaElement.Media ? mediaElement.Media[0] : undefined;
if (!playerElement) {
return undefined;
}
const positionMs = parseOptionalNumber(mediaElement.$.viewOffset);
const durationMs = parseOptionalNumber(mediaElement.$.duration);
const playbackState = parsePlaybackState(playerElement.$.state);
const location = parseLocation(sessionElement?.$.location);
const streams = mediaInfoElement?.Part?.[0]?.Stream ?? [];
const videoStream = streams.find((stream) => stream.$.streamType === "1");
const audioStream = streams.find((stream) => stream.$.streamType === "2");
return {
sessionId: sessionElement?.$.id ?? "unknown",
sessionName: `${playerElement.$.product} (${playerElement.$.title})`,
@@ -63,7 +102,30 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
episodeName: mediaElement.$.title ?? null,
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
episodeCount: mediaElement.$.index ?? null,
metadata: null,
playback: {
state: playbackState,
positionMs,
durationMs,
},
location,
metadata: {
video: {
resolution: parseResolution(videoStream?.$.width, videoStream?.$.height),
frameRate: parseOptionalNumber(videoStream?.$.frameRate),
},
audio: {
channelCount: parseOptionalNumber(audioStream?.$.channels),
codec: audioStream?.$.codec ?? mediaInfoElement?.$.audioCodec ?? null,
},
transcoding: {
container: transcodeElement?.$.container ?? null,
resolution: parseResolution(transcodeElement?.$.width, transcodeElement?.$.height),
target: {
audioCodec: transcodeElement?.$.audioCodec ?? null,
videoCodec: transcodeElement?.$.videoCodec ?? null,
},
},
},
},
};
})
@@ -23,6 +23,8 @@ type OptionMapping = {
const optionMapping: OptionMapping = {
"mediaRequests-requestList": {
linksTargetNewTab: (oldOptions) => oldOptions.openInNewTab,
statusFilter: () => undefined,
recentDays: () => undefined,
},
"mediaRequests-requestStats": {},
bookmarks: {
@@ -63,6 +65,9 @@ const optionMapping: OptionMapping = {
useCustomTimezone: () => true,
customTimeFormat: () => undefined,
customDateFormat: () => undefined,
showWeather: () => undefined,
weatherLocation: () => undefined,
isWeatherFormatFahrenheit: () => undefined,
},
downloads: {
activeTorrentThreshold: (oldOptions) =>
+4 -17
View File
@@ -300,10 +300,7 @@ async function getContainersWithStatsAsync() {
const stats = await instance
.getContainer(container.Id)
// Not in one-shot mode: the daemon then includes precpu_stats (the
// previous reading), which is required to compute the current CPU usage
// as a delta. One-shot omits it and would only expose lifetime totals.
.stats({ stream: false })
.stats({ stream: false, "one-shot": true })
.catch(
() =>
({
@@ -339,22 +336,12 @@ export function calculateCpuUsage(stats: ContainerStats): number {
}
const numberOfCpus = stats.cpu_stats.online_cpus;
// Docker's own formula for the current (instantaneous) CPU usage: it compares
// the latest reading against the previous one (precpu_stats). Dividing the
// cumulative total_usage by the cumulative system_cpu_usage instead would
// report the container's lifetime average, which stays high long after a load
// spike has ended. precpu_stats is only present when the stats are NOT fetched
// in one-shot mode; when it is absent (e.g. Podman) the deltas fall back to the
// cumulative totals, matching the previous behaviour.
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage ?? 0);
const systemDelta = (stats.cpu_stats.system_cpu_usage ?? 0) - (stats.precpu_stats?.system_cpu_usage ?? 0);
if (systemDelta <= 0 || cpuDelta < 0) {
const usage = stats.cpu_stats.system_cpu_usage;
if (!usage || usage === 0) {
return 0;
}
return (cpuDelta / systemDelta) * numberOfCpus * 100;
return (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100;
}
export function calculateMemoryUsage(stats: ContainerStats): number {
+1 -1
View File
@@ -33,7 +33,7 @@ export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
export const immichAlbumRequestHandler = createIntegrationRequestHandler<
ImmichAlbum,
IntegrationKindByCategory<"photoService">,
{ albumId: string }
{ albumId?: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
@@ -13,6 +13,7 @@ type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
}
export const createIntegrationRequestHandler = <
@@ -32,6 +33,7 @@ export const createIntegrationRequestHandler = <
return options.requestAsync(integration, input.options);
},
cacheTtlMs: options.cacheTtlMs,
fallbackToStaleOnError: options.fallbackToStaleOnError,
});
return {
@@ -1,6 +1,7 @@
interface Options<TData, TInput extends Record<string, unknown>> {
requestAsync: (input: TInput) => Promise<TData>;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
}
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
@@ -35,7 +36,6 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
if (cached && Date.now() < cached.expiresAt) {
return { data: cached.data, timestamp: cached.timestamp };
}
if (cached) cache.delete(key);
const existing = inflight.get(key);
if (existing) return existing;
@@ -55,6 +55,9 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
})
.catch((err) => {
inflight.delete(key);
if (options.fallbackToStaleOnError && cached) {
return cached;
}
throw err;
});
@@ -1,16 +1,44 @@
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { MediaRequest } from "@homarr/integrations/types";
import type { MediaRequest, MediaRequestStatus } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
const mediaRequestStatusValues = [
"pending",
"approved",
"declined",
"failed",
"completed",
] as const satisfies readonly MediaRequestStatus[];
const MS_PER_DAY = 24 * 60 * 60 * 1000;
export const mediaRequestListInputSchema = z.object({
statuses: z.array(z.enum(mediaRequestStatusValues)).nonempty(),
recentDays: z.number().min(0).max(365),
});
export type MediaRequestListInput = z.infer<typeof mediaRequestListInputSchema>;
export const mediaRequestListRequestHandler = createIntegrationRequestHandler<
MediaRequest[],
IntegrationKindByCategory<"mediaRequest">,
Record<string, never>
MediaRequestListInput
>({
async requestAsync(integration, _input) {
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getRequestsAsync();
const requests = await integrationInstance.getRequestsAsync();
const cutoff = input.recentDays > 0 ? Date.now() - input.recentDays * MS_PER_DAY : null;
const statusSet = new Set<MediaRequestStatus>(input.statuses);
return requests.filter((request) => {
if (!statusSet.has(request.status)) return false;
if (cutoff !== null && request.createdAt.getTime() < cutoff) return false;
return true;
});
},
cacheTtlMs: 60_000,
fallbackToStaleOnError: true,
});
@@ -16,4 +16,6 @@ export const mediaRequestStatsRequestHandler = createIntegrationRequestHandler<
users: await integrationInstance.getUsersAsync(),
};
},
cacheTtlMs: 60_000,
fallbackToStaleOnError: true,
});
@@ -134,7 +134,33 @@ const getGithubApi = (baseUrl: string, userAgent: string, token?: string) =>
baseUrl,
auth: token,
request: { fetch: fetchWithTrustedCertificatesAsync },
throttle: { enabled: true },
throttle: {
enabled: true,
onRateLimit: (
retryAfter: number,
options: { url?: string; method?: string },
_octokit: unknown,
retryCount: number,
) => {
logger.warn(`GitHub rate limit exceeded, retrying after ${retryAfter}s (attempt ${retryCount + 1})`, {
url: options.url,
method: options.method,
});
return retryCount < 1;
},
onSecondaryRateLimit: (
retryAfter: number,
options: { url?: string; method?: string },
_octokit: unknown,
retryCount: number,
) => {
logger.warn(`GitHub secondary rate limit exceeded, retrying after ${retryAfter}s (attempt ${retryCount + 1})`, {
url: options.url,
method: options.method,
});
return retryCount < 1;
},
},
userAgent,
});
@@ -719,6 +745,19 @@ const linuxServerIOReleasesSchema = z.object({
}),
});
// Parse a github.com project URL (as returned by the LSIO registry) into an
// "owner/repo" identifier, or null if it is not a github.com repository URL.
const parseGithubRepo = (url: string): string | null => {
try {
const parsed = new URL(url);
if (parsed.hostname !== "github.com") return null;
const [owner, repo] = parsed.pathname.replace(/^\/+/, "").split("/");
return owner && repo ? `${owner}/${repo}` : null;
} catch {
return null;
}
};
const getLinuxServerIOReleaseAsync = async (
baseUrl: string,
identifier: string,
@@ -756,27 +795,50 @@ const getLinuxServerIOReleaseAsync = async (
};
}
if (regex && !regex.test(release.version)) {
if (!regex || regex.test(release.version)) {
return {
success: false,
error: {
code: "noMatchingVersion",
message: `Regex /${versionRegex}/ does not match LSIO version "${release.version}"`,
success: true,
data: {
latestRelease: release.version,
latestReleaseAt: release.version_timestamp,
releaseDescription: release.changelog?.shift()?.desc,
projectUrl: release.github_url,
projectDescription: release.description,
isArchived: release.deprecated,
createdAt: release.initial_date,
starsCount: release.stars,
},
};
}
// LSIO's /api/v1/images only exposes the single latest version, so a filter
// that does not match it cannot surface an older release (#4351). Delegate to
// the image's GitHub repository to fetch the real release history while
// keeping LSIO's own image metadata.
const githubRepo = parseGithubRepo(release.github_url);
if (githubRepo) {
const githubResult = await getGithubReleaseAsync(getReleaseProviderDefaultUrl("github"), githubRepo, versionRegex);
if (githubResult.success) {
return {
success: true,
data: {
...githubResult.data,
projectUrl: release.github_url,
projectDescription: release.description,
isArchived: release.deprecated,
starsCount: release.stars,
createdAt: release.initial_date ?? githubResult.data.createdAt,
},
};
}
return githubResult;
}
return {
success: true,
data: {
latestRelease: release.version,
latestReleaseAt: release.version_timestamp,
releaseDescription: release.changelog?.shift()?.desc,
projectUrl: release.github_url,
projectDescription: release.description,
isArchived: release.deprecated,
createdAt: release.initial_date,
starsCount: release.stars,
success: false,
error: {
code: "noMatchingVersion",
message: `Regex /${versionRegex}/ does not match LSIO version "${release.version}"`,
},
};
};
@@ -75,26 +75,6 @@ describe("calculateCpuUsage", () => {
// (500 / 100000) * 2 * 100 = 1
expect(calculateCpuUsage(stats)).toBe(1);
});
test("should use the delta against precpu_stats for the current usage", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_002_000 }, system_cpu_usage: 20_000 },
precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 },
});
// Despite a large lifetime total_usage, only the recent delta counts:
// (2000 / 10000) * 4 * 100 = 80
expect(calculateCpuUsage(stats)).toBe(80);
});
test("should report 0 for a container that was busy over its lifetime but is idle now", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 20_000 },
precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 },
});
// No CPU delta since the last reading -> current usage is 0, even though the
// lifetime average would be high. This is the regression the fix addresses.
expect(calculateCpuUsage(stats)).toBe(0);
});
});
describe("calculateMemoryUsage", () => {
@@ -126,6 +126,128 @@ describe("getLatestMatchingReleaseAsync for GitHub Container Registry", () => {
});
});
describe("getLatestMatchingReleaseAsync for LinuxServer.io", () => {
beforeEach(() => {
mockedFetch.mockReset();
});
test("uses the LinuxServer.io latest release without fetching GitHub history", async () => {
mockedFetch.mockResolvedValueOnce(createLinuxServerResponse());
const result = await getLatestMatchingReleaseAsync({
id: "tautulli",
provider: "linuxServerIO",
identifier: "lscr.io/linuxserver/tautulli:latest",
});
expect(result).toEqual({
success: true,
data: {
latestRelease: "v2.17.2-ls237",
latestReleaseAt: new Date("2026-07-17T00:25:42.000Z"),
releaseDescription: "Rebase to Alpine 3.24.",
projectUrl: "https://github.com/linuxserver/docker-tautulli",
projectDescription: "Tautulli image",
isArchived: false,
createdAt: new Date("2015-08-10T00:00:00.000Z"),
starsCount: 934,
},
});
expect(mockedFetch).toHaveBeenCalledOnce();
expectUrl(0, "https://api.linuxserver.io/api/v1/images");
});
test("fetches GitHub history when a version filter requires it", async () => {
mockedFetch.mockImplementation(async (input) => {
const url = new URL(input.toString());
if (url.origin === "https://api.linuxserver.io") return createLinuxServerResponse();
if (url.pathname === "/repos/linuxserver/docker-tautulli/releases") {
return createJsonResponse([
{
tag_name: "v2.16.0-ls200",
published_at: "2026-01-10T12:00:00.000Z",
html_url: "https://github.com/linuxserver/docker-tautulli/releases/tag/v2.16.0-ls200",
body: "Older supported image",
prerelease: false,
},
]);
}
if (url.pathname === "/repos/linuxserver/docker-tautulli") {
return createJsonResponse({
html_url: "https://github.com/linuxserver/docker-tautulli",
description: "GitHub description",
fork: false,
archived: false,
created_at: "2015-08-10T00:00:00.000Z",
stargazers_count: 934,
open_issues_count: 2,
forks_count: 80,
});
}
return createJsonResponse({ message: "Not Found" }, { status: 404, statusText: "Not Found" });
});
const result = await getLatestMatchingReleaseAsync({
id: "tautulli",
provider: "linuxServerIO",
identifier: "linuxserver/tautulli",
versionRegex: "^v2\\.16\\..+-ls[0-9]+$",
});
expect(result).toEqual({
success: true,
data: expect.objectContaining({
latestRelease: "v2.16.0-ls200",
projectUrl: "https://github.com/linuxserver/docker-tautulli",
projectDescription: "Tautulli image",
}),
});
expect(mockedFetch).toHaveBeenCalledTimes(3);
});
test("uses the LinuxServer.io latest release when it matches the version filter", async () => {
mockedFetch.mockResolvedValueOnce(createLinuxServerResponse());
const result = await getLatestMatchingReleaseAsync({
id: "tautulli",
provider: "linuxServerIO",
identifier: "linuxserver/tautulli",
versionRegex: "^v2\\.17\\..+-ls[0-9]+$",
});
expect(result).toEqual({
success: true,
data: expect.objectContaining({ latestRelease: "v2.17.2-ls237" }),
});
expect(mockedFetch).toHaveBeenCalledOnce();
expectUrl(0, "https://api.linuxserver.io/api/v1/images");
});
test("returns the GitHub error when filtered release history cannot be fetched", async () => {
mockedFetch.mockImplementation(async (input) => {
const url = new URL(input.toString());
if (url.origin === "https://api.linuxserver.io") return createLinuxServerResponse();
return createJsonResponse({ message: "GitHub unavailable" }, { status: 404, statusText: "Not Found" });
});
const result = await getLatestMatchingReleaseAsync({
id: "tautulli",
provider: "linuxServerIO",
identifier: "linuxserver/tautulli",
versionRegex: "^v2\\.16\\..+-ls[0-9]+$",
});
expect(result).toEqual({
success: false,
error: expect.objectContaining({ code: "unexpected", message: expect.stringContaining("GitHub unavailable") }),
});
expect(mockedFetch.mock.calls.length).toBeGreaterThan(1);
});
});
interface MockGhcrResponsesInput {
registryOrigin?: string;
repositoryName: string;
@@ -185,6 +307,27 @@ const createJsonResponse = (body: unknown, init: JsonResponseInit = {}) =>
},
});
const createLinuxServerResponse = () =>
createJsonResponse({
data: {
repositories: {
linuxserver: [
{
name: "tautulli",
initial_date: "2015-08-10",
github_url: "https://github.com/linuxserver/docker-tautulli",
description: "Tautulli image",
version: "v2.17.2-ls237",
version_timestamp: "2026-07-17 00:25:42+00:00",
stars: 934,
deprecated: false,
changelog: [{ date: "2026-07-05", desc: "Rebase to Alpine 3.24." }],
},
],
},
},
});
const expectUrl = (callIndex: number, expectedUrl: string) => {
expect(mockedFetch.mock.calls[callIndex]?.[0].toString().split("?")[0]).toBe(expectedUrl);
};
+4 -1
View File
@@ -9,7 +9,7 @@ export const weatherRequestHandler = createWidgetRequestHandler({
async requestAsync(input: { latitude: number; longitude: number }) {
const res = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max&current_weather=true&timezone=auto`,
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max,relative_humidity_2m_mean&current_weather=true&timezone=auto`,
{ signal },
);
});
@@ -27,6 +27,7 @@ export const weatherRequestHandler = createWidgetRequestHandler({
sunset: weather.daily.sunset[index],
maxWindSpeed: weather.daily.wind_speed_10m_max[index],
maxWindGusts: weather.daily.wind_gusts_10m_max[index],
humidity: weather.daily.relative_humidity_2m_mean[index],
};
}),
} satisfies Weather;
@@ -48,6 +49,7 @@ const atLocationOutput = z.object({
sunset: z.array(z.string()),
wind_speed_10m_max: z.array(z.number()),
wind_gusts_10m_max: z.array(z.number()),
relative_humidity_2m_mean: z.array(z.number()),
}),
});
@@ -66,5 +68,6 @@ export interface Weather {
sunset: string | undefined;
maxWindSpeed: number | undefined;
maxWindGusts: number | undefined;
humidity: number | undefined;
}[];
}
+3
View File
@@ -10,6 +10,7 @@ export type SettingsContextProps = Pick<
| "openSearchInNewTab"
| "ddgBangs"
| "pingIconsEnabled"
| "enableRightClickOnWidgets"
> &
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus"> &
Pick<ServerSettings["user"], "enableGravatar">;
@@ -32,6 +33,7 @@ export type UserSettings = Pick<
| "openSearchInNewTab"
| "ddgBangs"
| "pingIconsEnabled"
| "enableRightClickOnWidgets"
>;
export const createSettings = ({
@@ -48,6 +50,7 @@ export const createSettings = ({
homeBoardId: user?.homeBoardId ?? serverSettings.board.homeBoardId,
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
pingIconsEnabled: user?.pingIconsEnabled ?? false,
enableRightClickOnWidgets: user?.enableRightClickOnWidgets ?? true,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
enableGravatar: serverSettings.user.enableGravatar,
@@ -56,6 +56,13 @@ export const userPreferenceDefinitions = [
mutationGroup: "pingIconsEnabled",
aliases: ["ping", "icon", "status"],
},
{
key: "enableRightClickOnWidgets",
kind: "boolean",
guest: false,
mutationGroup: "enableRightClickOnWidgets",
aliases: ["right click", "context menu", "tile", "widget menu"],
},
{
key: "fullPreferencesPage",
kind: "link",
@@ -17,6 +17,10 @@ export const appIntegrationBoardMode = {
return groups;
}
return groups.concat([appsSearchGroup, integrationsSearchGroup]);
return groups.concat(
session.user.permissions.includes("board-modify-all")
? [appsSearchGroup, integrationsSearchGroup]
: [integrationsSearchGroup],
);
},
} satisfies SearchMode;
@@ -87,7 +87,8 @@ export const globalCommandGroup = createGroup<Command>({
icon: IconBox,
name: tOption("newApp.label"),
useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
hidden: !session?.user.permissions.includes("app-create"),
hidden:
!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create"),
},
{
commandKey: "newIntegration",
@@ -76,7 +76,7 @@ export const pagesSearchGroup = createGroup<{
icon: IconBox,
path: "/manage/apps",
name: t("manageApp.label"),
hidden: !session,
hidden: !session?.user.permissions.includes("board-modify-all"),
},
{
icon: IconPlug,
@@ -3,6 +3,7 @@
import {
IconActivity,
IconCalendarWeek,
IconClick,
IconDeviceDesktop,
IconExternalLink,
IconHome,
@@ -46,6 +47,7 @@ export const preferenceIcons: Record<UserPreferenceKey, TablerIcon> = {
homeBoardId: IconHome,
mobileHomeBoardId: IconLayoutBoard,
pingIconsEnabled: IconActivity,
enableRightClickOnWidgets: IconClick,
fullPreferencesPage: IconSettings,
};
@@ -93,6 +93,7 @@ export const useUserPreferences = () => {
const homeBoardsMutation = clientApi.user.changeHomeBoards.useMutation({ onSuccess: refresh });
const firstDayMutation = clientApi.user.changeFirstDayOfWeek.useMutation({ onSuccess: refresh });
const pingMutation = clientApi.user.changePingIconsEnabled.useMutation({ onSuccess: refresh });
const rightClickMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation({ onSuccess: refresh });
const getEffective = (key: UserPreferenceKey): unknown =>
key in optimisticRef.current ? optimisticRef.current[key] : settingsRef.current[key as keyof SettingsContextProps];
@@ -116,6 +117,8 @@ export const useUserPreferences = () => {
}),
firstDayOfWeek: (uid, _key, value) => firstDayMutation.mutateAsync({ id: uid, firstDayOfWeek: value as DayOfWeek }),
pingIconsEnabled: (uid, _key, value) => pingMutation.mutateAsync({ id: uid, pingIconsEnabled: value as boolean }),
enableRightClickOnWidgets: (uid, _key, value) =>
rightClickMutation.mutateAsync({ id: uid, enableRightClickOnWidgets: value as boolean }),
};
const groupPending: Record<string, boolean> = {
@@ -123,6 +126,7 @@ export const useUserPreferences = () => {
homeBoards: homeBoardsMutation.isPending,
firstDayOfWeek: firstDayMutation.isPending,
pingIconsEnabled: pingMutation.isPending,
enableRightClickOnWidgets: rightClickMutation.isPending,
};
const persistedValues = useMemo<Record<UserPreferenceKey, unknown>>(() => {
+327 -3
View File
@@ -198,6 +198,10 @@
"pingIconsEnabled": {
"label": "Utilitza icones pels pings"
},
"enableRightClickOnWidgets": {
"label": "",
"description": ""
},
"defaultSearchEngine": {
"label": "Motor de cerca principal"
},
@@ -1285,6 +1289,14 @@
"label": "",
"newLabel": ""
},
"patchmonApiKey": {
"label": "",
"newLabel": ""
},
"patchmonApiSecret": {
"label": "",
"newLabel": ""
},
"githubAppId": {
"label": "",
"newLabel": ""
@@ -1328,9 +1340,21 @@
}
}
},
"traefik": {
"name": "",
"description": ""
},
"paperlessNgx": {
"name": "",
"description": ""
},
"patchmon": {
"name": "",
"description": ""
},
"bazarr": {
"name": "",
"description": ""
}
},
"media": {
@@ -1602,6 +1626,12 @@
"refresh": "",
"options": "",
"actions": ""
},
"status": {
"success": "",
"error": "",
"loading": "",
"idle": ""
}
},
"create": {
@@ -1875,6 +1905,16 @@
"customDateFormat": {
"label": "",
"description": ""
},
"showWeather": {
"label": "",
"description": ""
},
"weatherLocation": {
"label": ""
},
"isWeatherFormatFahrenheit": {
"label": ""
}
}
},
@@ -1936,6 +1976,8 @@
"clear": "",
"heading": "",
"align": "",
"directionLtr": "",
"directionRtl": "",
"blockquote": "",
"horizontalLine": "",
"bulletList": "",
@@ -2225,7 +2267,8 @@
"sunrise": "",
"sunset": "",
"maxWindSpeed": "",
"maxWindGusts": ""
"maxWindGusts": "",
"humidity": ""
},
"kind": {
"clear": "",
@@ -2689,6 +2732,11 @@
"user": "",
"name": "",
"id": "",
"status": "",
"directPlay": "",
"transcoding": "",
"paused": "",
"remaining": "",
"metadata": {
"title": "",
"video": {
@@ -2754,6 +2802,193 @@
"internalServerError": ""
}
},
"patchmon": {
"name": "",
"description": "",
"title": "",
"totalHosts": "",
"hostsNeedingUpdates": "",
"securityUpdates": "",
"upToDateHosts": "",
"hostsWithSecurityUpdates": "",
"recentUpdates24h": "",
"totalOutdatedPackages": "",
"totalRepos": "",
"osDistribution": "",
"compliance": "",
"complianceRatio": "",
"lastUpdated": "",
"option": {
"showTotalHosts": {
"label": "",
"description": ""
},
"showHostsNeedingUpdates": {
"label": "",
"description": ""
},
"showSecurityUpdates": {
"label": "",
"description": ""
},
"showUpToDateHosts": {
"label": "",
"description": ""
},
"showHostsWithSecurityUpdates": {
"label": "",
"description": ""
},
"showRecentUpdates24h": {
"label": "",
"description": ""
},
"showTotalOutdatedPackages": {
"label": "",
"description": ""
},
"showTotalRepos": {
"label": "",
"description": ""
},
"enableThresholdColors": {
"label": "",
"description": ""
},
"useCustomThresholds": {
"label": "",
"description": ""
},
"thresholdMode": {
"option": {
"absolute": "",
"percent": ""
}
},
"hostsNeedingUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesCriticalAt": {
"label": "",
"description": ""
},
"securityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"securityUpdatesWarningAt": {
"label": "",
"description": ""
},
"securityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"upToDateHostsThresholdMode": {
"label": "",
"description": ""
},
"upToDateHostsWarningAt": {
"label": "",
"description": ""
},
"upToDateHostsCriticalAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesThresholdMode": {
"label": "",
"description": ""
},
"totalOutdatedPackagesWarningAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesCriticalAt": {
"label": "",
"description": ""
},
"showOsDistribution": {
"label": "",
"description": ""
},
"osDistributionLimit": {
"label": "",
"description": "",
"option": {
"3": "",
"5": "",
"10": "",
"all": ""
}
},
"showOsVersion": {
"label": "",
"description": ""
},
"showComplianceHero": {
"label": "",
"description": ""
},
"osDisplayMode": {
"label": "",
"description": "",
"option": {
"bars": "",
"donut": ""
}
}
},
"error": {
"internalServerError": ""
}
},
"bazarr": {
"name": "",
"description": "",
"episodes": "",
"movies": "",
"providers": "",
"status": "",
"option": {
"showMissingEpisodes": {
"label": "",
"description": ""
},
"showMissingMovies": {
"label": "",
"description": ""
},
"showProviderIssues": {
"label": "",
"description": ""
},
"showHealthIssues": {
"label": "",
"description": ""
}
},
"error": {
"internalServerError": ""
}
},
"immich-serverStats": {
"name": "",
"description": "",
@@ -2782,6 +3017,7 @@
},
"immich-albumCarousel": {
"name": "",
"allPhotos": "",
"option": {
"albumId": {
"label": "",
@@ -2801,7 +3037,6 @@
}
},
"description": "",
"noAlbumSelected": "",
"noPhotos": ""
},
"downloads": {
@@ -2986,6 +3221,12 @@
"option": {
"linksTargetNewTab": {
"label": ""
},
"statusFilter": {
"label": ""
},
"recentDays": {
"label": ""
}
},
"pending": {
@@ -3093,6 +3334,35 @@
}
}
},
"mediaMissing": {
"name": "",
"description": "",
"option": {
"showMissing": {
"label": ""
},
"showQueued": {
"label": ""
},
"pageSize": {
"label": ""
}
},
"tab": {
"missing": "",
"queued": ""
},
"type": {
"movie": "",
"episode": ""
},
"empty": {
"missing": "",
"queued": "",
"noTabsEnabled": ""
},
"timeLeft": ""
},
"rssFeed": {
"name": "",
"description": "",
@@ -3336,7 +3606,11 @@
"name": "",
"description": "",
"noItems": "",
"option": {}
"option": {
"hideLogos": {
"label": ""
}
}
},
"speedtestTracker": {
"name": "",
@@ -3417,6 +3691,40 @@
"internalServerError": ""
}
},
"traefik": {
"name": "",
"description": "",
"option": {
"showTcp": {
"label": ""
},
"showUdp": {
"label": ""
},
"showEntryPoints": {
"label": ""
}
},
"instances": "",
"versionUnknown": "",
"summary": {
"routers": "",
"entryPoints": ""
},
"resource": {
"routers": "",
"services": "",
"middlewares": ""
},
"status": {
"healthy": "",
"errors": "",
"warnings": ""
},
"error": {
"internalServerError": ""
}
},
"audioStats": {
"name": "",
"description": "",
@@ -3792,6 +4100,11 @@
"read": "",
"write": ""
},
"storage": {
"title": "",
"subtitle": "",
"series": ""
},
"network": {
"title": "",
"subtitle": "",
@@ -4924,6 +5237,9 @@
"title": "",
"table": {
"updated": "",
"refresh": {
"lastUpdated": ""
},
"search": "",
"selected": "",
"footer": "",
@@ -5652,6 +5968,14 @@
}
}
},
"enableRightClickOnWidgets": {
"label": "",
"children": {
"detail": {
"title": ""
}
}
},
"fullPreferencesPage": {
"label": ""
}
+327 -3
View File
@@ -198,6 +198,10 @@
"pingIconsEnabled": {
"label": "为 ping 使用图标"
},
"enableRightClickOnWidgets": {
"label": "",
"description": ""
},
"defaultSearchEngine": {
"label": "默认搜索引擎"
},
@@ -1285,6 +1289,14 @@
"label": "API 密钥 (机密)",
"newLabel": "新建API密钥(机密)"
},
"patchmonApiKey": {
"label": "",
"newLabel": ""
},
"patchmonApiSecret": {
"label": "",
"newLabel": ""
},
"githubAppId": {
"label": "应用 ID",
"newLabel": "新应用 ID"
@@ -1328,9 +1340,21 @@
}
}
},
"traefik": {
"name": "",
"description": ""
},
"paperlessNgx": {
"name": "",
"description": ""
},
"patchmon": {
"name": "",
"description": ""
},
"bazarr": {
"name": "",
"description": ""
}
},
"media": {
@@ -1602,6 +1626,12 @@
"refresh": "",
"options": "",
"actions": ""
},
"status": {
"success": "",
"error": "",
"loading": "",
"idle": ""
}
},
"create": {
@@ -1875,6 +1905,16 @@
"customDateFormat": {
"label": "自定义日期格式",
"description": "使用 ISO 8601 格式化日期(这将覆盖其他选项)"
},
"showWeather": {
"label": "",
"description": ""
},
"weatherLocation": {
"label": ""
},
"isWeatherFormatFahrenheit": {
"label": ""
}
}
},
@@ -1936,6 +1976,8 @@
"clear": "清除格式",
"heading": "标题 {level}",
"align": "对齐文本: {position}",
"directionLtr": "",
"directionRtl": "",
"blockquote": "引用",
"horizontalLine": "横线",
"bulletList": "符号列表",
@@ -2225,7 +2267,8 @@
"sunrise": "日出",
"sunset": "日落",
"maxWindSpeed": "最大风速:{maxWindSpeed} {unit}",
"maxWindGusts": "最大阵风:{maxWindGusts} {unit}"
"maxWindGusts": "最大阵风:{maxWindGusts} {unit}",
"humidity": ""
},
"kind": {
"clear": "晴朗",
@@ -2689,6 +2732,11 @@
"user": "用户",
"name": "名称",
"id": "ID",
"status": "",
"directPlay": "",
"transcoding": "",
"paused": "",
"remaining": "",
"metadata": {
"title": "技术统计",
"video": {
@@ -2754,6 +2802,193 @@
"internalServerError": ""
}
},
"patchmon": {
"name": "",
"description": "",
"title": "",
"totalHosts": "",
"hostsNeedingUpdates": "",
"securityUpdates": "",
"upToDateHosts": "",
"hostsWithSecurityUpdates": "",
"recentUpdates24h": "",
"totalOutdatedPackages": "",
"totalRepos": "",
"osDistribution": "",
"compliance": "",
"complianceRatio": "",
"lastUpdated": "",
"option": {
"showTotalHosts": {
"label": "",
"description": ""
},
"showHostsNeedingUpdates": {
"label": "",
"description": ""
},
"showSecurityUpdates": {
"label": "",
"description": ""
},
"showUpToDateHosts": {
"label": "",
"description": ""
},
"showHostsWithSecurityUpdates": {
"label": "",
"description": ""
},
"showRecentUpdates24h": {
"label": "",
"description": ""
},
"showTotalOutdatedPackages": {
"label": "",
"description": ""
},
"showTotalRepos": {
"label": "",
"description": ""
},
"enableThresholdColors": {
"label": "",
"description": ""
},
"useCustomThresholds": {
"label": "",
"description": ""
},
"thresholdMode": {
"option": {
"absolute": "",
"percent": ""
}
},
"hostsNeedingUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesCriticalAt": {
"label": "",
"description": ""
},
"securityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"securityUpdatesWarningAt": {
"label": "",
"description": ""
},
"securityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"upToDateHostsThresholdMode": {
"label": "",
"description": ""
},
"upToDateHostsWarningAt": {
"label": "",
"description": ""
},
"upToDateHostsCriticalAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesThresholdMode": {
"label": "",
"description": ""
},
"totalOutdatedPackagesWarningAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesCriticalAt": {
"label": "",
"description": ""
},
"showOsDistribution": {
"label": "",
"description": ""
},
"osDistributionLimit": {
"label": "",
"description": "",
"option": {
"3": "",
"5": "",
"10": "",
"all": ""
}
},
"showOsVersion": {
"label": "",
"description": ""
},
"showComplianceHero": {
"label": "",
"description": ""
},
"osDisplayMode": {
"label": "",
"description": "",
"option": {
"bars": "",
"donut": ""
}
}
},
"error": {
"internalServerError": ""
}
},
"bazarr": {
"name": "",
"description": "",
"episodes": "",
"movies": "",
"providers": "",
"status": "",
"option": {
"showMissingEpisodes": {
"label": "",
"description": ""
},
"showMissingMovies": {
"label": "",
"description": ""
},
"showProviderIssues": {
"label": "",
"description": ""
},
"showHealthIssues": {
"label": "",
"description": ""
}
},
"error": {
"internalServerError": ""
}
},
"immich-serverStats": {
"name": "Immich 服务器统计",
"description": "有关您的 Immich 实例的信息",
@@ -2782,6 +3017,7 @@
},
"immich-albumCarousel": {
"name": "Immich 相册",
"allPhotos": "",
"option": {
"albumId": {
"label": "相册ID",
@@ -2801,7 +3037,6 @@
}
},
"description": "Immich 相册图片幻灯片",
"noAlbumSelected": "未选择相册,请编辑组件并选择一个相册",
"noPhotos": "此相册中没有照片"
},
"downloads": {
@@ -2986,6 +3221,12 @@
"option": {
"linksTargetNewTab": {
"label": "在新标签页中打开链接"
},
"statusFilter": {
"label": ""
},
"recentDays": {
"label": ""
}
},
"pending": {
@@ -3093,6 +3334,35 @@
}
}
},
"mediaMissing": {
"name": "",
"description": "",
"option": {
"showMissing": {
"label": ""
},
"showQueued": {
"label": ""
},
"pageSize": {
"label": ""
}
},
"tab": {
"missing": "",
"queued": ""
},
"type": {
"movie": "",
"episode": ""
},
"empty": {
"missing": "",
"queued": "",
"noTabsEnabled": ""
},
"timeLeft": ""
},
"rssFeed": {
"name": "RSS 订阅",
"description": "监视和显示一个或多个通用 RSS、ATOM 或 JSON 源",
@@ -3336,7 +3606,11 @@
"name": "通知",
"description": "在集成中显示通知历史",
"noItems": "没有可显示的通知。",
"option": {}
"option": {
"hideLogos": {
"label": ""
}
}
},
"speedtestTracker": {
"name": "网速测试追踪器",
@@ -3417,6 +3691,40 @@
"internalServerError": ""
}
},
"traefik": {
"name": "",
"description": "",
"option": {
"showTcp": {
"label": ""
},
"showUdp": {
"label": ""
},
"showEntryPoints": {
"label": ""
}
},
"instances": "",
"versionUnknown": "",
"summary": {
"routers": "",
"entryPoints": ""
},
"resource": {
"routers": "",
"services": "",
"middlewares": ""
},
"status": {
"healthy": "",
"errors": "",
"warnings": ""
},
"error": {
"internalServerError": ""
}
},
"audioStats": {
"name": "",
"description": "",
@@ -3792,6 +4100,11 @@
"read": "",
"write": ""
},
"storage": {
"title": "",
"subtitle": "",
"series": ""
},
"network": {
"title": "",
"subtitle": "",
@@ -4924,6 +5237,9 @@
"title": "容器",
"table": {
"updated": "已更新 {when}",
"refresh": {
"lastUpdated": ""
},
"search": "搜索 {count} 容器",
"selected": "{totalCount} 容器的 {selectCount}",
"footer": "总计 {count} 容器",
@@ -5652,6 +5968,14 @@
}
}
},
"enableRightClickOnWidgets": {
"label": "",
"children": {
"detail": {
"title": ""
}
}
},
"fullPreferencesPage": {
"label": ""
}
+333 -9
View File
@@ -198,6 +198,10 @@
"pingIconsEnabled": {
"label": "crwdns44:0crwdne44:0"
},
"enableRightClickOnWidgets": {
"label": "crwdns6095:0crwdne6095:0",
"description": "crwdns6097:0crwdne6097:0"
},
"defaultSearchEngine": {
"label": "crwdns2694:0crwdne2694:0"
},
@@ -862,7 +866,7 @@
"created": "crwdns5553:0{name}crwdne5553:0",
"updated": "crwdns5555:0{name}crwdne5555:0",
"deleted": "crwdns5557:0{name}crwdne5557:0",
"imported": "crwdns5559:0crwdne5559:0",
"imported": "crwdns6079:0crwdne6079:0",
"aiPromptCopied": "crwdns5561:0crwdne5561:0",
"aiPromptDescription": "crwdns5563:0crwdne5563:0",
"aiPromptCopy": "crwdns5565:0crwdne5565:0",
@@ -1285,6 +1289,14 @@
"label": "crwdns3618:0crwdne3618:0",
"newLabel": "crwdns3620:0crwdne3620:0"
},
"patchmonApiKey": {
"label": "crwdns5859:0crwdne5859:0",
"newLabel": "crwdns5861:0crwdne5861:0"
},
"patchmonApiSecret": {
"label": "crwdns5863:0crwdne5863:0",
"newLabel": "crwdns5865:0crwdne5865:0"
},
"githubAppId": {
"label": "crwdns3676:0crwdne3676:0",
"newLabel": "crwdns3678:0crwdne3678:0"
@@ -1328,9 +1340,21 @@
}
}
},
"traefik": {
"name": "crwdns5753:0crwdne5753:0",
"description": "crwdns5755:0crwdne5755:0"
},
"paperlessNgx": {
"name": "crwdns5003:0crwdne5003:0",
"description": "crwdns5005:0crwdne5005:0"
},
"patchmon": {
"name": "crwdns5867:0crwdne5867:0",
"description": "crwdns5869:0crwdne5869:0"
},
"bazarr": {
"name": "crwdns5799:0crwdne5799:0",
"description": "crwdns5801:0crwdne5801:0"
}
},
"media": {
@@ -1602,6 +1626,12 @@
"refresh": "crwdns5739:0crwdne5739:0",
"options": "crwdns5709:0crwdne5709:0",
"actions": "crwdns5711:0crwdne5711:0"
},
"status": {
"success": "crwdns6049:0crwdne6049:0",
"error": "crwdns6051:0crwdne6051:0",
"loading": "crwdns6053:0crwdne6053:0",
"idle": "crwdns6055:0crwdne6055:0"
}
},
"create": {
@@ -1875,6 +1905,16 @@
"customDateFormat": {
"label": "crwdns2726:0crwdne2726:0",
"description": "crwdns2728:0crwdne2728:0"
},
"showWeather": {
"label": "crwdns6113:0crwdne6113:0",
"description": "crwdns6115:0crwdne6115:0"
},
"weatherLocation": {
"label": "crwdns6117:0crwdne6117:0"
},
"isWeatherFormatFahrenheit": {
"label": "crwdns6119:0crwdne6119:0"
}
}
},
@@ -1936,6 +1976,8 @@
"clear": "crwdns884:0crwdne884:0",
"heading": "crwdns886:0{level}crwdne886:0",
"align": "crwdns888:0{position}crwdne888:0",
"directionLtr": "crwdns6043:0crwdne6043:0",
"directionRtl": "crwdns6045:0crwdne6045:0",
"blockquote": "crwdns890:0crwdne890:0",
"horizontalLine": "crwdns892:0crwdne892:0",
"bulletList": "crwdns894:0crwdne894:0",
@@ -2225,7 +2267,8 @@
"sunrise": "crwdns2712:0crwdne2712:0",
"sunset": "crwdns2714:0crwdne2714:0",
"maxWindSpeed": "crwdns3772:0{maxWindSpeed}crwdnd3772:0{unit}crwdne3772:0",
"maxWindGusts": "crwdns3774:0{maxWindGusts}crwdnd3774:0{unit}crwdne3774:0"
"maxWindGusts": "crwdns3774:0{maxWindGusts}crwdnd3774:0{unit}crwdne3774:0",
"humidity": "crwdns6111:0{humidity}crwdne6111:0"
},
"kind": {
"clear": "crwdns1032:0crwdne1032:0",
@@ -2689,6 +2732,11 @@
"user": "crwdns2476:0crwdne2476:0",
"name": "crwdns2478:0crwdne2478:0",
"id": "crwdns2480:0crwdne2480:0",
"status": "crwdns6085:0crwdne6085:0",
"directPlay": "crwdns6087:0crwdne6087:0",
"transcoding": "crwdns6089:0crwdne6089:0",
"paused": "crwdns6091:0crwdne6091:0",
"remaining": "crwdns6093:0{minutes}crwdne6093:0",
"metadata": {
"title": "crwdns3744:0crwdne3744:0",
"video": {
@@ -2754,6 +2802,193 @@
"internalServerError": "crwdns5055:0crwdne5055:0"
}
},
"patchmon": {
"name": "crwdns5871:0crwdne5871:0",
"description": "crwdns5873:0crwdne5873:0",
"title": "crwdns5875:0crwdne5875:0",
"totalHosts": "crwdns5877:0crwdne5877:0",
"hostsNeedingUpdates": "crwdns5879:0crwdne5879:0",
"securityUpdates": "crwdns5881:0crwdne5881:0",
"upToDateHosts": "crwdns5883:0crwdne5883:0",
"hostsWithSecurityUpdates": "crwdns5885:0crwdne5885:0",
"recentUpdates24h": "crwdns5887:0crwdne5887:0",
"totalOutdatedPackages": "crwdns5889:0crwdne5889:0",
"totalRepos": "crwdns5891:0crwdne5891:0",
"osDistribution": "crwdns5893:0crwdne5893:0",
"compliance": "crwdns5895:0crwdne5895:0",
"complianceRatio": "crwdns5897:0{upToDate}crwdnd5897:0{total}crwdne5897:0",
"lastUpdated": "crwdns5899:0{time}crwdne5899:0",
"option": {
"showTotalHosts": {
"label": "crwdns5901:0crwdne5901:0",
"description": "crwdns5903:0crwdne5903:0"
},
"showHostsNeedingUpdates": {
"label": "crwdns5905:0crwdne5905:0",
"description": "crwdns5907:0crwdne5907:0"
},
"showSecurityUpdates": {
"label": "crwdns5909:0crwdne5909:0",
"description": "crwdns5911:0crwdne5911:0"
},
"showUpToDateHosts": {
"label": "crwdns5913:0crwdne5913:0",
"description": "crwdns5915:0crwdne5915:0"
},
"showHostsWithSecurityUpdates": {
"label": "crwdns5917:0crwdne5917:0",
"description": "crwdns5919:0crwdne5919:0"
},
"showRecentUpdates24h": {
"label": "crwdns5921:0crwdne5921:0",
"description": "crwdns5923:0crwdne5923:0"
},
"showTotalOutdatedPackages": {
"label": "crwdns5925:0crwdne5925:0",
"description": "crwdns5927:0crwdne5927:0"
},
"showTotalRepos": {
"label": "crwdns5929:0crwdne5929:0",
"description": "crwdns5931:0crwdne5931:0"
},
"enableThresholdColors": {
"label": "crwdns5933:0crwdne5933:0",
"description": "crwdns5935:0crwdne5935:0"
},
"useCustomThresholds": {
"label": "crwdns5937:0crwdne5937:0",
"description": "crwdns5939:0crwdne5939:0"
},
"thresholdMode": {
"option": {
"absolute": "crwdns5941:0crwdne5941:0",
"percent": "crwdns5943:0crwdne5943:0"
}
},
"hostsNeedingUpdatesThresholdMode": {
"label": "crwdns5945:0crwdne5945:0",
"description": "crwdns5947:0crwdne5947:0"
},
"hostsNeedingUpdatesWarningAt": {
"label": "crwdns5949:0crwdne5949:0",
"description": "crwdns5951:0crwdne5951:0"
},
"hostsNeedingUpdatesCriticalAt": {
"label": "crwdns5953:0crwdne5953:0",
"description": "crwdns5955:0crwdne5955:0"
},
"securityUpdatesThresholdMode": {
"label": "crwdns5957:0crwdne5957:0",
"description": "crwdns5959:0crwdne5959:0"
},
"securityUpdatesWarningAt": {
"label": "crwdns5961:0crwdne5961:0",
"description": "crwdns5963:0crwdne5963:0"
},
"securityUpdatesCriticalAt": {
"label": "crwdns5965:0crwdne5965:0",
"description": "crwdns5967:0crwdne5967:0"
},
"hostsWithSecurityUpdatesThresholdMode": {
"label": "crwdns5969:0crwdne5969:0",
"description": "crwdns5971:0crwdne5971:0"
},
"hostsWithSecurityUpdatesWarningAt": {
"label": "crwdns5973:0crwdne5973:0",
"description": "crwdns5975:0crwdne5975:0"
},
"hostsWithSecurityUpdatesCriticalAt": {
"label": "crwdns5977:0crwdne5977:0",
"description": "crwdns5979:0crwdne5979:0"
},
"upToDateHostsThresholdMode": {
"label": "crwdns5981:0crwdne5981:0",
"description": "crwdns5983:0crwdne5983:0"
},
"upToDateHostsWarningAt": {
"label": "crwdns5985:0crwdne5985:0",
"description": "crwdns5987:0crwdne5987:0"
},
"upToDateHostsCriticalAt": {
"label": "crwdns5989:0crwdne5989:0",
"description": "crwdns5991:0crwdne5991:0"
},
"totalOutdatedPackagesThresholdMode": {
"label": "crwdns5993:0crwdne5993:0",
"description": "crwdns5995:0crwdne5995:0"
},
"totalOutdatedPackagesWarningAt": {
"label": "crwdns5997:0crwdne5997:0",
"description": "crwdns5999:0crwdne5999:0"
},
"totalOutdatedPackagesCriticalAt": {
"label": "crwdns6001:0crwdne6001:0",
"description": "crwdns6003:0crwdne6003:0"
},
"showOsDistribution": {
"label": "crwdns6005:0crwdne6005:0",
"description": "crwdns6007:0crwdne6007:0"
},
"osDistributionLimit": {
"label": "crwdns6009:0crwdne6009:0",
"description": "crwdns6011:0crwdne6011:0",
"option": {
"3": "crwdns6013:0crwdne6013:0",
"5": "crwdns6015:0crwdne6015:0",
"10": "crwdns6017:0crwdne6017:0",
"all": "crwdns6019:0crwdne6019:0"
}
},
"showOsVersion": {
"label": "crwdns6021:0crwdne6021:0",
"description": "crwdns6023:0crwdne6023:0"
},
"showComplianceHero": {
"label": "crwdns6025:0crwdne6025:0",
"description": "crwdns6027:0crwdne6027:0"
},
"osDisplayMode": {
"label": "crwdns6029:0crwdne6029:0",
"description": "crwdns6031:0crwdne6031:0",
"option": {
"bars": "crwdns6033:0crwdne6033:0",
"donut": "crwdns6035:0crwdne6035:0"
}
}
},
"error": {
"internalServerError": "crwdns6037:0crwdne6037:0"
}
},
"bazarr": {
"name": "crwdns5803:0crwdne5803:0",
"description": "crwdns5805:0crwdne5805:0",
"episodes": "crwdns5807:0crwdne5807:0",
"movies": "crwdns5809:0crwdne5809:0",
"providers": "crwdns5811:0crwdne5811:0",
"status": "crwdns5813:0crwdne5813:0",
"option": {
"showMissingEpisodes": {
"label": "crwdns5815:0crwdne5815:0",
"description": "crwdns5817:0crwdne5817:0"
},
"showMissingMovies": {
"label": "crwdns5819:0crwdne5819:0",
"description": "crwdns5821:0crwdne5821:0"
},
"showProviderIssues": {
"label": "crwdns5823:0crwdne5823:0",
"description": "crwdns5825:0crwdne5825:0"
},
"showHealthIssues": {
"label": "crwdns5827:0crwdne5827:0",
"description": "crwdns5829:0crwdne5829:0"
}
},
"error": {
"internalServerError": "crwdns5831:0crwdne5831:0"
}
},
"immich-serverStats": {
"name": "crwdns3880:0crwdne3880:0",
"description": "crwdns3882:0crwdne3882:0",
@@ -2782,10 +3017,11 @@
},
"immich-albumCarousel": {
"name": "crwdns3908:0crwdne3908:0",
"allPhotos": "crwdns6103:0crwdne6103:0",
"option": {
"albumId": {
"label": "crwdns5715:0crwdne5715:0",
"description": "crwdns5717:0crwdne5717:0"
"description": "crwdns6105:0crwdne6105:0"
},
"rotationIntervalSeconds": {
"label": "crwdns3914:0crwdne3914:0",
@@ -2800,9 +3036,8 @@
"description": "crwdns5721:0crwdne5721:0"
}
},
"description": "crwdns3922:0crwdne3922:0",
"noAlbumSelected": "crwdns3924:0crwdne3924:0",
"noPhotos": "crwdns3926:0crwdne3926:0"
"description": "crwdns6107:0crwdne6107:0",
"noPhotos": "crwdns6109:0crwdne6109:0"
},
"downloads": {
"name": "crwdns1170:0crwdne1170:0",
@@ -2986,6 +3221,12 @@
"option": {
"linksTargetNewTab": {
"label": "crwdns1306:0crwdne1306:0"
},
"statusFilter": {
"label": "crwdns6081:0crwdne6081:0"
},
"recentDays": {
"label": "crwdns6083:0crwdne6083:0"
}
},
"pending": {
@@ -3093,6 +3334,35 @@
}
}
},
"mediaMissing": {
"name": "crwdns5833:0crwdne5833:0",
"description": "crwdns5835:0crwdne5835:0",
"option": {
"showMissing": {
"label": "crwdns5837:0crwdne5837:0"
},
"showQueued": {
"label": "crwdns5839:0crwdne5839:0"
},
"pageSize": {
"label": "crwdns5841:0crwdne5841:0"
}
},
"tab": {
"missing": "crwdns5843:0crwdne5843:0",
"queued": "crwdns5845:0crwdne5845:0"
},
"type": {
"movie": "crwdns5847:0crwdne5847:0",
"episode": "crwdns5849:0crwdne5849:0"
},
"empty": {
"missing": "crwdns5851:0crwdne5851:0",
"queued": "crwdns5853:0crwdne5853:0",
"noTabsEnabled": "crwdns5855:0crwdne5855:0"
},
"timeLeft": "crwdns5857:0crwdne5857:0"
},
"rssFeed": {
"name": "crwdns1352:0crwdne1352:0",
"description": "crwdns1354:0crwdne1354:0",
@@ -3336,7 +3606,11 @@
"name": "crwdns3476:0crwdne3476:0",
"description": "crwdns3478:0crwdne3478:0",
"noItems": "crwdns3480:0crwdne3480:0",
"option": {}
"option": {
"hideLogos": {
"label": "crwdns5757:0crwdne5757:0"
}
}
},
"speedtestTracker": {
"name": "crwdns4014:0crwdne4014:0",
@@ -3417,6 +3691,40 @@
"internalServerError": "crwdns5095:0crwdne5095:0"
}
},
"traefik": {
"name": "crwdns5759:0crwdne5759:0",
"description": "crwdns5761:0crwdne5761:0",
"option": {
"showTcp": {
"label": "crwdns5763:0crwdne5763:0"
},
"showUdp": {
"label": "crwdns5765:0crwdne5765:0"
},
"showEntryPoints": {
"label": "crwdns5767:0crwdne5767:0"
}
},
"instances": "crwdns5769:0crwdne5769:0",
"versionUnknown": "crwdns5771:0crwdne5771:0",
"summary": {
"routers": "crwdns5773:0crwdne5773:0",
"entryPoints": "crwdns5775:0crwdne5775:0"
},
"resource": {
"routers": "crwdns5777:0crwdne5777:0",
"services": "crwdns5779:0crwdne5779:0",
"middlewares": "crwdns5781:0crwdne5781:0"
},
"status": {
"healthy": "crwdns5783:0crwdne5783:0",
"errors": "crwdns5785:0{count}crwdne5785:0",
"warnings": "crwdns5787:0{count}crwdne5787:0"
},
"error": {
"internalServerError": "crwdns5789:0crwdne5789:0"
}
},
"audioStats": {
"name": "crwdns5097:0crwdne5097:0",
"description": "crwdns5099:0crwdne5099:0",
@@ -3783,8 +4091,8 @@
},
"disk": {
"title": "crwdns4943:0crwdne4943:0",
"subtitle": "crwdns4945:0crwdne4945:0",
"series": "crwdns4947:0crwdne4947:0"
"subtitle": "crwdns6039:0crwdne6039:0",
"series": "crwdns6041:0crwdne6041:0"
},
"diskIO": {
"title": "crwdns4949:0crwdne4949:0",
@@ -3792,6 +4100,11 @@
"read": "crwdns4953:0crwdne4953:0",
"write": "crwdns4955:0crwdne4955:0"
},
"storage": {
"title": "crwdns5793:0crwdne5793:0",
"subtitle": "crwdns5795:0crwdne5795:0",
"series": "crwdns5797:0crwdne5797:0"
},
"network": {
"title": "crwdns4957:0crwdne4957:0",
"subtitle": "crwdns4959:0crwdne4959:0",
@@ -4924,6 +5237,9 @@
"title": "crwdns1892:0crwdne1892:0",
"table": {
"updated": "crwdns1894:0{when}crwdne1894:0",
"refresh": {
"lastUpdated": "crwdns6047:0{when}crwdne6047:0"
},
"search": "crwdns3432:0{count}crwdne3432:0",
"selected": "crwdns1898:0{selectCount}crwdnd1898:0{totalCount}crwdne1898:0",
"footer": "crwdns3434:0{count}crwdne3434:0",
@@ -5652,6 +5968,14 @@
}
}
},
"enableRightClickOnWidgets": {
"label": "crwdns6099:0crwdne6099:0",
"children": {
"detail": {
"title": "crwdns6101:0crwdne6101:0"
}
}
},
"fullPreferencesPage": {
"label": "crwdns4367:0crwdne4367:0"
}
+327 -3
View File
@@ -198,6 +198,10 @@
"pingIconsEnabled": {
"label": "Používat ikony pro pingy"
},
"enableRightClickOnWidgets": {
"label": "",
"description": ""
},
"defaultSearchEngine": {
"label": ""
},
@@ -1285,6 +1289,14 @@
"label": "",
"newLabel": ""
},
"patchmonApiKey": {
"label": "",
"newLabel": ""
},
"patchmonApiSecret": {
"label": "",
"newLabel": ""
},
"githubAppId": {
"label": "",
"newLabel": ""
@@ -1328,9 +1340,21 @@
}
}
},
"traefik": {
"name": "",
"description": ""
},
"paperlessNgx": {
"name": "",
"description": ""
},
"patchmon": {
"name": "",
"description": ""
},
"bazarr": {
"name": "",
"description": ""
}
},
"media": {
@@ -1602,6 +1626,12 @@
"refresh": "",
"options": "",
"actions": ""
},
"status": {
"success": "",
"error": "",
"loading": "",
"idle": ""
}
},
"create": {
@@ -1875,6 +1905,16 @@
"customDateFormat": {
"label": "",
"description": ""
},
"showWeather": {
"label": "",
"description": ""
},
"weatherLocation": {
"label": ""
},
"isWeatherFormatFahrenheit": {
"label": ""
}
}
},
@@ -1936,6 +1976,8 @@
"clear": "Vymazat formátování",
"heading": "Nadpis {level}",
"align": "Zarovnat text: {position}",
"directionLtr": "",
"directionRtl": "",
"blockquote": "Citace",
"horizontalLine": "Vodorovná čára",
"bulletList": "Odrážkový seznam",
@@ -2225,7 +2267,8 @@
"sunrise": "",
"sunset": "",
"maxWindSpeed": "",
"maxWindGusts": ""
"maxWindGusts": "",
"humidity": ""
},
"kind": {
"clear": "Jasno",
@@ -2689,6 +2732,11 @@
"user": "Uživatel",
"name": "Jméno",
"id": "Id",
"status": "",
"directPlay": "",
"transcoding": "",
"paused": "",
"remaining": "",
"metadata": {
"title": "",
"video": {
@@ -2754,6 +2802,193 @@
"internalServerError": ""
}
},
"patchmon": {
"name": "",
"description": "",
"title": "",
"totalHosts": "",
"hostsNeedingUpdates": "",
"securityUpdates": "",
"upToDateHosts": "",
"hostsWithSecurityUpdates": "",
"recentUpdates24h": "",
"totalOutdatedPackages": "",
"totalRepos": "",
"osDistribution": "",
"compliance": "",
"complianceRatio": "",
"lastUpdated": "",
"option": {
"showTotalHosts": {
"label": "",
"description": ""
},
"showHostsNeedingUpdates": {
"label": "",
"description": ""
},
"showSecurityUpdates": {
"label": "",
"description": ""
},
"showUpToDateHosts": {
"label": "",
"description": ""
},
"showHostsWithSecurityUpdates": {
"label": "",
"description": ""
},
"showRecentUpdates24h": {
"label": "",
"description": ""
},
"showTotalOutdatedPackages": {
"label": "",
"description": ""
},
"showTotalRepos": {
"label": "",
"description": ""
},
"enableThresholdColors": {
"label": "",
"description": ""
},
"useCustomThresholds": {
"label": "",
"description": ""
},
"thresholdMode": {
"option": {
"absolute": "",
"percent": ""
}
},
"hostsNeedingUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesCriticalAt": {
"label": "",
"description": ""
},
"securityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"securityUpdatesWarningAt": {
"label": "",
"description": ""
},
"securityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"upToDateHostsThresholdMode": {
"label": "",
"description": ""
},
"upToDateHostsWarningAt": {
"label": "",
"description": ""
},
"upToDateHostsCriticalAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesThresholdMode": {
"label": "",
"description": ""
},
"totalOutdatedPackagesWarningAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesCriticalAt": {
"label": "",
"description": ""
},
"showOsDistribution": {
"label": "",
"description": ""
},
"osDistributionLimit": {
"label": "",
"description": "",
"option": {
"3": "",
"5": "",
"10": "",
"all": ""
}
},
"showOsVersion": {
"label": "",
"description": ""
},
"showComplianceHero": {
"label": "",
"description": ""
},
"osDisplayMode": {
"label": "",
"description": "",
"option": {
"bars": "",
"donut": ""
}
}
},
"error": {
"internalServerError": ""
}
},
"bazarr": {
"name": "",
"description": "",
"episodes": "",
"movies": "",
"providers": "",
"status": "",
"option": {
"showMissingEpisodes": {
"label": "",
"description": ""
},
"showMissingMovies": {
"label": "",
"description": ""
},
"showProviderIssues": {
"label": "",
"description": ""
},
"showHealthIssues": {
"label": "",
"description": ""
}
},
"error": {
"internalServerError": ""
}
},
"immich-serverStats": {
"name": "",
"description": "",
@@ -2782,6 +3017,7 @@
},
"immich-albumCarousel": {
"name": "",
"allPhotos": "",
"option": {
"albumId": {
"label": "",
@@ -2801,7 +3037,6 @@
}
},
"description": "",
"noAlbumSelected": "",
"noPhotos": ""
},
"downloads": {
@@ -2986,6 +3221,12 @@
"option": {
"linksTargetNewTab": {
"label": "Otevírat odkazy v nové kartě"
},
"statusFilter": {
"label": ""
},
"recentDays": {
"label": ""
}
},
"pending": {
@@ -3093,6 +3334,35 @@
}
}
},
"mediaMissing": {
"name": "",
"description": "",
"option": {
"showMissing": {
"label": ""
},
"showQueued": {
"label": ""
},
"pageSize": {
"label": ""
}
},
"tab": {
"missing": "",
"queued": ""
},
"type": {
"movie": "",
"episode": ""
},
"empty": {
"missing": "",
"queued": "",
"noTabsEnabled": ""
},
"timeLeft": ""
},
"rssFeed": {
"name": "",
"description": "",
@@ -3336,7 +3606,11 @@
"name": "",
"description": "",
"noItems": "",
"option": {}
"option": {
"hideLogos": {
"label": ""
}
}
},
"speedtestTracker": {
"name": "",
@@ -3417,6 +3691,40 @@
"internalServerError": ""
}
},
"traefik": {
"name": "",
"description": "",
"option": {
"showTcp": {
"label": ""
},
"showUdp": {
"label": ""
},
"showEntryPoints": {
"label": ""
}
},
"instances": "",
"versionUnknown": "",
"summary": {
"routers": "",
"entryPoints": ""
},
"resource": {
"routers": "",
"services": "",
"middlewares": ""
},
"status": {
"healthy": "",
"errors": "",
"warnings": ""
},
"error": {
"internalServerError": ""
}
},
"audioStats": {
"name": "",
"description": "",
@@ -3792,6 +4100,11 @@
"read": "",
"write": ""
},
"storage": {
"title": "",
"subtitle": "",
"series": ""
},
"network": {
"title": "",
"subtitle": "",
@@ -4924,6 +5237,9 @@
"title": "Kontejnery",
"table": {
"updated": "",
"refresh": {
"lastUpdated": ""
},
"search": "",
"selected": "",
"footer": "",
@@ -5652,6 +5968,14 @@
}
}
},
"enableRightClickOnWidgets": {
"label": "",
"children": {
"detail": {
"title": ""
}
}
},
"fullPreferencesPage": {
"label": ""
}
+335 -11
View File
@@ -8,9 +8,9 @@
"action": {
"scratch": "Start fra bunden",
"importOldmarr": "Importer fra Homarr før 1.0",
"restoreSqlite": "",
"restoreSqliteDescription": "",
"backToOptions": ""
"restoreSqlite": "Gendan fra SQLite backup",
"restoreSqliteDescription": "Upload en fuld SQLite database backup fra en tidligere Homarr instans. Dette vil gendanne alle tavler, brugere, apps, integrationer og indstillinger.",
"backToOptions": "← Tilbage til opsætningsmuligheder"
}
},
"import": {
@@ -94,12 +94,12 @@
"subtitle": "Konfigurer serverindstillinger."
},
"integrations": {
"title": "",
"subtitle": "",
"description": "",
"title": "Integrationer",
"subtitle": "Vælg de tjenester, du bruger.",
"description": "Vælg de integrationer, du gerne vil forbinde. Vi konfigurerer dit dashboard med matchende widgets automatisk.",
"action": {
"continue": "",
"skip": ""
"continue": "Forsæt",
"skip": "Spring over for nu"
},
"configure": {
"progress": "",
@@ -198,6 +198,10 @@
"pingIconsEnabled": {
"label": "Brug ikoner til pings"
},
"enableRightClickOnWidgets": {
"label": "",
"description": ""
},
"defaultSearchEngine": {
"label": "Standardsøgemaskine"
},
@@ -1285,6 +1289,14 @@
"label": "API-nøgle (Secret)",
"newLabel": "Ny API-nøgle (Key)"
},
"patchmonApiKey": {
"label": "",
"newLabel": ""
},
"patchmonApiSecret": {
"label": "",
"newLabel": ""
},
"githubAppId": {
"label": "App Id",
"newLabel": "Nyt App Id"
@@ -1328,9 +1340,21 @@
}
}
},
"traefik": {
"name": "",
"description": ""
},
"paperlessNgx": {
"name": "",
"description": ""
},
"patchmon": {
"name": "",
"description": ""
},
"bazarr": {
"name": "",
"description": ""
}
},
"media": {
@@ -1602,6 +1626,12 @@
"refresh": "",
"options": "",
"actions": ""
},
"status": {
"success": "",
"error": "",
"loading": "",
"idle": ""
}
},
"create": {
@@ -1875,6 +1905,16 @@
"customDateFormat": {
"label": "Brugerdefineret datoformat",
"description": "Brug ISO 8601 til at formatere dato (dette vil tilsidesætte andre valg)"
},
"showWeather": {
"label": "",
"description": ""
},
"weatherLocation": {
"label": ""
},
"isWeatherFormatFahrenheit": {
"label": ""
}
}
},
@@ -1936,6 +1976,8 @@
"clear": "Ryd formatering",
"heading": "Overskrift {level}",
"align": "Juster tekst: {position}",
"directionLtr": "",
"directionRtl": "",
"blockquote": "Citatblok",
"horizontalLine": "Horisontal linje",
"bulletList": "Punktopstillet liste",
@@ -2225,7 +2267,8 @@
"sunrise": "Solopgang",
"sunset": "Solnedgang",
"maxWindSpeed": "Maks. vindhastighed: {maxWindSpeed} {unit}",
"maxWindGusts": "Maks. vindstød {maxWindGusts} {unit}"
"maxWindGusts": "Maks. vindstød {maxWindGusts} {unit}",
"humidity": ""
},
"kind": {
"clear": "Skyfrit",
@@ -2689,6 +2732,11 @@
"user": "Bruger",
"name": "Navn",
"id": "Id",
"status": "",
"directPlay": "",
"transcoding": "",
"paused": "",
"remaining": "",
"metadata": {
"title": "Statistik for nørder",
"video": {
@@ -2754,6 +2802,193 @@
"internalServerError": ""
}
},
"patchmon": {
"name": "",
"description": "",
"title": "",
"totalHosts": "",
"hostsNeedingUpdates": "",
"securityUpdates": "",
"upToDateHosts": "",
"hostsWithSecurityUpdates": "",
"recentUpdates24h": "",
"totalOutdatedPackages": "",
"totalRepos": "",
"osDistribution": "",
"compliance": "",
"complianceRatio": "",
"lastUpdated": "",
"option": {
"showTotalHosts": {
"label": "",
"description": ""
},
"showHostsNeedingUpdates": {
"label": "",
"description": ""
},
"showSecurityUpdates": {
"label": "",
"description": ""
},
"showUpToDateHosts": {
"label": "",
"description": ""
},
"showHostsWithSecurityUpdates": {
"label": "",
"description": ""
},
"showRecentUpdates24h": {
"label": "",
"description": ""
},
"showTotalOutdatedPackages": {
"label": "",
"description": ""
},
"showTotalRepos": {
"label": "",
"description": ""
},
"enableThresholdColors": {
"label": "",
"description": ""
},
"useCustomThresholds": {
"label": "",
"description": ""
},
"thresholdMode": {
"option": {
"absolute": "",
"percent": ""
}
},
"hostsNeedingUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesCriticalAt": {
"label": "",
"description": ""
},
"securityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"securityUpdatesWarningAt": {
"label": "",
"description": ""
},
"securityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"upToDateHostsThresholdMode": {
"label": "",
"description": ""
},
"upToDateHostsWarningAt": {
"label": "",
"description": ""
},
"upToDateHostsCriticalAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesThresholdMode": {
"label": "",
"description": ""
},
"totalOutdatedPackagesWarningAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesCriticalAt": {
"label": "",
"description": ""
},
"showOsDistribution": {
"label": "",
"description": ""
},
"osDistributionLimit": {
"label": "",
"description": "",
"option": {
"3": "",
"5": "",
"10": "",
"all": ""
}
},
"showOsVersion": {
"label": "",
"description": ""
},
"showComplianceHero": {
"label": "",
"description": ""
},
"osDisplayMode": {
"label": "",
"description": "",
"option": {
"bars": "",
"donut": ""
}
}
},
"error": {
"internalServerError": ""
}
},
"bazarr": {
"name": "",
"description": "",
"episodes": "",
"movies": "",
"providers": "",
"status": "",
"option": {
"showMissingEpisodes": {
"label": "",
"description": ""
},
"showMissingMovies": {
"label": "",
"description": ""
},
"showProviderIssues": {
"label": "",
"description": ""
},
"showHealthIssues": {
"label": "",
"description": ""
}
},
"error": {
"internalServerError": ""
}
},
"immich-serverStats": {
"name": "Immich Server Statistik",
"description": "Information om din Immich instans",
@@ -2782,6 +3017,7 @@
},
"immich-albumCarousel": {
"name": "Immich album",
"allPhotos": "",
"option": {
"albumId": {
"label": "Album ID",
@@ -2801,7 +3037,6 @@
}
},
"description": "Diasshow af billeder i dit Immich album",
"noAlbumSelected": "Intet album valgt, rediger widget og vælg et album",
"noPhotos": "Ingen fotos i dette album"
},
"downloads": {
@@ -2986,6 +3221,12 @@
"option": {
"linksTargetNewTab": {
"label": "Åbn links i ny fane"
},
"statusFilter": {
"label": ""
},
"recentDays": {
"label": ""
}
},
"pending": {
@@ -3093,6 +3334,35 @@
}
}
},
"mediaMissing": {
"name": "",
"description": "",
"option": {
"showMissing": {
"label": ""
},
"showQueued": {
"label": ""
},
"pageSize": {
"label": ""
}
},
"tab": {
"missing": "",
"queued": ""
},
"type": {
"movie": "",
"episode": ""
},
"empty": {
"missing": "",
"queued": "",
"noTabsEnabled": ""
},
"timeLeft": ""
},
"rssFeed": {
"name": "RSS feeds",
"description": "Overvåg og vis en eller flere generiske RSS, ATOM eller JSON feeds",
@@ -3336,7 +3606,11 @@
"name": "Notifikationer",
"description": "Vis notifikationshistorik fra en integration",
"noItems": "Ingen notifikationer at vise.",
"option": {}
"option": {
"hideLogos": {
"label": ""
}
}
},
"speedtestTracker": {
"name": "Speedtest Tracker",
@@ -3417,6 +3691,40 @@
"internalServerError": ""
}
},
"traefik": {
"name": "",
"description": "",
"option": {
"showTcp": {
"label": ""
},
"showUdp": {
"label": ""
},
"showEntryPoints": {
"label": ""
}
},
"instances": "",
"versionUnknown": "",
"summary": {
"routers": "",
"entryPoints": ""
},
"resource": {
"routers": "",
"services": "",
"middlewares": ""
},
"status": {
"healthy": "",
"errors": "",
"warnings": ""
},
"error": {
"internalServerError": ""
}
},
"audioStats": {
"name": "",
"description": "",
@@ -3792,6 +4100,11 @@
"read": "",
"write": ""
},
"storage": {
"title": "",
"subtitle": "",
"series": ""
},
"network": {
"title": "",
"subtitle": "",
@@ -4924,6 +5237,9 @@
"title": "Containere",
"table": {
"updated": "Opdateret {when}",
"refresh": {
"lastUpdated": ""
},
"search": "Søg {count} containere",
"selected": "{selectCount} af {totalCount} containere valgt",
"footer": "Total {count} containere",
@@ -5652,6 +5968,14 @@
}
}
},
"enableRightClickOnWidgets": {
"label": "",
"children": {
"detail": {
"title": ""
}
}
},
"fullPreferencesPage": {
"label": ""
}
+327 -3
View File
@@ -198,6 +198,10 @@
"pingIconsEnabled": {
"label": "Symbole für Pings verwenden"
},
"enableRightClickOnWidgets": {
"label": "",
"description": ""
},
"defaultSearchEngine": {
"label": "Standard Suchmaschine"
},
@@ -1285,6 +1289,14 @@
"label": "",
"newLabel": ""
},
"patchmonApiKey": {
"label": "",
"newLabel": ""
},
"patchmonApiSecret": {
"label": "",
"newLabel": ""
},
"githubAppId": {
"label": "",
"newLabel": ""
@@ -1328,9 +1340,21 @@
}
}
},
"traefik": {
"name": "",
"description": ""
},
"paperlessNgx": {
"name": "",
"description": ""
},
"patchmon": {
"name": "",
"description": ""
},
"bazarr": {
"name": "",
"description": ""
}
},
"media": {
@@ -1602,6 +1626,12 @@
"refresh": "",
"options": "",
"actions": ""
},
"status": {
"success": "",
"error": "",
"loading": "",
"idle": ""
}
},
"create": {
@@ -1875,6 +1905,16 @@
"customDateFormat": {
"label": "Benutzerdefiniertes Datumsformat",
"description": "ISO 8601 Standard benutzen um das Datum zu formatieren (dies wird andere Optionen überschreiben)"
},
"showWeather": {
"label": "",
"description": ""
},
"weatherLocation": {
"label": ""
},
"isWeatherFormatFahrenheit": {
"label": ""
}
}
},
@@ -1936,6 +1976,8 @@
"clear": "Formatierung entfernen",
"heading": "Überschrift {level}",
"align": "Text ausrichten: {position}",
"directionLtr": "",
"directionRtl": "",
"blockquote": "Blockzitat",
"horizontalLine": "Horizontale Linie",
"bulletList": "Aufzählung",
@@ -2225,7 +2267,8 @@
"sunrise": "Sonnenaufgang",
"sunset": "Sonnenuntergang",
"maxWindSpeed": "Maximale Windgeschwindigkeit: {maxWindSpeed} km/h",
"maxWindGusts": "Maximale Windböen: {maxWindGusts} km/h"
"maxWindGusts": "Maximale Windböen: {maxWindGusts} km/h",
"humidity": ""
},
"kind": {
"clear": "Klar",
@@ -2689,6 +2732,11 @@
"user": "Benutzer",
"name": "",
"id": "",
"status": "",
"directPlay": "",
"transcoding": "",
"paused": "",
"remaining": "",
"metadata": {
"title": "",
"video": {
@@ -2754,6 +2802,193 @@
"internalServerError": ""
}
},
"patchmon": {
"name": "",
"description": "",
"title": "",
"totalHosts": "",
"hostsNeedingUpdates": "",
"securityUpdates": "",
"upToDateHosts": "",
"hostsWithSecurityUpdates": "",
"recentUpdates24h": "",
"totalOutdatedPackages": "",
"totalRepos": "",
"osDistribution": "",
"compliance": "",
"complianceRatio": "",
"lastUpdated": "",
"option": {
"showTotalHosts": {
"label": "",
"description": ""
},
"showHostsNeedingUpdates": {
"label": "",
"description": ""
},
"showSecurityUpdates": {
"label": "",
"description": ""
},
"showUpToDateHosts": {
"label": "",
"description": ""
},
"showHostsWithSecurityUpdates": {
"label": "",
"description": ""
},
"showRecentUpdates24h": {
"label": "",
"description": ""
},
"showTotalOutdatedPackages": {
"label": "",
"description": ""
},
"showTotalRepos": {
"label": "",
"description": ""
},
"enableThresholdColors": {
"label": "",
"description": ""
},
"useCustomThresholds": {
"label": "",
"description": ""
},
"thresholdMode": {
"option": {
"absolute": "",
"percent": ""
}
},
"hostsNeedingUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesCriticalAt": {
"label": "",
"description": ""
},
"securityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"securityUpdatesWarningAt": {
"label": "",
"description": ""
},
"securityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"upToDateHostsThresholdMode": {
"label": "",
"description": ""
},
"upToDateHostsWarningAt": {
"label": "",
"description": ""
},
"upToDateHostsCriticalAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesThresholdMode": {
"label": "",
"description": ""
},
"totalOutdatedPackagesWarningAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesCriticalAt": {
"label": "",
"description": ""
},
"showOsDistribution": {
"label": "",
"description": ""
},
"osDistributionLimit": {
"label": "",
"description": "",
"option": {
"3": "",
"5": "",
"10": "",
"all": ""
}
},
"showOsVersion": {
"label": "",
"description": ""
},
"showComplianceHero": {
"label": "",
"description": ""
},
"osDisplayMode": {
"label": "",
"description": "",
"option": {
"bars": "",
"donut": ""
}
}
},
"error": {
"internalServerError": ""
}
},
"bazarr": {
"name": "",
"description": "",
"episodes": "",
"movies": "",
"providers": "",
"status": "",
"option": {
"showMissingEpisodes": {
"label": "",
"description": ""
},
"showMissingMovies": {
"label": "",
"description": ""
},
"showProviderIssues": {
"label": "",
"description": ""
},
"showHealthIssues": {
"label": "",
"description": ""
}
},
"error": {
"internalServerError": ""
}
},
"immich-serverStats": {
"name": "",
"description": "",
@@ -2782,6 +3017,7 @@
},
"immich-albumCarousel": {
"name": "",
"allPhotos": "",
"option": {
"albumId": {
"label": "",
@@ -2801,7 +3037,6 @@
}
},
"description": "",
"noAlbumSelected": "",
"noPhotos": ""
},
"downloads": {
@@ -2986,6 +3221,12 @@
"option": {
"linksTargetNewTab": {
"label": "Links in neuem Tab öffnen"
},
"statusFilter": {
"label": ""
},
"recentDays": {
"label": ""
}
},
"pending": {
@@ -3093,6 +3334,35 @@
}
}
},
"mediaMissing": {
"name": "",
"description": "",
"option": {
"showMissing": {
"label": ""
},
"showQueued": {
"label": ""
},
"pageSize": {
"label": ""
}
},
"tab": {
"missing": "",
"queued": ""
},
"type": {
"movie": "",
"episode": ""
},
"empty": {
"missing": "",
"queued": "",
"noTabsEnabled": ""
},
"timeLeft": ""
},
"rssFeed": {
"name": "RSS Feeds",
"description": "Einen oder mehrere generische RSS, ATOM oder JSON-Feeds überwachen und anzeigen",
@@ -3336,7 +3606,11 @@
"name": "",
"description": "",
"noItems": "",
"option": {}
"option": {
"hideLogos": {
"label": ""
}
}
},
"speedtestTracker": {
"name": "",
@@ -3417,6 +3691,40 @@
"internalServerError": ""
}
},
"traefik": {
"name": "",
"description": "",
"option": {
"showTcp": {
"label": ""
},
"showUdp": {
"label": ""
},
"showEntryPoints": {
"label": ""
}
},
"instances": "",
"versionUnknown": "",
"summary": {
"routers": "",
"entryPoints": ""
},
"resource": {
"routers": "",
"services": "",
"middlewares": ""
},
"status": {
"healthy": "",
"errors": "",
"warnings": ""
},
"error": {
"internalServerError": ""
}
},
"audioStats": {
"name": "",
"description": "",
@@ -3792,6 +4100,11 @@
"read": "",
"write": ""
},
"storage": {
"title": "",
"subtitle": "",
"series": ""
},
"network": {
"title": "",
"subtitle": "",
@@ -4924,6 +5237,9 @@
"title": "Container",
"table": {
"updated": "Aktualisiert {when}",
"refresh": {
"lastUpdated": ""
},
"search": "{count} Container durchsuchen",
"selected": "{selectCount} von {totalCount} ausgewählten Containern",
"footer": "",
@@ -5652,6 +5968,14 @@
}
}
},
"enableRightClickOnWidgets": {
"label": "",
"children": {
"detail": {
"title": ""
}
}
},
"fullPreferencesPage": {
"label": ""
}
+334 -10
View File
@@ -198,6 +198,10 @@
"pingIconsEnabled": {
"label": "Symbole für Pings verwenden"
},
"enableRightClickOnWidgets": {
"label": "",
"description": ""
},
"defaultSearchEngine": {
"label": "Standard Suchmaschine"
},
@@ -1021,7 +1025,7 @@
"mediaMonitoring": "Überwachung",
"speedtest": "Geschwindigkeitstest",
"analytics": "Nutzungsdaten",
"vpn": "",
"vpn": "VPN",
"mediaLibrary": "Medienbibliothek",
"uptimeMonitoring": "Überwachung der Betriebszeit"
},
@@ -1285,6 +1289,14 @@
"label": "API-Schlüssel (Secret)",
"newLabel": "Neuer API-Schlüssel (Secret)"
},
"patchmonApiKey": {
"label": "API Key",
"newLabel": "Neuer API Key"
},
"patchmonApiSecret": {
"label": "API Secret",
"newLabel": "Neues API Secret"
},
"githubAppId": {
"label": "App ID",
"newLabel": "Neue App ID"
@@ -1328,9 +1340,21 @@
}
}
},
"traefik": {
"name": "Traefik",
"description": ""
},
"paperlessNgx": {
"name": "Paperless-ngx",
"description": "Dokumentenverwaltungssystem zum Archivieren und Organisieren gescannter Dokumente"
},
"patchmon": {
"name": "",
"description": ""
},
"bazarr": {
"name": "",
"description": ""
}
},
"media": {
@@ -1599,9 +1623,15 @@
"menu": {
"label": {
"settings": "Einstellungen",
"refresh": "",
"options": "",
"actions": ""
"refresh": "Aktualisieren",
"options": "Optionen",
"actions": "Aktionen"
},
"status": {
"success": "",
"error": "Fehler",
"loading": "Wird geladen",
"idle": "Leerlauf"
}
},
"create": {
@@ -1875,6 +1905,16 @@
"customDateFormat": {
"label": "Benutzerdefiniertes Datumsformat",
"description": "ISO 8601 Standard benutzen um das Datum zu formatieren (dies wird andere Optionen überschreiben)"
},
"showWeather": {
"label": "",
"description": ""
},
"weatherLocation": {
"label": ""
},
"isWeatherFormatFahrenheit": {
"label": ""
}
}
},
@@ -1936,6 +1976,8 @@
"clear": "Formatierung entfernen",
"heading": "Überschrift {level}",
"align": "Text ausrichten: {position}",
"directionLtr": "",
"directionRtl": "",
"blockquote": "Blockzitat",
"horizontalLine": "Horizontale Linie",
"bulletList": "Aufzählung",
@@ -2225,7 +2267,8 @@
"sunrise": "Sonnenaufgang",
"sunset": "Sonnenuntergang",
"maxWindSpeed": "Maximale Windgeschwindigkeit: {maxWindSpeed} {unit}",
"maxWindGusts": "Maximale Windböen: {maxWindGusts} {unit}"
"maxWindGusts": "Maximale Windböen: {maxWindGusts} {unit}",
"humidity": ""
},
"kind": {
"clear": "Klar",
@@ -2287,7 +2330,7 @@
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
"placeholder": "Speichervolumen auswählen"
},
"defaultTab": {
"label": "Standard Tab"
@@ -2426,8 +2469,8 @@
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"description": "Übersicht deiner VPN-Verbindungen",
"serviceUnavailable": "VPN nicht verfügbar",
"option": {}
},
"ups": {
@@ -2689,6 +2732,11 @@
"user": "Benutzer",
"name": "Name",
"id": "Id",
"status": "Status",
"directPlay": "",
"transcoding": "",
"paused": "",
"remaining": "",
"metadata": {
"title": "Statistiken für Nerds",
"video": {
@@ -2754,6 +2802,193 @@
"internalServerError": "Fehler beim Abrufen der Paperless-ngx-Daten"
}
},
"patchmon": {
"name": "",
"description": "",
"title": "",
"totalHosts": "",
"hostsNeedingUpdates": "",
"securityUpdates": "",
"upToDateHosts": "",
"hostsWithSecurityUpdates": "",
"recentUpdates24h": "",
"totalOutdatedPackages": "",
"totalRepos": "",
"osDistribution": "",
"compliance": "",
"complianceRatio": "",
"lastUpdated": "",
"option": {
"showTotalHosts": {
"label": "Alle Hosts anzeigen",
"description": "Gesamtanzahl der überwachten Hosts anzeigen"
},
"showHostsNeedingUpdates": {
"label": "Hosts die Updates benötigen anzeigen",
"description": "Hosts mit mindestens einem veralteten Paket anzeigen"
},
"showSecurityUpdates": {
"label": "Sicherheits-Updates anzeigen",
"description": "Gesamtzahl der verfügbaren Sicherheits-Updates anzeigen"
},
"showUpToDateHosts": {
"label": "",
"description": ""
},
"showHostsWithSecurityUpdates": {
"label": "",
"description": ""
},
"showRecentUpdates24h": {
"label": "",
"description": ""
},
"showTotalOutdatedPackages": {
"label": "",
"description": ""
},
"showTotalRepos": {
"label": "",
"description": ""
},
"enableThresholdColors": {
"label": "",
"description": ""
},
"useCustomThresholds": {
"label": "",
"description": ""
},
"thresholdMode": {
"option": {
"absolute": "",
"percent": ""
}
},
"hostsNeedingUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesCriticalAt": {
"label": "",
"description": ""
},
"securityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"securityUpdatesWarningAt": {
"label": "",
"description": ""
},
"securityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"upToDateHostsThresholdMode": {
"label": "",
"description": ""
},
"upToDateHostsWarningAt": {
"label": "",
"description": ""
},
"upToDateHostsCriticalAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesThresholdMode": {
"label": "",
"description": ""
},
"totalOutdatedPackagesWarningAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesCriticalAt": {
"label": "",
"description": ""
},
"showOsDistribution": {
"label": "",
"description": ""
},
"osDistributionLimit": {
"label": "",
"description": "",
"option": {
"3": "",
"5": "",
"10": "",
"all": ""
}
},
"showOsVersion": {
"label": "",
"description": ""
},
"showComplianceHero": {
"label": "",
"description": ""
},
"osDisplayMode": {
"label": "",
"description": "",
"option": {
"bars": "",
"donut": ""
}
}
},
"error": {
"internalServerError": ""
}
},
"bazarr": {
"name": "",
"description": "",
"episodes": "",
"movies": "",
"providers": "",
"status": "",
"option": {
"showMissingEpisodes": {
"label": "",
"description": ""
},
"showMissingMovies": {
"label": "",
"description": ""
},
"showProviderIssues": {
"label": "",
"description": ""
},
"showHealthIssues": {
"label": "",
"description": ""
}
},
"error": {
"internalServerError": ""
}
},
"immich-serverStats": {
"name": "Immich Serverstatistik einblenden",
"description": "Informationen über Ihre Immich Instanz",
@@ -2782,6 +3017,7 @@
},
"immich-albumCarousel": {
"name": "Immich Album",
"allPhotos": "",
"option": {
"albumId": {
"label": "Album ID",
@@ -2801,7 +3037,6 @@
}
},
"description": "Diashow der Bilder in Ihrem Immich Album",
"noAlbumSelected": "Kein Album ausgewählt, Widget bearbeiten und Album auswählen",
"noPhotos": "Keine Fotos in diesem Album"
},
"downloads": {
@@ -2986,6 +3221,12 @@
"option": {
"linksTargetNewTab": {
"label": "Links in neuem Tab öffnen"
},
"statusFilter": {
"label": ""
},
"recentDays": {
"label": ""
}
},
"pending": {
@@ -3093,6 +3334,35 @@
}
}
},
"mediaMissing": {
"name": "",
"description": "",
"option": {
"showMissing": {
"label": ""
},
"showQueued": {
"label": ""
},
"pageSize": {
"label": ""
}
},
"tab": {
"missing": "",
"queued": ""
},
"type": {
"movie": "",
"episode": ""
},
"empty": {
"missing": "",
"queued": "",
"noTabsEnabled": ""
},
"timeLeft": ""
},
"rssFeed": {
"name": "RSS Feeds",
"description": "Einen oder mehrere generische RSS, ATOM oder JSON-Feeds überwachen und anzeigen",
@@ -3336,7 +3606,11 @@
"name": "Benachrichtigungen",
"description": "Benachrichtigungshistorie von einer Integration anzeigen",
"noItems": "Keine Benachrichtigungen zum Anzeigen.",
"option": {}
"option": {
"hideLogos": {
"label": ""
}
}
},
"speedtestTracker": {
"name": "Speedtest Tracker",
@@ -3417,6 +3691,40 @@
"internalServerError": "Fehler beim Abrufen der Laufzeit Daten"
}
},
"traefik": {
"name": "",
"description": "",
"option": {
"showTcp": {
"label": ""
},
"showUdp": {
"label": ""
},
"showEntryPoints": {
"label": ""
}
},
"instances": "",
"versionUnknown": "",
"summary": {
"routers": "",
"entryPoints": ""
},
"resource": {
"routers": "",
"services": "",
"middlewares": ""
},
"status": {
"healthy": "",
"errors": "",
"warnings": ""
},
"error": {
"internalServerError": ""
}
},
"audioStats": {
"name": "Audiostatistiken",
"description": "Bibliotheks- und Wiedergabestatistiken von Navidrome oder Audiobookshelf",
@@ -3792,6 +4100,11 @@
"read": "",
"write": ""
},
"storage": {
"title": "",
"subtitle": "",
"series": ""
},
"network": {
"title": "",
"subtitle": "",
@@ -4924,6 +5237,9 @@
"title": "Container",
"table": {
"updated": "Aktualisiert {when}",
"refresh": {
"lastUpdated": ""
},
"search": "{count} Container durchsuchen",
"selected": "{selectCount} von {totalCount} ausgewählten Containern",
"footer": "Insgesamt {count} Container",
@@ -5652,6 +5968,14 @@
}
}
},
"enableRightClickOnWidgets": {
"label": "",
"children": {
"detail": {
"title": ""
}
}
},
"fullPreferencesPage": {
"label": "Alle Einstellungen öffnen"
}
+327 -3
View File
@@ -198,6 +198,10 @@
"pingIconsEnabled": {
"label": ""
},
"enableRightClickOnWidgets": {
"label": "",
"description": ""
},
"defaultSearchEngine": {
"label": "Προεπιλεγμένη μηχανή αναζήτησης"
},
@@ -1285,6 +1289,14 @@
"label": "Κλειδί API (Μυστικό)",
"newLabel": "Νέο Κλειδί API (Μυστικό)"
},
"patchmonApiKey": {
"label": "",
"newLabel": ""
},
"patchmonApiSecret": {
"label": "",
"newLabel": ""
},
"githubAppId": {
"label": "",
"newLabel": ""
@@ -1328,9 +1340,21 @@
}
}
},
"traefik": {
"name": "",
"description": ""
},
"paperlessNgx": {
"name": "",
"description": ""
},
"patchmon": {
"name": "",
"description": ""
},
"bazarr": {
"name": "",
"description": ""
}
},
"media": {
@@ -1602,6 +1626,12 @@
"refresh": "",
"options": "",
"actions": ""
},
"status": {
"success": "",
"error": "",
"loading": "",
"idle": ""
}
},
"create": {
@@ -1875,6 +1905,16 @@
"customDateFormat": {
"label": "",
"description": ""
},
"showWeather": {
"label": "",
"description": ""
},
"weatherLocation": {
"label": ""
},
"isWeatherFormatFahrenheit": {
"label": ""
}
}
},
@@ -1936,6 +1976,8 @@
"clear": "Εκκαθάριση μορφοποίησης",
"heading": "Επικεφαλίδα {level}",
"align": "Στοίχιση κειμένου: {position}",
"directionLtr": "",
"directionRtl": "",
"blockquote": "Μπλοκ κειμένου παράθεσης",
"horizontalLine": "Οριζόντια γραμμή",
"bulletList": "Λίστα με κουκκίδες",
@@ -2225,7 +2267,8 @@
"sunrise": "",
"sunset": "",
"maxWindSpeed": "",
"maxWindGusts": ""
"maxWindGusts": "",
"humidity": ""
},
"kind": {
"clear": "Καθαρός",
@@ -2689,6 +2732,11 @@
"user": "",
"name": "",
"id": "",
"status": "",
"directPlay": "",
"transcoding": "",
"paused": "",
"remaining": "",
"metadata": {
"title": "",
"video": {
@@ -2754,6 +2802,193 @@
"internalServerError": ""
}
},
"patchmon": {
"name": "",
"description": "",
"title": "",
"totalHosts": "",
"hostsNeedingUpdates": "",
"securityUpdates": "",
"upToDateHosts": "",
"hostsWithSecurityUpdates": "",
"recentUpdates24h": "",
"totalOutdatedPackages": "",
"totalRepos": "",
"osDistribution": "",
"compliance": "",
"complianceRatio": "",
"lastUpdated": "",
"option": {
"showTotalHosts": {
"label": "",
"description": ""
},
"showHostsNeedingUpdates": {
"label": "",
"description": ""
},
"showSecurityUpdates": {
"label": "",
"description": ""
},
"showUpToDateHosts": {
"label": "",
"description": ""
},
"showHostsWithSecurityUpdates": {
"label": "",
"description": ""
},
"showRecentUpdates24h": {
"label": "",
"description": ""
},
"showTotalOutdatedPackages": {
"label": "",
"description": ""
},
"showTotalRepos": {
"label": "",
"description": ""
},
"enableThresholdColors": {
"label": "",
"description": ""
},
"useCustomThresholds": {
"label": "",
"description": ""
},
"thresholdMode": {
"option": {
"absolute": "",
"percent": ""
}
},
"hostsNeedingUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesCriticalAt": {
"label": "",
"description": ""
},
"securityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"securityUpdatesWarningAt": {
"label": "",
"description": ""
},
"securityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"upToDateHostsThresholdMode": {
"label": "",
"description": ""
},
"upToDateHostsWarningAt": {
"label": "",
"description": ""
},
"upToDateHostsCriticalAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesThresholdMode": {
"label": "",
"description": ""
},
"totalOutdatedPackagesWarningAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesCriticalAt": {
"label": "",
"description": ""
},
"showOsDistribution": {
"label": "",
"description": ""
},
"osDistributionLimit": {
"label": "",
"description": "",
"option": {
"3": "",
"5": "",
"10": "",
"all": ""
}
},
"showOsVersion": {
"label": "",
"description": ""
},
"showComplianceHero": {
"label": "",
"description": ""
},
"osDisplayMode": {
"label": "",
"description": "",
"option": {
"bars": "",
"donut": ""
}
}
},
"error": {
"internalServerError": ""
}
},
"bazarr": {
"name": "",
"description": "",
"episodes": "",
"movies": "",
"providers": "",
"status": "",
"option": {
"showMissingEpisodes": {
"label": "",
"description": ""
},
"showMissingMovies": {
"label": "",
"description": ""
},
"showProviderIssues": {
"label": "",
"description": ""
},
"showHealthIssues": {
"label": "",
"description": ""
}
},
"error": {
"internalServerError": ""
}
},
"immich-serverStats": {
"name": "",
"description": "",
@@ -2782,6 +3017,7 @@
},
"immich-albumCarousel": {
"name": "",
"allPhotos": "",
"option": {
"albumId": {
"label": "",
@@ -2801,7 +3037,6 @@
}
},
"description": "",
"noAlbumSelected": "",
"noPhotos": ""
},
"downloads": {
@@ -2986,6 +3221,12 @@
"option": {
"linksTargetNewTab": {
"label": "Άνοιγμα συνδέσμων σε νέα καρτέλα"
},
"statusFilter": {
"label": ""
},
"recentDays": {
"label": ""
}
},
"pending": {
@@ -3093,6 +3334,35 @@
}
}
},
"mediaMissing": {
"name": "",
"description": "",
"option": {
"showMissing": {
"label": ""
},
"showQueued": {
"label": ""
},
"pageSize": {
"label": ""
}
},
"tab": {
"missing": "",
"queued": ""
},
"type": {
"movie": "",
"episode": ""
},
"empty": {
"missing": "",
"queued": "",
"noTabsEnabled": ""
},
"timeLeft": ""
},
"rssFeed": {
"name": "",
"description": "",
@@ -3336,7 +3606,11 @@
"name": "",
"description": "",
"noItems": "",
"option": {}
"option": {
"hideLogos": {
"label": ""
}
}
},
"speedtestTracker": {
"name": "",
@@ -3417,6 +3691,40 @@
"internalServerError": ""
}
},
"traefik": {
"name": "",
"description": "",
"option": {
"showTcp": {
"label": ""
},
"showUdp": {
"label": ""
},
"showEntryPoints": {
"label": ""
}
},
"instances": "",
"versionUnknown": "",
"summary": {
"routers": "",
"entryPoints": ""
},
"resource": {
"routers": "",
"services": "",
"middlewares": ""
},
"status": {
"healthy": "",
"errors": "",
"warnings": ""
},
"error": {
"internalServerError": ""
}
},
"audioStats": {
"name": "",
"description": "",
@@ -3792,6 +4100,11 @@
"read": "",
"write": ""
},
"storage": {
"title": "",
"subtitle": "",
"series": ""
},
"network": {
"title": "",
"subtitle": "",
@@ -4924,6 +5237,9 @@
"title": "",
"table": {
"updated": "",
"refresh": {
"lastUpdated": ""
},
"search": "",
"selected": "",
"footer": "",
@@ -5652,6 +5968,14 @@
}
}
},
"enableRightClickOnWidgets": {
"label": "",
"children": {
"detail": {
"title": ""
}
}
},
"fullPreferencesPage": {
"label": ""
}
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff. Show more