Compare commits

..
Author SHA1 Message Date
Thomas Camlong e0dc782c9e ci: bump typecheck timeout to 15 minutes
Setup phase (ssh2 native build) eats ~5min, leaving insufficient time
for typecheck across 37 packages within the 10min limit.
2026-07-03 16:34:05 +02:00
Thomas Camlong 8b38c83cc0 merge: origin/dev (immich v3, release-providers cleanup)
Take dev's cleaner fetchProvider typing and repositoryId extraction.
No architectural changes — our simplified caching patterns preserved.
2026-07-03 16:01:22 +02:00
Thomas Camlong 9c829679f5 ci: remove memory-regression job from code-quality workflow 2026-07-03 10:32:51 +02:00
Thomas Camlong 16f82fcc6f fix: address PR review findings
- Strip integration secrets from server cache keys (security)
- Add max size + TTL eviction to in-process cache Map (memory)
- Scope widget refresh + cache age to current widget kind (UX)
- Remove double caching in update-checker (simplicity)
- Simplify timetable router (verbosity)
- Fix update-checker import path
2026-07-03 09:59:08 +02:00
ajnart 0fd495c277 style: auto-fix lint and format 2026-07-02 16:29:52 +00:00
Thomas Camlong e93b5f6469 Merge branch 'feat/simplify-caching-tanstack-persister' of github.com:homarr-labs/homarr into feat/simplify-caching-tanstack-persister 2026-07-02 18:28:36 +02:00
Thomas Camlong 5ea902d260 fix: resolve type errors from dev merge
- Fix URL/RequestInfo/RequestInit type conflicts with Node 24.18
- Fix optional repository.id type mismatch in releases router
2026-07-02 18:28:28 +02:00
Thomas Camlong 356fadb9d6 merge: origin/dev (widget-only integration refactor)
Resolve conflicts keeping our simplified request handlers (with TTL
cache) and adopting dev's new release-provider + timetable direct
implementations. Remove per-query staleTime from timetable widget.
2026-07-02 18:19:46 +02:00
Thomas Camlong 33080e3d4f feat: add server-side TTL cache + in-flight dedup to request handler
10s default TTL deduplicates concurrent requests from multiple clients
polling the same integration. 83 users hitting the same widget every
30s now produce ~6 upstream calls/min instead of ~166.
2026-07-02 18:17:04 +02:00
ajnart 230ee3e1fc style: auto-fix lint and format 2026-07-01 18:19:41 +00:00
Thomas Camlong 8571b4dcb7 feat: add server-side TTL cache + in-flight dedup to request handler
10s default TTL deduplicates concurrent requests from multiple clients
polling the same integration. 83 users hitting the same widget every
30s now produce ~6 upstream calls/min instead of ~166.
2026-07-01 20:18:37 +02:00
Thomas Camlong 781378dc76 fix: address PR review findings
- WidgetCacheAge reads from query cache on every tick (reactive)
- Guard empty timestamps array (no -Infinity edge case)
- Docker widget: add explicit refetchInterval (not under [["widget"]])
- system-resources: split integrationIds reset into separate effect
- Remove unused React import in vpn widget
2026-07-01 15:36:00 +02:00
Thomas Camlong 445a100cd7 Merge branch 'feat/simplify-caching-tanstack-persister' of github.com:homarr-labs/homarr into feat/simplify-caching-tanstack-persister 2026-07-01 15:18:55 +02:00
Thomas Camlong 5784612d59 merge: incorporate dev and add context menu refetch
- Resolve dns-hole conflicts (keep simplified polling, no per-query opts)
- Calendar hover/pin UI and docker column visibility preserved
- Subscriptions from dev (docker, calendar) correctly excluded by auto-merge
- Error boundary invalidateQueries fix preserved alongside context menu
- Add "Refresh" button to widget context menu with live "Xs ago" indicator
2026-07-01 15:18:25 +02:00
ajnart 39ffdd2830 style: auto-fix lint and format 2026-06-27 11:07:07 +00:00
Thomas Camlong 8e2cac6bf0 fix(lint): remove non-null assertion in VPN widget 2026-06-27 13:06:02 +02:00
Thomas Camlong 1cd04aaf45 fix: cache update checker responses for 30 minutes
GitHub API has strict rate limits; avoid hitting it on every poll.
2026-06-27 12:24:54 +02:00
ajnart 482e74f8c7 style: auto-fix lint and format 2026-06-27 10:15:09 +00:00
Thomas Camlong 12bf6ee4be fix: address CodeRabbit review findings
- docker-table: inspect refetch result instead of .catch() (TQ v5 compat)
- rssFeed: sort all entries by date before slicing to max posts
- toValidDate: reject null/boolean inputs before Date construction
- system-resources: reset chart history when integrationIds changes
- settleIntegrationQueries: wrap fn in async boundary for safe allSettled
2026-06-27 12:13:52 +02:00
Thomas Camlong 45c58c967e revert: re-apply all deletions and request handler simplifications
Restores the full PR changes after CodeRabbit review window.
2026-06-27 12:09:48 +02:00
Thomas Camlong e9611e8098 chore: temporarily restore deleted files for CodeRabbit review
Restoring 27 deleted files and 26 trivially-changed request handlers
to bring the PR under CodeRabbit's 150-file review limit.
These will be re-deleted in the next commit.
2026-06-27 11:49:15 +02:00
Thomas Camlong 96fe0d55c7 merge: incorporate dev changes and migrate VPN widget
Resolve merge conflicts favoring our simplified caching architecture:
- cron-jobs/index.ts: keep reduced job set (no integration cron jobs)
- en.json: keep reduced cron job labels
- docker-table.tsx: keep refetch() approach, adopt dev's initialData format

