Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0dc782c9e | ||
|
|
8b38c83cc0 | ||
|
|
9c829679f5 | ||
|
|
16f82fcc6f | ||
|
|
0fd495c277 | ||
|
|
e93b5f6469 | ||
|
|
5ea902d260 | ||
|
|
356fadb9d6 | ||
|
|
33080e3d4f | ||
|
|
230ee3e1fc | ||
|
|
8571b4dcb7 | ||
|
|
781378dc76 | ||
|
|
445a100cd7 | ||
|
|
5784612d59 | ||
|
|
39ffdd2830 | ||
|
|
8e2cac6bf0 | ||
|
|
1cd04aaf45 | ||
|
|
482e74f8c7 | ||
|
|
12bf6ee4be | ||
|
|
45c58c967e | ||
|
|
e9611e8098 | ||
|
|
96fe0d55c7 | ||
|
|
3e782a6907 | ||
|
|
2018e9e81b | ||
|
|
c848dbfc1f | ||
|
|
210a62b35a | ||
|
|
8f4f543d29 | ||
|
|
92920db07d | ||
|
|
e7037ddfe9 | ||
|
|
cc50e47668 | ||
|
|
cb5ad15305 | ||
|
|
2cb0964aa2 | ||
|
|
26a8d219af | ||
|
|
cf5934aef6 | ||
|
|
9c6f900b82 | ||
|
|
c4a664b7ac | ||
|
|
457b1b0ce0 | ||
|
|
5581b6db94 | ||
|
|
a164c951ce | ||
|
|
18d6013f3f | ||
|
|
6416080b79 |
No files matched your search
+1
-8
@@ -59,11 +59,4 @@ TURBO_TELEMETRY_DISABLED=1
|
|||||||
# ENABLE_KUBERNETES=true
|
# ENABLE_KUBERNETES=true
|
||||||
|
|
||||||
# Enable mock integration
|
# Enable mock integration
|
||||||
UNSAFE_ENABLE_MOCK_INTEGRATION=true
|
UNSAFE_ENABLE_MOCK_INTEGRATION=true
|
||||||
|
|
||||||
# Enable demo mode (seeds a demo user "demo"/"demo", a sample board and a mock integration)
|
|
||||||
# DEMO_MODE=true
|
|
||||||
|
|
||||||
# Whether demo mode is read-only. Defaults to true (the public demo blocks all mutations).
|
|
||||||
# Set to false for writable preview deployments so the demo user can modify the dashboard.
|
|
||||||
# DEMO_READ_ONLY=false
|
|
||||||
@@ -33,8 +33,6 @@ body:
|
|||||||
options:
|
options:
|
||||||
# The below comment is used to insert a new version with on-release.yml
|
# The below comment is used to insert a new version with on-release.yml
|
||||||
#NEXT_VERSION#
|
#NEXT_VERSION#
|
||||||
- 1.69.2
|
|
||||||
- 1.69.0
|
|
||||||
- 1.68.0
|
- 1.68.0
|
||||||
- 1.67.0
|
- 1.67.0
|
||||||
- 1.66.1
|
- 1.66.1
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ outputs:
|
|||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175486441
|
if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175486441
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
- name: Obtain token
|
- name: Obtain token
|
||||||
id: obtainToken
|
id: obtainToken
|
||||||
uses: actions/create-github-app-token@v3
|
uses: actions/create-github-app-token@v3
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-format-autofix:
|
lint-format-autofix:
|
||||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
|
if: github.event_name == 'pull_request'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ github.head_ref }}
|
ref: ${{ github.head_ref }}
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -90,9 +90,9 @@ jobs:
|
|||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup
|
- name: Setup
|
||||||
uses: ./tooling/github/setup
|
uses: ./tooling/github/setup
|
||||||
@@ -124,7 +124,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Setup
|
- name: Setup
|
||||||
uses: ./tooling/github/setup
|
uses: ./tooling/github/setup
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@@ -153,7 +153,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Setup
|
- name: Setup
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Obtain token
|
- name: Obtain token
|
||||||
id: obtainToken
|
id: obtainToken
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Upload Crowdin translations
|
- name: Upload Crowdin translations
|
||||||
uses: crowdin/github-action@v2
|
uses: crowdin/github-action@v2
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
args: "Beta release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
|
args: "Beta release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Get Next Version
|
- name: Get Next Version
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
||||||
permission-contents: write # required to commit package.json & changelog changes, merge them to dev / next and publish the release
|
permission-contents: write # required to commit package.json & changelog changes, merge them to dev / next and publish the release
|
||||||
|
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
if: env.SKIP_RELEASE == 'false'
|
if: env.SKIP_RELEASE == 'false'
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -119,7 +119,7 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
digest: ${{ steps.build.outputs.digest }}
|
digest: ${{ steps.build.outputs.digest }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.release.outputs.git_ref }}
|
ref: ${{ needs.release.outputs.git_ref }}
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
digest: ${{ steps.build.outputs.digest }}
|
digest: ${{ steps.build.outputs.digest }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.release.outputs.git_ref }}
|
ref: ${{ needs.release.outputs.git_ref }}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Setup
|
- name: Setup
|
||||||
uses: ./tooling/github/setup
|
uses: ./tooling/github/setup
|
||||||
- name: Build docs
|
- name: Build docs
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
|
args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Get Next Version
|
- name: Get Next Version
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup
|
- name: Setup
|
||||||
uses: ./tooling/github/setup
|
uses: ./tooling/github/setup
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: dev
|
ref: dev
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: dev
|
ref: dev
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Validate prebuilt dependencies for amd64
|
- name: Validate prebuilt dependencies for amd64
|
||||||
id: validate-amd64
|
id: validate-amd64
|
||||||
uses: ./.github/actions/prebuilt-debian
|
uses: ./.github/actions/prebuilt-debian
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Validate prebuilt dependencies for arm64
|
- name: Validate prebuilt dependencies for arm64
|
||||||
id: validate-arm64
|
id: validate-arm64
|
||||||
uses: ./.github/actions/prebuilt-debian
|
uses: ./.github/actions/prebuilt-debian
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 2
|
timeout-minutes: 2
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- run: |
|
- run: |
|
||||||
npx --yes --package renovate -- \
|
npx --yes --package renovate -- \
|
||||||
renovate-config-validator --strict .github/renovate.json5
|
renovate-config-validator --strict .github/renovate.json5
|
||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
permission-contents: write # required to commit to branch
|
permission-contents: write # required to commit to branch
|
||||||
permission-pull-requests: write # required to create pr & enable automerge
|
permission-pull-requests: write # required to create pr & enable automerge
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.obtainToken.outputs.token }}
|
token: ${{ steps.obtainToken.outputs.token }}
|
||||||
ref: dev
|
ref: dev
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
permission-pull-requests: write # required to create pr & enable automerge
|
permission-pull-requests: write # required to create pr & enable automerge
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
permission-contents: write # required to commit to branch
|
permission-contents: write # required to commit to branch
|
||||||
permission-pull-requests: write # required to create pr & enable automerge
|
permission-pull-requests: write # required to create pr & enable automerge
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||||
- name: Setup
|
- name: Setup
|
||||||
|
|||||||
@@ -1,48 +1,3 @@
|
|||||||
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **layout:** raise AppShellHeader z-index above Indicator dots ([687edb9](https://github.com/homarr-labs/homarr/commit/687edb9226307cb83986097abb24fe7f672e1778)), closes [#6194](https://github.com/homarr-labs/homarr/issues/6194)
|
|
||||||
|
|
||||||
## [1.69.1](https://github.com/homarr-labs/homarr/compare/v1.69.0...v1.69.1) (2026-07-04)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* unblock query cache mutations in demo mode and add cache buster ([#6176](https://github.com/homarr-labs/homarr/issues/6176)) ([a382c75](https://github.com/homarr-labs/homarr/commit/a382c75968e73bafff55b5890b1e95f0779b50d3))
|
|
||||||
|
|
||||||
## [1.69.0](https://github.com/homarr-labs/homarr/compare/v1.68.1...v1.69.0) (2026-07-03)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* add per-widget secret storage for release provider authentication ([77884a3](https://github.com/homarr-labs/homarr/commit/77884a31794e12a6f7bc59d96c8f61aa429ca68d))
|
|
||||||
* **config:** enable pnpm global virtual store for worktree support ([#6098](https://github.com/homarr-labs/homarr/issues/6098)) ([3864727](https://github.com/homarr-labs/homarr/commit/3864727502a64052c4e3838468094b30bf04a133))
|
|
||||||
* **docker-widget:** add per-column visibility toggle ([#6093](https://github.com/homarr-labs/homarr/issues/6093)) ([0765262](https://github.com/homarr-labs/homarr/commit/076526293d13379004461633d7ba4c2e83bd5ffe))
|
|
||||||
* **docs:** addd in documentation for archiveteam warrior ([f66469d](https://github.com/homarr-labs/homarr/commit/f66469d79797a2da214588f4e8253b03633c59a5))
|
|
||||||
* simplify caching — TanStack Query as single source of truth ([#6075](https://github.com/homarr-labs/homarr/issues/6075)) ([f19033e](https://github.com/homarr-labs/homarr/commit/f19033e0d53d248aed678737f3d04d5d8c7815b5))
|
|
||||||
* **widgets:** right-click context menu with auto-generated toggle actions ([#6126](https://github.com/homarr-labs/homarr/issues/6126)) ([7e58858](https://github.com/homarr-labs/homarr/commit/7e58858353ae866efd657270d4bb16d2c7d3e551))
|
|
||||||
* **widgets:** show calendar events on hover with pin on click ([#2637](https://github.com/homarr-labs/homarr/issues/2637)) ([#6081](https://github.com/homarr-labs/homarr/issues/6081)) ([db3d588](https://github.com/homarr-labs/homarr/commit/db3d58821e4de8d46b110125d0fa36919765248f))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* add id to repository schema and handle missing ids gracefully ([8747d5f](https://github.com/homarr-labs/homarr/commit/8747d5f93ee9a295aad3c7035e8c6c7080bf5ee6))
|
|
||||||
* address PR review - atomic upsert, MCP metadata, stale tokens, save UX ([757d598](https://github.com/homarr-labs/homarr/commit/757d59854e5ec82d91c053b8bc3d7eaf2e70efc4))
|
|
||||||
* address PR review findings for widget-only integration refactor ([c892731](https://github.com/homarr-labs/homarr/commit/c892731479b47fab6218bd1f52fa6eccbdaa371e))
|
|
||||||
* address review - paginate GH releases, GitLab tag_name, docs, test ([e687bc8](https://github.com/homarr-labs/homarr/commit/e687bc8c9277797acaa164a09c74b3808a2cb346))
|
|
||||||
* apply version filter to LinuxServer.io provider ([34d976e](https://github.com/homarr-labs/homarr/commit/34d976eeea09e396334ce8fce5c492c07e7dd32b))
|
|
||||||
* catch individual repo fetch failures to prevent batch crashes ([d893515](https://github.com/homarr-labs/homarr/commit/d893515b932608ea08c9c6e90b83f340e40f6f8e))
|
|
||||||
* compile versionRegex once and add fetch timeouts ([64c7512](https://github.com/homarr-labs/homarr/commit/64c75122833981a49e9c8bf1789d501faff6d62d))
|
|
||||||
* **deps:** update dependency postcss to ^8.5.16 ([#6169](https://github.com/homarr-labs/homarr/issues/6169)) ([9252fdc](https://github.com/homarr-labs/homarr/commit/9252fdc13971bd982224a31d7be577112f99ec45))
|
|
||||||
* dns-hole widgets showing stale disconnected state ([#6096](https://github.com/homarr-labs/homarr/issues/6096)) ([2443f1c](https://github.com/homarr-labs/homarr/commit/2443f1cd3ff6765a91783d60d42aa03d9ba63fcd))
|
|
||||||
* gracefully degrade when widget_secret table is missing or access denied ([30107e4](https://github.com/homarr-labs/homarr/commit/30107e4e76a454ff7402de9a8bc2e0dd6f422aa8))
|
|
||||||
* handle missing widgetSecrets table and add default releases repo ([af35a50](https://github.com/homarr-labs/homarr/commit/af35a50fa05fee2f0118e98727eb108b0200f762))
|
|
||||||
* **immich:** v3 API compatibility — use searchAssets for album assets, drop deviceAssetId ([#5977](https://github.com/homarr-labs/homarr/issues/5977)) ([a5ec880](https://github.com/homarr-labs/homarr/commit/a5ec880eec276e0814d2ab3ed7f9bc0b804d0465))
|
|
||||||
* quick wins — calendar, about, glances, admin UX, releases, iframe, notes ([#6116](https://github.com/homarr-labs/homarr/issues/6116)) ([f9da71a](https://github.com/homarr-labs/homarr/commit/f9da71a19bdfa589aafe26b164d0e0782208ac0a))
|
|
||||||
* restore ArchiveTeam Warrior as a proper integration ([8a5ad58](https://github.com/homarr-labs/homarr/commit/8a5ad5861ba4c2d0978f1fa3f6115a1c5974f8a2))
|
|
||||||
* **timetable:** restore station search by removing disabled query guard ([d670b69](https://github.com/homarr-labs/homarr/commit/d670b6939672932c1a3d17ab9913901491cb46e3))
|
|
||||||
* update CHANGES.md for ArchiveTeam Warrior restoration, remove typo translation key ([62baaf7](https://github.com/homarr-labs/homarr/commit/62baaf78130c4b74e899a948cee17097d8c02593))
|
|
||||||
* verbose error messages in all release providers ([2155f13](https://github.com/homarr-labs/homarr/commit/2155f13f245f601b99458e227b6e058fbfa44cd9))
|
|
||||||
* **widgets:** make downloads widget table background transparent ([#6025](https://github.com/homarr-labs/homarr/issues/6025)) ([73bed51](https://github.com/homarr-labs/homarr/commit/73bed51df895250f415efd7a87e721e403b3fd99))
|
|
||||||
|
|
||||||
## [1.68.1](https://github.com/homarr-labs/homarr/compare/v1.68.0...v1.68.1) (2026-06-30)
|
## [1.68.1](https://github.com/homarr-labs/homarr/compare/v1.68.0...v1.68.1) (2026-06-30)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -159,10 +159,6 @@ packages/integrations/src/search-ch @Meierschlumpf
|
|||||||
apps/docs/docs/integrations/speedtest-tracker @kylegrieder
|
apps/docs/docs/integrations/speedtest-tracker @kylegrieder
|
||||||
packages/integrations/src/speedtest-tracker @kylegrieder
|
packages/integrations/src/speedtest-tracker @kylegrieder
|
||||||
|
|
||||||
# Synology - introduced in https://github.com/homarr-labs/homarr/pull/6121 by @mrjoshuap
|
|
||||||
apps/docs/docs/integrations/synology @mrjoshuap
|
|
||||||
packages/integrations/src/synology @mrjoshuap
|
|
||||||
|
|
||||||
# Technitium DNS - introduced in https://github.com/homarr-labs/homarr/pull/5832 by @lopsch
|
# Technitium DNS - introduced in https://github.com/homarr-labs/homarr/pull/5832 by @lopsch
|
||||||
apps/docs/docs/integrations/technitium-dns @lopsch
|
apps/docs/docs/integrations/technitium-dns @lopsch
|
||||||
packages/integrations/src/technitium @lopsch
|
packages/integrations/src/technitium @lopsch
|
||||||
|
|||||||
@@ -39,26 +39,13 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats';
|
|||||||
'Scroll down to "API Keys"',
|
'Scroll down to "API Keys"',
|
||||||
'Click "New API Key"',
|
'Click "New API Key"',
|
||||||
'Enter a name, e.g. "Homarr"',
|
'Enter a name, e.g. "Homarr"',
|
||||||
(
|
(<span>
|
||||||
|
Select the following permissions:{" "}
|
||||||
<span>
|
<code>album.read</code>,{" "}
|
||||||
|
<code>user.read</code>,{" "}
|
||||||
Select the following permissions:{" "}
|
<code>server.statistics</code>,{" "}
|
||||||
<code>album.statistics</code>,{" "}
|
<code>asset.download</code>
|
||||||
<code>album.read</code>,{" "}
|
</span>),
|
||||||
<code>album.create</code>,{" "}
|
|
||||||
<code>user.read</code>,{" "}
|
|
||||||
<code>user.update</code>,{" "}
|
|
||||||
<code>asset.read</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>
|
|
||||||
|
|
||||||
),
|
|
||||||
'Create the integration in Homarr and copy the API key to there'
|
'Create the integration in Homarr and copy the API key to there'
|
||||||
]
|
]
|
||||||
}]} />
|
}]} />
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Synology DiskStation"
|
|
||||||
description: "Monitor CPU, memory, storage volumes, and system health from Synology DSM."
|
|
||||||
hide_title: true
|
|
||||||
---
|
|
||||||
|
|
||||||
import { IntegrationHeader } from "@site/src/components/integrations/header";
|
|
||||||
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
|
|
||||||
import { AddingIntegration } from "@site/src/components/integrations/adding";
|
|
||||||
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
|
|
||||||
import { synologyIntegration } from ".";
|
|
||||||
import { systemResourcesWidget } from "@site/docs/widgets/system-resources";
|
|
||||||
import { systemDisksWidget } from "@site/docs/widgets/system-disks";
|
|
||||||
import { healthMonitoringWidget } from "@site/docs/widgets/health-monitoring";
|
|
||||||
import Alert from "@theme/Admonition";
|
|
||||||
|
|
||||||
<IntegrationHeader integration={synologyIntegration} categories={["NAS", "Hardware"]} />
|
|
||||||
|
|
||||||
### Widgets & Capabilities
|
|
||||||
|
|
||||||
<IntegrationCapabilites
|
|
||||||
items={[
|
|
||||||
{ widget: healthMonitoringWidget },
|
|
||||||
{ widget: systemResourcesWidget },
|
|
||||||
{ widget: systemDisksWidget },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
### Supported metrics
|
|
||||||
|
|
||||||
| Widget section | DSM API source | May be unavailable |
|
|
||||||
| ---------------------------- | ----------------------------------------------------- | ------------------------------------------------------- |
|
|
||||||
| CPU ring / chart | `SYNO.Core.System.Utilization` | — |
|
|
||||||
| Memory ring / chart | `SYNO.Core.System.Utilization` | — |
|
|
||||||
| CPU temperature ring | `SYNO.Core.System` system info | Models without a temperature sensor |
|
|
||||||
| Network charts | `SYNO.Core.System.Utilization` network totals | Some DSM builds or permission configurations |
|
|
||||||
| Load average (info modal) | `SYNO.Core.System.Utilization` CPU load fields | — |
|
|
||||||
| Storage volume usage | `SYNO.Core.System` storage info / Storage Manager API | — |
|
|
||||||
| SMART status and temperature | Storage Manager and SMART APIs | Disk-to-volume mapping may be incomplete on some setups |
|
|
||||||
| Reboot / update indicator | `SYNO.Core.System.Status` / upgrade APIs | Permission-dependent |
|
|
||||||
| GPU | Not available | Typical DiskStation models |
|
|
||||||
|
|
||||||
### Adding the integration
|
|
||||||
|
|
||||||
<AddingIntegration />
|
|
||||||
|
|
||||||
<Alert type="info" title="Use the correct DSM URL">
|
|
||||||
Enter the HTTP or HTTPS URL of your Synology DSM web interface, for example <code>http://diskstation.local:5000</code>{" "}
|
|
||||||
or <code>https://diskstation.local:5001</code>. If DSM uses a self-signed certificate, add it under{" "}
|
|
||||||
<a href="/docs/management/certificates">Management > Tools > Certificates</a> so Homarr can connect securely.
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
### Secrets
|
|
||||||
|
|
||||||
<IntegrationSecrets
|
|
||||||
secrets={[
|
|
||||||
{
|
|
||||||
credentials: ["username", "password"],
|
|
||||||
steps: [
|
|
||||||
"Create a dedicated DSM user with a strong password for Homarr.",
|
|
||||||
"Add the user to the default Administrators group so Homarr can read `SYNO.Core.System` metrics.",
|
|
||||||
"Under User Groups and Permissions, deny access to shared folders and most applications.",
|
|
||||||
"Allow login to DSM only, optionally restricted by source IP address.",
|
|
||||||
"Log in once with the service account in DSM to accept terms and conditions.",
|
|
||||||
],
|
|
||||||
body: (
|
|
||||||
<>
|
|
||||||
<Alert type="caution" title="Administrators group required">
|
|
||||||
Homarr reads system, storage, and utilization data from APIs that require membership in the default
|
|
||||||
Administrators group.
|
|
||||||
</Alert>
|
|
||||||
<Alert type="warning" title="Two-factor authentication is not supported">
|
|
||||||
Accounts with 2-step verification enabled cannot authenticate with this integration. Use a dedicated service
|
|
||||||
account without 2FA.
|
|
||||||
</Alert>
|
|
||||||
<Alert type="tip" title="First-time setup">
|
|
||||||
If login fails during testing, temporarily disable automatic blocking for invalid logins under Control Panel
|
|
||||||
> Security > Protection, then re-enable it after Homarr connects successfully.
|
|
||||||
</Alert>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
### Storage volume selection
|
|
||||||
|
|
||||||
Health Monitoring and System Disks widgets can filter Synology storage volumes when **every** selected integration is a Synology DiskStation. Leave **Visible storage volumes** empty to show all volumes, or pick specific volumes from the list. When multiple Synology integrations are connected, volumes are labeled with the integration name.
|
|
||||||
|
|
||||||
<Alert type="note" title="Synology-only option">
|
|
||||||
The volume filter is hidden when the widget also uses non-Synology integrations such as TrueNAS or Unraid.
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Alert type="note" title="Volume ordering">
|
|
||||||
When no volume filter is configured, the first volume returned by DSM is not guaranteed to be `volume_1`.
|
|
||||||
</Alert>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { IntegrationDefinition } from "@site/src/types";
|
|
||||||
|
|
||||||
export const synologyIntegration: IntegrationDefinition = {
|
|
||||||
name: "Synology DiskStation",
|
|
||||||
description: "Monitor CPU, memory, storage volumes, and system health from Synology DSM.",
|
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/synology.svg",
|
|
||||||
path: "../../integrations/synology",
|
|
||||||
};
|
|
||||||
@@ -1,71 +1,61 @@
|
|||||||
---
|
---
|
||||||
title: "System Health Monitoring"
|
title: 'System Health Monitoring'
|
||||||
description: "Displays information showing the health and status of your system(s)."
|
description: 'Displays information showing the health and status of your system(s).'
|
||||||
hide_title: true
|
hide_title: true
|
||||||
---
|
---
|
||||||
|
|
||||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||||
import { IconAd } from "@tabler/icons-react";
|
import { IconAd } from "@tabler/icons-react";
|
||||||
import Tabs from "@theme/Tabs";
|
import Tabs from '@theme/Tabs';
|
||||||
import TabItem from "@theme/TabItem";
|
import TabItem from '@theme/TabItem';
|
||||||
import { healthMonitoringWidget } from ".";
|
import { healthMonitoringWidget } from '.';
|
||||||
import { dashDotIntegration } from "@site/docs/integrations/dash-dot";
|
import { dashDotIntegration } from '@site/docs/integrations/dash-dot';
|
||||||
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault";
|
import { openMediaVaultIntegration } from '@site/docs/integrations/open-media-vault';
|
||||||
import { proxmoxIntegration } from "@site/docs/integrations/proxmox";
|
import { proxmoxIntegration } from '@site/docs/integrations/proxmox';
|
||||||
import { glancesIntegration } from "@site/docs/integrations/glances";
|
import { glancesIntegration } from '@site/docs/integrations/glances';
|
||||||
|
|
||||||
<WidgetHeader widget={healthMonitoringWidget} categories={["Health monitoring"]} />
|
|
||||||
|
<WidgetHeader
|
||||||
|
widget={healthMonitoringWidget}
|
||||||
|
categories={['Health monitoring']}
|
||||||
|
/>
|
||||||
|
|
||||||
This widget displays information about the health and status of your system(s), including CPU, memory, disk usage, and more.
|
This widget displays information about the health and status of your system(s), including CPU, memory, disk usage, and more.
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
import systemView from "./img/system-view.png";
|
import systemView from './img/system-view.png';
|
||||||
import clusterView from "./img/cluster-view.png";
|
import clusterView from './img/cluster-view.png';
|
||||||
import { truenasIntegration } from "@site/docs/integrations/truenas";
|
import { truenasIntegration } from '@site/docs/integrations/truenas';
|
||||||
import { synologyIntegration } from "@site/docs/integrations/synology";
|
|
||||||
|
|
||||||
<img src={systemView} alt="health-monitoring system-view" width={256} height={256} />
|
<img src={systemView} alt="health-monitoring system-view" width={256} height={256} />
|
||||||
<img src={clusterView} alt="health-monitoring cluster-view" width={256} height={256} />
|
<img src={clusterView} alt="health-monitoring cluster-view" width={256} height={256} />
|
||||||
|
|
||||||
### Supported Integrations
|
### Supported Integrations
|
||||||
|
|
||||||
<WidgetIntegrations
|
<WidgetIntegrations items={[{
|
||||||
items={[
|
integration: dashDotIntegration,
|
||||||
{
|
note: "Will display information on the system tab"
|
||||||
integration: dashDotIntegration,
|
}, {
|
||||||
note: "Will display information on the system tab",
|
integration: openMediaVaultIntegration,
|
||||||
},
|
note: "Will display information on the system tab"
|
||||||
{
|
}, {
|
||||||
integration: openMediaVaultIntegration,
|
integration: truenasIntegration,
|
||||||
note: "Will display information on the system tab",
|
note: "Will display information on the system tab"
|
||||||
},
|
}, {
|
||||||
{
|
integration: glancesIntegration,
|
||||||
integration: truenasIntegration,
|
note: "Will display information on the system tab"
|
||||||
note: "Will display information on the system tab",
|
}, {
|
||||||
},
|
integration: proxmoxIntegration,
|
||||||
{
|
note: "Will display information on the cluster tab, only supports one integration at a time"
|
||||||
integration: synologyIntegration,
|
}]} />
|
||||||
note: "Will display information on the system tab",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
integration: glancesIntegration,
|
|
||||||
note: "Will display information on the system tab",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
integration: proxmoxIntegration,
|
|
||||||
note: "Will display information on the cluster tab, only supports one integration at a time",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
### Adding the widget
|
### Adding the widget
|
||||||
|
|
||||||
<AddingWidget />
|
<AddingWidget />
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
<WidgetConfig configuration={healthMonitoringWidget.configuration} />
|
<WidgetConfig configuration={healthMonitoringWidget.configuration} />
|
||||||
@@ -38,13 +38,6 @@ export const healthMonitoringWidget: WidgetDefinition = {
|
|||||||
values: { type: "boolean" },
|
values: { type: "boolean" },
|
||||||
defaultValue: "yes",
|
defaultValue: "yes",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "Visible storage volumes",
|
|
||||||
description:
|
|
||||||
"Only available when all selected integrations are Synology. Leave empty to show all detected storage volumes.",
|
|
||||||
values: "List of storage volumes",
|
|
||||||
defaultValue: "Empty list",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "Default tab",
|
name: "Default tab",
|
||||||
description: "Tab that should be visible by default. Only used when multiple integrations are available.",
|
description: "Tab that should be visible by default. Only used when multiple integrations are available.",
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
---
|
|
||||||
title: "System disks"
|
|
||||||
description: "Disk usage of your system"
|
|
||||||
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 { systemDisksWidget } from ".";
|
|
||||||
import { dashDotIntegration } from "@site/docs/integrations/dash-dot";
|
|
||||||
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault";
|
|
||||||
import { truenasIntegration } from "@site/docs/integrations/truenas";
|
|
||||||
import { unraidIntegration } from "@site/docs/integrations/unraid";
|
|
||||||
import { synologyIntegration } from "@site/docs/integrations/synology";
|
|
||||||
|
|
||||||
<WidgetHeader widget={systemDisksWidget} categories={["Storage", "Disks"]} />
|
|
||||||
|
|
||||||
This widget displays filesystem and SMART disk usage for supported server and NAS integrations.
|
|
||||||
|
|
||||||
### Supported Integrations
|
|
||||||
|
|
||||||
<WidgetIntegrations
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
integration: dashDotIntegration,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
integration: openMediaVaultIntegration,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
integration: truenasIntegration,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
integration: unraidIntegration,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
integration: synologyIntegration,
|
|
||||||
note: "Supports storage volume filtering when only Synology integrations are selected",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
### Adding the widget
|
|
||||||
|
|
||||||
<AddingWidget />
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
<WidgetConfig configuration={systemDisksWidget.configuration} />
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { WidgetDefinition } from "@site/src/types";
|
|
||||||
import { IconServer2 } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
export const systemDisksWidget: WidgetDefinition = {
|
|
||||||
icon: IconServer2,
|
|
||||||
name: "System disks",
|
|
||||||
description: "Disk usage of your system",
|
|
||||||
path: "../../widgets/system-disks",
|
|
||||||
configuration: {
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: "Show temperature if available",
|
|
||||||
description: "Display disk temperature when the integration provides it",
|
|
||||||
values: { type: "boolean" },
|
|
||||||
defaultValue: "Yes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Display mode",
|
|
||||||
description: "How disk usage values are shown",
|
|
||||||
values: {
|
|
||||||
type: "select",
|
|
||||||
options: ["Percentage (e.g., 76%)", "Absolute values (e.g., 800GB / 1TB)", "Free space percentage (e.g., 24% free)"],
|
|
||||||
},
|
|
||||||
defaultValue: "Percentage (e.g., 76%)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Show background usage bar",
|
|
||||||
description: "Fill the area below the usage value with a background bar",
|
|
||||||
values: { type: "boolean" },
|
|
||||||
defaultValue: "Yes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Visible storage volumes",
|
|
||||||
description:
|
|
||||||
"Only available when all selected integrations are Synology. Leave empty to show all detected storage volumes.",
|
|
||||||
values: "List of storage volumes",
|
|
||||||
defaultValue: "All volumes",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,69 +1,57 @@
|
|||||||
---
|
---
|
||||||
title: "System Resources"
|
title: 'System Resources'
|
||||||
description: "Displays CPU, RAM and network of your host in real time"
|
description: 'Displays CPU, RAM and network of your host in real time'
|
||||||
hide_title: true
|
hide_title: true
|
||||||
---
|
---
|
||||||
|
|
||||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||||
import { systemResourcesWidget } from ".";
|
import { systemResourcesWidget } from '.';
|
||||||
import { dashDotIntegration } from "@site/docs/integrations/dash-dot";
|
import { dashDotIntegration } from '@site/docs/integrations/dash-dot';
|
||||||
import { glancesIntegration } from "@site/docs/integrations/glances";
|
import { glancesIntegration } from '@site/docs/integrations/glances';
|
||||||
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault";
|
import { openMediaVaultIntegration } from '@site/docs/integrations/open-media-vault';
|
||||||
import { proxmoxIntegration } from "@site/docs/integrations/proxmox";
|
import { proxmoxIntegration } from '@site/docs/integrations/proxmox';
|
||||||
|
|
||||||
<WidgetHeader widget={systemResourcesWidget} categories={["CPU", "RAM", "Memory", "Network", "Interface"]} />
|
|
||||||
|
<WidgetHeader
|
||||||
|
widget={systemResourcesWidget}
|
||||||
|
categories={['CPU', 'RAM', 'Memory', 'Network', 'Interface']}
|
||||||
|
/>
|
||||||
|
|
||||||
Displays CPU, RAM and network of your host in real time
|
Displays CPU, RAM and network of your host in real time
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
import network from "./img/network.png";
|
import network from './img/network.png';
|
||||||
import cpu from "./img/cpu.png";
|
import cpu from './img/cpu.png';
|
||||||
import memory from "./img/memory.png";
|
import memory from './img/memory.png';
|
||||||
import cpuAndMemory from "./img/cpu-and-memory.png";
|
import cpuAndMemory from './img/cpu-and-memory.png';
|
||||||
import { truenasIntegration } from "@site/docs/integrations/truenas";
|
import { truenasIntegration } from '@site/docs/integrations/truenas';
|
||||||
import { synologyIntegration } from "@site/docs/integrations/synology";
|
|
||||||
|
|
||||||
<div className="flex gap-1">
|
<div className='flex gap-1'>
|
||||||
<div className="flex flex-col gap-1">
|
<div className='flex flex-col gap-1'>
|
||||||
<img src={network} alt="system-resources-widget network-only icon-label" width={256 + 4} height={128} />
|
<img src={network} alt="system-resources-widget network-only icon-label" width={256 + 4} height={128} />
|
||||||
<div className="flex gap-1">
|
<div className='flex gap-1'>
|
||||||
<img src={cpu} alt="system-resources-widget cpu-only no-label item-title" width={128} height={128} />
|
<img src={cpu} alt="system-resources-widget cpu-only no-label item-title" width={128} height={128} />
|
||||||
<img src={memory} alt="system-resources-widget memory-only text-label" width={128} height={128} />
|
<img src={memory} alt="system-resources-widget memory-only text-label" width={128} height={128} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<img
|
<img src={cpuAndMemory} alt="system-resources-widget cpu-and-memory icon-text-label" width={256 + 4} height={256 + 4} />
|
||||||
src={cpuAndMemory}
|
|
||||||
alt="system-resources-widget cpu-and-memory icon-text-label"
|
|
||||||
width={256 + 4}
|
|
||||||
height={256 + 4}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### Supported Integrations
|
### Supported Integrations
|
||||||
|
|
||||||
<WidgetIntegrations
|
<WidgetIntegrations items={[{
|
||||||
items={[
|
integration: dashDotIntegration
|
||||||
{
|
}, {
|
||||||
integration: dashDotIntegration,
|
integration: openMediaVaultIntegration
|
||||||
},
|
}, {
|
||||||
{
|
integration: truenasIntegration
|
||||||
integration: openMediaVaultIntegration,
|
}, {
|
||||||
},
|
integration: glancesIntegration
|
||||||
{
|
}]} />
|
||||||
integration: truenasIntegration,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
integration: synologyIntegration,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
integration: glancesIntegration,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
### Adding the widget
|
### Adding the widget
|
||||||
|
|
||||||
@@ -71,4 +59,4 @@ import { synologyIntegration } from "@site/docs/integrations/synology";
|
|||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
<WidgetConfig configuration={systemResourcesWidget.configuration} />
|
<WidgetConfig configuration={systemResourcesWidget.configuration} />
|
||||||
@@ -1,25 +1,18 @@
|
|||||||
// Algolia DocSearch crawler for homarr.dev
|
// Algolia DocSearch crawler for homarr.dev
|
||||||
// Crawler ID: cd77a285-2756-4557-bf21-ee703748df15
|
// Crawler ID: cd77a285-2756-4557-bf21-ee703748df15
|
||||||
// Paste this in the Algolia crawler editor.
|
// DocSearch-managed crawlers: paste this in the Algolia crawler editor or send to DocSearch support.
|
||||||
|
// Template: https://docsearch.algolia.com/docs/templates/#docusaurus-v3-template
|
||||||
|
// Markdown indexing: https://www.algolia.com/doc/guides/algolia-ai/askai/guides/markdown-indexing
|
||||||
|
|
||||||
new Crawler({
|
new Crawler({
|
||||||
appId: "N69WSPZTID",
|
appId: "N69WSPZTID",
|
||||||
apiKey: "YOUR_CRAWLER_ADMIN_API_KEY",
|
apiKey: "YOUR_CRAWLER_ADMIN_API_KEY",
|
||||||
indexPrefix: "",
|
rateLimit: 8,
|
||||||
rateLimit: 32,
|
maxDepth: 10,
|
||||||
maxDepth: 4,
|
|
||||||
startUrls: ["https://homarr.dev/"],
|
startUrls: ["https://homarr.dev/"],
|
||||||
sitemaps: ["https://homarr.dev/sitemap.xml"],
|
sitemaps: ["https://homarr.dev/sitemap.xml"],
|
||||||
ignoreCanonicalTo: true,
|
ignoreCanonicalTo: true,
|
||||||
discoveryPatterns: ["https://homarr.dev/**"],
|
discoveryPatterns: ["https://homarr.dev/**"],
|
||||||
exclusionPatterns: [
|
|
||||||
"https://homarr.dev/docs/tags/**",
|
|
||||||
"https://homarr.dev/blog/tags/**",
|
|
||||||
"https://homarr.dev/blog/authors/**",
|
|
||||||
"https://homarr.dev/blog/archive/**",
|
|
||||||
"https://homarr.dev/search/**",
|
|
||||||
"https://homarr.dev/docs/category/**",
|
|
||||||
],
|
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
indexName: "Docusaurus",
|
indexName: "Docusaurus",
|
||||||
@@ -112,6 +105,7 @@ new Crawler({
|
|||||||
advancedSyntax: true,
|
advancedSyntax: true,
|
||||||
attributeCriteriaComputedByMinProximity: true,
|
attributeCriteriaComputedByMinProximity: true,
|
||||||
removeWordsIfNoResults: "allOptional",
|
removeWordsIfNoResults: "allOptional",
|
||||||
|
separatorsToIndex: "_",
|
||||||
},
|
},
|
||||||
markdown: {
|
markdown: {
|
||||||
attributesForFaceting: ["lang", "language", "version", "docusaurus_tag"],
|
attributesForFaceting: ["lang", "language", "version", "docusaurus_tag"],
|
||||||
@@ -125,10 +119,4 @@ new Crawler({
|
|||||||
advancedSyntax: false,
|
advancedSyntax: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
apiKey: "YOUR_CRAWLER_ADMIN_API_KEY",
|
|
||||||
schedule: "on saturday",
|
|
||||||
renderJavaScript: false,
|
|
||||||
maxUrls: null,
|
|
||||||
cache: { enabled: false },
|
|
||||||
safetyChecks: { beforeIndexPublishing: { maxLostRecordsPercentage: 99 } },
|
|
||||||
});
|
});
|
||||||
@@ -8,7 +8,7 @@ const config: Config = {
|
|||||||
tagline: "A simple yet powerful dashboard for your server.",
|
tagline: "A simple yet powerful dashboard for your server.",
|
||||||
url: "https://homarr.dev",
|
url: "https://homarr.dev",
|
||||||
baseUrl: "/",
|
baseUrl: "/",
|
||||||
trailingSlash: undefined,
|
trailingSlash: true,
|
||||||
favicon: "img/logo.png",
|
favicon: "img/logo.png",
|
||||||
organizationName: "homarr-labs",
|
organizationName: "homarr-labs",
|
||||||
projectName: "homarr",
|
projectName: "homarr",
|
||||||
@@ -79,27 +79,8 @@ const config: Config = {
|
|||||||
sitemap: {
|
sitemap: {
|
||||||
changefreq: "weekly",
|
changefreq: "weekly",
|
||||||
priority: 0.5,
|
priority: 0.5,
|
||||||
ignorePatterns: ["/tags/**", "/docs/category/**"],
|
ignorePatterns: ["/tags/**"],
|
||||||
filename: "sitemap.xml",
|
filename: "sitemap.xml",
|
||||||
createSitemapItems: async ({ routes, siteConfig, defaultCreateSitemapItems }) => {
|
|
||||||
const items = await defaultCreateSitemapItems({ routes, siteConfig });
|
|
||||||
return items.map((item) => {
|
|
||||||
const path = new URL(item.url).pathname;
|
|
||||||
if (path === "/" || path === "") {
|
|
||||||
return { ...item, priority: 1.0, changefreq: "weekly" };
|
|
||||||
}
|
|
||||||
if (path.startsWith("/docs/")) {
|
|
||||||
return { ...item, priority: 0.7, changefreq: "monthly" };
|
|
||||||
}
|
|
||||||
if (path.startsWith("/blog/")) {
|
|
||||||
return { ...item, priority: 0.3, changefreq: "never" };
|
|
||||||
}
|
|
||||||
if (path.startsWith("/about-us")) {
|
|
||||||
return { ...item, priority: 0.4, changefreq: "yearly" };
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
} satisfies Preset.Options,
|
} satisfies Preset.Options,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -32,19 +32,19 @@
|
|||||||
"@scalar/api-reference-react": "catalog:",
|
"@scalar/api-reference-react": "catalog:",
|
||||||
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
|
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
|
||||||
"@tabler/icons-react": "catalog:",
|
"@tabler/icons-react": "catalog:",
|
||||||
"@tailwindcss/postcss": "^4.3.2",
|
"@tailwindcss/postcss": "^4.3.1",
|
||||||
"clsx": "catalog:",
|
"clsx": "catalog:",
|
||||||
"dayjs": "catalog:",
|
"dayjs": "catalog:",
|
||||||
"docusaurus-plugin-image-zoom": "^3.0.1",
|
"docusaurus-plugin-image-zoom": "^3.0.1",
|
||||||
"leader-line-new": "^1.1.9",
|
"leader-line-new": "^1.1.9",
|
||||||
"postcss": "^8.5.16",
|
"postcss": "^8.5.15",
|
||||||
"posthog-docusaurus": "^2.0.5",
|
"posthog-docusaurus": "^2.0.5",
|
||||||
"prism-react-renderer": "^2.4.1",
|
"prism-react-renderer": "^2.4.1",
|
||||||
"random-word-slugs": "^0.1.7",
|
"random-word-slugs": "^0.1.7",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"swiper": "^14.0.1",
|
"swiper": "^12.2.0",
|
||||||
"tailwindcss": "^4.3.2"
|
"tailwindcss": "^4.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@docusaurus/module-type-aliases": "^3.10.1",
|
"@docusaurus/module-type-aliases": "^3.10.1",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
@import url("https://api.fontshare.com/v2/css?f[]=work-sans@2,1&display=swap");
|
|
||||||
@import "tailwindcss/theme" layer(theme);
|
@import "tailwindcss/theme" layer(theme);
|
||||||
@import "tailwindcss/utilities" layer(utilities);
|
@import "tailwindcss/utilities" layer(utilities);
|
||||||
|
|
||||||
|
@import url(https://api.fontshare.com/v2/css?f[]=work-sans@2,1&display=swap);
|
||||||
|
|
||||||
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
|
"extends": "@docusaurus/tsconfig",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"ignoreDeprecations": "6.0",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"target": "es5",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
@@ -9,14 +12,10 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"paths": {
|
|
||||||
"@site/*": ["./*"]
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
|
|||||||
+14
-12
@@ -5,9 +5,21 @@ import "@homarr/common/env";
|
|||||||
import "@homarr/core/infrastructure/logs/env";
|
import "@homarr/core/infrastructure/logs/env";
|
||||||
import "@homarr/docker/env";
|
import "@homarr/docker/env";
|
||||||
|
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
|
const homarrVersion = (() => {
|
||||||
|
for (const candidate of ["../../package.json", "./package.json"]) {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
|
||||||
|
if (pkg.name === "homarr") return pkg.version as string;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
})();
|
||||||
|
|
||||||
// Package path does not work... so we need to use relative path
|
// Package path does not work... so we need to use relative path
|
||||||
const withNextIntl = createNextIntlPlugin({
|
const withNextIntl = createNextIntlPlugin({
|
||||||
requestConfig: "../../packages/translation/src/request.ts",
|
requestConfig: "../../packages/translation/src/request.ts",
|
||||||
@@ -15,7 +27,7 @@ const withNextIntl = createNextIntlPlugin({
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
env: {
|
env: {
|
||||||
HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown",
|
HOMARR_VERSION: homarrVersion,
|
||||||
},
|
},
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
@@ -31,19 +43,9 @@ const nextConfig: NextConfig = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||||
turbopackFileSystemCacheForDev: true,
|
turbopackFileSystemCacheForDev: true,
|
||||||
|
preloadEntriesOnStart: false,
|
||||||
webpackMemoryOptimizations: true,
|
webpackMemoryOptimizations: true,
|
||||||
},
|
},
|
||||||
turbopack: {
|
|
||||||
// ponytail: known Turbopack NFT warning from path.join(process.cwd(), …) in
|
|
||||||
// src/app/api/backup/{route,shared}.ts. No working placement in 16.2.x
|
|
||||||
// (see vercel/next.js#95125). Suppress until upstream fix lands.
|
|
||||||
ignoreIssue: [
|
|
||||||
{
|
|
||||||
path: "**/*",
|
|
||||||
title: "Encountered unexpected file in NFT list",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
|
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
|
||||||
images: {
|
images: {
|
||||||
localPatterns: [
|
localPatterns: [
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "HOMARR_VERSION=$(node -p \"require('../../package.json').version\") pnpm with-env next build",
|
"build": "pnpm with-env next build",
|
||||||
"clean": "git clean -xdf .next .turbo node_modules",
|
"clean": "git clean -xdf .next .turbo node_modules",
|
||||||
"dev": "pnpm with-env next dev",
|
"dev": "pnpm with-env next dev",
|
||||||
"format": "oxfmt .",
|
"format": "oxfmt .",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||||
import superjson from "superjson";
|
|
||||||
|
|
||||||
import { fetchApi } from "@homarr/api/client";
|
import { fetchApi } from "@homarr/api/client";
|
||||||
import { getActiveQueryCacheBoardId, queryCacheStoragePrefix } from "@homarr/api/query-cache";
|
import { getActiveQueryCacheBoardId, queryCacheStoragePrefix } from "@homarr/api/query-cache";
|
||||||
@@ -27,6 +26,4 @@ export const createWidgetQueryPersister = () =>
|
|||||||
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
|
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
|
||||||
key: queryCacheStoragePrefix,
|
key: queryCacheStoragePrefix,
|
||||||
throttleTime: 2000,
|
throttleTime: 2000,
|
||||||
serialize: (data) => superjson.stringify(data),
|
|
||||||
deserialize: (data) => superjson.parse(data),
|
|
||||||
});
|
});
|
||||||
@@ -24,7 +24,6 @@ import type { AppRouter } from "@homarr/api";
|
|||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import {
|
import {
|
||||||
isPersistableWidgetQueryKey,
|
isPersistableWidgetQueryKey,
|
||||||
queryCacheBuster,
|
|
||||||
queryCacheDefaultGcTimeMs,
|
queryCacheDefaultGcTimeMs,
|
||||||
queryCacheDefaultRefetchIntervalMs,
|
queryCacheDefaultRefetchIntervalMs,
|
||||||
queryCacheDefaultStaleTimeMs,
|
queryCacheDefaultStaleTimeMs,
|
||||||
@@ -137,7 +136,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
client={queryClient}
|
client={queryClient}
|
||||||
persistOptions={{
|
persistOptions={{
|
||||||
persister,
|
persister,
|
||||||
buster: queryCacheBuster,
|
|
||||||
maxAge: queryCacheDefaultGcTimeMs,
|
maxAge: queryCacheDefaultGcTimeMs,
|
||||||
dehydrateOptions: {
|
dehydrateOptions: {
|
||||||
shouldDehydrateQuery: (query) =>
|
shouldDehydrateQuery: (query) =>
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import "~/styles/gridstack.scss";
|
|||||||
|
|
||||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||||
import type { PersistedClient } from "@tanstack/react-query-persist-client";
|
import type { PersistedClient } from "@tanstack/react-query-persist-client";
|
||||||
import superjson from "superjson";
|
|
||||||
import { queryCacheBuster } from "@homarr/api/query-cache";
|
|
||||||
import { getQueryClient } from "@homarr/api/server";
|
import { getQueryClient } from "@homarr/api/server";
|
||||||
import { IntegrationProvider } from "@homarr/auth/client";
|
import { IntegrationProvider } from "@homarr/auth/client";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
@@ -123,9 +121,8 @@ async function QueryCacheHydration({ userId, boardId }: { userId: string; boardI
|
|||||||
const serialized = await getQueryCacheAsync(userId, boardId);
|
const serialized = await getQueryCacheAsync(userId, boardId);
|
||||||
if (!serialized) return null;
|
if (!serialized) return null;
|
||||||
|
|
||||||
const persisted = superjson.parse<PersistedClient | undefined>(serialized);
|
const persisted = JSON.parse(serialized) as PersistedClient;
|
||||||
if (!persisted || persisted.buster !== queryCacheBuster) return null;
|
if (!persisted?.clientState?.queries?.length) return null;
|
||||||
if (!persisted.clientState?.queries?.length) return null;
|
|
||||||
|
|
||||||
return <HydrationBoundary state={persisted.clientState} />;
|
return <HydrationBoundary state={persisted.clientState} />;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -45,9 +45,7 @@ export const BoardContentHeaderActions = () => {
|
|||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const { hasChangeAccess } = useBoardPermissions(board);
|
const { hasChangeAccess } = useBoardPermissions(board);
|
||||||
// Fall back to read-only if the query has no data (e.g. errored) so we never expose
|
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
|
||||||
// edit/save/settings UI that would then fail server-side.
|
|
||||||
const { data: demoReadOnly = true, isLoading } = clientApi.info.isDemoReadOnly.useQuery();
|
|
||||||
|
|
||||||
if (!hasChangeAccess || isLoading) {
|
if (!hasChangeAccess || isLoading) {
|
||||||
return <SelectBoardsMenu />;
|
return <SelectBoardsMenu />;
|
||||||
@@ -57,9 +55,9 @@ export const BoardContentHeaderActions = () => {
|
|||||||
<>
|
<>
|
||||||
{isEditMode && <AddMenu />}
|
{isEditMode && <AddMenu />}
|
||||||
|
|
||||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
<EditModeMenu demoMode={demoMode ?? false} />
|
||||||
|
|
||||||
{!demoReadOnly && (
|
{!demoMode && (
|
||||||
<OnboardingTour.Target id="board-settings">
|
<OnboardingTour.Target id="board-settings">
|
||||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||||
<IconSettings stroke={1.5} />
|
<IconSettings stroke={1.5} />
|
||||||
@@ -160,7 +158,7 @@ const AddMenu = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
|
||||||
const [isEditMode, { open, close }] = useEditMode();
|
const [isEditMode, { open, close }] = useEditMode();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
@@ -190,11 +188,11 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
|||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
if (demoReadOnly) return discardDemoChanges();
|
if (demoMode) return discardDemoChanges();
|
||||||
return saveBoard(board);
|
return saveBoard(board);
|
||||||
}
|
}
|
||||||
open();
|
open();
|
||||||
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]);
|
}, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
|
||||||
|
|
||||||
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
|
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
|
||||||
usePreventLeaveWithDirty(isEditMode);
|
usePreventLeaveWithDirty(isEditMode);
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
export const findMigrationsFolder = () => {
|
const migrationsFolderCandidates = [
|
||||||
const cwd = process.cwd();
|
path.join(process.cwd(), "db/migrations/sqlite"),
|
||||||
const migrationsFolderCandidates = [
|
path.join(process.cwd(), "packages/db/migrations/sqlite"),
|
||||||
path.join(cwd, "db/migrations/sqlite"),
|
path.resolve(process.cwd(), "../../db/migrations/sqlite"),
|
||||||
path.join(cwd, "packages/db/migrations/sqlite"),
|
"/app/db/migrations/sqlite",
|
||||||
path.resolve(cwd, "../../db/migrations/sqlite"),
|
];
|
||||||
"/app/db/migrations/sqlite",
|
|
||||||
];
|
export const findMigrationsFolder = () => migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
|
||||||
return migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
|
|
||||||
};
|
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import type { MutableRefObject, ReactNode } from "react";
|
import type { MutableRefObject, ReactNode } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
|
import { Group, Menu, Switch, Text } from "@mantine/core";
|
||||||
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
|
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
@@ -50,9 +50,8 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const widgetQueryKey = useMemo(() => [["widget", item.kind]], [item.kind]);
|
const widgetQueryKey = useMemo(() => [["widget", item.kind]], [item.kind]);
|
||||||
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
|
|
||||||
const handleRefetch = useCallback(() => {
|
const handleRefetch = useCallback(() => {
|
||||||
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
|
void queryClient.invalidateQueries({ queryKey: widgetQueryKey });
|
||||||
}, [queryClient, widgetQueryKey]);
|
}, [queryClient, widgetQueryKey]);
|
||||||
|
|
||||||
const options = useMemo(
|
const options = useMemo(
|
||||||
@@ -224,15 +223,11 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
|||||||
|
|
||||||
<>
|
<>
|
||||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
|
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
|
||||||
<Menu.Item
|
<Menu.Item closeMenuOnClick leftSection={<IconRefresh size={16} />} onClick={handleRefetch}>
|
||||||
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
|
|
||||||
onClick={handleRefetch}
|
|
||||||
disabled={isWidgetFetching}
|
|
||||||
>
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
{tMenu("refresh")}
|
{tMenu("refresh")}
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} />
|
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} />
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@@ -270,24 +265,13 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const WidgetCacheAge = ({
|
const WidgetCacheAge = ({ queryClient, queryKey }: { queryClient: QueryClient; queryKey: unknown[] }) => {
|
||||||
queryClient,
|
|
||||||
queryKey,
|
|
||||||
isFetching,
|
|
||||||
}: {
|
|
||||||
queryClient: QueryClient;
|
|
||||||
queryKey: unknown[];
|
|
||||||
isFetching: boolean;
|
|
||||||
}) => {
|
|
||||||
// 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity
|
|
||||||
const [, setTick] = useState(0);
|
const [, setTick] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setTick((n) => n + 1), 1000);
|
const id = setInterval(() => setTick((n) => n + 1), 1000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (isFetching) return <Loader size={12} />;
|
|
||||||
|
|
||||||
const timestamps = queryClient
|
const timestamps = queryClient
|
||||||
.getQueryCache()
|
.getQueryCache()
|
||||||
.findAll({ queryKey })
|
.findAll({ queryKey })
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ interface Props {
|
|||||||
|
|
||||||
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||||
return (
|
return (
|
||||||
<AppShellHeader maw="100vw" zIndex={201} style={{ overflowX: "hidden" }}>
|
<AppShellHeader maw="100vw" style={{ overflowX: "hidden" }}>
|
||||||
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
|
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
|
||||||
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
|
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
|
||||||
{hasNavigation && <ClientBurger />}
|
{hasNavigation && <ClientBurger />}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "homarr-prebuilt-debian",
|
"name": "homarr-prebuilt-debian",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.11.1"
|
"better-sqlite3": "^12.10.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.18.0",
|
"node": ">=24.18.0",
|
||||||
|
|||||||
+5
-5
@@ -9,16 +9,16 @@ importers:
|
|||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
better-sqlite3:
|
better-sqlite3:
|
||||||
specifier: ^12.11.1
|
specifier: ^12.10.0
|
||||||
version: 12.11.1
|
version: 12.10.0
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
base64-js@1.5.1:
|
base64-js@1.5.1:
|
||||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||||
|
|
||||||
better-sqlite3@12.11.1:
|
better-sqlite3@12.10.0:
|
||||||
resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==}
|
resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==}
|
||||||
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x}
|
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x}
|
||||||
|
|
||||||
bindings@1.5.0:
|
bindings@1.5.0:
|
||||||
@@ -148,7 +148,7 @@ snapshots:
|
|||||||
|
|
||||||
base64-js@1.5.1: {}
|
base64-js@1.5.1: {}
|
||||||
|
|
||||||
better-sqlite3@12.11.1:
|
better-sqlite3@12.10.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
bindings: 1.5.0
|
bindings: 1.5.0
|
||||||
prebuild-install: 7.1.3
|
prebuild-install: 7.1.3
|
||||||
|
|||||||
+1
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homarr",
|
"name": "homarr",
|
||||||
"version": "1.69.2",
|
"version": "1.68.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "cross-env CI=true turbo build",
|
"build": "cross-env CI=true turbo build",
|
||||||
@@ -43,7 +43,6 @@
|
|||||||
"@semantic-release/release-notes-generator": "catalog:",
|
"@semantic-release/release-notes-generator": "catalog:",
|
||||||
"@testcontainers/redis": "catalog:",
|
"@testcontainers/redis": "catalog:",
|
||||||
"@turbo/gen": "catalog:",
|
"@turbo/gen": "catalog:",
|
||||||
|
|
||||||
"@vitejs/plugin-react": "catalog:",
|
"@vitejs/plugin-react": "catalog:",
|
||||||
"@vitest/coverage-v8": "catalog:",
|
"@vitest/coverage-v8": "catalog:",
|
||||||
"@vitest/ui": "catalog:",
|
"@vitest/ui": "catalog:",
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ export const env = createEnv({
|
|||||||
server: {
|
server: {
|
||||||
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
|
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
|
||||||
DEMO_MODE: createBooleanSchema(false),
|
DEMO_MODE: createBooleanSchema(false),
|
||||||
DEMO_READ_ONLY: createBooleanSchema(true),
|
|
||||||
},
|
},
|
||||||
runtimeEnv: {
|
runtimeEnv: {
|
||||||
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
|
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
|
||||||
DEMO_MODE: process.env.DEMO_MODE,
|
DEMO_MODE: process.env.DEMO_MODE,
|
||||||
DEMO_READ_ONLY: process.env.DEMO_READ_ONLY,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -5,7 +5,6 @@ export const queryCacheDefaultRefetchIntervalMs = 30_000;
|
|||||||
export const queryCacheDefaultGcTimeMs = 1000 * 60 * 60 * 24;
|
export const queryCacheDefaultGcTimeMs = 1000 * 60 * 60 * 24;
|
||||||
export const queryCacheMaxValueBytes = 1024 * 1024;
|
export const queryCacheMaxValueBytes = 1024 * 1024;
|
||||||
export const queryCacheStoragePrefix = "homarr-widget-query";
|
export const queryCacheStoragePrefix = "homarr-widget-query";
|
||||||
export const queryCacheBuster = "v2-superjson";
|
|
||||||
|
|
||||||
let activeQueryCacheBoardId: string | null = null;
|
let activeQueryCacheBoardId: string | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import z from "zod/v4";
|
import z from "zod/v4";
|
||||||
|
|
||||||
import packageJson from "../../../../package.json";
|
import packageJson from "../../../../package.json";
|
||||||
import { createTRPCRouter, isDemoMode, isDemoReadOnly, publicProcedure, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc";
|
||||||
|
|
||||||
export const infoRouter = createTRPCRouter({
|
export const infoRouter = createTRPCRouter({
|
||||||
getInfo: protectedProcedure
|
getInfo: protectedProcedure
|
||||||
@@ -20,5 +20,4 @@ export const infoRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
isDemoMode: publicProcedure.query(() => isDemoMode),
|
isDemoMode: publicProcedure.query(() => isDemoMode),
|
||||||
isDemoReadOnly: publicProcedure.query(() => isDemoReadOnly),
|
|
||||||
});
|
});
|
||||||
@@ -5,7 +5,7 @@ import { boards } from "@homarr/db/schema";
|
|||||||
import { removeQueryCacheAsync, setQueryCacheAsync } from "@homarr/redis";
|
import { removeQueryCacheAsync, setQueryCacheAsync } from "@homarr/redis";
|
||||||
|
|
||||||
import { queryCacheDefaultGcTimeMs, queryCacheMaxValueBytes } from "../query-cache";
|
import { queryCacheDefaultGcTimeMs, queryCacheMaxValueBytes } from "../query-cache";
|
||||||
import { createTRPCRouter, internalProcedure } from "../trpc";
|
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||||
|
|
||||||
const queryCacheBoardInput = z.object({
|
const queryCacheBoardInput = z.object({
|
||||||
@@ -21,7 +21,7 @@ const validateBoardAccessAsync = async (ctx: Parameters<typeof throwIfActionForb
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const queryCacheRouter = createTRPCRouter({
|
export const queryCacheRouter = createTRPCRouter({
|
||||||
setItem: internalProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => {
|
setItem: publicProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => {
|
||||||
await validateBoardAccessAsync(ctx, input.boardId);
|
await validateBoardAccessAsync(ctx, input.boardId);
|
||||||
const userId = ctx.session?.user.id ?? "anonymous";
|
const userId = ctx.session?.user.id ?? "anonymous";
|
||||||
return {
|
return {
|
||||||
@@ -34,7 +34,7 @@ export const queryCacheRouter = createTRPCRouter({
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
removeItem: internalProcedure.input(queryCacheBoardInput).mutation(async ({ ctx, input }) => {
|
removeItem: publicProcedure.input(queryCacheBoardInput).mutation(async ({ ctx, input }) => {
|
||||||
await validateBoardAccessAsync(ctx, input.boardId);
|
await validateBoardAccessAsync(ctx, input.boardId);
|
||||||
const userId = ctx.session?.user.id ?? "anonymous";
|
const userId = ctx.session?.user.id ?? "anonymous";
|
||||||
await removeQueryCacheAsync(userId, input.boardId);
|
await removeQueryCacheAsync(userId, input.boardId);
|
||||||
|
|||||||
@@ -1,31 +1,21 @@
|
|||||||
import { createIntegrationAsync } from "@homarr/integrations";
|
|
||||||
import { SynologyIntegration } from "@homarr/integrations";
|
|
||||||
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
|
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
|
||||||
|
|
||||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
const healthMonitoringIntegrationKinds = [
|
|
||||||
"openmediavault",
|
|
||||||
"dashDot",
|
|
||||||
"truenas",
|
|
||||||
"unraid",
|
|
||||||
"glances",
|
|
||||||
"synology",
|
|
||||||
"mock",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const healthMonitoringRouter = createTRPCRouter({
|
export const healthMonitoringRouter = createTRPCRouter({
|
||||||
getSystemHealthStatus: publicProcedure
|
getSystemHealthStatus: publicProcedure
|
||||||
.meta({
|
.meta({
|
||||||
mcp: {
|
mcp: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
description:
|
description:
|
||||||
"Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Synology/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
|
"Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
|
.concat(
|
||||||
|
createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"),
|
||||||
|
)
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||||
const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
|
const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
|
||||||
@@ -37,23 +27,6 @@ export const healthMonitoringRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
listStorageVolumes: publicProcedure
|
|
||||||
.meta({
|
|
||||||
mcp: {
|
|
||||||
enabled: true,
|
|
||||||
description:
|
|
||||||
"List storage volumes from a Synology DiskStation integration for widget configuration. REQUIRED: integrationId from integration_all (Synology integration only)",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.concat(createOneIntegrationMiddleware("query", "synology"))
|
|
||||||
.query(async ({ ctx }) => {
|
|
||||||
const integrationInstance = await createIntegrationAsync(ctx.integration);
|
|
||||||
if (!(integrationInstance instanceof SynologyIntegration)) {
|
|
||||||
throw new Error("Expected Synology integration");
|
|
||||||
}
|
|
||||||
|
|
||||||
return await integrationInstance.listStorageVolumesAsync();
|
|
||||||
}),
|
|
||||||
getClusterHealthStatus: publicProcedure
|
getClusterHealthStatus: publicProcedure
|
||||||
.meta({
|
.meta({
|
||||||
mcp: {
|
mcp: {
|
||||||
|
|||||||
@@ -95,12 +95,8 @@ export const createTRPCRouter = t.router;
|
|||||||
|
|
||||||
export const isDemoMode = env.DEMO_MODE;
|
export const isDemoMode = env.DEMO_MODE;
|
||||||
|
|
||||||
// The public "real demo" (demo + mock integrations) stays read-only, while preview
|
|
||||||
// deployments run with DEMO_MODE + DEMO_READ_ONLY=false to allow mutations.
|
|
||||||
export const isDemoReadOnly = env.DEMO_MODE && env.DEMO_READ_ONLY;
|
|
||||||
|
|
||||||
const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
|
const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
|
||||||
if (isDemoReadOnly && type === "mutation") {
|
if (env.DEMO_MODE && type === "mutation") {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Mutations are disabled in demo mode",
|
message: "Mutations are disabled in demo mode",
|
||||||
@@ -109,7 +105,7 @@ const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
|
|||||||
return next({ ctx });
|
return next({ ctx });
|
||||||
});
|
});
|
||||||
|
|
||||||
const baseProcedure = isDemoReadOnly ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
|
const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public (unauthed) procedure
|
* Public (unauthed) procedure
|
||||||
@@ -120,8 +116,6 @@ const baseProcedure = isDemoReadOnly ? t.procedure.use(enforceDemoModeReadOnly)
|
|||||||
*/
|
*/
|
||||||
export const publicProcedure = baseProcedure;
|
export const publicProcedure = baseProcedure;
|
||||||
|
|
||||||
export const internalProcedure = t.procedure;
|
|
||||||
|
|
||||||
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
|
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
|
||||||
if (!ctx.session?.user) {
|
if (!ctx.session?.user) {
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ export function parseCookies(cookieString: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setClientCookie(name: string, value: string, options: SerializeOptions = {}) {
|
export function setClientCookie(name: string, value: string, options: SerializeOptions = {}) {
|
||||||
document.cookie = stringifySetCookie({ name, value, ...options });
|
document.cookie = stringifySetCookie(name, value, options);
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@ export class AxiosHttpErrorHandler extends HttpErrorHandler {
|
|||||||
|
|
||||||
return new RequestError(requestErrorInput, {
|
return new RequestError(requestErrorInput, {
|
||||||
cause: error,
|
cause: error,
|
||||||
}) as AnyRequestError;
|
});
|
||||||
}
|
}
|
||||||
handleResponseError(error: unknown): ResponseError | undefined {
|
handleResponseError(error: unknown): ResponseError | undefined {
|
||||||
if (!(error instanceof AxiosError)) return undefined;
|
if (!(error instanceof AxiosError)) return undefined;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export class FetchHttpErrorHandler extends HttpErrorHandler {
|
|||||||
const result = matchErrorCode(error.cause.code);
|
const result = matchErrorCode(error.cause.code);
|
||||||
if (!result) return undefined;
|
if (!result) return undefined;
|
||||||
|
|
||||||
return new RequestError(result, { cause: error }) as AnyRequestError;
|
return new RequestError(result, { cause: error });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,7 +59,7 @@ export const matchErrorCode = (code: string): AnyRequestErrorInput | undefined =
|
|||||||
type: key,
|
type: key,
|
||||||
reason: found[0] as RequestErrorReason<typeof key>,
|
reason: found[0] as RequestErrorReason<typeof key>,
|
||||||
code: code as RequestErrorCode,
|
code: code as RequestErrorCode,
|
||||||
} as AnyRequestErrorInput;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export class NodeFetchHttpErrorHandler extends HttpErrorHandler {
|
|||||||
|
|
||||||
return new RequestError(requestErrorInput, {
|
return new RequestError(requestErrorInput, {
|
||||||
cause: error,
|
cause: error,
|
||||||
}) as AnyRequestError;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
CREATE TABLE `widget_secret` (
|
|
||||||
`item_id` varchar(64) NOT NULL,
|
|
||||||
`kind` varchar(64) NOT NULL,
|
|
||||||
`value` text NOT NULL,
|
|
||||||
`updated_at` timestamp NOT NULL,
|
|
||||||
CONSTRAINT `widget_secret_item_id_kind_pk` PRIMARY KEY(`item_id`,`kind`)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE `widget_secret` ADD CONSTRAINT `widget_secret_item_id_item_id_fk` FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON DELETE cascade ON UPDATE no action;
|
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -295,13 +295,6 @@
|
|||||||
"when": 1780764500000,
|
"when": 1780764500000,
|
||||||
"tag": "0041_merge_custom_widget_url",
|
"tag": "0041_merge_custom_widget_url",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 42,
|
|
||||||
"version": "5",
|
|
||||||
"when": 1783176100870,
|
|
||||||
"tag": "0042_add_widget_secret",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
CREATE TABLE "widget_secret" (
|
|
||||||
"item_id" varchar(64) NOT NULL,
|
|
||||||
"kind" varchar(64) NOT NULL,
|
|
||||||
"value" text NOT NULL,
|
|
||||||
"updated_at" timestamp NOT NULL,
|
|
||||||
CONSTRAINT "widget_secret_item_id_kind_pk" PRIMARY KEY("item_id","kind")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "widget_secret" ADD CONSTRAINT "widget_secret_item_id_item_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."item"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -57,13 +57,6 @@
|
|||||||
"when": 1780764500000,
|
"when": 1780764500000,
|
||||||
"tag": "0007_merge_custom_widget_url",
|
"tag": "0007_merge_custom_widget_url",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 8,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1783176100870,
|
|
||||||
"tag": "0008_add_widget_secret",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
CREATE TABLE `widget_secret` (
|
|
||||||
`item_id` text NOT NULL,
|
|
||||||
`kind` text NOT NULL,
|
|
||||||
`value` text NOT NULL,
|
|
||||||
`updated_at` integer NOT NULL,
|
|
||||||
PRIMARY KEY(`item_id`, `kind`),
|
|
||||||
FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -281,13 +281,6 @@
|
|||||||
"when": 1780764500000,
|
"when": 1780764500000,
|
||||||
"tag": "0039_merge_custom_widget_url",
|
"tag": "0039_merge_custom_widget_url",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 40,
|
|
||||||
"version": "6",
|
|
||||||
"when": 1783176100870,
|
|
||||||
"tag": "0040_add_widget_secret",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,6 @@ const integrationAliases: Partial<Record<IntegrationKind, readonly string[]>> =
|
|||||||
paperlessNgx: ["paperless-ngx", "paperless"],
|
paperlessNgx: ["paperless-ngx", "paperless"],
|
||||||
coolify: ["coolify"],
|
coolify: ["coolify"],
|
||||||
truenas: ["truenas"],
|
truenas: ["truenas"],
|
||||||
synology: ["synology", "diskstation"],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const matchIntegrationKind = (search: string): IntegrationKind | null => {
|
export const matchIntegrationKind = (search: string): IntegrationKind | null => {
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/tags/connections"
|
| "/docs/tags/connections"
|
||||||
| "/docs/tags/custom-widgets"
|
| "/docs/tags/custom-widgets"
|
||||||
| "/docs/tags/customization"
|
| "/docs/tags/customization"
|
||||||
| "/docs/tags/data"
|
|
||||||
| "/docs/tags/data-sources"
|
| "/docs/tags/data-sources"
|
||||||
|
| "/docs/tags/data"
|
||||||
| "/docs/tags/database"
|
| "/docs/tags/database"
|
||||||
| "/docs/tags/developer"
|
| "/docs/tags/developer"
|
||||||
| "/docs/tags/development"
|
| "/docs/tags/development"
|
||||||
@@ -99,8 +99,8 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/tags/redis"
|
| "/docs/tags/redis"
|
||||||
| "/docs/tags/responsive"
|
| "/docs/tags/responsive"
|
||||||
| "/docs/tags/roles"
|
| "/docs/tags/roles"
|
||||||
| "/docs/tags/search"
|
|
||||||
| "/docs/tags/search-engines"
|
| "/docs/tags/search-engines"
|
||||||
|
| "/docs/tags/search"
|
||||||
| "/docs/tags/secrets"
|
| "/docs/tags/secrets"
|
||||||
| "/docs/tags/security"
|
| "/docs/tags/security"
|
||||||
| "/docs/tags/self-signed"
|
| "/docs/tags/self-signed"
|
||||||
@@ -132,6 +132,15 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/advanced/running-as-different-user"
|
| "/docs/advanced/running-as-different-user"
|
||||||
| "/docs/advanced/single-sign-on"
|
| "/docs/advanced/single-sign-on"
|
||||||
| "/docs/advanced/styling"
|
| "/docs/advanced/styling"
|
||||||
|
| "/docs/category/advanced"
|
||||||
|
| "/docs/category/community"
|
||||||
|
| "/docs/category/developer-guides"
|
||||||
|
| "/docs/category/getting-started"
|
||||||
|
| "/docs/category/installation-1"
|
||||||
|
| "/docs/category/installation"
|
||||||
|
| "/docs/category/integrations"
|
||||||
|
| "/docs/category/management"
|
||||||
|
| "/docs/category/widgets"
|
||||||
| "/docs/community/donate"
|
| "/docs/community/donate"
|
||||||
| "/docs/community/faq"
|
| "/docs/community/faq"
|
||||||
| "/docs/community/get-in-touch"
|
| "/docs/community/get-in-touch"
|
||||||
@@ -160,11 +169,16 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/integrations/aria2"
|
| "/docs/integrations/aria2"
|
||||||
| "/docs/integrations/audiobookshelf"
|
| "/docs/integrations/audiobookshelf"
|
||||||
| "/docs/integrations/beszel"
|
| "/docs/integrations/beszel"
|
||||||
|
| "/docs/integrations/codeberg"
|
||||||
| "/docs/integrations/coolify"
|
| "/docs/integrations/coolify"
|
||||||
| "/docs/integrations/dash-dot"
|
| "/docs/integrations/dash-dot"
|
||||||
| "/docs/integrations/deluge"
|
| "/docs/integrations/deluge"
|
||||||
|
| "/docs/integrations/docker-hub"
|
||||||
| "/docs/integrations/docker"
|
| "/docs/integrations/docker"
|
||||||
| "/docs/integrations/emby"
|
| "/docs/integrations/emby"
|
||||||
|
| "/docs/integrations/github-containerregistry"
|
||||||
|
| "/docs/integrations/github"
|
||||||
|
| "/docs/integrations/gitlab"
|
||||||
| "/docs/integrations/glances"
|
| "/docs/integrations/glances"
|
||||||
| "/docs/integrations/gluetun"
|
| "/docs/integrations/gluetun"
|
||||||
| "/docs/integrations/gotify"
|
| "/docs/integrations/gotify"
|
||||||
@@ -175,8 +189,10 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/integrations/jellyseerr"
|
| "/docs/integrations/jellyseerr"
|
||||||
| "/docs/integrations/kubernetes"
|
| "/docs/integrations/kubernetes"
|
||||||
| "/docs/integrations/lidarr"
|
| "/docs/integrations/lidarr"
|
||||||
|
| "/docs/integrations/linux-server-io"
|
||||||
| "/docs/integrations/navidrome"
|
| "/docs/integrations/navidrome"
|
||||||
| "/docs/integrations/nextcloud"
|
| "/docs/integrations/nextcloud"
|
||||||
|
| "/docs/integrations/npm"
|
||||||
| "/docs/integrations/ntfy"
|
| "/docs/integrations/ntfy"
|
||||||
| "/docs/integrations/nzbget"
|
| "/docs/integrations/nzbget"
|
||||||
| "/docs/integrations/open-media-vault"
|
| "/docs/integrations/open-media-vault"
|
||||||
@@ -189,14 +205,15 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/integrations/prowlarr"
|
| "/docs/integrations/prowlarr"
|
||||||
| "/docs/integrations/proxmox"
|
| "/docs/integrations/proxmox"
|
||||||
| "/docs/integrations/q-bittorent"
|
| "/docs/integrations/q-bittorent"
|
||||||
|
| "/docs/integrations/quay"
|
||||||
| "/docs/integrations/radarr"
|
| "/docs/integrations/radarr"
|
||||||
| "/docs/integrations/readarr"
|
| "/docs/integrations/readarr"
|
||||||
| "/docs/integrations/sabnzbd"
|
| "/docs/integrations/sabnzbd"
|
||||||
|
| "/docs/integrations/search-ch"
|
||||||
| "/docs/integrations/seerr"
|
| "/docs/integrations/seerr"
|
||||||
| "/docs/integrations/slskd"
|
| "/docs/integrations/slskd"
|
||||||
| "/docs/integrations/sonarr"
|
| "/docs/integrations/sonarr"
|
||||||
| "/docs/integrations/speedtest-tracker"
|
| "/docs/integrations/speedtest-tracker"
|
||||||
| "/docs/integrations/synology"
|
|
||||||
| "/docs/integrations/tdarr"
|
| "/docs/integrations/tdarr"
|
||||||
| "/docs/integrations/technitium-dns"
|
| "/docs/integrations/technitium-dns"
|
||||||
| "/docs/integrations/tracearr"
|
| "/docs/integrations/tracearr"
|
||||||
@@ -258,7 +275,6 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/widgets/smart-home-execute-automation"
|
| "/docs/widgets/smart-home-execute-automation"
|
||||||
| "/docs/widgets/speedtest-tracker"
|
| "/docs/widgets/speedtest-tracker"
|
||||||
| "/docs/widgets/stock-price"
|
| "/docs/widgets/stock-price"
|
||||||
| "/docs/widgets/system-disks"
|
|
||||||
| "/docs/widgets/system-resources"
|
| "/docs/widgets/system-resources"
|
||||||
| "/docs/widgets/timetable"
|
| "/docs/widgets/timetable"
|
||||||
| "/docs/widgets/tracearr"
|
| "/docs/widgets/tracearr"
|
||||||
@@ -305,7 +321,6 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/integrations/ical"
|
| "/docs/integrations/ical"
|
||||||
| "/docs/integrations/anchor"
|
| "/docs/integrations/anchor"
|
||||||
| "/docs/integrations/truenas"
|
| "/docs/integrations/truenas"
|
||||||
| "/docs/integrations/synology"
|
|
||||||
| "/docs/integrations/unraid"
|
| "/docs/integrations/unraid"
|
||||||
| "/docs/integrations/coolify"
|
| "/docs/integrations/coolify"
|
||||||
| "/docs/integrations/immich"
|
| "/docs/integrations/immich"
|
||||||
@@ -351,7 +366,6 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/widgets/notifications"
|
| "/docs/widgets/notifications"
|
||||||
| "/docs/widgets/system-resources"
|
| "/docs/widgets/system-resources"
|
||||||
| "/docs/widgets/coolify"
|
| "/docs/widgets/coolify"
|
||||||
| "/docs/widgets/system-disks"
|
|
||||||
| "/docs/widgets/timetable"
|
| "/docs/widgets/timetable"
|
||||||
| "/docs/widgets/immich-server-stats"
|
| "/docs/widgets/immich-server-stats"
|
||||||
| "/docs/widgets/immich-album-carousel"
|
| "/docs/widgets/immich-album-carousel"
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
|||||||
ical: "ical",
|
ical: "ical",
|
||||||
anchor: "anchor",
|
anchor: "anchor",
|
||||||
truenas: "truenas",
|
truenas: "truenas",
|
||||||
synology: "synology",
|
|
||||||
unraid: "unraid",
|
unraid: "unraid",
|
||||||
coolify: "coolify",
|
coolify: "coolify",
|
||||||
immich: "immich",
|
immich: "immich",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
|||||||
notifications: "notifications",
|
notifications: "notifications",
|
||||||
systemResources: "system-resources",
|
systemResources: "system-resources",
|
||||||
coolify: "coolify",
|
coolify: "coolify",
|
||||||
systemDisks: "system-disks",
|
systemDisks: null,
|
||||||
timetable: "timetable",
|
timetable: "timetable",
|
||||||
"immich-serverStats": "immich-server-stats",
|
"immich-serverStats": "immich-server-stats",
|
||||||
"immich-albumCarousel": "immich-album-carousel",
|
"immich-albumCarousel": "immich-album-carousel",
|
||||||
|
|||||||
@@ -321,14 +321,6 @@ export const integrationDefs = {
|
|||||||
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
|
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
|
||||||
defaultPort: 80,
|
defaultPort: 80,
|
||||||
},
|
},
|
||||||
synology: {
|
|
||||||
name: "Synology DiskStation",
|
|
||||||
secretKinds: [["username", "password"]],
|
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/synology.svg",
|
|
||||||
category: ["healthMonitoring"],
|
|
||||||
documentationUrl: createDocumentationLink("/docs/integrations/synology"),
|
|
||||||
defaultPort: 5000,
|
|
||||||
},
|
|
||||||
unraid: {
|
unraid: {
|
||||||
name: "Unraid",
|
name: "Unraid",
|
||||||
secretKinds: [["apiKey"]],
|
secretKinds: [["apiKey"]],
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
|||||||
firewall: getIntegrationKindsByCategory("firewall"),
|
firewall: getIntegrationKindsByCategory("firewall"),
|
||||||
notifications: getIntegrationKindsByCategory("notifications"),
|
notifications: getIntegrationKindsByCategory("notifications"),
|
||||||
mediaReleases: ["emby", "jellyfin", "plex"],
|
mediaReleases: ["emby", "jellyfin", "plex"],
|
||||||
systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
|
systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances"],
|
||||||
systemDisks: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
|
systemDisks: ["dashDot", "openmediavault", "truenas", "unraid"],
|
||||||
coolify: ["coolify"],
|
coolify: ["coolify"],
|
||||||
"immich-serverStats": ["immich"],
|
"immich-serverStats": ["immich"],
|
||||||
"immich-albumCarousel": ["immich"],
|
"immich-albumCarousel": ["immich"],
|
||||||
|
|||||||
@@ -72,14 +72,7 @@ export const AppForm = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
form.initialize(toFormValues(initialValues));
|
form.initialize(toFormValues(initialValues));
|
||||||
// `form` is a new object every render (Mantine useForm returns a fresh literal),
|
}, [initialValuesKey, form, initialValues]);
|
||||||
// so it must not be a dep here — it would re-run every render and, since
|
|
||||||
// form.initialize() always calls clearErrors() (new {} object) internally,
|
|
||||||
// trigger an infinite update loop. initialValuesKey (a primitive string)
|
|
||||||
// already captures the only case this effect exists for: re-initializing
|
|
||||||
// when initialValues actually changes (e.g. navigating between edit pages).
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [initialValuesKey]);
|
|
||||||
|
|
||||||
// Debounce the name value with 200ms delay
|
// Debounce the name value with 200ms delay
|
||||||
const [debouncedName] = useDebouncedValue(form.values.name, 200);
|
const [debouncedName] = useDebouncedValue(form.values.name, 200);
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
|
|||||||
import { SeerrIntegration } from "../seerr/seerr-integration";
|
import { SeerrIntegration } from "../seerr/seerr-integration";
|
||||||
import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
|
import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
|
||||||
import { TracearrIntegration } from "../tracearr/tracearr-integration";
|
import { TracearrIntegration } from "../tracearr/tracearr-integration";
|
||||||
import { SynologyIntegration } from "../synology/synology-integration";
|
|
||||||
import { TrueNasIntegration } from "../truenas/truenas-integration";
|
import { TrueNasIntegration } from "../truenas/truenas-integration";
|
||||||
import { UmamiIntegration } from "../umami/umami-integration";
|
import { UmamiIntegration } from "../umami/umami-integration";
|
||||||
import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
|
import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
|
||||||
@@ -55,86 +54,83 @@ import type { Integration, IntegrationInput } from "./integration";
|
|||||||
|
|
||||||
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
|
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
|
||||||
integration: IntegrationInput & { kind: TKind },
|
integration: IntegrationInput & { kind: TKind },
|
||||||
): Promise<IntegrationInstanceByKind[TKind]> => {
|
) => {
|
||||||
if (!(integration.kind in integrationCreators)) {
|
if (!(integration.kind in integrationCreators)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unknown integration kind ${integration.kind}. Did you forget to add it to the integration creator?`,
|
`Unknown integration kind ${integration.kind}. Did you forget to add it to the integration creator?`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await integrationCreators[integration.kind].create(integration)) as IntegrationInstanceByKind[TKind];
|
const creator = integrationCreators[integration.kind];
|
||||||
|
|
||||||
|
// factories are an array, to differentiate in js between class constructors and functions
|
||||||
|
if (Array.isArray(creator)) {
|
||||||
|
return (await creator[0](integration)) as IntegrationInstanceOfKind<TKind>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new creator(integration) as IntegrationInstanceOfKind<TKind>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type IntegrationConstructor<TIntegration extends Integration = Integration> = new (
|
type IntegrationInstance = new (integration: IntegrationInput) => Integration;
|
||||||
integration: IntegrationInput,
|
|
||||||
) => TIntegration;
|
|
||||||
type IntegrationFactory<TIntegration extends Integration = Integration> = (
|
|
||||||
input: IntegrationInput,
|
|
||||||
) => Promise<TIntegration>;
|
|
||||||
type IntegrationCreator<TIntegration extends Integration = Integration> = {
|
|
||||||
create: (input: IntegrationInput) => TIntegration | Promise<TIntegration>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fromClass = <TIntegration extends Integration>(Constructor: IntegrationConstructor<TIntegration>) => ({
|
|
||||||
create: (input: IntegrationInput) => new Constructor(input),
|
|
||||||
});
|
|
||||||
|
|
||||||
const fromFactory = <TIntegration extends Integration>(create: IntegrationFactory<TIntegration>) => ({ create });
|
|
||||||
|
|
||||||
|
// factories are an array, to differentiate in js between class constructors and functions
|
||||||
export const integrationCreators = {
|
export const integrationCreators = {
|
||||||
anchor: fromClass(AnchorIntegration),
|
anchor: AnchorIntegration,
|
||||||
piHole: fromFactory(createPiHoleIntegrationAsync),
|
piHole: [createPiHoleIntegrationAsync],
|
||||||
adGuardHome: fromClass(AdGuardHomeIntegration),
|
adGuardHome: AdGuardHomeIntegration,
|
||||||
technitiumDns: fromFactory(createTechnitiumDnsIntegrationAsync),
|
technitiumDns: [createTechnitiumDnsIntegrationAsync],
|
||||||
homeAssistant: fromClass(HomeAssistantIntegration),
|
homeAssistant: HomeAssistantIntegration,
|
||||||
jellyfin: fromClass(JellyfinIntegration),
|
jellyfin: JellyfinIntegration,
|
||||||
plex: fromClass(PlexIntegration),
|
plex: PlexIntegration,
|
||||||
sonarr: fromClass(SonarrIntegration),
|
sonarr: SonarrIntegration,
|
||||||
radarr: fromClass(RadarrIntegration),
|
radarr: RadarrIntegration,
|
||||||
sabNzbd: fromClass(SabnzbdIntegration),
|
sabNzbd: SabnzbdIntegration,
|
||||||
nzbGet: fromClass(NzbGetIntegration),
|
nzbGet: NzbGetIntegration,
|
||||||
qBittorrent: fromClass(QBitTorrentIntegration),
|
qBittorrent: QBitTorrentIntegration,
|
||||||
deluge: fromClass(DelugeIntegration),
|
deluge: DelugeIntegration,
|
||||||
transmission: fromClass(TransmissionIntegration),
|
transmission: TransmissionIntegration,
|
||||||
slskd: fromClass(SlskdIntegration),
|
slskd: SlskdIntegration,
|
||||||
aria2: fromClass(Aria2Integration),
|
aria2: Aria2Integration,
|
||||||
jellyseerr: fromClass(JellyseerrIntegration),
|
jellyseerr: JellyseerrIntegration,
|
||||||
seerr: fromClass(SeerrIntegration),
|
seerr: SeerrIntegration,
|
||||||
overseerr: fromClass(OverseerrIntegration),
|
overseerr: OverseerrIntegration,
|
||||||
prowlarr: fromClass(ProwlarrIntegration),
|
prowlarr: ProwlarrIntegration,
|
||||||
openmediavault: fromClass(OpenMediaVaultIntegration),
|
openmediavault: OpenMediaVaultIntegration,
|
||||||
lidarr: fromClass(LidarrIntegration),
|
lidarr: LidarrIntegration,
|
||||||
readarr: fromClass(ReadarrIntegration),
|
readarr: ReadarrIntegration,
|
||||||
dashDot: fromClass(DashDotIntegration),
|
dashDot: DashDotIntegration,
|
||||||
tdarr: fromClass(TdarrIntegration),
|
tdarr: TdarrIntegration,
|
||||||
proxmox: fromClass(ProxmoxIntegration),
|
proxmox: ProxmoxIntegration,
|
||||||
emby: fromClass(EmbyIntegration),
|
emby: EmbyIntegration,
|
||||||
nextcloud: fromClass(NextcloudIntegration),
|
nextcloud: NextcloudIntegration,
|
||||||
unifiController: fromClass(UnifiControllerIntegration),
|
unifiController: UnifiControllerIntegration,
|
||||||
opnsense: fromClass(OPNsenseIntegration),
|
opnsense: OPNsenseIntegration,
|
||||||
ical: fromClass(ICalIntegration),
|
ical: ICalIntegration,
|
||||||
ntfy: fromClass(NTFYIntegration),
|
ntfy: NTFYIntegration,
|
||||||
gotify: fromClass(GotifyIntegration),
|
gotify: GotifyIntegration,
|
||||||
mock: fromClass(MockIntegration),
|
mock: MockIntegration,
|
||||||
truenas: fromClass(TrueNasIntegration),
|
truenas: TrueNasIntegration,
|
||||||
synology: fromClass(SynologyIntegration),
|
unraid: UnraidIntegration,
|
||||||
unraid: fromClass(UnraidIntegration),
|
coolify: CoolifyIntegration,
|
||||||
coolify: fromClass(CoolifyIntegration),
|
tracearr: TracearrIntegration,
|
||||||
tracearr: fromClass(TracearrIntegration),
|
glances: GlancesIntegration,
|
||||||
glances: fromClass(GlancesIntegration),
|
immich: ImmichIntegration,
|
||||||
immich: fromClass(ImmichIntegration),
|
paperlessNgx: PaperlessNgxIntegration,
|
||||||
paperlessNgx: fromClass(PaperlessNgxIntegration),
|
speedtestTracker: SpeedtestTrackerIntegration,
|
||||||
speedtestTracker: fromClass(SpeedtestTrackerIntegration),
|
audiobookshelf: AudiobookshelfIntegration,
|
||||||
audiobookshelf: fromClass(AudiobookshelfIntegration),
|
navidrome: NavidromeIntegration,
|
||||||
navidrome: fromClass(NavidromeIntegration),
|
umami: UmamiIntegration,
|
||||||
umami: fromClass(UmamiIntegration),
|
gluetun: GluetunIntegration,
|
||||||
gluetun: fromClass(GluetunIntegration),
|
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
|
||||||
archiveTeamWarrior: fromClass(ArchiveTeamWarriorIntegration),
|
uptimeKuma: UptimeKumaIntegration,
|
||||||
uptimeKuma: fromClass(UptimeKumaIntegration),
|
peaNut: PeaNutIntegration,
|
||||||
peaNut: fromClass(PeaNutIntegration),
|
beszel: BeszelIntegration,
|
||||||
beszel: fromClass(BeszelIntegration),
|
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||||
} satisfies Record<IntegrationKind, IntegrationCreator>;
|
|
||||||
|
|
||||||
type IntegrationInstanceByKind = {
|
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
||||||
[TKind in keyof typeof integrationCreators]: Awaited<ReturnType<(typeof integrationCreators)[TKind]["create"]>>;
|
[kind in TKind]: (typeof integrationCreators)[kind] extends [(input: IntegrationInput) => Promise<Integration>]
|
||||||
};
|
? Awaited<ReturnType<(typeof integrationCreators)[kind][0]>>
|
||||||
|
: (typeof integrationCreators)[kind] extends IntegrationInstance
|
||||||
|
? InstanceType<(typeof integrationCreators)[kind]>
|
||||||
|
: never;
|
||||||
|
}[TKind];
|
||||||
@@ -46,7 +46,7 @@ const gluetunServerSelectionSchema = z.object({
|
|||||||
isps: z.array(z.string()).nullable(),
|
isps: z.array(z.string()).nullable(),
|
||||||
names: z.array(z.string()).nullable(),
|
names: z.array(z.string()).nullable(),
|
||||||
numbers: z.array(z.number()).nullable(),
|
numbers: z.array(z.number()).nullable(),
|
||||||
hostnames: z.array(z.string()).nullable(),
|
hostnames: z.array(z.string()),
|
||||||
owned_only: z.boolean(),
|
owned_only: z.boolean(),
|
||||||
free_only: z.boolean(),
|
free_only: z.boolean(),
|
||||||
premium_only: z.boolean(),
|
premium_only: z.boolean(),
|
||||||
@@ -65,7 +65,7 @@ const gluetunPortForwardingSchema = z.object({
|
|||||||
status_file_path: z.string(),
|
status_file_path: z.string(),
|
||||||
up_command: z.string(),
|
up_command: z.string(),
|
||||||
down_command: z.string(),
|
down_command: z.string(),
|
||||||
listening_port: z.union([z.number(), z.array(z.number())]),
|
listening_port: z.number(),
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
});
|
});
|
||||||
@@ -98,7 +98,7 @@ const gluetunOpenvpnSettingsSchema = z.object({
|
|||||||
const gluetunWireguardSettingsSchema = z.object({
|
const gluetunWireguardSettingsSchema = z.object({
|
||||||
private_key: z.string(),
|
private_key: z.string(),
|
||||||
pre_shared_key: z.string(),
|
pre_shared_key: z.string(),
|
||||||
addresses: z.array(z.string()).nullable(),
|
addresses: z.array(z.string()),
|
||||||
allowed_ips: z.array(z.string()),
|
allowed_ips: z.array(z.string()),
|
||||||
interface: z.string(),
|
interface: z.string(),
|
||||||
persistent_keep_alive_interval: z.number(),
|
persistent_keep_alive_interval: z.number(),
|
||||||
|
|||||||
@@ -124,126 +124,6 @@ const VPN_SETTINGS_PAYLOAD = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALT_VPN_SETTINGS_PAYLOAD = {
|
|
||||||
type: "openvpn",
|
|
||||||
provider: {
|
|
||||||
name: "custom",
|
|
||||||
server_selection: {
|
|
||||||
vpn: "openvpn",
|
|
||||||
countries: null,
|
|
||||||
categories: null,
|
|
||||||
regions: null,
|
|
||||||
cities: null,
|
|
||||||
isps: null,
|
|
||||||
names: null,
|
|
||||||
numbers: null,
|
|
||||||
hostnames: null,
|
|
||||||
owned_only: false,
|
|
||||||
free_only: false,
|
|
||||||
premium_only: false,
|
|
||||||
stream_only: false,
|
|
||||||
multi_hop_only: false,
|
|
||||||
port_forward_only: false,
|
|
||||||
secure_core_only: false,
|
|
||||||
tor_only: false,
|
|
||||||
openvpn: {
|
|
||||||
config_file_path: "/gluetun/custom.conf",
|
|
||||||
protocol: "tcp",
|
|
||||||
endpoint_ip: "0.0.0.0",
|
|
||||||
custom_port: 0,
|
|
||||||
pia_encryption_preset: "",
|
|
||||||
},
|
|
||||||
wireguard: {
|
|
||||||
endpoint_ip: "0.0.0.0",
|
|
||||||
endpoint_port: 0,
|
|
||||||
public_key: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
port_forwarding: {
|
|
||||||
enabled: false,
|
|
||||||
provider: "",
|
|
||||||
status_file_path: "/tmp/gluetun/forwarded_port",
|
|
||||||
up_command: "",
|
|
||||||
down_command: "",
|
|
||||||
listening_port: [0],
|
|
||||||
ports_count: 0,
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
amneziawg: {
|
|
||||||
wireguard: {
|
|
||||||
private_key: "",
|
|
||||||
pre_shared_key: "",
|
|
||||||
addresses: null,
|
|
||||||
allowed_ips: ["0.0.0.0/0", "::/0"],
|
|
||||||
interface: "tun0",
|
|
||||||
persistent_keep_alive_interval: 0,
|
|
||||||
mtu: 0,
|
|
||||||
implementation: "userspace",
|
|
||||||
},
|
|
||||||
junk_packet_count: 0,
|
|
||||||
junk_packet_min: 0,
|
|
||||||
junk_packet_max: 0,
|
|
||||||
padding_s1: 0,
|
|
||||||
padding_s2: 0,
|
|
||||||
padding_s3: 0,
|
|
||||||
padding_s4: 0,
|
|
||||||
header_h1: "",
|
|
||||||
header_h2: "",
|
|
||||||
header_h3: "",
|
|
||||||
header_h4: "",
|
|
||||||
init_packet_i1: "",
|
|
||||||
init_packet_i2: "",
|
|
||||||
init_packet_i3: "",
|
|
||||||
init_packet_i4: "",
|
|
||||||
init_packet_i5: "",
|
|
||||||
},
|
|
||||||
openvpn: {
|
|
||||||
version: "2.6",
|
|
||||||
user: "",
|
|
||||||
password: "",
|
|
||||||
config_file_path: "/gluetun/custom.conf",
|
|
||||||
ciphers: null,
|
|
||||||
auth: "",
|
|
||||||
cert: "",
|
|
||||||
key: "",
|
|
||||||
encrypted_key: "",
|
|
||||||
key_passphrase: "",
|
|
||||||
pia_encryption_preset: "",
|
|
||||||
mssfix: 0,
|
|
||||||
interface: "tun0",
|
|
||||||
process_user: "nonrootuser",
|
|
||||||
verbosity: 1,
|
|
||||||
flags: null,
|
|
||||||
},
|
|
||||||
wireguard: {
|
|
||||||
private_key: "",
|
|
||||||
pre_shared_key: "",
|
|
||||||
addresses: null,
|
|
||||||
allowed_ips: ["0.0.0.0/0", "::/0"],
|
|
||||||
interface: "tun0",
|
|
||||||
persistent_keep_alive_interval: 0,
|
|
||||||
mtu: 0,
|
|
||||||
implementation: "auto",
|
|
||||||
},
|
|
||||||
pmtud: {
|
|
||||||
icmp_addresses: ["1.1.1.1", "8.8.8.8"],
|
|
||||||
tcp_addresses: [
|
|
||||||
"1.1.1.1:443",
|
|
||||||
"8.8.8.8:443",
|
|
||||||
"1.1.1.1:53",
|
|
||||||
"8.8.8.8:53",
|
|
||||||
"[2606:4700:4700::1111]:53",
|
|
||||||
"[2001:4860:4860::8888]:53",
|
|
||||||
"[2606:4700:4700::1111]:443",
|
|
||||||
"[2001:4860:4860::8888]:443",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
up_command: "",
|
|
||||||
down_command: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const jsonResponse = (body: unknown, init: ResponseInit = { status: 200 }): FetchResponse =>
|
const jsonResponse = (body: unknown, init: ResponseInit = { status: 200 }): FetchResponse =>
|
||||||
new Response(JSON.stringify(body), {
|
new Response(JSON.stringify(body), {
|
||||||
...init,
|
...init,
|
||||||
@@ -488,54 +368,4 @@ describe("gluetun schemas", () => {
|
|||||||
test("vpn settings schema rejects a non-string type", () => {
|
test("vpn settings schema rejects a non-string type", () => {
|
||||||
expect(() => gluetunVpnSettingsSchema.parse({ ...VPN_SETTINGS_PAYLOAD, type: 1 })).toThrow();
|
expect(() => gluetunVpnSettingsSchema.parse({ ...VPN_SETTINGS_PAYLOAD, type: 1 })).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("vpn settings schema parses the live gluetun-latest custom/OpenVPN payload", () => {
|
|
||||||
const parsed = gluetunVpnSettingsSchema.parse(ALT_VPN_SETTINGS_PAYLOAD);
|
|
||||||
expect(parsed.type).toBe("openvpn");
|
|
||||||
expect(parsed.provider.name).toBe("custom");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("vpn settings schema accepts null server-selection hostnames", () => {
|
|
||||||
const payload = {
|
|
||||||
...VPN_SETTINGS_PAYLOAD,
|
|
||||||
provider: {
|
|
||||||
...VPN_SETTINGS_PAYLOAD.provider,
|
|
||||||
server_selection: {
|
|
||||||
...VPN_SETTINGS_PAYLOAD.provider.server_selection,
|
|
||||||
hostnames: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(() => gluetunVpnSettingsSchema.parse(payload)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("vpn settings schema accepts listening_port as a number or a list", () => {
|
|
||||||
const asList = {
|
|
||||||
...VPN_SETTINGS_PAYLOAD,
|
|
||||||
provider: {
|
|
||||||
...VPN_SETTINGS_PAYLOAD.provider,
|
|
||||||
port_forwarding: {
|
|
||||||
...VPN_SETTINGS_PAYLOAD.provider.port_forwarding,
|
|
||||||
listening_port: [0],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// The happy-path fixture already exercises the scalar form (listening_port: 0).
|
|
||||||
expect(() => gluetunVpnSettingsSchema.parse(VPN_SETTINGS_PAYLOAD)).not.toThrow();
|
|
||||||
expect(() => gluetunVpnSettingsSchema.parse(asList)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("vpn settings schema accepts null wireguard addresses", () => {
|
|
||||||
const payload = {
|
|
||||||
...VPN_SETTINGS_PAYLOAD,
|
|
||||||
wireguard: {
|
|
||||||
...VPN_SETTINGS_PAYLOAD.wireguard,
|
|
||||||
addresses: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(() => gluetunVpnSettingsSchema.parse(payload)).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -29,7 +29,6 @@ export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
|
|||||||
export { PlexIntegration } from "./plex/plex-integration";
|
export { PlexIntegration } from "./plex/plex-integration";
|
||||||
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
|
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
|
||||||
export { TrueNasIntegration } from "./truenas/truenas-integration";
|
export { TrueNasIntegration } from "./truenas/truenas-integration";
|
||||||
export { SynologyIntegration } from "./synology/synology-integration";
|
|
||||||
export { UnraidIntegration } from "./unraid/unraid-integration";
|
export { UnraidIntegration } from "./unraid/unraid-integration";
|
||||||
export { OPNsenseIntegration } from "./opnsense/opnsense-integration";
|
export { OPNsenseIntegration } from "./opnsense/opnsense-integration";
|
||||||
export { ICalIntegration } from "./ical/ical-integration";
|
export { ICalIntegration } from "./ical/ical-integration";
|
||||||
|
|||||||
@@ -1,411 +0,0 @@
|
|||||||
import type { Headers } from "undici";
|
|
||||||
import type { z } from "zod";
|
|
||||||
|
|
||||||
import { ResponseError } from "@homarr/common/server";
|
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
|
||||||
|
|
||||||
import type { SessionStore } from "../base/session-store";
|
|
||||||
import {
|
|
||||||
AUTH_API_NAME,
|
|
||||||
INFO_API_NAME,
|
|
||||||
REQUEST_TIMEOUT_MS,
|
|
||||||
SESSION_ERROR_CODES,
|
|
||||||
synologyApiInfoResponseSchema,
|
|
||||||
synologyAuthResponseSchema,
|
|
||||||
synologyEnvelopeSchema,
|
|
||||||
synologyLegacyStorageDataSchema,
|
|
||||||
synologySmartInfoDataSchema,
|
|
||||||
synologyStorageLoadInfoDataSchema,
|
|
||||||
synologyStorageV2DataSchema,
|
|
||||||
synologySystemInfoDataSchema,
|
|
||||||
synologySystemStatusDataSchema,
|
|
||||||
synologyUpgradeCheckDataSchema,
|
|
||||||
synologyUpgradeStatusDataSchema,
|
|
||||||
synologyUtilizationDataSchema,
|
|
||||||
type SynologyDiskRecord,
|
|
||||||
type SynologyVolumeRecord,
|
|
||||||
} from "./synology-types";
|
|
||||||
|
|
||||||
const logger = createLogger({ module: "synologyClient" });
|
|
||||||
|
|
||||||
const AUTH_SESSION_NAME = "DownloadStation";
|
|
||||||
|
|
||||||
type ApiDefinition = {
|
|
||||||
cgiPath: string;
|
|
||||||
maxVersion: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type StoredSession = {
|
|
||||||
cookieHeader: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SynologyClientOptions = {
|
|
||||||
integrationId: string;
|
|
||||||
baseUrl: string;
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
sessionStore: SessionStore<StoredSession>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const apiDefinitionCache = new Map<string, ApiDefinition>();
|
|
||||||
|
|
||||||
export class SynologyClient {
|
|
||||||
private readonly integrationId: string;
|
|
||||||
private readonly baseUrl: string;
|
|
||||||
private readonly username: string;
|
|
||||||
private readonly password: string;
|
|
||||||
private readonly sessionStore: SessionStore<StoredSession>;
|
|
||||||
|
|
||||||
constructor(options: SynologyClientOptions) {
|
|
||||||
this.integrationId = options.integrationId;
|
|
||||||
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
||||||
this.username = options.username;
|
|
||||||
this.password = options.password;
|
|
||||||
this.sessionStore = options.sessionStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getSystemInfoAsync() {
|
|
||||||
const response = await this.requestAsync("SYNO.Core.System", { method: "info" });
|
|
||||||
return synologySystemInfoDataSchema.parse(response.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getUtilizationAsync() {
|
|
||||||
const response = await this.requestAsync("SYNO.Core.System.Utilization", { method: "get" });
|
|
||||||
return synologyUtilizationDataSchema.parse(response.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getStorageVolumesAsync(): Promise<SynologyVolumeRecord[]> {
|
|
||||||
try {
|
|
||||||
const storageV2Response = await this.requestAsync("SYNO.Core.System", {
|
|
||||||
method: "info",
|
|
||||||
extraParams: { type: "storage_v2" },
|
|
||||||
});
|
|
||||||
const storageV2 = synologyStorageV2DataSchema.parse(storageV2Response.data);
|
|
||||||
const volumesFromV2 = this.mapStorageV2Volumes(storageV2);
|
|
||||||
if (volumesFromV2.length > 0) {
|
|
||||||
return volumesFromV2;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.debug("storage_v2 unavailable, falling back to legacy storage info", {
|
|
||||||
integrationId: this.integrationId,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const legacyResponse = await this.requestAsync("SYNO.Core.System", {
|
|
||||||
method: "info",
|
|
||||||
extraParams: { type: "storage" },
|
|
||||||
});
|
|
||||||
const legacyStorage = synologyLegacyStorageDataSchema.parse(legacyResponse.data);
|
|
||||||
return this.mapLegacyVolumes(legacyStorage.vol_info ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getStorageLoadInfoAsync() {
|
|
||||||
const response = await this.requestOptionalAsync("SYNO.Storage.CGI.Storage", { method: "load_info" });
|
|
||||||
if (!response) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return synologyStorageLoadInfoDataSchema.parse(response.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getSmartInfoAsync() {
|
|
||||||
const response = await this.requestOptionalAsync("SYNO.Storage.CGI.Smart", { method: "get_smart_info" });
|
|
||||||
if (!response) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return synologySmartInfoDataSchema.parse(response.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getSystemStatusAsync() {
|
|
||||||
const response = await this.requestOptionalAsync("SYNO.Core.System.Status", { method: "get" });
|
|
||||||
if (!response) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return synologySystemStatusDataSchema.parse(response.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getUpgradeStatusAsync() {
|
|
||||||
const response = await this.requestOptionalAsync("SYNO.Core.Upgrade", { method: "status" });
|
|
||||||
if (!response) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return synologyUpgradeStatusDataSchema.parse(response.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getUpgradeCheckAsync() {
|
|
||||||
const response = await this.requestOptionalAsync("SYNO.Core.Upgrade.Server", { method: "check" });
|
|
||||||
if (!response) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return synologyUpgradeCheckDataSchema.parse(response.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async testConnectionAsync() {
|
|
||||||
await this.getSystemInfoAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapLegacyVolumes(volumes: z.infer<typeof synologyLegacyStorageDataSchema>["vol_info"]) {
|
|
||||||
return (volumes ?? [])
|
|
||||||
.map((volume) => this.mapVolumeRecord(volume.name, volume.used_size, volume.total_size, volume.status))
|
|
||||||
.filter((volume): volume is SynologyVolumeRecord => volume !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapStorageV2Volumes(storage: z.infer<typeof synologyStorageV2DataSchema>) {
|
|
||||||
return (storage.volumes ?? [])
|
|
||||||
.map((volume) => {
|
|
||||||
const name = volume.id ?? volume.vol_path ?? volume.display_name;
|
|
||||||
if (!name) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return this.mapVolumeRecord(
|
|
||||||
name,
|
|
||||||
volume.size?.used,
|
|
||||||
volume.size?.total,
|
|
||||||
volume.status,
|
|
||||||
volume.display_name,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.filter((volume): volume is SynologyVolumeRecord => volume !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapVolumeRecord(
|
|
||||||
name: string,
|
|
||||||
usedValue: number | string | undefined,
|
|
||||||
totalValue: number | string | undefined,
|
|
||||||
status: string | undefined,
|
|
||||||
displayName?: string,
|
|
||||||
): SynologyVolumeRecord | null {
|
|
||||||
const usedBytes = parseNumericValue(usedValue);
|
|
||||||
const totalBytes = parseNumericValue(totalValue);
|
|
||||||
if (usedBytes === null || totalBytes === null || totalBytes <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
displayName: displayName && displayName !== name ? displayName : undefined,
|
|
||||||
usedBytes,
|
|
||||||
totalBytes,
|
|
||||||
status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public mapDisksFromLoadInfo(loadInfo: z.infer<typeof synologyStorageLoadInfoDataSchema>): SynologyDiskRecord[] {
|
|
||||||
return (loadInfo.disks ?? []).map((disk) => ({
|
|
||||||
identifier: disk.id ?? disk.name ?? disk.display_name ?? "unknown-disk",
|
|
||||||
name: disk.display_name ?? disk.name ?? disk.id ?? "unknown-disk",
|
|
||||||
status: disk.smart_status ?? disk.status,
|
|
||||||
temperature: parseOptionalNumericValue(disk.temp ?? disk.temperature),
|
|
||||||
volumeName: disk.volume_id ?? disk.vol_path,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
public mapDisksFromSmartInfo(smartInfo: z.infer<typeof synologySmartInfoDataSchema>): SynologyDiskRecord[] {
|
|
||||||
const disks = smartInfo.disks ?? smartInfo.items ?? [];
|
|
||||||
return disks.map((disk) => ({
|
|
||||||
identifier: disk.disk_id ?? disk.id ?? disk.name ?? "unknown-disk",
|
|
||||||
name: disk.name ?? disk.disk_id ?? disk.id ?? "unknown-disk",
|
|
||||||
status: disk.overall_status ?? disk.status,
|
|
||||||
temperature: parseOptionalNumericValue(disk.temp ?? disk.temperature),
|
|
||||||
volumeName: undefined,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async requestOptionalAsync(
|
|
||||||
apiName: string,
|
|
||||||
options: { method: string; extraParams?: Record<string, string> },
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
return await this.requestAsync(apiName, options);
|
|
||||||
} catch (error) {
|
|
||||||
logger.debug("Optional Synology API request failed", {
|
|
||||||
integrationId: this.integrationId,
|
|
||||||
apiName,
|
|
||||||
method: options.method,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async requestAsync(
|
|
||||||
apiName: string,
|
|
||||||
options: { method: string; extraParams?: Record<string, string> },
|
|
||||||
): Promise<z.infer<typeof synologyEnvelopeSchema>> {
|
|
||||||
return await this.withAuthenticatedSessionAsync(async (session) => {
|
|
||||||
const apiDefinition = await this.getApiDefinitionAsync(apiName);
|
|
||||||
const requestUrl = new URL(`${this.baseUrl}/webapi/${apiDefinition.cgiPath}`);
|
|
||||||
requestUrl.searchParams.set("api", apiName);
|
|
||||||
requestUrl.searchParams.set("version", String(apiDefinition.maxVersion));
|
|
||||||
requestUrl.searchParams.set("method", options.method);
|
|
||||||
for (const [key, value] of Object.entries(options.extraParams ?? {})) {
|
|
||||||
requestUrl.searchParams.set(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetchWithTrustedCertificatesAsync(requestUrl, {
|
|
||||||
headers: {
|
|
||||||
Cookie: session.cookieHeader,
|
|
||||||
"User-Agent": "Homarr",
|
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new ResponseError(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = synologyEnvelopeSchema.parse(await response.json());
|
|
||||||
if (!payload.success) {
|
|
||||||
const errorCode = payload.error?.code;
|
|
||||||
if (errorCode !== undefined && SESSION_ERROR_CODES.has(errorCode)) {
|
|
||||||
throw new SynologySessionError(errorCode);
|
|
||||||
}
|
|
||||||
throw new ResponseError({
|
|
||||||
status: 502,
|
|
||||||
url: requestUrl.toString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async withAuthenticatedSessionAsync<T>(callback: (session: StoredSession) => Promise<T>): Promise<T> {
|
|
||||||
const storedSession = await this.sessionStore.getAsync();
|
|
||||||
if (storedSession) {
|
|
||||||
try {
|
|
||||||
return await callback(storedSession);
|
|
||||||
} catch (error) {
|
|
||||||
if (!(error instanceof SynologySessionError)) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
logger.debug("Synology session expired, re-authenticating", {
|
|
||||||
integrationId: this.integrationId,
|
|
||||||
errorCode: error.errorCode,
|
|
||||||
});
|
|
||||||
await this.sessionStore.clearAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await this.loginAsync();
|
|
||||||
await this.sessionStore.setAsync(session);
|
|
||||||
return await callback(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loginAsync(): Promise<StoredSession> {
|
|
||||||
const authDefinition = await this.getApiDefinitionAsync(AUTH_API_NAME);
|
|
||||||
const loginUrl = new URL(`${this.baseUrl}/webapi/${authDefinition.cgiPath}`);
|
|
||||||
loginUrl.searchParams.set("api", AUTH_API_NAME);
|
|
||||||
loginUrl.searchParams.set("version", String(authDefinition.maxVersion));
|
|
||||||
loginUrl.searchParams.set("method", "login");
|
|
||||||
const safeLoginUrl = loginUrl.toString();
|
|
||||||
|
|
||||||
const response = await fetchWithTrustedCertificatesAsync(loginUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"User-Agent": "Homarr",
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
account: this.username,
|
|
||||||
passwd: this.password,
|
|
||||||
session: AUTH_SESSION_NAME,
|
|
||||||
format: "cookie",
|
|
||||||
}).toString(),
|
|
||||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new ResponseError({ status: response.status, url: safeLoginUrl });
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = synologyAuthResponseSchema.parse(await response.json());
|
|
||||||
if (!payload.success) {
|
|
||||||
throw new ResponseError({
|
|
||||||
status: 401,
|
|
||||||
url: safeLoginUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const cookieHeader = extractCookieHeader(response.headers);
|
|
||||||
if (!cookieHeader) {
|
|
||||||
throw new ResponseError({
|
|
||||||
status: 401,
|
|
||||||
url: safeLoginUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { cookieHeader };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getApiDefinitionAsync(apiName: string): Promise<ApiDefinition> {
|
|
||||||
const cacheKey = `${this.integrationId}:${apiName}`;
|
|
||||||
const cachedDefinition = apiDefinitionCache.get(cacheKey);
|
|
||||||
if (cachedDefinition) {
|
|
||||||
return cachedDefinition;
|
|
||||||
}
|
|
||||||
|
|
||||||
const infoUrl = new URL(`${this.baseUrl}/webapi/query.cgi`);
|
|
||||||
infoUrl.searchParams.set("api", INFO_API_NAME);
|
|
||||||
infoUrl.searchParams.set("version", "1");
|
|
||||||
infoUrl.searchParams.set("method", "query");
|
|
||||||
infoUrl.searchParams.set("query", apiName);
|
|
||||||
|
|
||||||
const response = await fetchWithTrustedCertificatesAsync(infoUrl, {
|
|
||||||
headers: {
|
|
||||||
"User-Agent": "Homarr",
|
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new ResponseError(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = synologyApiInfoResponseSchema.parse(await response.json());
|
|
||||||
const apiDefinition = payload.data?.[apiName];
|
|
||||||
if (!payload.success || !apiDefinition) {
|
|
||||||
throw new ResponseError({
|
|
||||||
status: 502,
|
|
||||||
url: infoUrl.toString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const definition = {
|
|
||||||
cgiPath: apiDefinition.path,
|
|
||||||
maxVersion: apiDefinition.maxVersion,
|
|
||||||
};
|
|
||||||
apiDefinitionCache.set(cacheKey, definition);
|
|
||||||
return definition;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SynologySessionError extends Error {
|
|
||||||
constructor(public readonly errorCode: number) {
|
|
||||||
super(`Synology session error ${String(errorCode)}`);
|
|
||||||
this.name = SynologySessionError.name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractCookieHeader(headers: Headers): string | null {
|
|
||||||
const cookies = headers.getSetCookie();
|
|
||||||
if (cookies.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cookies.map((cookie) => cookie.split(";")[0]).join("; ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseNumericValue(value: number | string | undefined): number | null {
|
|
||||||
if (value === undefined) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const parsed = typeof value === "number" ? value : Number.parseFloat(value);
|
|
||||||
return Number.isFinite(parsed) ? parsed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOptionalNumericValue(value: number | string | undefined): number | null {
|
|
||||||
const parsed = parseNumericValue(value);
|
|
||||||
return parsed === null ? null : parsed;
|
|
||||||
}
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
import type { z } from "zod";
|
|
||||||
|
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
|
||||||
|
|
||||||
import { HandleIntegrationErrors } from "../base/errors/decorator";
|
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
|
||||||
import { Integration } from "../base/integration";
|
|
||||||
import { createSessionStore } from "../base/session-store";
|
|
||||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
|
||||||
import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
|
|
||||||
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
|
|
||||||
import { SynologyClient } from "./synology-client";
|
|
||||||
import type { SynologyDiskRecord, SynologyVolumeRecord } from "./synology-types";
|
|
||||||
import type { synologySystemInfoDataSchema, synologyUtilizationDataSchema } from "./synology-types";
|
|
||||||
|
|
||||||
const logger = createLogger({ module: "synologyIntegration" });
|
|
||||||
|
|
||||||
type StoredSession = {
|
|
||||||
cookieHeader: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
@HandleIntegrationErrors([])
|
|
||||||
export class SynologyIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
|
||||||
private readonly sessionStore = createSessionStore<StoredSession>(this.integration);
|
|
||||||
private client?: SynologyClient;
|
|
||||||
|
|
||||||
protected async testingAsync(_input: IntegrationTestingInput): Promise<TestingResult> {
|
|
||||||
await this.getClient().testConnectionAsync();
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
|
|
||||||
const client = this.getClient();
|
|
||||||
|
|
||||||
const [systemInfo, utilization, volumes] = await Promise.all([
|
|
||||||
client.getSystemInfoAsync(),
|
|
||||||
client.getUtilizationAsync(),
|
|
||||||
client.getStorageVolumesAsync(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [storageLoadInfoResult, smartInfoResult, systemStatusResult, upgradeStatusResult, upgradeCheckResult] =
|
|
||||||
await Promise.allSettled([
|
|
||||||
client.getStorageLoadInfoAsync(),
|
|
||||||
client.getSmartInfoAsync(),
|
|
||||||
client.getSystemStatusAsync(),
|
|
||||||
client.getUpgradeStatusAsync(),
|
|
||||||
client.getUpgradeCheckAsync(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const storageLoadInfo = getSettledValue(storageLoadInfoResult);
|
|
||||||
const smartInfo = getSettledValue(smartInfoResult);
|
|
||||||
const systemStatus = getSettledValue(systemStatusResult);
|
|
||||||
const upgradeStatus = getSettledValue(upgradeStatusResult);
|
|
||||||
const upgradeCheck = getSettledValue(upgradeCheckResult);
|
|
||||||
|
|
||||||
const diskRecords = mergeDiskRecords(
|
|
||||||
storageLoadInfo ? client.mapDisksFromLoadInfo(storageLoadInfo) : [],
|
|
||||||
smartInfo ? client.mapDisksFromSmartInfo(smartInfo) : [],
|
|
||||||
);
|
|
||||||
|
|
||||||
const memory = mapMemoryBytes(utilization);
|
|
||||||
const network = mapNetworkTraffic(utilization);
|
|
||||||
const loadAverage = mapLoadAverage(utilization);
|
|
||||||
const smart = mapVolumeSmartEntries(volumes, diskRecords);
|
|
||||||
const rebootRequired = Boolean(systemStatus?.reboot_required ?? upgradeStatus?.reboot_required ?? false);
|
|
||||||
const availablePkgUpdates = upgradeCheck?.update_count ?? (upgradeCheck?.available ? 1 : 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
version: mapVersion(systemInfo),
|
|
||||||
cpuModelName: systemInfo.model ?? "Unknown CPU",
|
|
||||||
cpuUtilization: mapCpuUtilization(utilization),
|
|
||||||
memUsedInBytes: memory.usedBytes,
|
|
||||||
memAvailableInBytes: memory.availableBytes,
|
|
||||||
uptime: mapUptimeSeconds(systemInfo),
|
|
||||||
network,
|
|
||||||
loadAverage,
|
|
||||||
rebootRequired,
|
|
||||||
availablePkgUpdates,
|
|
||||||
cpuTemp: mapCpuTemperature(systemInfo),
|
|
||||||
fileSystem: mapFileSystemEntries(volumes),
|
|
||||||
smart,
|
|
||||||
gpu: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async listStorageVolumesAsync(): Promise<{ value: string; label: string }[]> {
|
|
||||||
const volumes = await this.getClient().getStorageVolumesAsync();
|
|
||||||
const { id: integrationId, name: integrationName } = this.integration;
|
|
||||||
return volumes.map((volume) => ({
|
|
||||||
value: `${integrationId}:${volume.name}`,
|
|
||||||
label: `${volume.displayName ?? volume.name} (${integrationName})`,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
private getClient(): SynologyClient {
|
|
||||||
return (this.client ??= new SynologyClient({
|
|
||||||
integrationId: this.integration.id,
|
|
||||||
baseUrl: this.integration.url,
|
|
||||||
username: this.getSecretValue("username"),
|
|
||||||
password: this.getSecretValue("password"),
|
|
||||||
sessionStore: this.sessionStore,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSettledValue<T>(result: PromiseSettledResult<T>): T | null {
|
|
||||||
if (result.status === "rejected") {
|
|
||||||
logger.debug("Optional Synology API call failed", { reason: result.reason });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return result.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapVersion(systemInfo: z.infer<typeof synologySystemInfoDataSchema>): string {
|
|
||||||
if (systemInfo.version_string) {
|
|
||||||
return systemInfo.version_string;
|
|
||||||
}
|
|
||||||
if (systemInfo.firmware_ver) {
|
|
||||||
return systemInfo.firmware_ver;
|
|
||||||
}
|
|
||||||
if (systemInfo.version !== undefined) {
|
|
||||||
return String(systemInfo.version);
|
|
||||||
}
|
|
||||||
return "Unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapCpuUtilization(utilization: z.infer<typeof synologyUtilizationDataSchema>): number {
|
|
||||||
const userLoad = parseNumericValue(utilization.cpu?.user_load) ?? 0;
|
|
||||||
const systemLoad = parseNumericValue(utilization.cpu?.system_load) ?? 0;
|
|
||||||
const totalLoad = userLoad + systemLoad;
|
|
||||||
return Math.min(totalLoad, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapMemoryBytes(utilization: z.infer<typeof synologyUtilizationDataSchema>) {
|
|
||||||
const memory = utilization.memory;
|
|
||||||
const totalRealKib = parseNumericValue(memory?.total_real);
|
|
||||||
const realUsagePercent = parseNumericValue(memory?.real_usage);
|
|
||||||
|
|
||||||
// DSM utilization API reports memory sizes in KiB, not bytes.
|
|
||||||
if (totalRealKib !== null && realUsagePercent !== null) {
|
|
||||||
const totalBytes = totalRealKib * 1024;
|
|
||||||
const usedBytes = Math.round((totalBytes * realUsagePercent) / 100);
|
|
||||||
return {
|
|
||||||
usedBytes,
|
|
||||||
availableBytes: Math.max(totalBytes - usedBytes, 0),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const availableKib = parseNumericValue(memory?.avail_real) ?? 0;
|
|
||||||
const cachedKib = parseNumericValue(memory?.cached) ?? 0;
|
|
||||||
const totalKib = totalRealKib ?? availableKib + cachedKib;
|
|
||||||
const usedKib = Math.max(totalKib - availableKib, 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
usedBytes: usedKib * 1024,
|
|
||||||
availableBytes: availableKib * 1024,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapNetworkTraffic(utilization: z.infer<typeof synologyUtilizationDataSchema>) {
|
|
||||||
const totalNetwork = utilization.network?.find((entry) => entry.device === "total");
|
|
||||||
if (!totalNetwork) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const received = parseNumericValue(totalNetwork.rx);
|
|
||||||
const transmitted = parseNumericValue(totalNetwork.tx);
|
|
||||||
if (received === null || transmitted === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
up: transmitted,
|
|
||||||
down: received,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapLoadAverage(utilization: z.infer<typeof synologyUtilizationDataSchema>) {
|
|
||||||
const oneMinute = parseNumericValue(utilization.cpu?.["1min_load"]);
|
|
||||||
const fiveMinute = parseNumericValue(utilization.cpu?.["5min_load"]);
|
|
||||||
const fifteenMinute = parseNumericValue(utilization.cpu?.["15min_load"]);
|
|
||||||
|
|
||||||
if (oneMinute === null && fiveMinute === null && fifteenMinute === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"1min": oneMinute ?? 0,
|
|
||||||
"5min": fiveMinute ?? 0,
|
|
||||||
"15min": fifteenMinute ?? 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapUptimeSeconds(systemInfo: z.infer<typeof synologySystemInfoDataSchema>): number {
|
|
||||||
if (typeof systemInfo.uptime === "number") {
|
|
||||||
return systemInfo.uptime;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!systemInfo.up_time) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = systemInfo.up_time.split(":").map((part) => Number.parseInt(part, 10));
|
|
||||||
if (parts.length !== 3 || parts.some((part) => Number.isNaN(part))) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [hours = 0, minutes = 0, seconds = 0] = parts;
|
|
||||||
return hours * 3600 + minutes * 60 + seconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapCpuTemperature(systemInfo: z.infer<typeof synologySystemInfoDataSchema>): number | undefined {
|
|
||||||
const temperature = parseNumericValue(systemInfo.sys_temp ?? systemInfo.temperature);
|
|
||||||
return temperature === null ? undefined : temperature;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapFileSystemEntries(volumes: SynologyVolumeRecord[]) {
|
|
||||||
return volumes.map((volume) => {
|
|
||||||
const availableBytes = Math.max(volume.totalBytes - volume.usedBytes, 0);
|
|
||||||
return {
|
|
||||||
deviceName: volume.name,
|
|
||||||
used: `${volume.usedBytes}`,
|
|
||||||
available: `${availableBytes}`,
|
|
||||||
percentage: (volume.usedBytes / volume.totalBytes) * 100,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapVolumeSmartEntries(volumes: SynologyVolumeRecord[], diskRecords: SynologyDiskRecord[]) {
|
|
||||||
return volumes.map((volume) => {
|
|
||||||
const matchingDisks = diskRecords.filter((disk) => diskBelongsToVolume(disk, volume.name));
|
|
||||||
const temperatures = matchingDisks
|
|
||||||
.map((disk) => disk.temperature)
|
|
||||||
.filter((temperature): temperature is number => temperature !== null);
|
|
||||||
const overallStatus = volume.status ?? pickWorstDiskStatus(matchingDisks);
|
|
||||||
|
|
||||||
return {
|
|
||||||
deviceName: volume.name,
|
|
||||||
healthy: isHealthyStatus(overallStatus),
|
|
||||||
overallStatus,
|
|
||||||
temperature: temperatures.length > 0 ? Math.max(...temperatures) : null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeDiskRecords(
|
|
||||||
loadInfoDisks: SynologyDiskRecord[],
|
|
||||||
smartInfoDisks: SynologyDiskRecord[],
|
|
||||||
): SynologyDiskRecord[] {
|
|
||||||
const diskMap = new Map<string, SynologyDiskRecord>();
|
|
||||||
|
|
||||||
for (const disk of loadInfoDisks) {
|
|
||||||
diskMap.set(disk.identifier, disk);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const disk of smartInfoDisks) {
|
|
||||||
const existingDisk = diskMap.get(disk.identifier);
|
|
||||||
diskMap.set(disk.identifier, {
|
|
||||||
identifier: disk.identifier,
|
|
||||||
name: existingDisk?.name ?? disk.name,
|
|
||||||
status: disk.status ?? existingDisk?.status,
|
|
||||||
temperature: disk.temperature ?? existingDisk?.temperature ?? null,
|
|
||||||
volumeName: existingDisk?.volumeName ?? disk.volumeName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...diskMap.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
function diskBelongsToVolume(disk: SynologyDiskRecord, volumeName: string): boolean {
|
|
||||||
if (!disk.volumeName) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
disk.volumeName === volumeName ||
|
|
||||||
disk.volumeName.endsWith(volumeName) ||
|
|
||||||
volumeName.endsWith(disk.volumeName) ||
|
|
||||||
disk.volumeName.includes(volumeName) ||
|
|
||||||
volumeName.includes(disk.volumeName)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const UNHEALTHY_STATUS_TOKENS = ["critical", "error", "crash", "failed", "fail", "degraded", "warning"] as const;
|
|
||||||
|
|
||||||
function getStatusSeverityIndex(status: string): number {
|
|
||||||
const normalizedStatus = status.toLowerCase();
|
|
||||||
const index = UNHEALTHY_STATUS_TOKENS.findIndex((token) => normalizedStatus.includes(token));
|
|
||||||
return index === -1 ? UNHEALTHY_STATUS_TOKENS.length : index;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickWorstDiskStatus(disks: SynologyDiskRecord[]): string {
|
|
||||||
const statuses = disks
|
|
||||||
.map((disk) => disk.status)
|
|
||||||
.filter((status): status is string => status !== undefined && status.length > 0);
|
|
||||||
|
|
||||||
if (statuses.length === 0) {
|
|
||||||
return "normal";
|
|
||||||
}
|
|
||||||
|
|
||||||
return statuses.reduce((worstStatus, currentStatus) =>
|
|
||||||
getStatusSeverityIndex(currentStatus) < getStatusSeverityIndex(worstStatus) ? currentStatus : worstStatus,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHealthyStatus(status: string | undefined): boolean {
|
|
||||||
if (!status) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedStatus = status.toLowerCase();
|
|
||||||
return !["crash", "degraded", "fail", "failed", "warning", "error", "critical"].some((unhealthyToken) =>
|
|
||||||
normalizedStatus.includes(unhealthyToken),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseNumericValue(value: number | string | undefined): number | null {
|
|
||||||
if (value === undefined) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const parsed = typeof value === "number" ? value : Number.parseFloat(value);
|
|
||||||
return Number.isFinite(parsed) ? parsed : null;
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export const REQUEST_TIMEOUT_MS = 5000;
|
|
||||||
|
|
||||||
export const AUTH_API_NAME = "SYNO.API.Auth";
|
|
||||||
export const INFO_API_NAME = "SYNO.API.Info";
|
|
||||||
|
|
||||||
export const SESSION_ERROR_CODES = new Set([106, 107, 119]);
|
|
||||||
|
|
||||||
export const synologyEnvelopeSchema = z.object({
|
|
||||||
success: z.boolean(),
|
|
||||||
data: z.unknown().optional(),
|
|
||||||
error: z
|
|
||||||
.object({
|
|
||||||
code: z.number(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const synologyApiDefinitionSchema = z.object({
|
|
||||||
path: z.string(),
|
|
||||||
maxVersion: z.number(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const synologyApiInfoResponseSchema = synologyEnvelopeSchema.extend({
|
|
||||||
data: z.record(z.string(), synologyApiDefinitionSchema).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const synologyAuthResponseSchema = synologyEnvelopeSchema.extend({
|
|
||||||
data: z
|
|
||||||
.object({
|
|
||||||
sid: z.string().optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const numericString = z.union([z.number(), z.string()]);
|
|
||||||
|
|
||||||
export const synologySystemInfoDataSchema = z
|
|
||||||
.object({
|
|
||||||
up_time: z.string().optional(),
|
|
||||||
uptime: z.number().optional(),
|
|
||||||
version_string: z.string().optional(),
|
|
||||||
firmware_ver: z.string().optional(),
|
|
||||||
version: z.union([z.string(), z.number()]).optional(),
|
|
||||||
model: z.string().optional(),
|
|
||||||
sys_temp: numericString.optional(),
|
|
||||||
temperature: numericString.optional(),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
|
|
||||||
export const synologyVolumeInfoSchema = z.object({
|
|
||||||
name: z.string(),
|
|
||||||
used_size: numericString.optional(),
|
|
||||||
total_size: numericString.optional(),
|
|
||||||
status: z.string().optional(),
|
|
||||||
vol_path: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const synologyLegacyStorageDataSchema = z.object({
|
|
||||||
vol_info: z.array(synologyVolumeInfoSchema).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const synologyStorageV2VolumeSchema = z
|
|
||||||
.object({
|
|
||||||
id: z.string().optional(),
|
|
||||||
vol_path: z.string().optional(),
|
|
||||||
display_name: z.string().optional(),
|
|
||||||
size: z
|
|
||||||
.object({
|
|
||||||
total: z.union([z.number(), z.string()]).optional(),
|
|
||||||
used: z.union([z.number(), z.string()]).optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
status: z.string().optional(),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
|
|
||||||
export const synologyStorageV2DataSchema = z.object({
|
|
||||||
volumes: z.array(synologyStorageV2VolumeSchema).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const synologyUtilizationCpuSchema = z.object({
|
|
||||||
user_load: numericString.optional(),
|
|
||||||
system_load: numericString.optional(),
|
|
||||||
other_load: numericString.optional(),
|
|
||||||
"1min_load": numericString.optional(),
|
|
||||||
"5min_load": numericString.optional(),
|
|
||||||
"15min_load": numericString.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const synologyUtilizationMemorySchema = z.object({
|
|
||||||
real_usage: numericString.optional(),
|
|
||||||
avail_real: numericString.optional(),
|
|
||||||
cached: numericString.optional(),
|
|
||||||
total_real: numericString.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const synologyUtilizationNetworkSchema = z.object({
|
|
||||||
device: z.string(),
|
|
||||||
rx: numericString.optional(),
|
|
||||||
tx: numericString.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const synologyUtilizationDataSchema = z.object({
|
|
||||||
cpu: synologyUtilizationCpuSchema.optional(),
|
|
||||||
memory: synologyUtilizationMemorySchema.optional(),
|
|
||||||
network: z.array(synologyUtilizationNetworkSchema).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const synologyStorageDiskSchema = z
|
|
||||||
.object({
|
|
||||||
id: z.string().optional(),
|
|
||||||
name: z.string().optional(),
|
|
||||||
display_name: z.string().optional(),
|
|
||||||
status: z.string().optional(),
|
|
||||||
temp: numericString.optional(),
|
|
||||||
temperature: numericString.optional(),
|
|
||||||
smart_status: z.string().optional(),
|
|
||||||
volume_id: z.string().optional(),
|
|
||||||
vol_path: z.string().optional(),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
|
|
||||||
export const synologyStorageLoadInfoDataSchema = z
|
|
||||||
.object({
|
|
||||||
disks: z.array(synologyStorageDiskSchema).optional(),
|
|
||||||
volumes: z.array(synologyStorageV2VolumeSchema).optional(),
|
|
||||||
overview_data: z
|
|
||||||
.object({
|
|
||||||
volumes: z.array(synologyVolumeInfoSchema).optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
|
|
||||||
export const synologySmartDiskSchema = z
|
|
||||||
.object({
|
|
||||||
id: z.string().optional(),
|
|
||||||
disk_id: z.string().optional(),
|
|
||||||
name: z.string().optional(),
|
|
||||||
status: z.string().optional(),
|
|
||||||
temp: numericString.optional(),
|
|
||||||
temperature: numericString.optional(),
|
|
||||||
overall_status: z.string().optional(),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
|
|
||||||
export const synologySmartInfoDataSchema = z
|
|
||||||
.object({
|
|
||||||
disks: z.array(synologySmartDiskSchema).optional(),
|
|
||||||
items: z.array(synologySmartDiskSchema).optional(),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
|
|
||||||
export const synologySystemStatusDataSchema = z
|
|
||||||
.object({
|
|
||||||
reboot_required: z.boolean().optional(),
|
|
||||||
system_need_repair: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
|
|
||||||
export const synologyUpgradeStatusDataSchema = z
|
|
||||||
.object({
|
|
||||||
reboot_required: z.boolean().optional(),
|
|
||||||
upgrade_ready: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
|
|
||||||
export const synologyUpgradeCheckDataSchema = z
|
|
||||||
.object({
|
|
||||||
available: z.boolean().optional(),
|
|
||||||
update_count: z.number().optional(),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
|
|
||||||
export type SynologyVolumeRecord = {
|
|
||||||
name: string;
|
|
||||||
displayName?: string;
|
|
||||||
usedBytes: number;
|
|
||||||
totalBytes: number;
|
|
||||||
status: string | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SynologyDiskRecord = {
|
|
||||||
identifier: string;
|
|
||||||
name: string;
|
|
||||||
status: string | undefined;
|
|
||||||
temperature: number | null;
|
|
||||||
volumeName: string | undefined;
|
|
||||||
};
|
|
||||||
@@ -1,585 +0,0 @@
|
|||||||
// @vitest-environment node
|
|
||||||
|
|
||||||
import { Response } from "undici";
|
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
vi.hoisted(() => {
|
|
||||||
process.env.CI = "true";
|
|
||||||
process.env.NODE_ENV = "test";
|
|
||||||
process.env.SECRET_ENCRYPTION_KEY = "0".repeat(64);
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
|
||||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@homarr/core/infrastructure/logs", () => ({
|
|
||||||
createLogger: () => ({
|
|
||||||
debug: vi.fn(),
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../base/session-store", () => ({
|
|
||||||
createSessionStore: () => ({
|
|
||||||
getAsync: vi.fn().mockResolvedValue(null),
|
|
||||||
setAsync: vi.fn().mockResolvedValue(undefined),
|
|
||||||
clearAsync: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { ResponseError } from "@homarr/common/server";
|
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
|
||||||
|
|
||||||
import { IntegrationResponseError } from "../../base/errors/http/integration-response-error";
|
|
||||||
|
|
||||||
import type { IntegrationSecret } from "../../base/types";
|
|
||||||
import { SynologyIntegration } from "../synology-integration";
|
|
||||||
|
|
||||||
const TEST_URL = "https://synology.example.com:5001";
|
|
||||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
|
||||||
|
|
||||||
const createIntegration = (secrets: IntegrationSecret[] = []) =>
|
|
||||||
new SynologyIntegration({
|
|
||||||
id: "test-synology",
|
|
||||||
name: "Test Synology",
|
|
||||||
url: TEST_URL,
|
|
||||||
externalUrl: null,
|
|
||||||
decryptedSecrets: secrets.length
|
|
||||||
? secrets
|
|
||||||
: [
|
|
||||||
{ kind: "username", value: "homarr" },
|
|
||||||
{ kind: "password", value: "secret-password" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const apiDefinitions = {
|
|
||||||
"SYNO.API.Auth": { path: "auth.cgi", maxVersion: 7 },
|
|
||||||
"SYNO.API.Info": { path: "query.cgi", maxVersion: 1 },
|
|
||||||
"SYNO.Core.System": { path: "entry.cgi", maxVersion: 3 },
|
|
||||||
"SYNO.Core.System.Utilization": { path: "entry.cgi", maxVersion: 1 },
|
|
||||||
"SYNO.Storage.CGI.Storage": { path: "entry.cgi", maxVersion: 1 },
|
|
||||||
"SYNO.Storage.CGI.Smart": { path: "entry.cgi", maxVersion: 1 },
|
|
||||||
"SYNO.Core.System.Status": { path: "entry.cgi", maxVersion: 1 },
|
|
||||||
"SYNO.Core.Upgrade": { path: "entry.cgi", maxVersion: 1 },
|
|
||||||
"SYNO.Core.Upgrade.Server": { path: "entry.cgi", maxVersion: 1 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const systemInfoResponse = {
|
|
||||||
success: true as const,
|
|
||||||
data: {
|
|
||||||
up_time: "48:30:15",
|
|
||||||
version_string: "DSM 7.3-12345",
|
|
||||||
model: "DS920+",
|
|
||||||
sys_temp: 42,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const utilizationResponse = {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
cpu: {
|
|
||||||
user_load: 12,
|
|
||||||
system_load: 8,
|
|
||||||
"1min_load": 10,
|
|
||||||
"5min_load": 14,
|
|
||||||
"15min_load": 9,
|
|
||||||
},
|
|
||||||
memory: {
|
|
||||||
real_usage: 35,
|
|
||||||
total_real: 8 * 1024 * 1024,
|
|
||||||
avail_real: Math.round(8 * 1024 * 1024 * 0.65),
|
|
||||||
cached: 900_000,
|
|
||||||
},
|
|
||||||
network: [
|
|
||||||
{ device: "total", rx: 4096, tx: 8192 },
|
|
||||||
{ device: "eth0", rx: 4096, tx: 8192 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const storageV2Response = {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
volumes: [
|
|
||||||
{
|
|
||||||
id: "volume_1",
|
|
||||||
display_name: "Data Volume 1",
|
|
||||||
size: { total: 10_000_000_000, used: 4_000_000_000 },
|
|
||||||
status: "normal",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "volume_2",
|
|
||||||
display_name: "Data Volume 2",
|
|
||||||
size: { total: 20_000_000_000, used: 5_000_000_000 },
|
|
||||||
status: "degraded",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const storageLoadInfoResponse = {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
disks: [
|
|
||||||
{
|
|
||||||
id: "sda",
|
|
||||||
display_name: "Drive 1",
|
|
||||||
temp: 38,
|
|
||||||
status: "normal",
|
|
||||||
volume_id: "volume_1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "sdb",
|
|
||||||
display_name: "Drive 2",
|
|
||||||
temp: 44,
|
|
||||||
status: "normal",
|
|
||||||
volume_id: "volume_2",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const smartInfoResponse = {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
disks: [
|
|
||||||
{
|
|
||||||
disk_id: "sda",
|
|
||||||
name: "Drive 1",
|
|
||||||
overall_status: "normal",
|
|
||||||
temp: 39,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const systemStatusResponse = {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
reboot_required: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const upgradeStatusResponse = {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
reboot_required: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const upgradeCheckResponse = {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
available: true,
|
|
||||||
update_count: 2,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const toUrlString = (url: unknown): string => String(url);
|
|
||||||
|
|
||||||
const getApiFromUrl = (url: string) => {
|
|
||||||
const parsedUrl = new URL(url);
|
|
||||||
return parsedUrl.searchParams.get("api");
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMethodFromUrl = (url: string) => {
|
|
||||||
const parsedUrl = new URL(url);
|
|
||||||
return parsedUrl.searchParams.get("method");
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTypeFromUrl = (url: string) => {
|
|
||||||
const parsedUrl = new URL(url);
|
|
||||||
return parsedUrl.searchParams.get("type");
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockSuccessfulFlow = (options?: {
|
|
||||||
skipStorageV2?: boolean;
|
|
||||||
skipOptional?: boolean;
|
|
||||||
systemInfo?: {
|
|
||||||
success: boolean;
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
utilization?: typeof utilizationResponse;
|
|
||||||
storageV2?: typeof storageV2Response;
|
|
||||||
storageLoadInfo?: typeof storageLoadInfoResponse;
|
|
||||||
}) => {
|
|
||||||
mockFetch.mockImplementation((url) => {
|
|
||||||
const urlString = toUrlString(url);
|
|
||||||
const apiName = getApiFromUrl(urlString);
|
|
||||||
const method = getMethodFromUrl(urlString);
|
|
||||||
const type = getTypeFromUrl(urlString);
|
|
||||||
|
|
||||||
if (apiName === "SYNO.API.Info" && method === "query") {
|
|
||||||
const query = new URL(urlString).searchParams.get("query") ?? "SYNO.API.Auth";
|
|
||||||
const definition = apiDefinitions[query as keyof typeof apiDefinitions];
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(JSON.stringify({ success: true, data: { [query]: definition } }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
}),
|
|
||||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiName === "SYNO.API.Auth" && method === "login") {
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(JSON.stringify({ success: true, data: { sid: "test-session-id" } }), {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Set-Cookie": "id=test-session-id; Path=/; HttpOnly",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiName === "SYNO.Core.System" && method === "info" && type === "storage_v2") {
|
|
||||||
if (options?.skipStorageV2) {
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(JSON.stringify({ success: false, error: { code: 103 } }), { status: 200 }),
|
|
||||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
||||||
}
|
|
||||||
return Promise.resolve(new Response(JSON.stringify(options?.storageV2 ?? storageV2Response), { status: 200 })) as unknown as ReturnType<
|
|
||||||
typeof fetchWithTrustedCertificatesAsync
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiName === "SYNO.Core.System" && method === "info" && type === "storage") {
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
vol_info: [
|
|
||||||
{
|
|
||||||
name: "volume_1",
|
|
||||||
used_size: 4_000_000_000,
|
|
||||||
total_size: 10_000_000_000,
|
|
||||||
status: "normal",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{ status: 200 },
|
|
||||||
),
|
|
||||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiName === "SYNO.Core.System" && method === "info" && !type) {
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(JSON.stringify(options?.systemInfo ?? systemInfoResponse), { status: 200 }),
|
|
||||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiName === "SYNO.Core.System.Utilization" && method === "get") {
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(JSON.stringify(options?.utilization ?? utilizationResponse), { status: 200 }),
|
|
||||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.skipOptional) {
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(JSON.stringify({ success: false, error: { code: 105 } }), { status: 200 }),
|
|
||||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiName === "SYNO.Storage.CGI.Storage" && method === "load_info") {
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(JSON.stringify(options?.storageLoadInfo ?? storageLoadInfoResponse), { status: 200 }),
|
|
||||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiName === "SYNO.Storage.CGI.Smart" && method === "get_smart_info") {
|
|
||||||
return Promise.resolve(new Response(JSON.stringify(smartInfoResponse), { status: 200 })) as unknown as ReturnType<
|
|
||||||
typeof fetchWithTrustedCertificatesAsync
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiName === "SYNO.Core.System.Status" && method === "get") {
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(JSON.stringify(systemStatusResponse), { status: 200 }),
|
|
||||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiName === "SYNO.Core.Upgrade" && method === "status") {
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(JSON.stringify(upgradeStatusResponse), { status: 200 }),
|
|
||||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiName === "SYNO.Core.Upgrade.Server" && method === "check") {
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(JSON.stringify(upgradeCheckResponse), { status: 200 }),
|
|
||||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(JSON.stringify({ success: false, error: { code: 102 } }), { status: 200 }),
|
|
||||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockFetch.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("SynologyIntegration.getSystemInfoAsync", () => {
|
|
||||||
test("maps all supported health monitoring fields", async () => {
|
|
||||||
mockSuccessfulFlow();
|
|
||||||
const integration = createIntegration();
|
|
||||||
const result = await integration.getSystemInfoAsync();
|
|
||||||
|
|
||||||
expect(result.version).toBe("DSM 7.3-12345");
|
|
||||||
expect(result.cpuModelName).toBe("DS920+");
|
|
||||||
expect(result.cpuUtilization).toBe(20);
|
|
||||||
expect(result.memUsedInBytes).toBe(Math.round((8 * 1024 * 1024 * 1024 * 35) / 100));
|
|
||||||
expect(result.memAvailableInBytes).toBe(8 * 1024 * 1024 * 1024 - Math.round((8 * 1024 * 1024 * 1024 * 35) / 100));
|
|
||||||
expect(result.uptime).toBe(48 * 3600 + 30 * 60 + 15);
|
|
||||||
expect(result.cpuTemp).toBe(42);
|
|
||||||
expect(result.network).toEqual({ up: 8192, down: 4096 });
|
|
||||||
expect(result.loadAverage).toEqual({ "1min": 10, "5min": 14, "15min": 9 });
|
|
||||||
expect(result.rebootRequired).toBe(true);
|
|
||||||
expect(result.availablePkgUpdates).toBe(2);
|
|
||||||
expect(result.gpu).toEqual([]);
|
|
||||||
expect(result.fileSystem).toHaveLength(2);
|
|
||||||
expect(result.fileSystem[0]).toEqual({
|
|
||||||
deviceName: "volume_1",
|
|
||||||
used: "4000000000",
|
|
||||||
available: "6000000000",
|
|
||||||
percentage: 40,
|
|
||||||
});
|
|
||||||
expect(result.smart).toEqual([
|
|
||||||
{
|
|
||||||
deviceName: "volume_1",
|
|
||||||
healthy: true,
|
|
||||||
overallStatus: "normal",
|
|
||||||
temperature: 39,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
deviceName: "volume_2",
|
|
||||||
healthy: false,
|
|
||||||
overallStatus: "degraded",
|
|
||||||
temperature: 44,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("picks worst disk status when volume status is missing", async () => {
|
|
||||||
mockSuccessfulFlow({
|
|
||||||
storageV2: {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
volumes: [
|
|
||||||
{
|
|
||||||
id: "volume_1",
|
|
||||||
display_name: "Data Volume 1",
|
|
||||||
size: { total: 10_000_000_000, used: 4_000_000_000 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
storageLoadInfo: {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
disks: [
|
|
||||||
{
|
|
||||||
id: "sda",
|
|
||||||
display_name: "Drive 1",
|
|
||||||
temp: 38,
|
|
||||||
status: "normal",
|
|
||||||
volume_id: "volume_1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "sdb",
|
|
||||||
display_name: "Drive 2",
|
|
||||||
temp: 40,
|
|
||||||
status: "degraded",
|
|
||||||
volume_id: "volume_1",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const integration = createIntegration();
|
|
||||||
const result = await integration.getSystemInfoAsync();
|
|
||||||
|
|
||||||
expect(result.smart).toEqual([
|
|
||||||
{
|
|
||||||
deviceName: "volume_1",
|
|
||||||
healthy: false,
|
|
||||||
overallStatus: "degraded",
|
|
||||||
temperature: 40,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back to legacy storage info when storage_v2 is unavailable", async () => {
|
|
||||||
mockSuccessfulFlow({ skipStorageV2: true });
|
|
||||||
const integration = createIntegration();
|
|
||||||
const result = await integration.getSystemInfoAsync();
|
|
||||||
|
|
||||||
expect(result.fileSystem).toHaveLength(1);
|
|
||||||
expect(result.fileSystem[0]?.deviceName).toBe("volume_1");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("continues when optional tier-2 calls fail", async () => {
|
|
||||||
mockSuccessfulFlow({ skipOptional: true });
|
|
||||||
const integration = createIntegration();
|
|
||||||
const result = await integration.getSystemInfoAsync();
|
|
||||||
|
|
||||||
expect(result.cpuUtilization).toBe(20);
|
|
||||||
expect(result.rebootRequired).toBe(false);
|
|
||||||
expect(result.availablePkgUpdates).toBe(0);
|
|
||||||
expect(result.smart.every((entry) => entry.temperature === null)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("converts memory values from KiB to bytes", async () => {
|
|
||||||
mockSuccessfulFlow({
|
|
||||||
utilization: {
|
|
||||||
...utilizationResponse,
|
|
||||||
data: {
|
|
||||||
...utilizationResponse.data,
|
|
||||||
memory: {
|
|
||||||
real_usage: 17,
|
|
||||||
total_real: 16_351_532,
|
|
||||||
avail_real: 426_568,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const integration = createIntegration();
|
|
||||||
const result = await integration.getSystemInfoAsync();
|
|
||||||
|
|
||||||
const totalBytes = 16_351_532 * 1024;
|
|
||||||
expect(result.memUsedInBytes).toBe(Math.round((totalBytes * 17) / 100));
|
|
||||||
expect(result.memAvailableInBytes).toBe(totalBytes - Math.round((totalBytes * 17) / 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("caps cpu utilization at 100 when user and system load exceed 100", async () => {
|
|
||||||
mockSuccessfulFlow({
|
|
||||||
utilization: {
|
|
||||||
...utilizationResponse,
|
|
||||||
data: {
|
|
||||||
...utilizationResponse.data,
|
|
||||||
cpu: {
|
|
||||||
...utilizationResponse.data.cpu,
|
|
||||||
user_load: 80,
|
|
||||||
system_load: 30,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const integration = createIntegration();
|
|
||||||
const result = await integration.getSystemInfoAsync();
|
|
||||||
|
|
||||||
expect(result.cpuUtilization).toBe(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns undefined cpu temperature when sensor is absent", async () => {
|
|
||||||
mockSuccessfulFlow({
|
|
||||||
systemInfo: {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
up_time: "01:00:00",
|
|
||||||
version_string: "DSM 7.3-12345",
|
|
||||||
model: "DS920+",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const integration = createIntegration();
|
|
||||||
const result = await integration.getSystemInfoAsync();
|
|
||||||
expect(result.cpuTemp).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("SynologyIntegration.listStorageVolumesAsync", () => {
|
|
||||||
test("returns auto-detected volume names", async () => {
|
|
||||||
mockSuccessfulFlow();
|
|
||||||
const integration = createIntegration();
|
|
||||||
const volumes = await integration.listStorageVolumesAsync();
|
|
||||||
|
|
||||||
expect(volumes).toEqual([
|
|
||||||
{ value: "test-synology:volume_1", label: "Data Volume 1 (Test Synology)" },
|
|
||||||
{ value: "test-synology:volume_2", label: "Data Volume 2 (Test Synology)" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("SynologyIntegration authentication", () => {
|
|
||||||
test("rejects two-factor authentication during login", async () => {
|
|
||||||
mockFetch.mockImplementation((url) => {
|
|
||||||
const urlString = toUrlString(url);
|
|
||||||
const apiName = getApiFromUrl(urlString);
|
|
||||||
const method = getMethodFromUrl(urlString);
|
|
||||||
|
|
||||||
if (apiName === "SYNO.API.Info" && method === "query") {
|
|
||||||
const query = new URL(urlString).searchParams.get("query") ?? "SYNO.API.Auth";
|
|
||||||
const definition = apiDefinitions[query as keyof typeof apiDefinitions];
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(JSON.stringify({ success: true, data: { [query]: definition } }), { status: 200 }),
|
|
||||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiName === "SYNO.API.Auth" && method === "login") {
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(JSON.stringify({ success: false, error: { code: 403 } }), { status: 200 }),
|
|
||||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.resolve(new Response("{}", { status: 500 })) as unknown as ReturnType<
|
|
||||||
typeof fetchWithTrustedCertificatesAsync
|
|
||||||
>;
|
|
||||||
});
|
|
||||||
|
|
||||||
const integration = createIntegration();
|
|
||||||
await expect(integration.getSystemInfoAsync()).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not leak credentials in login error URLs", async () => {
|
|
||||||
mockFetch.mockImplementation((url, init) => {
|
|
||||||
const urlString = toUrlString(url);
|
|
||||||
const apiName = getApiFromUrl(urlString);
|
|
||||||
const method = getMethodFromUrl(urlString);
|
|
||||||
|
|
||||||
if (apiName === "SYNO.API.Info" && method === "query") {
|
|
||||||
const query = new URL(urlString).searchParams.get("query") ?? "SYNO.API.Auth";
|
|
||||||
const definition = apiDefinitions[query as keyof typeof apiDefinitions];
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(JSON.stringify({ success: true, data: { [query]: definition } }), { status: 200 }),
|
|
||||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiName === "SYNO.API.Auth" && method === "login") {
|
|
||||||
expect(init?.method).toBe("POST");
|
|
||||||
expect(init?.body).toContain("account=homarr");
|
|
||||||
expect(init?.body).toContain("passwd=secret-password");
|
|
||||||
expect(urlString).not.toContain("account=");
|
|
||||||
expect(urlString).not.toContain("passwd=");
|
|
||||||
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(JSON.stringify({ success: false, error: { code: 403 } }), { status: 200 }),
|
|
||||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.resolve(new Response("{}", { status: 500 })) as unknown as ReturnType<
|
|
||||||
typeof fetchWithTrustedCertificatesAsync
|
|
||||||
>;
|
|
||||||
});
|
|
||||||
|
|
||||||
const integration = createIntegration();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await integration.getSystemInfoAsync();
|
|
||||||
expect.unreachable("Expected login to fail");
|
|
||||||
} catch (error) {
|
|
||||||
expect(error).toBeInstanceOf(IntegrationResponseError);
|
|
||||||
const responseError = (error as IntegrationResponseError).cause;
|
|
||||||
expect(responseError).toBeInstanceOf(ResponseError);
|
|
||||||
expect(responseError.url).not.toContain("secret-password");
|
|
||||||
expect(responseError.url).not.toContain("passwd=");
|
|
||||||
expect(responseError.url).not.toContain("account=homarr");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -165,7 +165,6 @@ const optionMapping: OptionMapping = {
|
|||||||
"sectionIndicatorColor" in oldOptions ? oldOptions.sectionIndicatorColor : undefined,
|
"sectionIndicatorColor" in oldOptions ? oldOptions.sectionIndicatorColor : undefined,
|
||||||
showUptime: () => undefined,
|
showUptime: () => undefined,
|
||||||
gpu: () => undefined,
|
gpu: () => undefined,
|
||||||
visibleStorageVolumes: () => undefined,
|
|
||||||
visibleClusterSections: (oldOptions) => {
|
visibleClusterSections: (oldOptions) => {
|
||||||
if (!("showNode" in oldOptions)) return undefined;
|
if (!("showNode" in oldOptions)) return undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ interface Options<TData, TInput extends Record<string, unknown>> {
|
|||||||
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
|
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
|
||||||
|
|
||||||
const MAX_CACHE_SIZE = 1000;
|
const MAX_CACHE_SIZE = 1000;
|
||||||
|
const cache = new Map<string, CacheEntry<unknown>>();
|
||||||
|
const inflight = new Map<string, Promise<CacheEntry<unknown>>>();
|
||||||
|
|
||||||
const DEFAULT_TTL_MS = 10_000;
|
const DEFAULT_TTL_MS = 10_000;
|
||||||
|
|
||||||
const evictExpired = <TData>(cache: Map<string, CacheEntry<TData>>) => {
|
const evictExpired = () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [key, entry] of cache) {
|
for (const [key, entry] of cache) {
|
||||||
if (now >= entry.expiresAt) cache.delete(key);
|
if (now >= entry.expiresAt) cache.delete(key);
|
||||||
@@ -17,46 +20,41 @@ const evictExpired = <TData>(cache: Map<string, CacheEntry<TData>>) => {
|
|||||||
|
|
||||||
export const createRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
export const createRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||||
options: Options<TData, TInput>,
|
options: Options<TData, TInput>,
|
||||||
) => {
|
) => ({
|
||||||
const cache = new Map<string, CacheEntry<TData>>();
|
handler: (input: TInput) => ({
|
||||||
const inflight = new Map<string, Promise<CacheEntry<TData>>>();
|
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
|
||||||
|
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
|
||||||
|
const key = JSON.stringify(input);
|
||||||
|
|
||||||
return {
|
const cached = cache.get(key) as CacheEntry<TData> | undefined;
|
||||||
handler: (input: TInput) => ({
|
if (cached && Date.now() < cached.expiresAt) {
|
||||||
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
|
return { data: cached.data, timestamp: cached.timestamp };
|
||||||
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
|
}
|
||||||
const key = JSON.stringify(input);
|
if (cached) cache.delete(key);
|
||||||
|
|
||||||
const cached = cache.get(key);
|
const existing = inflight.get(key);
|
||||||
if (cached && Date.now() < cached.expiresAt) {
|
if (existing) return existing as Promise<CacheEntry<TData>>;
|
||||||
return { data: cached.data, timestamp: cached.timestamp };
|
|
||||||
}
|
|
||||||
if (cached) cache.delete(key);
|
|
||||||
|
|
||||||
const existing = inflight.get(key);
|
const promise = options
|
||||||
if (existing) return existing;
|
.requestAsync(input)
|
||||||
|
.then((data) => {
|
||||||
|
if (cache.size >= MAX_CACHE_SIZE) evictExpired();
|
||||||
|
if (cache.size >= MAX_CACHE_SIZE) {
|
||||||
|
const oldest = cache.keys().next().value;
|
||||||
|
if (oldest) cache.delete(oldest);
|
||||||
|
}
|
||||||
|
const entry: CacheEntry<TData> = { data, timestamp: new Date(), expiresAt: Date.now() + ttl };
|
||||||
|
cache.set(key, entry as CacheEntry<unknown>);
|
||||||
|
inflight.delete(key);
|
||||||
|
return entry;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
inflight.delete(key);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
const promise = options
|
inflight.set(key, promise as Promise<CacheEntry<unknown>>);
|
||||||
.requestAsync(input)
|
return promise;
|
||||||
.then((data) => {
|
},
|
||||||
if (cache.size >= MAX_CACHE_SIZE) evictExpired(cache);
|
}),
|
||||||
if (cache.size >= MAX_CACHE_SIZE) {
|
});
|
||||||
const oldest = cache.keys().next().value;
|
|
||||||
if (oldest) cache.delete(oldest);
|
|
||||||
}
|
|
||||||
const entry: CacheEntry<TData> = { data, timestamp: new Date(), expiresAt: Date.now() + ttl };
|
|
||||||
cache.set(key, entry);
|
|
||||||
inflight.delete(key);
|
|
||||||
return entry;
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
inflight.delete(key);
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
|
|
||||||
inflight.set(key, promise);
|
|
||||||
return promise;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1598,10 +1598,7 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"label": {
|
"label": {
|
||||||
"settings": "",
|
"settings": ""
|
||||||
"refresh": "",
|
|
||||||
"options": "",
|
|
||||||
"actions": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
@@ -1903,9 +1900,6 @@
|
|||||||
"title": "",
|
"title": "",
|
||||||
"noStation": "",
|
"noStation": "",
|
||||||
"option": {
|
"option": {
|
||||||
"baseUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"station": {
|
"station": {
|
||||||
"label": ""
|
"label": ""
|
||||||
}
|
}
|
||||||
@@ -2789,10 +2783,6 @@
|
|||||||
"showPhotoInfo": {
|
"showPhotoInfo": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"description": ""
|
"description": ""
|
||||||
},
|
|
||||||
"randomizePhotos": {
|
|
||||||
"label": "",
|
|
||||||
"description": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -3157,9 +3147,6 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
"providerUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"identifier": {
|
"identifier": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"placeholder": ""
|
"placeholder": ""
|
||||||
@@ -3207,13 +3194,6 @@
|
|||||||
"noProvider": {
|
"noProvider": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"tooltip": ""
|
"tooltip": ""
|
||||||
},
|
|
||||||
"tokens": {
|
|
||||||
"label": "",
|
|
||||||
"token": "",
|
|
||||||
"configured": "",
|
|
||||||
"notConfigured": "",
|
|
||||||
"save": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3236,9 +3216,8 @@
|
|||||||
"invalidIdentifier": "",
|
"invalidIdentifier": "",
|
||||||
"noMatchingVersion": "",
|
"noMatchingVersion": "",
|
||||||
"noReleasesFound": "",
|
"noReleasesFound": "",
|
||||||
"noProviderSelected": "",
|
"noProviderSeleceted": "",
|
||||||
"noProviderResponse": "",
|
"noProviderResponse": ""
|
||||||
"unexpected": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4545,14 +4524,89 @@
|
|||||||
"disabled": ""
|
"disabled": ""
|
||||||
},
|
},
|
||||||
"job": {
|
"job": {
|
||||||
|
"minecraftServerStatus": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"timetable": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"iconsUpdater": {
|
"iconsUpdater": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"smartHomeEntityState": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"ping": {
|
"ping": {
|
||||||
"label": ""
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaServer": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaOrganizer": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaRequestStats": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaRequestList": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"rssFeeds": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"indexerManager": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"healthMonitoring": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"dnsHole": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"updateChecker": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaTranscoding": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"networkController": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"refreshNotifications": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"dockerContainers": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallCpu": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallMemory": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallVersion": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallInterfaces": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"weather": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"tracearr": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"umamiActiveVisitors": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"vpn": {
|
||||||
|
"label": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interval": {
|
"interval": {
|
||||||
|
|||||||
@@ -1598,10 +1598,7 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"label": {
|
"label": {
|
||||||
"settings": "设置",
|
"settings": "设置"
|
||||||
"refresh": "",
|
|
||||||
"options": "",
|
|
||||||
"actions": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
@@ -1903,9 +1900,6 @@
|
|||||||
"title": "出发时间 {station}",
|
"title": "出发时间 {station}",
|
||||||
"noStation": "未选择车站",
|
"noStation": "未选择车站",
|
||||||
"option": {
|
"option": {
|
||||||
"baseUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"station": {
|
"station": {
|
||||||
"label": "车站"
|
"label": "车站"
|
||||||
}
|
}
|
||||||
@@ -2789,10 +2783,6 @@
|
|||||||
"showPhotoInfo": {
|
"showPhotoInfo": {
|
||||||
"label": "显示图片信息",
|
"label": "显示图片信息",
|
||||||
"description": "在幻灯片底部显示图片日期和当前索引"
|
"description": "在幻灯片底部显示图片日期和当前索引"
|
||||||
},
|
|
||||||
"randomizePhotos": {
|
|
||||||
"label": "",
|
|
||||||
"description": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Immich 相册图片幻灯片",
|
"description": "Immich 相册图片幻灯片",
|
||||||
@@ -3157,9 +3147,6 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"label": "提供者"
|
"label": "提供者"
|
||||||
},
|
},
|
||||||
"providerUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"identifier": {
|
"identifier": {
|
||||||
"label": "标识符",
|
"label": "标识符",
|
||||||
"placeholder": "姓名或所有者名称"
|
"placeholder": "姓名或所有者名称"
|
||||||
@@ -3207,13 +3194,6 @@
|
|||||||
"noProvider": {
|
"noProvider": {
|
||||||
"label": "没有提供者",
|
"label": "没有提供者",
|
||||||
"tooltip": "无法解析该提供者,请在导入镜像后手动设置"
|
"tooltip": "无法解析该提供者,请在导入镜像后手动设置"
|
||||||
},
|
|
||||||
"tokens": {
|
|
||||||
"label": "",
|
|
||||||
"token": "",
|
|
||||||
"configured": "",
|
|
||||||
"notConfigured": "",
|
|
||||||
"save": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3236,9 +3216,8 @@
|
|||||||
"invalidIdentifier": "无效标识符",
|
"invalidIdentifier": "无效标识符",
|
||||||
"noMatchingVersion": "没有找到匹配的版本",
|
"noMatchingVersion": "没有找到匹配的版本",
|
||||||
"noReleasesFound": "没有发现发布信息",
|
"noReleasesFound": "没有发现发布信息",
|
||||||
"noProviderSelected": "",
|
"noProviderSeleceted": "没有选择提供者",
|
||||||
"noProviderResponse": "提供者没有响应",
|
"noProviderResponse": "提供者没有响应"
|
||||||
"unexpected": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4545,14 +4524,89 @@
|
|||||||
"disabled": "禁用"
|
"disabled": "禁用"
|
||||||
},
|
},
|
||||||
"job": {
|
"job": {
|
||||||
|
"minecraftServerStatus": {
|
||||||
|
"label": "Minecraft 服务状态"
|
||||||
|
},
|
||||||
|
"timetable": {
|
||||||
|
"label": "时刻表"
|
||||||
|
},
|
||||||
"iconsUpdater": {
|
"iconsUpdater": {
|
||||||
"label": "图标更新"
|
"label": "图标更新"
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"label": "分析"
|
"label": "分析"
|
||||||
},
|
},
|
||||||
|
"smartHomeEntityState": {
|
||||||
|
"label": "智能家居实体状态"
|
||||||
|
},
|
||||||
"ping": {
|
"ping": {
|
||||||
"label": "Pings"
|
"label": "Pings"
|
||||||
|
},
|
||||||
|
"mediaServer": {
|
||||||
|
"label": "媒体服务"
|
||||||
|
},
|
||||||
|
"mediaOrganizer": {
|
||||||
|
"label": "媒体组织者"
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"label": "正在下载"
|
||||||
|
},
|
||||||
|
"mediaRequestStats": {
|
||||||
|
"label": "媒体请求统计"
|
||||||
|
},
|
||||||
|
"mediaRequestList": {
|
||||||
|
"label": "媒体请求列表"
|
||||||
|
},
|
||||||
|
"rssFeeds": {
|
||||||
|
"label": "RSS订阅"
|
||||||
|
},
|
||||||
|
"indexerManager": {
|
||||||
|
"label": "索引器管理"
|
||||||
|
},
|
||||||
|
"healthMonitoring": {
|
||||||
|
"label": "健康监测"
|
||||||
|
},
|
||||||
|
"dnsHole": {
|
||||||
|
"label": "DNS Hole 数据"
|
||||||
|
},
|
||||||
|
"updateChecker": {
|
||||||
|
"label": "更新检查"
|
||||||
|
},
|
||||||
|
"mediaTranscoding": {
|
||||||
|
"label": "媒体转码"
|
||||||
|
},
|
||||||
|
"networkController": {
|
||||||
|
"label": "网络控制器"
|
||||||
|
},
|
||||||
|
"refreshNotifications": {
|
||||||
|
"label": "更新通知"
|
||||||
|
},
|
||||||
|
"dockerContainers": {
|
||||||
|
"label": "Docker 容器"
|
||||||
|
},
|
||||||
|
"firewallCpu": {
|
||||||
|
"label": "防火墙 CPU"
|
||||||
|
},
|
||||||
|
"firewallMemory": {
|
||||||
|
"label": "防火墙内存"
|
||||||
|
},
|
||||||
|
"firewallVersion": {
|
||||||
|
"label": "防火墙版本"
|
||||||
|
},
|
||||||
|
"firewallInterfaces": {
|
||||||
|
"label": "防火墙接口"
|
||||||
|
},
|
||||||
|
"weather": {
|
||||||
|
"label": "天气"
|
||||||
|
},
|
||||||
|
"tracearr": {
|
||||||
|
"label": "Tracearr"
|
||||||
|
},
|
||||||
|
"umamiActiveVisitors": {
|
||||||
|
"label": "Umami 活跃访客"
|
||||||
|
},
|
||||||
|
"vpn": {
|
||||||
|
"label": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interval": {
|
"interval": {
|
||||||
|
|||||||
@@ -1598,10 +1598,7 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"label": {
|
"label": {
|
||||||
"settings": "crwdns702:0crwdne702:0",
|
"settings": "crwdns702:0crwdne702:0"
|
||||||
"refresh": "crwdns5739:0crwdne5739:0",
|
|
||||||
"options": "crwdns5709:0crwdne5709:0",
|
|
||||||
"actions": "crwdns5711:0crwdne5711:0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
@@ -1903,9 +1900,6 @@
|
|||||||
"title": "crwdns4004:0{station}crwdne4004:0",
|
"title": "crwdns4004:0{station}crwdne4004:0",
|
||||||
"noStation": "crwdns4006:0crwdne4006:0",
|
"noStation": "crwdns4006:0crwdne4006:0",
|
||||||
"option": {
|
"option": {
|
||||||
"baseUrl": {
|
|
||||||
"label": "crwdns5713:0crwdne5713:0"
|
|
||||||
},
|
|
||||||
"station": {
|
"station": {
|
||||||
"label": "crwdns4008:0crwdne4008:0"
|
"label": "crwdns4008:0crwdne4008:0"
|
||||||
}
|
}
|
||||||
@@ -2779,8 +2773,8 @@
|
|||||||
"name": "crwdns3908:0crwdne3908:0",
|
"name": "crwdns3908:0crwdne3908:0",
|
||||||
"option": {
|
"option": {
|
||||||
"albumId": {
|
"albumId": {
|
||||||
"label": "crwdns5715:0crwdne5715:0",
|
"label": "crwdns3910:0crwdne3910:0",
|
||||||
"description": "crwdns5717:0crwdne5717:0"
|
"description": "crwdns3912:0crwdne3912:0"
|
||||||
},
|
},
|
||||||
"rotationIntervalSeconds": {
|
"rotationIntervalSeconds": {
|
||||||
"label": "crwdns3914:0crwdne3914:0",
|
"label": "crwdns3914:0crwdne3914:0",
|
||||||
@@ -2789,10 +2783,6 @@
|
|||||||
"showPhotoInfo": {
|
"showPhotoInfo": {
|
||||||
"label": "crwdns3918:0crwdne3918:0",
|
"label": "crwdns3918:0crwdne3918:0",
|
||||||
"description": "crwdns3920:0crwdne3920:0"
|
"description": "crwdns3920:0crwdne3920:0"
|
||||||
},
|
|
||||||
"randomizePhotos": {
|
|
||||||
"label": "crwdns5719:0crwdne5719:0",
|
|
||||||
"description": "crwdns5721:0crwdne5721:0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "crwdns3922:0crwdne3922:0",
|
"description": "crwdns3922:0crwdne3922:0",
|
||||||
@@ -3157,9 +3147,6 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"label": "crwdns3204:0crwdne3204:0"
|
"label": "crwdns3204:0crwdne3204:0"
|
||||||
},
|
},
|
||||||
"providerUrl": {
|
|
||||||
"label": "crwdns5723:0crwdne5723:0"
|
|
||||||
},
|
|
||||||
"identifier": {
|
"identifier": {
|
||||||
"label": "crwdns3206:0crwdne3206:0",
|
"label": "crwdns3206:0crwdne3206:0",
|
||||||
"placeholder": "crwdns3208:0crwdne3208:0"
|
"placeholder": "crwdns3208:0crwdne3208:0"
|
||||||
@@ -3207,13 +3194,6 @@
|
|||||||
"noProvider": {
|
"noProvider": {
|
||||||
"label": "crwdns3508:0crwdne3508:0",
|
"label": "crwdns3508:0crwdne3508:0",
|
||||||
"tooltip": "crwdns3510:0crwdne3510:0"
|
"tooltip": "crwdns3510:0crwdne3510:0"
|
||||||
},
|
|
||||||
"tokens": {
|
|
||||||
"label": "crwdns5725:0crwdne5725:0",
|
|
||||||
"token": "crwdns5727:0crwdne5727:0",
|
|
||||||
"configured": "crwdns5729:0crwdne5729:0",
|
|
||||||
"notConfigured": "crwdns5731:0crwdne5731:0",
|
|
||||||
"save": "crwdns5733:0crwdne5733:0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3236,9 +3216,8 @@
|
|||||||
"invalidIdentifier": "crwdns3512:0crwdne3512:0",
|
"invalidIdentifier": "crwdns3512:0crwdne3512:0",
|
||||||
"noMatchingVersion": "crwdns3514:0crwdne3514:0",
|
"noMatchingVersion": "crwdns3514:0crwdne3514:0",
|
||||||
"noReleasesFound": "crwdns3516:0crwdne3516:0",
|
"noReleasesFound": "crwdns3516:0crwdne3516:0",
|
||||||
"noProviderSelected": "crwdns5735:0crwdne5735:0",
|
"noProviderSeleceted": "crwdns3518:0crwdne3518:0",
|
||||||
"noProviderResponse": "crwdns3520:0crwdne3520:0",
|
"noProviderResponse": "crwdns3520:0crwdne3520:0"
|
||||||
"unexpected": "crwdns5737:0crwdne5737:0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4545,14 +4524,89 @@
|
|||||||
"disabled": "crwdns3484:0crwdne3484:0"
|
"disabled": "crwdns3484:0crwdne3484:0"
|
||||||
},
|
},
|
||||||
"job": {
|
"job": {
|
||||||
|
"minecraftServerStatus": {
|
||||||
|
"label": "crwdns2496:0crwdne2496:0"
|
||||||
|
},
|
||||||
|
"timetable": {
|
||||||
|
"label": "crwdns4010:0crwdne4010:0"
|
||||||
|
},
|
||||||
"iconsUpdater": {
|
"iconsUpdater": {
|
||||||
"label": "crwdns1828:0crwdne1828:0"
|
"label": "crwdns1828:0crwdne1828:0"
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"label": "crwdns1830:0crwdne1830:0"
|
"label": "crwdns1830:0crwdne1830:0"
|
||||||
},
|
},
|
||||||
|
"smartHomeEntityState": {
|
||||||
|
"label": "crwdns1832:0crwdne1832:0"
|
||||||
|
},
|
||||||
"ping": {
|
"ping": {
|
||||||
"label": "crwdns1834:0crwdne1834:0"
|
"label": "crwdns1834:0crwdne1834:0"
|
||||||
|
},
|
||||||
|
"mediaServer": {
|
||||||
|
"label": "crwdns1836:0crwdne1836:0"
|
||||||
|
},
|
||||||
|
"mediaOrganizer": {
|
||||||
|
"label": "crwdns1838:0crwdne1838:0"
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"label": "crwdns1840:0crwdne1840:0"
|
||||||
|
},
|
||||||
|
"mediaRequestStats": {
|
||||||
|
"label": "crwdns1842:0crwdne1842:0"
|
||||||
|
},
|
||||||
|
"mediaRequestList": {
|
||||||
|
"label": "crwdns1844:0crwdne1844:0"
|
||||||
|
},
|
||||||
|
"rssFeeds": {
|
||||||
|
"label": "crwdns1846:0crwdne1846:0"
|
||||||
|
},
|
||||||
|
"indexerManager": {
|
||||||
|
"label": "crwdns1848:0crwdne1848:0"
|
||||||
|
},
|
||||||
|
"healthMonitoring": {
|
||||||
|
"label": "crwdns1850:0crwdne1850:0"
|
||||||
|
},
|
||||||
|
"dnsHole": {
|
||||||
|
"label": "crwdns1852:0crwdne1852:0"
|
||||||
|
},
|
||||||
|
"updateChecker": {
|
||||||
|
"label": "crwdns2258:0crwdne2258:0"
|
||||||
|
},
|
||||||
|
"mediaTranscoding": {
|
||||||
|
"label": "crwdns2436:0crwdne2436:0"
|
||||||
|
},
|
||||||
|
"networkController": {
|
||||||
|
"label": "crwdns3153:0crwdne3153:0"
|
||||||
|
},
|
||||||
|
"refreshNotifications": {
|
||||||
|
"label": "crwdns3482:0crwdne3482:0"
|
||||||
|
},
|
||||||
|
"dockerContainers": {
|
||||||
|
"label": "crwdns3430:0crwdne3430:0"
|
||||||
|
},
|
||||||
|
"firewallCpu": {
|
||||||
|
"label": "crwdns3592:0crwdne3592:0"
|
||||||
|
},
|
||||||
|
"firewallMemory": {
|
||||||
|
"label": "crwdns3594:0crwdne3594:0"
|
||||||
|
},
|
||||||
|
"firewallVersion": {
|
||||||
|
"label": "crwdns3596:0crwdne3596:0"
|
||||||
|
},
|
||||||
|
"firewallInterfaces": {
|
||||||
|
"label": "crwdns3598:0crwdne3598:0"
|
||||||
|
},
|
||||||
|
"weather": {
|
||||||
|
"label": "crwdns3724:0crwdne3724:0"
|
||||||
|
},
|
||||||
|
"tracearr": {
|
||||||
|
"label": "crwdns3990:0crwdne3990:0"
|
||||||
|
},
|
||||||
|
"umamiActiveVisitors": {
|
||||||
|
"label": "crwdns4223:0crwdne4223:0"
|
||||||
|
},
|
||||||
|
"vpn": {
|
||||||
|
"label": "crwdns5695:0crwdne5695:0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interval": {
|
"interval": {
|
||||||
|
|||||||
@@ -1598,10 +1598,7 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"label": {
|
"label": {
|
||||||
"settings": "Nastavení",
|
"settings": "Nastavení"
|
||||||
"refresh": "",
|
|
||||||
"options": "",
|
|
||||||
"actions": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
@@ -1903,9 +1900,6 @@
|
|||||||
"title": "",
|
"title": "",
|
||||||
"noStation": "",
|
"noStation": "",
|
||||||
"option": {
|
"option": {
|
||||||
"baseUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"station": {
|
"station": {
|
||||||
"label": ""
|
"label": ""
|
||||||
}
|
}
|
||||||
@@ -2789,10 +2783,6 @@
|
|||||||
"showPhotoInfo": {
|
"showPhotoInfo": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"description": ""
|
"description": ""
|
||||||
},
|
|
||||||
"randomizePhotos": {
|
|
||||||
"label": "",
|
|
||||||
"description": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -3157,9 +3147,6 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
"providerUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"identifier": {
|
"identifier": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"placeholder": ""
|
"placeholder": ""
|
||||||
@@ -3207,13 +3194,6 @@
|
|||||||
"noProvider": {
|
"noProvider": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"tooltip": ""
|
"tooltip": ""
|
||||||
},
|
|
||||||
"tokens": {
|
|
||||||
"label": "",
|
|
||||||
"token": "",
|
|
||||||
"configured": "",
|
|
||||||
"notConfigured": "",
|
|
||||||
"save": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3236,9 +3216,8 @@
|
|||||||
"invalidIdentifier": "",
|
"invalidIdentifier": "",
|
||||||
"noMatchingVersion": "",
|
"noMatchingVersion": "",
|
||||||
"noReleasesFound": "",
|
"noReleasesFound": "",
|
||||||
"noProviderSelected": "",
|
"noProviderSeleceted": "",
|
||||||
"noProviderResponse": "",
|
"noProviderResponse": ""
|
||||||
"unexpected": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4545,14 +4524,89 @@
|
|||||||
"disabled": ""
|
"disabled": ""
|
||||||
},
|
},
|
||||||
"job": {
|
"job": {
|
||||||
|
"minecraftServerStatus": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"timetable": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"iconsUpdater": {
|
"iconsUpdater": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"smartHomeEntityState": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"ping": {
|
"ping": {
|
||||||
"label": ""
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaServer": {
|
||||||
|
"label": "Mediální server"
|
||||||
|
},
|
||||||
|
"mediaOrganizer": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaRequestStats": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaRequestList": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"rssFeeds": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"indexerManager": {
|
||||||
|
"label": "Správce indexeru"
|
||||||
|
},
|
||||||
|
"healthMonitoring": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"dnsHole": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"updateChecker": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaTranscoding": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"networkController": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"refreshNotifications": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"dockerContainers": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallCpu": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallMemory": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallVersion": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallInterfaces": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"weather": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"tracearr": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"umamiActiveVisitors": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"vpn": {
|
||||||
|
"label": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interval": {
|
"interval": {
|
||||||
|
|||||||
@@ -1598,10 +1598,7 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"label": {
|
"label": {
|
||||||
"settings": "Indstillinger",
|
"settings": "Indstillinger"
|
||||||
"refresh": "",
|
|
||||||
"options": "",
|
|
||||||
"actions": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
@@ -1903,9 +1900,6 @@
|
|||||||
"title": "Afgange {station}",
|
"title": "Afgange {station}",
|
||||||
"noStation": "Ingen station valgt",
|
"noStation": "Ingen station valgt",
|
||||||
"option": {
|
"option": {
|
||||||
"baseUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"station": {
|
"station": {
|
||||||
"label": "Station"
|
"label": "Station"
|
||||||
}
|
}
|
||||||
@@ -2789,10 +2783,6 @@
|
|||||||
"showPhotoInfo": {
|
"showPhotoInfo": {
|
||||||
"label": "Vis billedinfo",
|
"label": "Vis billedinfo",
|
||||||
"description": "Vis dato for billedet og det aktuelle indeks i bunden af diasshowet"
|
"description": "Vis dato for billedet og det aktuelle indeks i bunden af diasshowet"
|
||||||
},
|
|
||||||
"randomizePhotos": {
|
|
||||||
"label": "",
|
|
||||||
"description": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Diasshow af billeder i dit Immich album",
|
"description": "Diasshow af billeder i dit Immich album",
|
||||||
@@ -3157,9 +3147,6 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"label": "Udbyder"
|
"label": "Udbyder"
|
||||||
},
|
},
|
||||||
"providerUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"identifier": {
|
"identifier": {
|
||||||
"label": "Identifikator",
|
"label": "Identifikator",
|
||||||
"placeholder": "Navn eller ejer/navn"
|
"placeholder": "Navn eller ejer/navn"
|
||||||
@@ -3207,13 +3194,6 @@
|
|||||||
"noProvider": {
|
"noProvider": {
|
||||||
"label": "Ingen Udbyder",
|
"label": "Ingen Udbyder",
|
||||||
"tooltip": "Udbyderen kunne ikke fortolkes, angiv venligst det manuelt efter import af billederne"
|
"tooltip": "Udbyderen kunne ikke fortolkes, angiv venligst det manuelt efter import af billederne"
|
||||||
},
|
|
||||||
"tokens": {
|
|
||||||
"label": "",
|
|
||||||
"token": "",
|
|
||||||
"configured": "",
|
|
||||||
"notConfigured": "",
|
|
||||||
"save": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3236,9 +3216,8 @@
|
|||||||
"invalidIdentifier": "Ugyldig identifikator",
|
"invalidIdentifier": "Ugyldig identifikator",
|
||||||
"noMatchingVersion": "Ingen matchende version fundet",
|
"noMatchingVersion": "Ingen matchende version fundet",
|
||||||
"noReleasesFound": "Ingen udgivelser fundet",
|
"noReleasesFound": "Ingen udgivelser fundet",
|
||||||
"noProviderSelected": "",
|
"noProviderSeleceted": "Ingen udbyder valgt",
|
||||||
"noProviderResponse": "Intet svar fra udbyder",
|
"noProviderResponse": "Intet svar fra udbyder"
|
||||||
"unexpected": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4545,14 +4524,89 @@
|
|||||||
"disabled": "Deaktiveret"
|
"disabled": "Deaktiveret"
|
||||||
},
|
},
|
||||||
"job": {
|
"job": {
|
||||||
|
"minecraftServerStatus": {
|
||||||
|
"label": "Status for Minecraft server"
|
||||||
|
},
|
||||||
|
"timetable": {
|
||||||
|
"label": "Tidsskema"
|
||||||
|
},
|
||||||
"iconsUpdater": {
|
"iconsUpdater": {
|
||||||
"label": "Opdatering Af Ikoner"
|
"label": "Opdatering Af Ikoner"
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"label": "Analyser"
|
"label": "Analyser"
|
||||||
},
|
},
|
||||||
|
"smartHomeEntityState": {
|
||||||
|
"label": "Smarthjem ententitets tilstand"
|
||||||
|
},
|
||||||
"ping": {
|
"ping": {
|
||||||
"label": "Pings"
|
"label": "Pings"
|
||||||
|
},
|
||||||
|
"mediaServer": {
|
||||||
|
"label": "Medieserver"
|
||||||
|
},
|
||||||
|
"mediaOrganizer": {
|
||||||
|
"label": "Medieorganisatorer"
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"label": "Downloads"
|
||||||
|
},
|
||||||
|
"mediaRequestStats": {
|
||||||
|
"label": "Statistik For Medieforespørgsel"
|
||||||
|
},
|
||||||
|
"mediaRequestList": {
|
||||||
|
"label": "Liste Over Medieforespørgsler"
|
||||||
|
},
|
||||||
|
"rssFeeds": {
|
||||||
|
"label": "RSS feeds"
|
||||||
|
},
|
||||||
|
"indexerManager": {
|
||||||
|
"label": "Indekser Manager"
|
||||||
|
},
|
||||||
|
"healthMonitoring": {
|
||||||
|
"label": "Helbredsovervågning"
|
||||||
|
},
|
||||||
|
"dnsHole": {
|
||||||
|
"label": "DNS Hole Data"
|
||||||
|
},
|
||||||
|
"updateChecker": {
|
||||||
|
"label": "Opdaterings checker"
|
||||||
|
},
|
||||||
|
"mediaTranscoding": {
|
||||||
|
"label": "Omkodning af medier"
|
||||||
|
},
|
||||||
|
"networkController": {
|
||||||
|
"label": "Netværkskontroller"
|
||||||
|
},
|
||||||
|
"refreshNotifications": {
|
||||||
|
"label": "Notifikations opdaterer"
|
||||||
|
},
|
||||||
|
"dockerContainers": {
|
||||||
|
"label": "Docker containers"
|
||||||
|
},
|
||||||
|
"firewallCpu": {
|
||||||
|
"label": "Firewall CPU"
|
||||||
|
},
|
||||||
|
"firewallMemory": {
|
||||||
|
"label": "Firewall Hukommelse"
|
||||||
|
},
|
||||||
|
"firewallVersion": {
|
||||||
|
"label": "Firewall Version"
|
||||||
|
},
|
||||||
|
"firewallInterfaces": {
|
||||||
|
"label": "Firewall Grænseflader"
|
||||||
|
},
|
||||||
|
"weather": {
|
||||||
|
"label": "Vejr"
|
||||||
|
},
|
||||||
|
"tracearr": {
|
||||||
|
"label": "Tracearr"
|
||||||
|
},
|
||||||
|
"umamiActiveVisitors": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"vpn": {
|
||||||
|
"label": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interval": {
|
"interval": {
|
||||||
|
|||||||
@@ -1598,10 +1598,7 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"label": {
|
"label": {
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen"
|
||||||
"refresh": "",
|
|
||||||
"options": "",
|
|
||||||
"actions": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
@@ -1903,9 +1900,6 @@
|
|||||||
"title": "",
|
"title": "",
|
||||||
"noStation": "",
|
"noStation": "",
|
||||||
"option": {
|
"option": {
|
||||||
"baseUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"station": {
|
"station": {
|
||||||
"label": ""
|
"label": ""
|
||||||
}
|
}
|
||||||
@@ -2789,10 +2783,6 @@
|
|||||||
"showPhotoInfo": {
|
"showPhotoInfo": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"description": ""
|
"description": ""
|
||||||
},
|
|
||||||
"randomizePhotos": {
|
|
||||||
"label": "",
|
|
||||||
"description": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -3157,9 +3147,6 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
"providerUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"identifier": {
|
"identifier": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"placeholder": ""
|
"placeholder": ""
|
||||||
@@ -3207,13 +3194,6 @@
|
|||||||
"noProvider": {
|
"noProvider": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"tooltip": ""
|
"tooltip": ""
|
||||||
},
|
|
||||||
"tokens": {
|
|
||||||
"label": "",
|
|
||||||
"token": "",
|
|
||||||
"configured": "",
|
|
||||||
"notConfigured": "",
|
|
||||||
"save": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3236,9 +3216,8 @@
|
|||||||
"invalidIdentifier": "",
|
"invalidIdentifier": "",
|
||||||
"noMatchingVersion": "",
|
"noMatchingVersion": "",
|
||||||
"noReleasesFound": "",
|
"noReleasesFound": "",
|
||||||
"noProviderSelected": "",
|
"noProviderSeleceted": "",
|
||||||
"noProviderResponse": "",
|
"noProviderResponse": ""
|
||||||
"unexpected": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4545,14 +4524,89 @@
|
|||||||
"disabled": ""
|
"disabled": ""
|
||||||
},
|
},
|
||||||
"job": {
|
"job": {
|
||||||
|
"minecraftServerStatus": {
|
||||||
|
"label": "Status des Minecraft Servers"
|
||||||
|
},
|
||||||
|
"timetable": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"iconsUpdater": {
|
"iconsUpdater": {
|
||||||
"label": "Symbol Updater"
|
"label": "Symbol Updater"
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"label": "Nutzungsdaten"
|
"label": "Nutzungsdaten"
|
||||||
},
|
},
|
||||||
|
"smartHomeEntityState": {
|
||||||
|
"label": "Status der Smart Home Entitäten"
|
||||||
|
},
|
||||||
"ping": {
|
"ping": {
|
||||||
"label": ""
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaServer": {
|
||||||
|
"label": "Medien Server"
|
||||||
|
},
|
||||||
|
"mediaOrganizer": {
|
||||||
|
"label": "Medienorganisatoren"
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaRequestStats": {
|
||||||
|
"label": "Statistik der Medienanfragen"
|
||||||
|
},
|
||||||
|
"mediaRequestList": {
|
||||||
|
"label": "Liste der Medienanfragen"
|
||||||
|
},
|
||||||
|
"rssFeeds": {
|
||||||
|
"label": "RSS Feeds"
|
||||||
|
},
|
||||||
|
"indexerManager": {
|
||||||
|
"label": "Index Manager"
|
||||||
|
},
|
||||||
|
"healthMonitoring": {
|
||||||
|
"label": "Überwachung des Gesundheit"
|
||||||
|
},
|
||||||
|
"dnsHole": {
|
||||||
|
"label": "DNS Hole Daten"
|
||||||
|
},
|
||||||
|
"updateChecker": {
|
||||||
|
"label": "Updateprüfer"
|
||||||
|
},
|
||||||
|
"mediaTranscoding": {
|
||||||
|
"label": "Medien Transkodierung"
|
||||||
|
},
|
||||||
|
"networkController": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"refreshNotifications": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"dockerContainers": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallCpu": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallMemory": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallVersion": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallInterfaces": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"weather": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"tracearr": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"umamiActiveVisitors": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"vpn": {
|
||||||
|
"label": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interval": {
|
"interval": {
|
||||||
|
|||||||
@@ -1598,10 +1598,7 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"label": {
|
"label": {
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen"
|
||||||
"refresh": "",
|
|
||||||
"options": "",
|
|
||||||
"actions": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
@@ -1903,9 +1900,6 @@
|
|||||||
"title": "{station} Abfahrt",
|
"title": "{station} Abfahrt",
|
||||||
"noStation": "Keine Station ausgewählt",
|
"noStation": "Keine Station ausgewählt",
|
||||||
"option": {
|
"option": {
|
||||||
"baseUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"station": {
|
"station": {
|
||||||
"label": "Station"
|
"label": "Station"
|
||||||
}
|
}
|
||||||
@@ -2789,10 +2783,6 @@
|
|||||||
"showPhotoInfo": {
|
"showPhotoInfo": {
|
||||||
"label": "Foto Infos anzeigen",
|
"label": "Foto Infos anzeigen",
|
||||||
"description": "Datum des Bildes und des aktuellen Indexes am unteren Rand der Diashow anzeigen"
|
"description": "Datum des Bildes und des aktuellen Indexes am unteren Rand der Diashow anzeigen"
|
||||||
},
|
|
||||||
"randomizePhotos": {
|
|
||||||
"label": "",
|
|
||||||
"description": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Diashow der Bilder in Ihrem Immich Album",
|
"description": "Diashow der Bilder in Ihrem Immich Album",
|
||||||
@@ -3157,9 +3147,6 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"label": "Anbieter"
|
"label": "Anbieter"
|
||||||
},
|
},
|
||||||
"providerUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"identifier": {
|
"identifier": {
|
||||||
"label": "Identifier",
|
"label": "Identifier",
|
||||||
"placeholder": "Name oder Eigentümer/Name"
|
"placeholder": "Name oder Eigentümer/Name"
|
||||||
@@ -3207,13 +3194,6 @@
|
|||||||
"noProvider": {
|
"noProvider": {
|
||||||
"label": "Keine Anbieter",
|
"label": "Keine Anbieter",
|
||||||
"tooltip": "Der Anbieter konnte nicht festgestellt werden, bitte manuell nach dem Import der Bilder festlegen"
|
"tooltip": "Der Anbieter konnte nicht festgestellt werden, bitte manuell nach dem Import der Bilder festlegen"
|
||||||
},
|
|
||||||
"tokens": {
|
|
||||||
"label": "",
|
|
||||||
"token": "",
|
|
||||||
"configured": "",
|
|
||||||
"notConfigured": "",
|
|
||||||
"save": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3236,9 +3216,8 @@
|
|||||||
"invalidIdentifier": "Ungültige Kennung",
|
"invalidIdentifier": "Ungültige Kennung",
|
||||||
"noMatchingVersion": "Keine passende Version gefunden",
|
"noMatchingVersion": "Keine passende Version gefunden",
|
||||||
"noReleasesFound": "Keine Veröffentlichungen gefunden",
|
"noReleasesFound": "Keine Veröffentlichungen gefunden",
|
||||||
"noProviderSelected": "",
|
"noProviderSeleceted": "Kein Anbieter ausgewählt",
|
||||||
"noProviderResponse": "Keine Antwort vom Anbieter",
|
"noProviderResponse": "Keine Antwort vom Anbieter"
|
||||||
"unexpected": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4545,14 +4524,89 @@
|
|||||||
"disabled": "Deaktiviert"
|
"disabled": "Deaktiviert"
|
||||||
},
|
},
|
||||||
"job": {
|
"job": {
|
||||||
|
"minecraftServerStatus": {
|
||||||
|
"label": "Status des Minecraft Servers"
|
||||||
|
},
|
||||||
|
"timetable": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"iconsUpdater": {
|
"iconsUpdater": {
|
||||||
"label": "Symbol Updater"
|
"label": "Symbol Updater"
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"label": "Nutzungsdaten"
|
"label": "Nutzungsdaten"
|
||||||
},
|
},
|
||||||
|
"smartHomeEntityState": {
|
||||||
|
"label": "Status der Smart Home Entitäten"
|
||||||
|
},
|
||||||
"ping": {
|
"ping": {
|
||||||
"label": "Pings"
|
"label": "Pings"
|
||||||
|
},
|
||||||
|
"mediaServer": {
|
||||||
|
"label": "Medien Server"
|
||||||
|
},
|
||||||
|
"mediaOrganizer": {
|
||||||
|
"label": "Medienorganisatoren"
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"label": "Downloads"
|
||||||
|
},
|
||||||
|
"mediaRequestStats": {
|
||||||
|
"label": "Statistik der Medienanfragen"
|
||||||
|
},
|
||||||
|
"mediaRequestList": {
|
||||||
|
"label": "Liste der Medienanfragen"
|
||||||
|
},
|
||||||
|
"rssFeeds": {
|
||||||
|
"label": "RSS Feeds"
|
||||||
|
},
|
||||||
|
"indexerManager": {
|
||||||
|
"label": "Index Manager"
|
||||||
|
},
|
||||||
|
"healthMonitoring": {
|
||||||
|
"label": "Überwachung des Zustandes"
|
||||||
|
},
|
||||||
|
"dnsHole": {
|
||||||
|
"label": "DNS Hole Daten"
|
||||||
|
},
|
||||||
|
"updateChecker": {
|
||||||
|
"label": "Updateprüfer"
|
||||||
|
},
|
||||||
|
"mediaTranscoding": {
|
||||||
|
"label": "Medien Transkodierung"
|
||||||
|
},
|
||||||
|
"networkController": {
|
||||||
|
"label": "Netzwerk Controller"
|
||||||
|
},
|
||||||
|
"refreshNotifications": {
|
||||||
|
"label": "Benachrichtigungs Updater"
|
||||||
|
},
|
||||||
|
"dockerContainers": {
|
||||||
|
"label": "Docker Container"
|
||||||
|
},
|
||||||
|
"firewallCpu": {
|
||||||
|
"label": "Firewall CPU"
|
||||||
|
},
|
||||||
|
"firewallMemory": {
|
||||||
|
"label": "Firewall Speicher"
|
||||||
|
},
|
||||||
|
"firewallVersion": {
|
||||||
|
"label": "Firewall Version"
|
||||||
|
},
|
||||||
|
"firewallInterfaces": {
|
||||||
|
"label": "Firewall Schnittstellen"
|
||||||
|
},
|
||||||
|
"weather": {
|
||||||
|
"label": "Wetter"
|
||||||
|
},
|
||||||
|
"tracearr": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"umamiActiveVisitors": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"vpn": {
|
||||||
|
"label": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interval": {
|
"interval": {
|
||||||
|
|||||||
@@ -1598,10 +1598,7 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"label": {
|
"label": {
|
||||||
"settings": "Ρυθμίσεις",
|
"settings": "Ρυθμίσεις"
|
||||||
"refresh": "",
|
|
||||||
"options": "",
|
|
||||||
"actions": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
@@ -1903,9 +1900,6 @@
|
|||||||
"title": "",
|
"title": "",
|
||||||
"noStation": "",
|
"noStation": "",
|
||||||
"option": {
|
"option": {
|
||||||
"baseUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"station": {
|
"station": {
|
||||||
"label": ""
|
"label": ""
|
||||||
}
|
}
|
||||||
@@ -2789,10 +2783,6 @@
|
|||||||
"showPhotoInfo": {
|
"showPhotoInfo": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"description": ""
|
"description": ""
|
||||||
},
|
|
||||||
"randomizePhotos": {
|
|
||||||
"label": "",
|
|
||||||
"description": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -3157,9 +3147,6 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
"providerUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"identifier": {
|
"identifier": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"placeholder": ""
|
"placeholder": ""
|
||||||
@@ -3207,13 +3194,6 @@
|
|||||||
"noProvider": {
|
"noProvider": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"tooltip": ""
|
"tooltip": ""
|
||||||
},
|
|
||||||
"tokens": {
|
|
||||||
"label": "",
|
|
||||||
"token": "",
|
|
||||||
"configured": "",
|
|
||||||
"notConfigured": "",
|
|
||||||
"save": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3236,9 +3216,8 @@
|
|||||||
"invalidIdentifier": "",
|
"invalidIdentifier": "",
|
||||||
"noMatchingVersion": "",
|
"noMatchingVersion": "",
|
||||||
"noReleasesFound": "",
|
"noReleasesFound": "",
|
||||||
"noProviderSelected": "",
|
"noProviderSeleceted": "",
|
||||||
"noProviderResponse": "",
|
"noProviderResponse": ""
|
||||||
"unexpected": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4545,14 +4524,89 @@
|
|||||||
"disabled": ""
|
"disabled": ""
|
||||||
},
|
},
|
||||||
"job": {
|
"job": {
|
||||||
|
"minecraftServerStatus": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"timetable": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"iconsUpdater": {
|
"iconsUpdater": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"smartHomeEntityState": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"ping": {
|
"ping": {
|
||||||
"label": ""
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaServer": {
|
||||||
|
"label": "Διακομιστής πολυμέσων"
|
||||||
|
},
|
||||||
|
"mediaOrganizer": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaRequestStats": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaRequestList": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"rssFeeds": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"indexerManager": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"healthMonitoring": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"dnsHole": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"updateChecker": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaTranscoding": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"networkController": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"refreshNotifications": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"dockerContainers": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallCpu": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallMemory": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallVersion": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallInterfaces": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"weather": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"tracearr": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"umamiActiveVisitors": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"vpn": {
|
||||||
|
"label": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interval": {
|
"interval": {
|
||||||
|
|||||||
@@ -1598,10 +1598,7 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"label": {
|
"label": {
|
||||||
"settings": "Settings",
|
"settings": "Settings"
|
||||||
"refresh": "",
|
|
||||||
"options": "",
|
|
||||||
"actions": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
@@ -1903,9 +1900,6 @@
|
|||||||
"title": "Departures {station}",
|
"title": "Departures {station}",
|
||||||
"noStation": "No station selected",
|
"noStation": "No station selected",
|
||||||
"option": {
|
"option": {
|
||||||
"baseUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"station": {
|
"station": {
|
||||||
"label": "Station"
|
"label": "Station"
|
||||||
}
|
}
|
||||||
@@ -2789,10 +2783,6 @@
|
|||||||
"showPhotoInfo": {
|
"showPhotoInfo": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"description": ""
|
"description": ""
|
||||||
},
|
|
||||||
"randomizePhotos": {
|
|
||||||
"label": "",
|
|
||||||
"description": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -3157,9 +3147,6 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
"providerUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"identifier": {
|
"identifier": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"placeholder": ""
|
"placeholder": ""
|
||||||
@@ -3207,13 +3194,6 @@
|
|||||||
"noProvider": {
|
"noProvider": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"tooltip": ""
|
"tooltip": ""
|
||||||
},
|
|
||||||
"tokens": {
|
|
||||||
"label": "",
|
|
||||||
"token": "",
|
|
||||||
"configured": "",
|
|
||||||
"notConfigured": "",
|
|
||||||
"save": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3236,9 +3216,8 @@
|
|||||||
"invalidIdentifier": "",
|
"invalidIdentifier": "",
|
||||||
"noMatchingVersion": "",
|
"noMatchingVersion": "",
|
||||||
"noReleasesFound": "",
|
"noReleasesFound": "",
|
||||||
"noProviderSelected": "",
|
"noProviderSeleceted": "",
|
||||||
"noProviderResponse": "",
|
"noProviderResponse": ""
|
||||||
"unexpected": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4545,14 +4524,89 @@
|
|||||||
"disabled": ""
|
"disabled": ""
|
||||||
},
|
},
|
||||||
"job": {
|
"job": {
|
||||||
|
"minecraftServerStatus": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"timetable": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"iconsUpdater": {
|
"iconsUpdater": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"smartHomeEntityState": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"ping": {
|
"ping": {
|
||||||
"label": ""
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaServer": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaOrganizer": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaRequestStats": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaRequestList": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"rssFeeds": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"indexerManager": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"healthMonitoring": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"dnsHole": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"updateChecker": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaTranscoding": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"networkController": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"refreshNotifications": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"dockerContainers": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallCpu": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallMemory": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallVersion": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallInterfaces": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"weather": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"tracearr": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"umamiActiveVisitors": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"vpn": {
|
||||||
|
"label": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interval": {
|
"interval": {
|
||||||
|
|||||||
@@ -2284,11 +2284,6 @@
|
|||||||
"fileSystem": {
|
"fileSystem": {
|
||||||
"label": "Show Filesystem Info"
|
"label": "Show Filesystem Info"
|
||||||
},
|
},
|
||||||
"visibleStorageVolumes": {
|
|
||||||
"label": "Visible storage volumes",
|
|
||||||
"description": "Only available when all selected integrations are Synology. Leave empty to show all detected storage volumes.",
|
|
||||||
"placeholder": "Select storage volumes"
|
|
||||||
},
|
|
||||||
"defaultTab": {
|
"defaultTab": {
|
||||||
"label": "Default tab"
|
"label": "Default tab"
|
||||||
},
|
},
|
||||||
@@ -3543,11 +3538,6 @@
|
|||||||
},
|
},
|
||||||
"showBackgroundBar": {
|
"showBackgroundBar": {
|
||||||
"label": "Show background usage bar"
|
"label": "Show background usage bar"
|
||||||
},
|
|
||||||
"visibleStorageVolumes": {
|
|
||||||
"label": "Visible storage volumes",
|
|
||||||
"description": "Only available when all selected integrations are Synology. Leave empty to show all detected storage volumes.",
|
|
||||||
"placeholder": "Select storage volumes"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
|
|||||||
@@ -1598,10 +1598,7 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"label": {
|
"label": {
|
||||||
"settings": "Ajustes",
|
"settings": "Ajustes"
|
||||||
"refresh": "",
|
|
||||||
"options": "",
|
|
||||||
"actions": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
@@ -1903,9 +1900,6 @@
|
|||||||
"title": "Salidas {station}",
|
"title": "Salidas {station}",
|
||||||
"noStation": "No se ha seleccionado ninguna integración",
|
"noStation": "No se ha seleccionado ninguna integración",
|
||||||
"option": {
|
"option": {
|
||||||
"baseUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"station": {
|
"station": {
|
||||||
"label": "Estación"
|
"label": "Estación"
|
||||||
}
|
}
|
||||||
@@ -2789,10 +2783,6 @@
|
|||||||
"showPhotoInfo": {
|
"showPhotoInfo": {
|
||||||
"label": "Mostrar información de la foto",
|
"label": "Mostrar información de la foto",
|
||||||
"description": "Mostrar la fecha y el índice actual de la foto en la parte inferior de la presentación"
|
"description": "Mostrar la fecha y el índice actual de la foto en la parte inferior de la presentación"
|
||||||
},
|
|
||||||
"randomizePhotos": {
|
|
||||||
"label": "",
|
|
||||||
"description": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Presentación de fotos de un álbum de Immich",
|
"description": "Presentación de fotos de un álbum de Immich",
|
||||||
@@ -3157,9 +3147,6 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"label": "Proveedor"
|
"label": "Proveedor"
|
||||||
},
|
},
|
||||||
"providerUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"identifier": {
|
"identifier": {
|
||||||
"label": "Identificador",
|
"label": "Identificador",
|
||||||
"placeholder": "Nombre o Propietario/Nombre"
|
"placeholder": "Nombre o Propietario/Nombre"
|
||||||
@@ -3207,13 +3194,6 @@
|
|||||||
"noProvider": {
|
"noProvider": {
|
||||||
"label": "Sin Proveedor",
|
"label": "Sin Proveedor",
|
||||||
"tooltip": "El proveedor no pudo ser analizado, por favor configurarlo manualmente después de importar las imágenes"
|
"tooltip": "El proveedor no pudo ser analizado, por favor configurarlo manualmente después de importar las imágenes"
|
||||||
},
|
|
||||||
"tokens": {
|
|
||||||
"label": "",
|
|
||||||
"token": "",
|
|
||||||
"configured": "",
|
|
||||||
"notConfigured": "",
|
|
||||||
"save": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3236,9 +3216,8 @@
|
|||||||
"invalidIdentifier": "Identificador no válido",
|
"invalidIdentifier": "Identificador no válido",
|
||||||
"noMatchingVersion": "No se encontró una versión correspondiente",
|
"noMatchingVersion": "No se encontró una versión correspondiente",
|
||||||
"noReleasesFound": "No se encontraron publicaciones",
|
"noReleasesFound": "No se encontraron publicaciones",
|
||||||
"noProviderSelected": "",
|
"noProviderSeleceted": "No se ha seleccionado ningún proveedor",
|
||||||
"noProviderResponse": "No hay respuesta del proveedor",
|
"noProviderResponse": "No hay respuesta del proveedor"
|
||||||
"unexpected": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4545,14 +4524,89 @@
|
|||||||
"disabled": "Deshabilitado"
|
"disabled": "Deshabilitado"
|
||||||
},
|
},
|
||||||
"job": {
|
"job": {
|
||||||
|
"minecraftServerStatus": {
|
||||||
|
"label": "Estado del Servidor de Minecraft"
|
||||||
|
},
|
||||||
|
"timetable": {
|
||||||
|
"label": "Horarios de estación"
|
||||||
|
},
|
||||||
"iconsUpdater": {
|
"iconsUpdater": {
|
||||||
"label": "Actualizador de iconos"
|
"label": "Actualizador de iconos"
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"label": "Estadísticas"
|
"label": "Estadísticas"
|
||||||
},
|
},
|
||||||
|
"smartHomeEntityState": {
|
||||||
|
"label": "Estado de Entidad de Smart Home"
|
||||||
|
},
|
||||||
"ping": {
|
"ping": {
|
||||||
"label": "Pings"
|
"label": "Pings"
|
||||||
|
},
|
||||||
|
"mediaServer": {
|
||||||
|
"label": "Servidor Multimedia"
|
||||||
|
},
|
||||||
|
"mediaOrganizer": {
|
||||||
|
"label": "Organizadores multimedia"
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"label": "Descargas"
|
||||||
|
},
|
||||||
|
"mediaRequestStats": {
|
||||||
|
"label": "Estadísticas de solicitudes de medios"
|
||||||
|
},
|
||||||
|
"mediaRequestList": {
|
||||||
|
"label": "Lista de solicitudes de medios"
|
||||||
|
},
|
||||||
|
"rssFeeds": {
|
||||||
|
"label": "Fuentes RSS"
|
||||||
|
},
|
||||||
|
"indexerManager": {
|
||||||
|
"label": "Administrador de Índices"
|
||||||
|
},
|
||||||
|
"healthMonitoring": {
|
||||||
|
"label": "Monitoreo de Salud"
|
||||||
|
},
|
||||||
|
"dnsHole": {
|
||||||
|
"label": "Datos de agujero DNS"
|
||||||
|
},
|
||||||
|
"updateChecker": {
|
||||||
|
"label": "Comprobador de actualización"
|
||||||
|
},
|
||||||
|
"mediaTranscoding": {
|
||||||
|
"label": "Transcodificación de medios"
|
||||||
|
},
|
||||||
|
"networkController": {
|
||||||
|
"label": "Controlador de red"
|
||||||
|
},
|
||||||
|
"refreshNotifications": {
|
||||||
|
"label": "Actualizador de notificaciones"
|
||||||
|
},
|
||||||
|
"dockerContainers": {
|
||||||
|
"label": "Contenedores de Docker"
|
||||||
|
},
|
||||||
|
"firewallCpu": {
|
||||||
|
"label": "CPU del Firewall"
|
||||||
|
},
|
||||||
|
"firewallMemory": {
|
||||||
|
"label": "Memoria del Firewall"
|
||||||
|
},
|
||||||
|
"firewallVersion": {
|
||||||
|
"label": "Versión del Firewall"
|
||||||
|
},
|
||||||
|
"firewallInterfaces": {
|
||||||
|
"label": "Interfaces del Firewall"
|
||||||
|
},
|
||||||
|
"weather": {
|
||||||
|
"label": "Tiempo"
|
||||||
|
},
|
||||||
|
"tracearr": {
|
||||||
|
"label": "Tracearr"
|
||||||
|
},
|
||||||
|
"umamiActiveVisitors": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"vpn": {
|
||||||
|
"label": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interval": {
|
"interval": {
|
||||||
|
|||||||
@@ -1598,10 +1598,7 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"label": {
|
"label": {
|
||||||
"settings": "",
|
"settings": ""
|
||||||
"refresh": "",
|
|
||||||
"options": "",
|
|
||||||
"actions": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
@@ -1903,9 +1900,6 @@
|
|||||||
"title": "",
|
"title": "",
|
||||||
"noStation": "",
|
"noStation": "",
|
||||||
"option": {
|
"option": {
|
||||||
"baseUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"station": {
|
"station": {
|
||||||
"label": ""
|
"label": ""
|
||||||
}
|
}
|
||||||
@@ -2789,10 +2783,6 @@
|
|||||||
"showPhotoInfo": {
|
"showPhotoInfo": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"description": ""
|
"description": ""
|
||||||
},
|
|
||||||
"randomizePhotos": {
|
|
||||||
"label": "",
|
|
||||||
"description": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -3157,9 +3147,6 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
"providerUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"identifier": {
|
"identifier": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"placeholder": ""
|
"placeholder": ""
|
||||||
@@ -3207,13 +3194,6 @@
|
|||||||
"noProvider": {
|
"noProvider": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"tooltip": ""
|
"tooltip": ""
|
||||||
},
|
|
||||||
"tokens": {
|
|
||||||
"label": "",
|
|
||||||
"token": "",
|
|
||||||
"configured": "",
|
|
||||||
"notConfigured": "",
|
|
||||||
"save": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3236,9 +3216,8 @@
|
|||||||
"invalidIdentifier": "",
|
"invalidIdentifier": "",
|
||||||
"noMatchingVersion": "",
|
"noMatchingVersion": "",
|
||||||
"noReleasesFound": "",
|
"noReleasesFound": "",
|
||||||
"noProviderSelected": "",
|
"noProviderSeleceted": "",
|
||||||
"noProviderResponse": "",
|
"noProviderResponse": ""
|
||||||
"unexpected": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4545,14 +4524,89 @@
|
|||||||
"disabled": ""
|
"disabled": ""
|
||||||
},
|
},
|
||||||
"job": {
|
"job": {
|
||||||
|
"minecraftServerStatus": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"timetable": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"iconsUpdater": {
|
"iconsUpdater": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
|
"smartHomeEntityState": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"ping": {
|
"ping": {
|
||||||
"label": ""
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaServer": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaOrganizer": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaRequestStats": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaRequestList": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"rssFeeds": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"indexerManager": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"healthMonitoring": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"dnsHole": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"updateChecker": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"mediaTranscoding": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"networkController": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"refreshNotifications": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"dockerContainers": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallCpu": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallMemory": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallVersion": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"firewallInterfaces": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"weather": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"tracearr": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"umamiActiveVisitors": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"vpn": {
|
||||||
|
"label": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interval": {
|
"interval": {
|
||||||
|
|||||||
@@ -1598,10 +1598,7 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"label": {
|
"label": {
|
||||||
"settings": "Asetukset",
|
"settings": "Asetukset"
|
||||||
"refresh": "",
|
|
||||||
"options": "",
|
|
||||||
"actions": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
@@ -1903,9 +1900,6 @@
|
|||||||
"title": "",
|
"title": "",
|
||||||
"noStation": "",
|
"noStation": "",
|
||||||
"option": {
|
"option": {
|
||||||
"baseUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"station": {
|
"station": {
|
||||||
"label": ""
|
"label": ""
|
||||||
}
|
}
|
||||||
@@ -2789,10 +2783,6 @@
|
|||||||
"showPhotoInfo": {
|
"showPhotoInfo": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"description": ""
|
"description": ""
|
||||||
},
|
|
||||||
"randomizePhotos": {
|
|
||||||
"label": "",
|
|
||||||
"description": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -3157,9 +3147,6 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
"providerUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"identifier": {
|
"identifier": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"placeholder": ""
|
"placeholder": ""
|
||||||
@@ -3207,13 +3194,6 @@
|
|||||||
"noProvider": {
|
"noProvider": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"tooltip": ""
|
"tooltip": ""
|
||||||
},
|
|
||||||
"tokens": {
|
|
||||||
"label": "",
|
|
||||||
"token": "",
|
|
||||||
"configured": "",
|
|
||||||
"notConfigured": "",
|
|
||||||
"save": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3236,9 +3216,8 @@
|
|||||||
"invalidIdentifier": "",
|
"invalidIdentifier": "",
|
||||||
"noMatchingVersion": "",
|
"noMatchingVersion": "",
|
||||||
"noReleasesFound": "",
|
"noReleasesFound": "",
|
||||||
"noProviderSelected": "",
|
"noProviderSeleceted": "Toimittajaa ei ole valittu",
|
||||||
"noProviderResponse": "",
|
"noProviderResponse": ""
|
||||||
"unexpected": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4545,14 +4524,89 @@
|
|||||||
"disabled": "Ei käytössä"
|
"disabled": "Ei käytössä"
|
||||||
},
|
},
|
||||||
"job": {
|
"job": {
|
||||||
|
"minecraftServerStatus": {
|
||||||
|
"label": "Minecraft-palvelimen tila"
|
||||||
|
},
|
||||||
|
"timetable": {
|
||||||
|
"label": "Aikataulu"
|
||||||
|
},
|
||||||
"iconsUpdater": {
|
"iconsUpdater": {
|
||||||
"label": "Kuvakepäivitykset"
|
"label": "Kuvakepäivitykset"
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"label": "Analytiikka"
|
"label": "Analytiikka"
|
||||||
},
|
},
|
||||||
|
"smartHomeEntityState": {
|
||||||
|
"label": "Älykodin entiteettitila"
|
||||||
|
},
|
||||||
"ping": {
|
"ping": {
|
||||||
"label": "Ping-koestukset"
|
"label": "Ping-koestukset"
|
||||||
|
},
|
||||||
|
"mediaServer": {
|
||||||
|
"label": "Mediapalvelimet"
|
||||||
|
},
|
||||||
|
"mediaOrganizer": {
|
||||||
|
"label": "Median hallintatyökalut"
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"label": "Lataukset"
|
||||||
|
},
|
||||||
|
"mediaRequestStats": {
|
||||||
|
"label": "Mediapyyntöjen tilastot"
|
||||||
|
},
|
||||||
|
"mediaRequestList": {
|
||||||
|
"label": "Mediapyyntöjen listaus"
|
||||||
|
},
|
||||||
|
"rssFeeds": {
|
||||||
|
"label": "RSS-syötteet"
|
||||||
|
},
|
||||||
|
"indexerManager": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"healthMonitoring": {
|
||||||
|
"label": "Terveydentilan valvonta"
|
||||||
|
},
|
||||||
|
"dnsHole": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"updateChecker": {
|
||||||
|
"label": "Päivitystarkistus"
|
||||||
|
},
|
||||||
|
"mediaTranscoding": {
|
||||||
|
"label": "Median transkoodaus"
|
||||||
|
},
|
||||||
|
"networkController": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"refreshNotifications": {
|
||||||
|
"label": "Ilmoituspäivitykset"
|
||||||
|
},
|
||||||
|
"dockerContainers": {
|
||||||
|
"label": "Docker-kontit"
|
||||||
|
},
|
||||||
|
"firewallCpu": {
|
||||||
|
"label": "Palomuurin suoritin"
|
||||||
|
},
|
||||||
|
"firewallMemory": {
|
||||||
|
"label": "Palomuurin muisti"
|
||||||
|
},
|
||||||
|
"firewallVersion": {
|
||||||
|
"label": "Palomuurin versio"
|
||||||
|
},
|
||||||
|
"firewallInterfaces": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"weather": {
|
||||||
|
"label": "Sää"
|
||||||
|
},
|
||||||
|
"tracearr": {
|
||||||
|
"label": "Tracearr"
|
||||||
|
},
|
||||||
|
"umamiActiveVisitors": {
|
||||||
|
"label": "Umamin aktiiviset käyttäjät"
|
||||||
|
},
|
||||||
|
"vpn": {
|
||||||
|
"label": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interval": {
|
"interval": {
|
||||||
|
|||||||
@@ -732,8 +732,8 @@
|
|||||||
"displayType": "",
|
"displayType": "",
|
||||||
"jsonPath": "",
|
"jsonPath": "",
|
||||||
"jsonPathHint": "",
|
"jsonPathHint": "",
|
||||||
"label": "Étiquette",
|
"label": "",
|
||||||
"unit": "Unité",
|
"unit": "",
|
||||||
"mappings": "",
|
"mappings": "",
|
||||||
"tablePath": "",
|
"tablePath": "",
|
||||||
"tablePathHint": "",
|
"tablePathHint": "",
|
||||||
@@ -755,14 +755,14 @@
|
|||||||
"striped": "",
|
"striped": "",
|
||||||
"compact": "",
|
"compact": "",
|
||||||
"maxHeight": "",
|
"maxHeight": "",
|
||||||
"color": "Couleur",
|
"color": "",
|
||||||
"buttonLabel": "",
|
"buttonLabel": "",
|
||||||
"buttonColor": "",
|
"buttonColor": "",
|
||||||
"confirmText": "Texte de confirmation",
|
"confirmText": "",
|
||||||
"confirmTextHint": "",
|
"confirmTextHint": "",
|
||||||
"successMessage": "Message de réussite",
|
"successMessage": "",
|
||||||
"successMessageHint": "",
|
"successMessageHint": "",
|
||||||
"enabled": "Activé",
|
"enabled": "",
|
||||||
"template": {
|
"template": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"description": ""
|
"description": ""
|
||||||
@@ -1017,13 +1017,13 @@
|
|||||||
"timetable": "",
|
"timetable": "",
|
||||||
"photoService": "",
|
"photoService": "",
|
||||||
"documents": "",
|
"documents": "",
|
||||||
"notes": "Notes",
|
"notes": "",
|
||||||
"mediaMonitoring": "Supervision",
|
"mediaMonitoring": "",
|
||||||
"speedtest": "Test de débit",
|
"speedtest": "",
|
||||||
"analytics": "",
|
"analytics": "",
|
||||||
"vpn": "",
|
"vpn": "",
|
||||||
"mediaLibrary": "Bibliothèque de médias",
|
"mediaLibrary": "",
|
||||||
"uptimeMonitoring": "Surveillance des disponibilités"
|
"uptimeMonitoring": ""
|
||||||
},
|
},
|
||||||
"testConnection": {
|
"testConnection": {
|
||||||
"action": {
|
"action": {
|
||||||
@@ -1298,7 +1298,7 @@
|
|||||||
"newLabel": "Nouvelle clé privée"
|
"newLabel": "Nouvelle clé privée"
|
||||||
},
|
},
|
||||||
"slug": {
|
"slug": {
|
||||||
"label": "Slug de la page de statut",
|
"label": "",
|
||||||
"newLabel": ""
|
"newLabel": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1598,10 +1598,7 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"label": {
|
"label": {
|
||||||
"settings": "Paramètres",
|
"settings": "Paramètres"
|
||||||
"refresh": "",
|
|
||||||
"options": "",
|
|
||||||
"actions": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
@@ -1903,9 +1900,6 @@
|
|||||||
"title": "",
|
"title": "",
|
||||||
"noStation": "",
|
"noStation": "",
|
||||||
"option": {
|
"option": {
|
||||||
"baseUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"station": {
|
"station": {
|
||||||
"label": ""
|
"label": ""
|
||||||
}
|
}
|
||||||
@@ -2789,10 +2783,6 @@
|
|||||||
"showPhotoInfo": {
|
"showPhotoInfo": {
|
||||||
"label": "Afficher les informations de la photo",
|
"label": "Afficher les informations de la photo",
|
||||||
"description": "Afficher la date et l'index de la photo en bas du diaporama"
|
"description": "Afficher la date et l'index de la photo en bas du diaporama"
|
||||||
},
|
|
||||||
"randomizePhotos": {
|
|
||||||
"label": "",
|
|
||||||
"description": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Diaporama de photos dans votre album Immich",
|
"description": "Diaporama de photos dans votre album Immich",
|
||||||
@@ -3157,9 +3147,6 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"label": "Fournisseur"
|
"label": "Fournisseur"
|
||||||
},
|
},
|
||||||
"providerUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"identifier": {
|
"identifier": {
|
||||||
"label": "Identifiant",
|
"label": "Identifiant",
|
||||||
"placeholder": "Nom ou Propriétaire / Nom"
|
"placeholder": "Nom ou Propriétaire / Nom"
|
||||||
@@ -3207,13 +3194,6 @@
|
|||||||
"noProvider": {
|
"noProvider": {
|
||||||
"label": "Aucun fournisseur",
|
"label": "Aucun fournisseur",
|
||||||
"tooltip": "Le fournisseur n'a pas pu être analysé, veuillez le définir manuellement après l'importation des images"
|
"tooltip": "Le fournisseur n'a pas pu être analysé, veuillez le définir manuellement après l'importation des images"
|
||||||
},
|
|
||||||
"tokens": {
|
|
||||||
"label": "",
|
|
||||||
"token": "",
|
|
||||||
"configured": "",
|
|
||||||
"notConfigured": "",
|
|
||||||
"save": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3236,9 +3216,8 @@
|
|||||||
"invalidIdentifier": "Identifiant non valide",
|
"invalidIdentifier": "Identifiant non valide",
|
||||||
"noMatchingVersion": "Aucune version correspondante trouvée",
|
"noMatchingVersion": "Aucune version correspondante trouvée",
|
||||||
"noReleasesFound": "Aucune publication trouvée",
|
"noReleasesFound": "Aucune publication trouvée",
|
||||||
"noProviderSelected": "",
|
"noProviderSeleceted": "Aucun fournisseur sélectionné",
|
||||||
"noProviderResponse": "Aucune réponse du fournisseur",
|
"noProviderResponse": "Aucune réponse du fournisseur"
|
||||||
"unexpected": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4545,14 +4524,89 @@
|
|||||||
"disabled": "Désactivé"
|
"disabled": "Désactivé"
|
||||||
},
|
},
|
||||||
"job": {
|
"job": {
|
||||||
|
"minecraftServerStatus": {
|
||||||
|
"label": "État du serveur Minecraft"
|
||||||
|
},
|
||||||
|
"timetable": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"iconsUpdater": {
|
"iconsUpdater": {
|
||||||
"label": "Mise à jour des icônes"
|
"label": "Mise à jour des icônes"
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"label": "Statistiques"
|
"label": "Statistiques"
|
||||||
},
|
},
|
||||||
|
"smartHomeEntityState": {
|
||||||
|
"label": "État de l'entité Maison Connectée"
|
||||||
|
},
|
||||||
"ping": {
|
"ping": {
|
||||||
"label": "Pings"
|
"label": "Pings"
|
||||||
|
},
|
||||||
|
"mediaServer": {
|
||||||
|
"label": "Serveur multimédia"
|
||||||
|
},
|
||||||
|
"mediaOrganizer": {
|
||||||
|
"label": "Organisateurs de Médias"
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"label": "Téléchargements"
|
||||||
|
},
|
||||||
|
"mediaRequestStats": {
|
||||||
|
"label": "Statistiques des demandes de médias"
|
||||||
|
},
|
||||||
|
"mediaRequestList": {
|
||||||
|
"label": "Liste des demandes de médias"
|
||||||
|
},
|
||||||
|
"rssFeeds": {
|
||||||
|
"label": "Flux RSS"
|
||||||
|
},
|
||||||
|
"indexerManager": {
|
||||||
|
"label": "Gestionnaire d’indexeur"
|
||||||
|
},
|
||||||
|
"healthMonitoring": {
|
||||||
|
"label": "Moniteur de santé"
|
||||||
|
},
|
||||||
|
"dnsHole": {
|
||||||
|
"label": "Données du puit DNS"
|
||||||
|
},
|
||||||
|
"updateChecker": {
|
||||||
|
"label": "Vérificateur de mise à jour"
|
||||||
|
},
|
||||||
|
"mediaTranscoding": {
|
||||||
|
"label": "Transcodage des médias"
|
||||||
|
},
|
||||||
|
"networkController": {
|
||||||
|
"label": "Contrôleur réseau"
|
||||||
|
},
|
||||||
|
"refreshNotifications": {
|
||||||
|
"label": "Mise à jour des notifications"
|
||||||
|
},
|
||||||
|
"dockerContainers": {
|
||||||
|
"label": "Conteneurs Docker"
|
||||||
|
},
|
||||||
|
"firewallCpu": {
|
||||||
|
"label": "Processeur du pare-feu"
|
||||||
|
},
|
||||||
|
"firewallMemory": {
|
||||||
|
"label": "Mémoire du pare-feu"
|
||||||
|
},
|
||||||
|
"firewallVersion": {
|
||||||
|
"label": "Version du pare-feu"
|
||||||
|
},
|
||||||
|
"firewallInterfaces": {
|
||||||
|
"label": "Interfaces du pare-feu"
|
||||||
|
},
|
||||||
|
"weather": {
|
||||||
|
"label": "Météo"
|
||||||
|
},
|
||||||
|
"tracearr": {
|
||||||
|
"label": "Tracearr"
|
||||||
|
},
|
||||||
|
"umamiActiveVisitors": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"vpn": {
|
||||||
|
"label": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interval": {
|
"interval": {
|
||||||
|
|||||||
@@ -1598,10 +1598,7 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"label": {
|
"label": {
|
||||||
"settings": "הגדרות",
|
"settings": "הגדרות"
|
||||||
"refresh": "",
|
|
||||||
"options": "",
|
|
||||||
"actions": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
@@ -1903,9 +1900,6 @@
|
|||||||
"title": "",
|
"title": "",
|
||||||
"noStation": "",
|
"noStation": "",
|
||||||
"option": {
|
"option": {
|
||||||
"baseUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"station": {
|
"station": {
|
||||||
"label": ""
|
"label": ""
|
||||||
}
|
}
|
||||||
@@ -2789,10 +2783,6 @@
|
|||||||
"showPhotoInfo": {
|
"showPhotoInfo": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"description": ""
|
"description": ""
|
||||||
},
|
|
||||||
"randomizePhotos": {
|
|
||||||
"label": "",
|
|
||||||
"description": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -3157,9 +3147,6 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"label": "ספק"
|
"label": "ספק"
|
||||||
},
|
},
|
||||||
"providerUrl": {
|
|
||||||
"label": ""
|
|
||||||
},
|
|
||||||
"identifier": {
|
"identifier": {
|
||||||
"label": "מזהה",
|
"label": "מזהה",
|
||||||
"placeholder": "שם או בעלים/שם"
|
"placeholder": "שם או בעלים/שם"
|
||||||
@@ -3207,13 +3194,6 @@
|
|||||||
"noProvider": {
|
"noProvider": {
|
||||||
"label": "ללא ספק",
|
"label": "ללא ספק",
|
||||||
"tooltip": "לא ניתן היה לנתח את הספק, אנא הגדר אותו ידנית לאחר ייבוא התמונות"
|
"tooltip": "לא ניתן היה לנתח את הספק, אנא הגדר אותו ידנית לאחר ייבוא התמונות"
|
||||||
},
|
|
||||||
"tokens": {
|
|
||||||
"label": "",
|
|
||||||
"token": "",
|
|
||||||
"configured": "",
|
|
||||||
"notConfigured": "",
|
|
||||||
"save": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3236,9 +3216,8 @@
|
|||||||
"invalidIdentifier": "מזהה לא חוקי",
|
"invalidIdentifier": "מזהה לא חוקי",
|
||||||
"noMatchingVersion": "לא נמצאה גרסה תואמת",
|
"noMatchingVersion": "לא נמצאה גרסה תואמת",
|
||||||
"noReleasesFound": "לא נמצאו שיחרורים",
|
"noReleasesFound": "לא נמצאו שיחרורים",
|
||||||
"noProviderSelected": "",
|
"noProviderSeleceted": "לא נבחר ספק",
|
||||||
"noProviderResponse": "אין תשובה מהספק",
|
"noProviderResponse": "אין תשובה מהספק"
|
||||||
"unexpected": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4545,14 +4524,89 @@
|
|||||||
"disabled": "מושבת"
|
"disabled": "מושבת"
|
||||||
},
|
},
|
||||||
"job": {
|
"job": {
|
||||||
|
"minecraftServerStatus": {
|
||||||
|
"label": "סטטוס שרת מיינקראפט"
|
||||||
|
},
|
||||||
|
"timetable": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
"iconsUpdater": {
|
"iconsUpdater": {
|
||||||
"label": "עדכון אייקונים"
|
"label": "עדכון אייקונים"
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"label": "ניתוחים"
|
"label": "ניתוחים"
|
||||||
},
|
},
|
||||||
|
"smartHomeEntityState": {
|
||||||
|
"label": "סטטוס יישות בית חכם"
|
||||||
|
},
|
||||||
"ping": {
|
"ping": {
|
||||||
"label": "פינגים"
|
"label": "פינגים"
|
||||||
|
},
|
||||||
|
"mediaServer": {
|
||||||
|
"label": "שרת מדיה"
|
||||||
|
},
|
||||||
|
"mediaOrganizer": {
|
||||||
|
"label": "מארגני מדיה"
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"label": "הורדות"
|
||||||
|
},
|
||||||
|
"mediaRequestStats": {
|
||||||
|
"label": "סטטוס בקשות מדיה"
|
||||||
|
},
|
||||||
|
"mediaRequestList": {
|
||||||
|
"label": "רשימת בקשות מדיה"
|
||||||
|
},
|
||||||
|
"rssFeeds": {
|
||||||
|
"label": "הזנות RSS"
|
||||||
|
},
|
||||||
|
"indexerManager": {
|
||||||
|
"label": "מנהל אינדקסים"
|
||||||
|
},
|
||||||
|
"healthMonitoring": {
|
||||||
|
"label": "ניטור בריאות המערכת"
|
||||||
|
},
|
||||||
|
"dnsHole": {
|
||||||
|
"label": "נתוני חור DNS"
|
||||||
|
},
|
||||||
|
"updateChecker": {
|
||||||
|
"label": "בודק עדכונים"
|
||||||
|
},
|
||||||
|
"mediaTranscoding": {
|
||||||
|
"label": "המרת קידוד מדיה"
|
||||||
|
},
|
||||||
|
"networkController": {
|
||||||
|
"label": "בקר רשת"
|
||||||
|
},
|
||||||
|
"refreshNotifications": {
|
||||||
|
"label": "עדכון התראות"
|
||||||
|
},
|
||||||
|
"dockerContainers": {
|
||||||
|
"label": "מכולות דוקר"
|
||||||
|
},
|
||||||
|
"firewallCpu": {
|
||||||
|
"label": "מעבד חומת האש"
|
||||||
|
},
|
||||||
|
"firewallMemory": {
|
||||||
|
"label": "זיכרון חומת אש"
|
||||||
|
},
|
||||||
|
"firewallVersion": {
|
||||||
|
"label": "גרסת חומת אש"
|
||||||
|
},
|
||||||
|
"firewallInterfaces": {
|
||||||
|
"label": "ממשקי חומת אש"
|
||||||
|
},
|
||||||
|
"weather": {
|
||||||
|
"label": "מזג אוויר"
|
||||||
|
},
|
||||||
|
"tracearr": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"umamiActiveVisitors": {
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"vpn": {
|
||||||
|
"label": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interval": {
|
"interval": {
|
||||||
|
|||||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user