Migrate new VPN widget to polling architecture:
- Remove VPN cron job and subscription-based pattern
- Simplify request handler to createIntegrationRequestHandler
- Simplify tRPC router to use getDataAsync + settleIntegrationQueries
- Convert widget from useSuspenseQuery + subscription to useQuery with polling
2026-06-26 20:38:13 +02:00
Thomas Camlong 3e782a6907 style: remove remaining ponytail comments 2026-06-26 18:38:23 +02:00
ajnart 2018e9e81b style: auto-fix lint and format 2026-06-25 15:39:31 +00:00
Thomas Camlong c848dbfc1f fix: scope polling to widgets, fix retry, guard dates, throttle umami
- Remove global refetchInterval; scope to widget queries via setQueryDefaults
- Fix error boundary retry: correct queryKey to [["widget"]], move invalidation to onReset
- Add toValidDate() helper in @homarr/common for cache hydration safety
- Guard date formatting in media-releases and media-requests against non-Date values
- Set umami refetchInterval to 60s via shared umamiQueryOptions constant
- Simplify dns-hole mutations to invalidate() instead of manual optimistic setData
2026-06-25 17:38:13 +02:00
ajnart 210a62b35a style: auto-fix lint and format 2026-06-25 12:49:13 +00:00
Thomas Camlong 8f4f543d29 fix: resilient RSS/releases fetching and effect deps
- RSS feed: Promise.allSettled so one bad URL doesn't fail all feeds
- Releases: Promise.allSettled so one bad repo doesn't blank widget
- system-resources: add data to useEffect deps
- media-requests: invalidate all mediaRequests queries on answer
2026-06-25 14:47:58 +02:00
Thomas Camlong 92920db07d style: fix dep ordering and restore redis null guard 2026-06-25 13:02:39 +02:00
Thomas Camlong e7037ddfe9 chore: remove dead code leftovers from caching rework
- Remove dead Redis exports (exampleChannel, homeAssistantEntityState, queueChannel)
- Remove dead Beszel subscribeRealtimeMetrics/subscribeTopic (~100 lines)
- Add missing onSettled invalidation to downloads queue + indexer-manager mutations
- Rename invalidate-update-checker-cache → warm-update-checker
- Delete empty cron-jobs/integrations directory
- Fix stale comment and unused type imports
2026-06-25 12:37:33 +02:00
ajnart cc50e47668 style: auto-fix lint and format 2026-06-25 10:02:36 +00:00
Thomas Camlong cb5ad15305 fix: remove unused isEditMode params and dead clientApi import
Post-merge lint errors from dev — unused variables left behind
after subscription removal.
2026-06-25 12:01:01 +02:00
ajnart 2cb0964aa2 style: auto-fix lint and format 2026-06-25 09:57:29 +00:00
Thomas Camlong 26a8d219af refactor: resilient integration queries + simplification
- Extract settleIntegrationQueries helper (Promise.allSettled + logging)
  so one failing integration no longer kills the entire widget
- Convert 16 widget routers from Promise.all to settleIntegrationQueries
- Add onSettled query invalidation to downloads and media-requests mutations
- Inline 4 single-use firewall useUpdating* hooks
- Deduplicate firewall router (4 identical procedures → shared factory)
- Fix system-resources chart duplicate initial point (ref-based dedup)
- Simplify docker table refresh (refetch + isFetching, drop manual state)
- Trim invalidateIntegrationCacheAsync to session-store only (dead scan)
- Fix Beszel getAlerts: inner Promise.allSettled so systems failure
  doesn't discard alerts
2026-06-25 11:56:05 +02:00
Thomas cf5934aef6 Merge branch 'dev' into feat/simplify-caching-tanstack-persister 2026-06-25 11:49:39 +02:00
ajnart 9c6f900b82 style: auto-fix lint and format 2026-06-25 09:26:18 +00:00
Thomas Camlong c4a664b7ac fix: address PR review — bugs, dead code, error handling
Critical fixes:
- Smart Home: invalidate entity state query after toggle mutation
- Beszel live mode: use 5s refetchInterval instead of global 30s
- Fix unit test assertions for 5-arg setQueryCacheAsync signature
- Fix serialize/deserialize mismatch: JSON.parse on read (matches persister's JSON.stringify)
- Remove render-time setActiveQueryCacheBoardId race (keep only useEffect)

Error handling:
- Log persistence errors instead of swallowing with catch(() => {})
- Log QueryCacheHydration failures with ErrorWithMetadata
- Beszel: log partial failures (system details, container stats)

Dead code removal:
- Remove dead queryKey from all 39 request handlers + lib files
- Delete unused Redis APIs: createCacheChannel, createChannelWithLatestAndEvents,
  createChannelEventHistory, history-channel.ts
- Remove stale createCacheChannel mock from docker test

Cleanup:
- Seed system-resources chart from initial query data (no empty start)
- Remove redundant refetchOnWindowFocus/refetchOnReconnect from BoardProvider
2026-06-25 11:25:09 +02:00
ajnart 457b1b0ce0 style: auto-fix lint and format 2026-06-25 09:07:24 +00:00
Thomas Camlong 5581b6db94 refactor: simplify request handlers and deduplicate config
- Re-enable TanStack Query retry (retry: 3 with exponential backoff),
  delete all server-side retry logic from request handlers
- Strip handler factories to bare minimum: remove isValid, retry, widgetKind
- Remove redundant refetchOnWindowFocus/refetchOnReconnect from docker-table
- Deduplicate queryCacheMaxValueBytes (pass as param, single source of truth)
- Simplify system-resources chart: use dataUpdatedAt instead of ref-based tracking
- Fix leading blank lines in 27 request handler files
2026-06-25 11:06:03 +02:00
ajnart a164c951ce style: auto-fix lint and format 2026-06-24 13:11:34 +00:00
Thomas Camlong 18d6013f3f merge: resolve conflicts with origin/dev
Take dev's newer dependency versions (tiptap 3.27.1, testcontainers 12.0.3).
Resolve beszel widget conflicts by keeping our approach (no per-widget
staleTime/refetchInterval/subscriptions, globals handle it).
2026-06-24 15:10:10 +02:00
Thomas Camlong 6416080b79 feat: replace server-side Redis caching with TanStack Query AsyncStoragePersister
Consolidate two overlapping cache systems (broken TanStack persister + per-query Redis hash + pub/sub) into one: TanStack Query as single source of truth with Redis as its persistence layer via createAsyncStoragePersister.

- Replace QueryClientProvider with PersistQueryClientProvider backed by Redis
- Set global query defaults (staleTime: 0, refetchInterval: 30s, gcTime: 24h)
- Remove all per-widget staleTime/refetchInterval/retry/refetchOnWindowFocus overrides
- Replace cached request handlers with simplified fetch-only versions
- Remove all tRPC subscription procedures and useSubscription calls in favor of polling
- Delete 20 cron jobs that only pre-warmed server-side cache
- Simplify Redis from per-query hash to single blob per user+board
2026-06-24 14:55:17 +02:00
136 changed files with 4723 additions and 20487 deletions

No files matched your search

+1 -8
View File
@@ -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
-2
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+9 -9
View File
@@ -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
+1 -1
View File
@@ -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 }}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
-45
View File
@@ -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
-4
View File
@@ -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
+8 -21
View File
@@ -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 &gt; Tools &gt; 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
&gt; Security &gt; 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} />
+6 -18
View File
@@ -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 } },
}); });
+2 -21
View File
@@ -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,
], ],
+4 -4
View File
@@ -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",
+2 -1
View File
@@ -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 {
+4 -5
View File
@@ -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
View File
@@ -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: [
+1 -1
View File
@@ -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);
+8 -10
View File
@@ -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 })
+1 -1
View File
@@ -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 />}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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:",
-2
View File
@@ -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,
}, },
}); });
-1
View File
@@ -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 -2
View File
@@ -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),
}); });
+3 -3
View File
@@ -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: {
+2 -8
View File
@@ -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" });
+1 -1
View File
@@ -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",
-8
View File
@@ -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);
+69 -73
View File
@@ -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();
});
}); });
-1
View File
@@ -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;
},
}),
};
};
+78 -24
View File
@@ -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": {
+78 -24
View File
@@ -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": {
+80 -26
View File
@@ -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": {
+78 -24
View File
@@ -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": {
+78 -24
View File
@@ -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": {
+78 -24
View File
@@ -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": {
+78 -24
View File
@@ -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": {
+78 -24
View File
@@ -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": {
+78 -24
View File
@@ -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": {
-10
View File
@@ -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": {
+78 -24
View File
@@ -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": {
+78 -24
View File
@@ -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": {
+78 -24
View File
@@ -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": {
+90 -36
View File
@@ -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": {
+78 -24
View File
@@ -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