Compare commits

..
Author SHA1 Message Date
ajnart 7cecd58626 style: auto-fix lint and format 2026-07-09 16:58:04 +00:00
Thomas Camlong e4b061920c refactor(integrations): simplify integration creator typing 2026-07-09 18:55:32 +02:00
Josh Preston 65ba866239 feat: add Synology integration support for health/system monitoring (#6121) 2026-07-09 18:23:21 +02:00
Thomasandajnart 260b167134 fix: context menu refresh stays open with loading spinner, cache age reacts to refetch
* fix: context menu refresh stays open with loading spinner, cache age reacts to refetch

* style: auto-fix lint and format

* refactor: pass isFetching as prop to WidgetCacheAge to avoid duplicate useIsFetching

---------

Co-authored-by: ajnart <ajnart@users.noreply.github.com>
2026-07-07 18:39:34 +02:00
Thomas VervestandThomas 7a4de03c0e fix(integrations): accept gluetun-latest VPN settings payload (#6149)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-07-07 17:24:50 +02:00
homarr-renovate[bot] 68f8e8146b chore(deps): update dependency @scalar/api-reference-react to ^0.9.51 (#6243)
* chore(deps): update dependency @scalar/api-reference-react to ^0.9.51

* style: auto-fix lint and format

---------

Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-07 13:23:56 +00:00
Thomas 1297e6c2ac fix(query-cache): guard against undefined from superjson.parse on legacy cache entries (#6228) 2026-07-07 11:28:13 +02:00
Thomas Camlong 31288dcbfe fix(docs): update Algolia crawler config with exclusion patterns and ignoreCanonicalTo 2026-07-07 10:08:24 +02:00
Thomas fc2c251557 feat(demo): allow mutations in demo mode via DEMO_READ_ONLY flag (#6224) 2026-07-07 10:01:36 +02:00
Thomas Vervest d067261171 ci: skip lint-format-autofix on fork PRs (#6237) 2026-07-07 10:00:24 +02:00
Thomas Camlong ae2f05c834 fix(docs): sitemap priorities, exclude category redirects, remove trailingSlash 2026-07-07 09:57:32 +02:00
homarr-renovate[bot] c7fa7cdb50 chore(deps): update dependency vite@>=5.0.0 <=5.4.18 to >=8.1.3 (#6242)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-07 07:49:42 +00:00
homarr-renovate[bot] 0c28224e82 chore(deps): update nextjs monorepo to v16.2.10 (#6238)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-06 21:56:19 +00:00
homarr-renovate[bot] 4e6272dbe2 chore(deps): update dependency posthog-js to ^1.396.4 (#6232)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-06 17:56:28 +00:00
homarr-renovate[bot] f5bc090d36 chore(deps): update dependency lucide-react to ^1.23.0 (#6225)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-06 17:53:57 +00:00
homarr-renovate[bot] f23e06dff1 chore(deps): update dependency @semantic-release/github to ^12.0.9 (#6231)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-06 17:53:39 +00:00
homarr-renovate[bot] 24188ce51f chore(deps): update dependency posthog-node to ^5.39.2 (#6226)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-06 14:37:51 +00:00
homarr-renovate[bot] e31f60fdec chore(deps): update dependency cookie to v2.0.1 (#6223)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-06 14:36:14 +00:00
homarr-renovate[bot] 6bb547f3c5 chore(deps): update dependency conventional-changelog-conventionalcommits to ^10.2.0 (#6222)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-06 10:32:35 +00:00
Thomas b07cc14d0e Merge pull request #6221 from homarr-labs/feat/fix-build
fix(deps): revert typescript from 7.0.1-rc to ^6.0.3
2026-07-06 12:16:11 +02:00
Thomas Camlong 0dc93c1a02 fix(deps): revert typescript from 7.0.1-rc to ^6.0.3
Next.js 16.2.9 does not fully support TypeScript 7 (tsgo) as the
primary typescript package. The Go-native compiler has no in-process
JavaScript API — lib/typescript.js is missing — which causes Next.js's
hasNecessaryDependencies check to fail and the build to SIGABRT with
CI=true. Vercel is working on native tsgo integration expected in Q3
2026 alongside TypeScript 7 stable.
2026-07-06 12:15:38 +02:00
Thomas 00661c9b3d fix: fix pnpm lockfile (#6220) 2026-07-06 10:52:58 +02:00
Thomas 2e8fddd29f fix(docs): fix docs build — MDX acorn error and CSS @import ordering (#6217) 2026-07-06 10:14:36 +02:00
homarr-releases[bot] 302d5a0d04 chore: update bug report template (#6181) 2026-07-06 10:14:15 +02:00
ThomasandReleases Homarr a9ed95856a fix(layout): raise AppShellHeader z-index above Indicator dots (#6218)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-07-06 10:13:54 +02:00
Thomasandhomarr-renovate[bot] 4d42eb65e4 chore(deps): mega dependency update (#6212)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-06 10:05:51 +02:00
Thomas 82858ecd75 fix(forms): break AppForm infinite render loop on edit page (#6198) (#6215) 2026-07-06 10:03:22 +02:00
homarr-crowdin[bot] 0e365ed53c chore(lang): updated translations from crowdin
* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

---------

Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-07-06 02:04:20 +00:00
85 changed files with 3520 additions and 1085 deletions

No files matched your search

+8 -1
View File
@@ -59,4 +59,11 @@ TURBO_TELEMETRY_DISABLED=1
# ENABLE_KUBERNETES=true
# 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
+1
View File
@@ -33,6 +33,7 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.69.2
- 1.69.0
- 1.68.0
- 1.67.0
+1 -1
View File
@@ -11,7 +11,7 @@ outputs:
runs:
using: "composite"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
persist-credentials: false
- 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
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Obtain token
id: obtainToken
uses: actions/create-github-app-token@v3
+8 -8
View File
@@ -25,13 +25,13 @@ env:
jobs:
lint-format-autofix:
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
@@ -60,7 +60,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
@@ -78,7 +78,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
@@ -92,7 +92,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
@@ -106,7 +106,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
@@ -124,7 +124,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
- name: Set up Docker Buildx
@@ -153,7 +153,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Obtain token
id: obtainToken
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Upload Crowdin translations
uses: crowdin/github-action@v2
@@ -26,7 +26,7 @@ jobs:
with:
args: "Beta release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Get Next Version
@@ -57,7 +57,7 @@ jobs:
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
- uses: actions/checkout@v6
- uses: actions/checkout@v7
if: env.SKIP_RELEASE == 'false'
with:
persist-credentials: false
@@ -119,7 +119,7 @@ jobs:
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: ${{ needs.release.outputs.git_ref }}
@@ -158,7 +158,7 @@ jobs:
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: ${{ needs.release.outputs.git_ref }}
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
- name: Build docs
@@ -29,7 +29,7 @@ jobs:
with:
args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Get Next Version
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: dev
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: dev
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Validate prebuilt dependencies for amd64
id: validate-amd64
uses: ./.github/actions/prebuilt-debian
@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Validate prebuilt dependencies for arm64
id: validate-arm64
uses: ./.github/actions/prebuilt-debian
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- run: |
npx --yes --package renovate -- \
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-pull-requests: write # required to create pr & enable automerge
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
token: ${{ steps.obtainToken.outputs.token }}
ref: dev
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
@@ -28,7 +28,7 @@ jobs:
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
- name: Setup
+6
View File
@@ -1,3 +1,9 @@
## [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
+4
View File
@@ -159,6 +159,10 @@ packages/integrations/src/search-ch @Meierschlumpf
apps/docs/docs/integrations/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
apps/docs/docs/integrations/technitium-dns @lopsch
packages/integrations/src/technitium @lopsch
+1 -1
View File
@@ -58,7 +58,7 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats';
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'
]
}]} />
@@ -0,0 +1,96 @@
---
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>
@@ -0,0 +1,8 @@
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,61 +1,71 @@
---
title: 'System Health Monitoring'
description: 'Displays information showing the health and status of your system(s).'
title: "System Health Monitoring"
description: "Displays information showing the health and status of your system(s)."
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { IconAd } from "@tabler/icons-react";
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import { healthMonitoringWidget } from '.';
import { dashDotIntegration } from '@site/docs/integrations/dash-dot';
import { openMediaVaultIntegration } from '@site/docs/integrations/open-media-vault';
import { proxmoxIntegration } from '@site/docs/integrations/proxmox';
import { glancesIntegration } from '@site/docs/integrations/glances';
import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem";
import { healthMonitoringWidget } from ".";
import { dashDotIntegration } from "@site/docs/integrations/dash-dot";
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault";
import { proxmoxIntegration } from "@site/docs/integrations/proxmox";
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.
### Screenshots
import systemView from './img/system-view.png';
import clusterView from './img/cluster-view.png';
import { truenasIntegration } from '@site/docs/integrations/truenas';
import systemView from "./img/system-view.png";
import clusterView from "./img/cluster-view.png";
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={clusterView} alt="health-monitoring cluster-view" width={256} height={256} />
### Supported Integrations
<WidgetIntegrations items={[{
integration: dashDotIntegration,
note: "Will display information on the system tab"
}, {
integration: openMediaVaultIntegration,
note: "Will display information on the system tab"
}, {
integration: truenasIntegration,
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"
}]} />
<WidgetIntegrations
items={[
{
integration: dashDotIntegration,
note: "Will display information on the system tab",
},
{
integration: openMediaVaultIntegration,
note: "Will display information on the system tab",
},
{
integration: truenasIntegration,
note: "Will display information on the system tab",
},
{
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
<AddingWidget />
### Configuration
<WidgetConfig configuration={healthMonitoringWidget.configuration} />
@@ -38,6 +38,13 @@ export const healthMonitoringWidget: WidgetDefinition = {
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: "Empty list",
},
{
name: "Default tab",
description: "Tab that should be visible by default. Only used when multiple integrations are available.",
@@ -0,0 +1,51 @@
---
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} />
@@ -0,0 +1,41 @@
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,57 +1,69 @@
---
title: 'System Resources'
description: 'Displays CPU, RAM and network of your host in real time'
title: "System Resources"
description: "Displays CPU, RAM and network of your host in real time"
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 { systemResourcesWidget } from '.';
import { dashDotIntegration } from '@site/docs/integrations/dash-dot';
import { glancesIntegration } from '@site/docs/integrations/glances';
import { openMediaVaultIntegration } from '@site/docs/integrations/open-media-vault';
import { proxmoxIntegration } from '@site/docs/integrations/proxmox';
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 { systemResourcesWidget } from ".";
import { dashDotIntegration } from "@site/docs/integrations/dash-dot";
import { glancesIntegration } from "@site/docs/integrations/glances";
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault";
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
### Screenshots
import network from './img/network.png';
import cpu from './img/cpu.png';
import memory from './img/memory.png';
import cpuAndMemory from './img/cpu-and-memory.png';
import { truenasIntegration } from '@site/docs/integrations/truenas';
import network from "./img/network.png";
import cpu from "./img/cpu.png";
import memory from "./img/memory.png";
import cpuAndMemory from "./img/cpu-and-memory.png";
import { truenasIntegration } from "@site/docs/integrations/truenas";
import { synologyIntegration } from "@site/docs/integrations/synology";
<div className='flex gap-1'>
<div className='flex flex-col gap-1'>
<div className="flex 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} />
<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={memory} alt="system-resources-widget memory-only text-label" width={128} height={128} />
</div>
</div>
<img src={cpuAndMemory} alt="system-resources-widget cpu-and-memory icon-text-label" width={256 + 4} height={256 + 4} />
<img
src={cpuAndMemory}
alt="system-resources-widget cpu-and-memory icon-text-label"
width={256 + 4}
height={256 + 4}
/>
</div>
### Supported Integrations
<WidgetIntegrations items={[{
integration: dashDotIntegration
}, {
integration: openMediaVaultIntegration
}, {
integration: truenasIntegration
}, {
integration: glancesIntegration
}]} />
<WidgetIntegrations
items={[
{
integration: dashDotIntegration,
},
{
integration: openMediaVaultIntegration,
},
{
integration: truenasIntegration,
},
{
integration: synologyIntegration,
},
{
integration: glancesIntegration,
},
]}
/>
### Adding the widget
@@ -59,4 +71,4 @@ import { truenasIntegration } from '@site/docs/integrations/truenas';
### Configuration
<WidgetConfig configuration={systemResourcesWidget.configuration} />
<WidgetConfig configuration={systemResourcesWidget.configuration} />
+18 -6
View File
@@ -1,18 +1,25 @@
// Algolia DocSearch crawler for homarr.dev
// Crawler ID: cd77a285-2756-4557-bf21-ee703748df15
// 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
// Paste this in the Algolia crawler editor.
new Crawler({
appId: "N69WSPZTID",
apiKey: "YOUR_CRAWLER_ADMIN_API_KEY",
rateLimit: 8,
maxDepth: 10,
indexPrefix: "",
rateLimit: 32,
maxDepth: 4,
startUrls: ["https://homarr.dev/"],
sitemaps: ["https://homarr.dev/sitemap.xml"],
ignoreCanonicalTo: true,
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: [
{
indexName: "Docusaurus",
@@ -105,7 +112,6 @@ new Crawler({
advancedSyntax: true,
attributeCriteriaComputedByMinProximity: true,
removeWordsIfNoResults: "allOptional",
separatorsToIndex: "_",
},
markdown: {
attributesForFaceting: ["lang", "language", "version", "docusaurus_tag"],
@@ -119,4 +125,10 @@ new Crawler({
advancedSyntax: false,
},
},
apiKey: "YOUR_CRAWLER_ADMIN_API_KEY",
schedule: "on saturday",
renderJavaScript: false,
maxUrls: null,
cache: { enabled: false },
safetyChecks: { beforeIndexPublishing: { maxLostRecordsPercentage: 99 } },
});
+21 -2
View File
@@ -8,7 +8,7 @@ const config: Config = {
tagline: "A simple yet powerful dashboard for your server.",
url: "https://homarr.dev",
baseUrl: "/",
trailingSlash: true,
trailingSlash: undefined,
favicon: "img/logo.png",
organizationName: "homarr-labs",
projectName: "homarr",
@@ -79,8 +79,27 @@ const config: Config = {
sitemap: {
changefreq: "weekly",
priority: 0.5,
ignorePatterns: ["/tags/**"],
ignorePatterns: ["/tags/**", "/docs/category/**"],
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,
],
+1 -1
View File
@@ -43,7 +43,7 @@
"random-word-slugs": "^0.1.7",
"react": "catalog:",
"react-dom": "catalog:",
"swiper": "^12.2.0",
"swiper": "^14.0.1",
"tailwindcss": "^4.3.2"
},
"devDependencies": {
+1 -2
View File
@@ -1,8 +1,7 @@
@import url("https://api.fontshare.com/v2/css?f[]=work-sans@2,1&display=swap");
@import "tailwindcss/theme" layer(theme);
@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"] *));
@theme {
+5 -4
View File
@@ -1,9 +1,6 @@
{
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"target": "es5",
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
@@ -12,10 +9,14 @@
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@site/*": ["./*"]
},
},
"include": [
"**/*.ts",
+12 -14
View File
@@ -5,21 +5,9 @@ import "@homarr/common/env";
import "@homarr/core/infrastructure/logs/env";
import "@homarr/docker/env";
import { readFileSync } from "fs";
import type { NextConfig } from "next";
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
const withNextIntl = createNextIntlPlugin({
requestConfig: "../../packages/translation/src/request.ts",
@@ -27,7 +15,7 @@ const withNextIntl = createNextIntlPlugin({
const nextConfig: NextConfig = {
env: {
HOMARR_VERSION: homarrVersion,
HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown",
},
output: "standalone",
reactStrictMode: true,
@@ -43,9 +31,19 @@ const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true,
preloadEntriesOnStart: false,
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"],
images: {
localPatterns: [
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"build": "pnpm with-env next build",
"build": "HOMARR_VERSION=$(node -p \"require('../../package.json').version\") pnpm with-env next build",
"clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev",
"format": "oxfmt .",
@@ -123,9 +123,9 @@ async function QueryCacheHydration({ userId, boardId }: { userId: string; boardI
const serialized = await getQueryCacheAsync(userId, boardId);
if (!serialized) return null;
const persisted = superjson.parse<PersistedClient>(serialized);
if (persisted.buster !== queryCacheBuster) return null;
if (!persisted?.clientState?.queries?.length) return null;
const persisted = superjson.parse<PersistedClient | undefined>(serialized);
if (!persisted || persisted.buster !== queryCacheBuster) return null;
if (!persisted.clientState?.queries?.length) return null;
return <HydrationBoundary state={persisted.clientState} />;
} catch (error) {
@@ -45,7 +45,9 @@ export const BoardContentHeaderActions = () => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
// Fall back to read-only if the query has no data (e.g. errored) so we never expose
// edit/save/settings UI that would then fail server-side.
const { data: demoReadOnly = true, isLoading } = clientApi.info.isDemoReadOnly.useQuery();
if (!hasChangeAccess || isLoading) {
return <SelectBoardsMenu />;
@@ -55,9 +57,9 @@ export const BoardContentHeaderActions = () => {
<>
{isEditMode && <AddMenu />}
<EditModeMenu demoMode={demoMode ?? false} />
<EditModeMenu demoReadOnly={demoReadOnly} />
{!demoMode && (
{!demoReadOnly && (
<OnboardingTour.Target id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
@@ -158,7 +160,7 @@ const AddMenu = () => {
);
};
const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode, { open, close }] = useEditMode();
const board = useRequiredBoard();
const utils = clientApi.useUtils();
@@ -188,11 +190,11 @@ const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
const toggle = useCallback(() => {
if (isEditMode) {
if (demoMode) return discardDemoChanges();
if (demoReadOnly) return discardDemoChanges();
return saveBoard(board);
}
open();
}, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
usePreventLeaveWithDirty(isEditMode);
+10 -8
View File
@@ -1,11 +1,13 @@
import fs from "fs";
import path from "path";
const migrationsFolderCandidates = [
path.join(process.cwd(), "db/migrations/sqlite"),
path.join(process.cwd(), "packages/db/migrations/sqlite"),
path.resolve(process.cwd(), "../../db/migrations/sqlite"),
"/app/db/migrations/sqlite",
];
export const findMigrationsFolder = () => migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
export const findMigrationsFolder = () => {
const cwd = process.cwd();
const migrationsFolderCandidates = [
path.join(cwd, "db/migrations/sqlite"),
path.join(cwd, "packages/db/migrations/sqlite"),
path.resolve(cwd, "../../db/migrations/sqlite"),
"/app/db/migrations/sqlite",
];
return migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
};
@@ -2,10 +2,10 @@
import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Group, Menu, Switch, Text } from "@mantine/core";
import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
import type { QueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
@@ -50,8 +50,9 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const queryClient = useQueryClient();
const widgetQueryKey = useMemo(() => [["widget", item.kind]], [item.kind]);
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
const handleRefetch = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: widgetQueryKey });
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
}, [queryClient, widgetQueryKey]);
const options = useMemo(
@@ -223,11 +224,15 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
<>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
<Menu.Item closeMenuOnClick leftSection={<IconRefresh size={16} />} onClick={handleRefetch}>
<Menu.Item
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
onClick={handleRefetch}
disabled={isWidgetFetching}
>
<Group justify="space-between" wrap="nowrap">
{tMenu("refresh")}
<Text size="xs" c="dimmed">
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} />
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} />
</Text>
</Group>
</Menu.Item>
@@ -265,13 +270,24 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
);
};
const WidgetCacheAge = ({ queryClient, queryKey }: { queryClient: QueryClient; queryKey: unknown[] }) => {
const WidgetCacheAge = ({
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);
useEffect(() => {
const id = setInterval(() => setTick((n) => n + 1), 1000);
return () => clearInterval(id);
}, []);
if (isFetching) return <Loader size={12} />;
const timestamps = queryClient
.getQueryCache()
.findAll({ queryKey })
+1 -1
View File
@@ -18,7 +18,7 @@ interface Props {
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
return (
<AppShellHeader maw="100vw" style={{ overflowX: "hidden" }}>
<AppShellHeader maw="100vw" zIndex={201} style={{ overflowX: "hidden" }}>
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
{hasNavigation && <ClientBurger />}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "homarr-prebuilt-debian",
"private": true,
"dependencies": {
"better-sqlite3": "^12.10.0"
"better-sqlite3": "^12.11.1"
},
"engines": {
"node": ">=24.18.0",
+5 -5
View File
@@ -9,16 +9,16 @@ importers:
.:
dependencies:
better-sqlite3:
specifier: ^12.10.0
version: 12.10.0
specifier: ^12.11.1
version: 12.11.1
packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
better-sqlite3@12.10.0:
resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==}
better-sqlite3@12.11.1:
resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==}
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x}
bindings@1.5.0:
@@ -148,7 +148,7 @@ snapshots:
base64-js@1.5.1: {}
better-sqlite3@12.10.0:
better-sqlite3@12.11.1:
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.3
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.69.1",
"version": "1.69.2",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -43,6 +43,7 @@
"@semantic-release/release-notes-generator": "catalog:",
"@testcontainers/redis": "catalog:",
"@turbo/gen": "catalog:",
"@vitejs/plugin-react": "catalog:",
"@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:",
+2
View File
@@ -6,9 +6,11 @@ export const env = createEnv({
server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),
},
runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
DEMO_MODE: process.env.DEMO_MODE,
DEMO_READ_ONLY: process.env.DEMO_READ_ONLY,
},
});
+2 -1
View File
@@ -1,7 +1,7 @@
import z from "zod/v4";
import packageJson from "../../../../package.json";
import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc";
import { createTRPCRouter, isDemoMode, isDemoReadOnly, publicProcedure, protectedProcedure } from "../trpc";
export const infoRouter = createTRPCRouter({
getInfo: protectedProcedure
@@ -20,4 +20,5 @@ export const infoRouter = createTRPCRouter({
};
}),
isDemoMode: publicProcedure.query(() => isDemoMode),
isDemoReadOnly: publicProcedure.query(() => isDemoReadOnly),
});
@@ -1,21 +1,31 @@
import { createIntegrationAsync } from "@homarr/integrations";
import { SynologyIntegration } from "@homarr/integrations";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const healthMonitoringIntegrationKinds = [
"openmediavault",
"dashDot",
"truenas",
"unraid",
"glances",
"synology",
"mock",
] as const;
export const healthMonitoringRouter = createTRPCRouter({
getSystemHealthStatus: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"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)",
"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)",
},
})
.concat(
createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"),
)
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
@@ -27,6 +37,23 @@ 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
.meta({
mcp: {
+6 -2
View File
@@ -95,8 +95,12 @@ export const createTRPCRouter = t.router;
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 }) => {
if (env.DEMO_MODE && type === "mutation") {
if (isDemoReadOnly && type === "mutation") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Mutations are disabled in demo mode",
@@ -105,7 +109,7 @@ const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
return next({ ctx });
});
const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
const baseProcedure = isDemoReadOnly ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
/**
* Public (unauthed) procedure
+1 -1
View File
@@ -6,5 +6,5 @@ export function parseCookies(cookieString: string) {
}
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, {
cause: error,
});
}) as AnyRequestError;
}
handleResponseError(error: unknown): ResponseError | undefined {
if (!(error instanceof AxiosError)) return undefined;
@@ -20,7 +20,7 @@ export class FetchHttpErrorHandler extends HttpErrorHandler {
const result = matchErrorCode(error.cause.code);
if (!result) return undefined;
return new RequestError(result, { cause: error });
return new RequestError(result, { cause: error }) as AnyRequestError;
}
/**
@@ -59,7 +59,7 @@ export const matchErrorCode = (code: string): AnyRequestErrorInput | undefined =
type: key,
reason: found[0] as RequestErrorReason<typeof key>,
code: code as RequestErrorCode,
};
} as AnyRequestErrorInput;
}
return undefined;
@@ -30,7 +30,7 @@ export class NodeFetchHttpErrorHandler extends HttpErrorHandler {
return new RequestError(requestErrorInput, {
cause: error,
});
}) as AnyRequestError;
}
/**
@@ -33,6 +33,7 @@ const integrationAliases: Partial<Record<IntegrationKind, readonly string[]>> =
paperlessNgx: ["paperless-ngx", "paperless"],
coolify: ["coolify"],
truenas: ["truenas"],
synology: ["synology", "diskstation"],
};
export const matchIntegrationKind = (search: string): IntegrationKind | null => {
@@ -60,8 +60,8 @@ export type HomarrDocumentationPath =
| "/docs/tags/connections"
| "/docs/tags/custom-widgets"
| "/docs/tags/customization"
| "/docs/tags/data-sources"
| "/docs/tags/data"
| "/docs/tags/data-sources"
| "/docs/tags/database"
| "/docs/tags/developer"
| "/docs/tags/development"
@@ -99,8 +99,8 @@ export type HomarrDocumentationPath =
| "/docs/tags/redis"
| "/docs/tags/responsive"
| "/docs/tags/roles"
| "/docs/tags/search-engines"
| "/docs/tags/search"
| "/docs/tags/search-engines"
| "/docs/tags/secrets"
| "/docs/tags/security"
| "/docs/tags/self-signed"
@@ -132,15 +132,6 @@ export type HomarrDocumentationPath =
| "/docs/advanced/running-as-different-user"
| "/docs/advanced/single-sign-on"
| "/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/faq"
| "/docs/community/get-in-touch"
@@ -205,6 +196,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/slskd"
| "/docs/integrations/sonarr"
| "/docs/integrations/speedtest-tracker"
| "/docs/integrations/synology"
| "/docs/integrations/tdarr"
| "/docs/integrations/technitium-dns"
| "/docs/integrations/tracearr"
@@ -266,6 +258,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/smart-home-execute-automation"
| "/docs/widgets/speedtest-tracker"
| "/docs/widgets/stock-price"
| "/docs/widgets/system-disks"
| "/docs/widgets/system-resources"
| "/docs/widgets/timetable"
| "/docs/widgets/tracearr"
@@ -312,6 +305,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/ical"
| "/docs/integrations/anchor"
| "/docs/integrations/truenas"
| "/docs/integrations/synology"
| "/docs/integrations/unraid"
| "/docs/integrations/coolify"
| "/docs/integrations/immich"
@@ -357,6 +351,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/notifications"
| "/docs/widgets/system-resources"
| "/docs/widgets/coolify"
| "/docs/widgets/system-disks"
| "/docs/widgets/timetable"
| "/docs/widgets/immich-server-stats"
| "/docs/widgets/immich-album-carousel"
@@ -36,6 +36,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
ical: "ical",
anchor: "anchor",
truenas: "truenas",
synology: "synology",
unraid: "unraid",
coolify: "coolify",
immich: "immich",
@@ -33,7 +33,7 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
notifications: "notifications",
systemResources: "system-resources",
coolify: "coolify",
systemDisks: null,
systemDisks: "system-disks",
timetable: "timetable",
"immich-serverStats": "immich-server-stats",
"immich-albumCarousel": "immich-album-carousel",
+8
View File
@@ -321,6 +321,14 @@ export const integrationDefs = {
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
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: {
name: "Unraid",
secretKinds: [["apiKey"]],
@@ -27,8 +27,8 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
firewall: getIntegrationKindsByCategory("firewall"),
notifications: getIntegrationKindsByCategory("notifications"),
mediaReleases: ["emby", "jellyfin", "plex"],
systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances"],
systemDisks: ["dashDot", "openmediavault", "truenas", "unraid"],
systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
systemDisks: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
coolify: ["coolify"],
"immich-serverStats": ["immich"],
"immich-albumCarousel": ["immich"],
+73 -69
View File
@@ -45,6 +45,7 @@ import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
import { SeerrIntegration } from "../seerr/seerr-integration";
import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
import { TracearrIntegration } from "../tracearr/tracearr-integration";
import { SynologyIntegration } from "../synology/synology-integration";
import { TrueNasIntegration } from "../truenas/truenas-integration";
import { UmamiIntegration } from "../umami/umami-integration";
import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
@@ -54,83 +55,86 @@ import type { Integration, IntegrationInput } from "./integration";
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
integration: IntegrationInput & { kind: TKind },
) => {
): Promise<IntegrationInstanceByKind[TKind]> => {
if (!(integration.kind in integrationCreators)) {
throw new Error(
`Unknown integration kind ${integration.kind}. Did you forget to add it to the integration creator?`,
);
}
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>;
return (await integrationCreators[integration.kind].create(integration)) as IntegrationInstanceByKind[TKind];
};
type IntegrationInstance = new (integration: IntegrationInput) => Integration;
type IntegrationConstructor<TIntegration extends Integration = Integration> = new (
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 = {
anchor: AnchorIntegration,
piHole: [createPiHoleIntegrationAsync],
adGuardHome: AdGuardHomeIntegration,
technitiumDns: [createTechnitiumDnsIntegrationAsync],
homeAssistant: HomeAssistantIntegration,
jellyfin: JellyfinIntegration,
plex: PlexIntegration,
sonarr: SonarrIntegration,
radarr: RadarrIntegration,
sabNzbd: SabnzbdIntegration,
nzbGet: NzbGetIntegration,
qBittorrent: QBitTorrentIntegration,
deluge: DelugeIntegration,
transmission: TransmissionIntegration,
slskd: SlskdIntegration,
aria2: Aria2Integration,
jellyseerr: JellyseerrIntegration,
seerr: SeerrIntegration,
overseerr: OverseerrIntegration,
prowlarr: ProwlarrIntegration,
openmediavault: OpenMediaVaultIntegration,
lidarr: LidarrIntegration,
readarr: ReadarrIntegration,
dashDot: DashDotIntegration,
tdarr: TdarrIntegration,
proxmox: ProxmoxIntegration,
emby: EmbyIntegration,
nextcloud: NextcloudIntegration,
unifiController: UnifiControllerIntegration,
opnsense: OPNsenseIntegration,
ical: ICalIntegration,
ntfy: NTFYIntegration,
gotify: GotifyIntegration,
mock: MockIntegration,
truenas: TrueNasIntegration,
unraid: UnraidIntegration,
coolify: CoolifyIntegration,
tracearr: TracearrIntegration,
glances: GlancesIntegration,
immich: ImmichIntegration,
paperlessNgx: PaperlessNgxIntegration,
speedtestTracker: SpeedtestTrackerIntegration,
audiobookshelf: AudiobookshelfIntegration,
navidrome: NavidromeIntegration,
umami: UmamiIntegration,
gluetun: GluetunIntegration,
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration,
beszel: BeszelIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
anchor: fromClass(AnchorIntegration),
piHole: fromFactory(createPiHoleIntegrationAsync),
adGuardHome: fromClass(AdGuardHomeIntegration),
technitiumDns: fromFactory(createTechnitiumDnsIntegrationAsync),
homeAssistant: fromClass(HomeAssistantIntegration),
jellyfin: fromClass(JellyfinIntegration),
plex: fromClass(PlexIntegration),
sonarr: fromClass(SonarrIntegration),
radarr: fromClass(RadarrIntegration),
sabNzbd: fromClass(SabnzbdIntegration),
nzbGet: fromClass(NzbGetIntegration),
qBittorrent: fromClass(QBitTorrentIntegration),
deluge: fromClass(DelugeIntegration),
transmission: fromClass(TransmissionIntegration),
slskd: fromClass(SlskdIntegration),
aria2: fromClass(Aria2Integration),
jellyseerr: fromClass(JellyseerrIntegration),
seerr: fromClass(SeerrIntegration),
overseerr: fromClass(OverseerrIntegration),
prowlarr: fromClass(ProwlarrIntegration),
openmediavault: fromClass(OpenMediaVaultIntegration),
lidarr: fromClass(LidarrIntegration),
readarr: fromClass(ReadarrIntegration),
dashDot: fromClass(DashDotIntegration),
tdarr: fromClass(TdarrIntegration),
proxmox: fromClass(ProxmoxIntegration),
emby: fromClass(EmbyIntegration),
nextcloud: fromClass(NextcloudIntegration),
unifiController: fromClass(UnifiControllerIntegration),
opnsense: fromClass(OPNsenseIntegration),
ical: fromClass(ICalIntegration),
ntfy: fromClass(NTFYIntegration),
gotify: fromClass(GotifyIntegration),
mock: fromClass(MockIntegration),
truenas: fromClass(TrueNasIntegration),
synology: fromClass(SynologyIntegration),
unraid: fromClass(UnraidIntegration),
coolify: fromClass(CoolifyIntegration),
tracearr: fromClass(TracearrIntegration),
glances: fromClass(GlancesIntegration),
immich: fromClass(ImmichIntegration),
paperlessNgx: fromClass(PaperlessNgxIntegration),
speedtestTracker: fromClass(SpeedtestTrackerIntegration),
audiobookshelf: fromClass(AudiobookshelfIntegration),
navidrome: fromClass(NavidromeIntegration),
umami: fromClass(UmamiIntegration),
gluetun: fromClass(GluetunIntegration),
archiveTeamWarrior: fromClass(ArchiveTeamWarriorIntegration),
uptimeKuma: fromClass(UptimeKumaIntegration),
peaNut: fromClass(PeaNutIntegration),
beszel: fromClass(BeszelIntegration),
} satisfies Record<IntegrationKind, IntegrationCreator>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
[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];
type IntegrationInstanceByKind = {
[TKind in keyof typeof integrationCreators]: Awaited<ReturnType<(typeof integrationCreators)[TKind]["create"]>>;
};
@@ -46,7 +46,7 @@ const gluetunServerSelectionSchema = z.object({
isps: z.array(z.string()).nullable(),
names: z.array(z.string()).nullable(),
numbers: z.array(z.number()).nullable(),
hostnames: z.array(z.string()),
hostnames: z.array(z.string()).nullable(),
owned_only: z.boolean(),
free_only: z.boolean(),
premium_only: z.boolean(),
@@ -65,7 +65,7 @@ const gluetunPortForwardingSchema = z.object({
status_file_path: z.string(),
up_command: z.string(),
down_command: z.string(),
listening_port: z.number(),
listening_port: z.union([z.number(), z.array(z.number())]),
username: z.string(),
password: z.string(),
});
@@ -98,7 +98,7 @@ const gluetunOpenvpnSettingsSchema = z.object({
const gluetunWireguardSettingsSchema = z.object({
private_key: z.string(),
pre_shared_key: z.string(),
addresses: z.array(z.string()),
addresses: z.array(z.string()).nullable(),
allowed_ips: z.array(z.string()),
interface: z.string(),
persistent_keep_alive_interval: z.number(),
@@ -124,6 +124,126 @@ 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 =>
new Response(JSON.stringify(body), {
...init,
@@ -368,4 +488,54 @@ describe("gluetun schemas", () => {
test("vpn settings schema rejects a non-string type", () => {
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,6 +29,7 @@ export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
export { PlexIntegration } from "./plex/plex-integration";
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
export { TrueNasIntegration } from "./truenas/truenas-integration";
export { SynologyIntegration } from "./synology/synology-integration";
export { UnraidIntegration } from "./unraid/unraid-integration";
export { OPNsenseIntegration } from "./opnsense/opnsense-integration";
export { ICalIntegration } from "./ical/ical-integration";
@@ -0,0 +1,411 @@
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;
}
@@ -0,0 +1,323 @@
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;
}
@@ -0,0 +1,191 @@
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;
};
@@ -0,0 +1,585 @@
// @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,6 +165,7 @@ const optionMapping: OptionMapping = {
"sectionIndicatorColor" in oldOptions ? oldOptions.sectionIndicatorColor : undefined,
showUptime: () => undefined,
gpu: () => undefined,
visibleStorageVolumes: () => undefined,
visibleClusterSections: (oldOptions) => {
if (!("showNode" in oldOptions)) return undefined;
+10
View File
@@ -2284,6 +2284,11 @@
"fileSystem": {
"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": {
"label": "Default tab"
},
@@ -3538,6 +3543,11 @@
},
"showBackgroundBar": {
"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": {
+33 -33
View File
@@ -6,9 +6,9 @@
"subtitle": "",
"description": "",
"action": {
"scratch": "",
"scratch": "Sākt no jauna",
"importOldmarr": "",
"restoreSqlite": "",
"restoreSqlite": "SQLite rezerves kopija",
"restoreSqliteDescription": "",
"backToOptions": ""
}
@@ -26,19 +26,19 @@
}
},
"importSettings": {
"title": "",
"title": "Importēt iestatījumus",
"description": ""
},
"boardSelection": {
"title": "",
"description": "",
"action": {
"selectAll": "",
"selectAll": "Atzīmēt visu",
"unselectAll": ""
}
},
"summary": {
"title": "",
"title": "Importēšanas kopsavilkums",
"description": "",
"action": {
"import": ""
@@ -46,7 +46,7 @@
"entities": {
"apps": "Lietotnes",
"boards": "Dēļi",
"integrations": "",
"integrations": "Integrācijas",
"credentialUsers": ""
}
},
@@ -67,15 +67,15 @@
}
},
"user": {
"title": "",
"title": "Administratora lietotājs",
"subtitle": "",
"notification": {
"success": {
"title": "",
"title": "Lietotājs izveidots",
"message": ""
},
"error": {
"title": ""
"title": "Lietotāja izveide neizdevās"
}
}
},
@@ -94,19 +94,19 @@
"subtitle": ""
},
"integrations": {
"title": "",
"title": "Integrācijas",
"subtitle": "",
"description": "",
"action": {
"continue": "",
"skip": ""
"continue": "Turpināt",
"skip": "Pagaidām izlaist šo soli"
},
"configure": {
"progress": "",
"skip": "",
"progress": "{current} no {total}",
"skip": "Izlaist šo integrāciju",
"finishing": "",
"error": {
"title": "",
"title": "Neizdevās iestatīt!",
"message": ""
},
"helpers": {
@@ -115,7 +115,7 @@
}
},
"urlTemplate": {
"label": "",
"label": "Bāzes URL",
"placeholder": "",
"hint": "",
"warning": "",
@@ -136,14 +136,14 @@
}
},
"finish": {
"title": "",
"title": "Pabeigt Iestatīšanu",
"subtitle": "",
"description": "",
"action": {
"goToBoard": "",
"createBoard": "",
"inviteUser": "",
"docs": ""
"inviteUser": "Ielūgt citus lietotājus",
"docs": "Lasīt dokumentāciju"
}
}
},
@@ -158,7 +158,7 @@
"subtitle": ""
},
"invite": {
"title": "",
"title": "Pievienoties Homarr",
"subtitle": "",
"description": ""
},
@@ -177,20 +177,20 @@
},
"password": {
"label": "Parole",
"suggestionsTitle": "",
"suggestionsTitle": "Paroles ieteikumi",
"suggestion": {
"length": "",
"length": "Vismaz 8 rakstzīmes",
"lowercase": "Ietver mazo burtu",
"uppercase": "Ietver lielo burtu",
"number": "Ietver numuru",
"special": ""
"special": "Iekļauj speciālo simbolu"
}
},
"passwordConfirm": {
"label": "Apstipriniet paroli"
},
"previousPassword": {
"label": ""
"label": "Iepriekšējā parole"
},
"homeBoard": {
"label": ""
@@ -218,10 +218,10 @@
"notification": {
"success": {
"title": "",
"message": ""
"message": "Jūs esat veiksmīgi pieteicies"
},
"error": {
"title": "",
"title": "Pieteikšanās neizdevās",
"message": ""
}
},
@@ -245,13 +245,13 @@
},
"create": "Izveidot lietotāju",
"changePassword": {
"label": "",
"label": "Mainīt paroli",
"notification": {
"success": {
"message": ""
"message": "Parole veiksmīgi nomainīta."
},
"error": {
"message": ""
"message": "Nav iespējams nomainīt paroli"
}
}
},
@@ -595,12 +595,12 @@
}
},
"defaultGroup": {
"name": "",
"name": "Noklusētā grupa",
"description": ""
}
},
"app": {
"search": "",
"search": "Atrast aplikāciju",
"page": {
"list": {
"title": "Lietotnes",
@@ -689,7 +689,7 @@
"list": {
"title": "",
"noResults": "",
"loading": ""
"loading": "Notiek ielāde..."
},
"create": {
"title": "",
@@ -700,7 +700,7 @@
}
},
"action": {
"create": "",
"create": "Izveidot",
"import": "",
"save": "",
"delete": "",
+12 -12
View File
@@ -1599,7 +1599,7 @@
"menu": {
"label": {
"settings": "Ustawienia",
"refresh": "",
"refresh": "Odśwież",
"options": "Opcje",
"actions": "Akcje"
}
@@ -1904,7 +1904,7 @@
"noStation": "Nie wybrano stacji",
"option": {
"baseUrl": {
"label": ""
"label": "Szukaj adresu URL"
},
"station": {
"label": "Stacja"
@@ -2791,8 +2791,8 @@
"description": "Wyświetl datę zdjęcia oraz bieżący indeks na dole pokazu slajdów"
},
"randomizePhotos": {
"label": "",
"description": ""
"label": "Losowe zdjęcia",
"description": "Pokaż zdjęcia w losowej kolejności, zamiast zawsze rozpoczynać się od tej samej kolejności albumu."
}
},
"description": "Pokaz slajdów zdjęć z Twojego albumu w Immich",
@@ -3158,7 +3158,7 @@
"label": "Dostawca"
},
"providerUrl": {
"label": ""
"label": "URL dostawcy"
},
"identifier": {
"label": "Identyfikator",
@@ -3209,11 +3209,11 @@
"tooltip": "Nie udało się rozpoznać dostawcy. Ustaw go ręcznie po zaimportowaniu obrazów."
},
"tokens": {
"label": "",
"token": "",
"configured": "",
"notConfigured": "",
"save": ""
"label": "Tokeny dostawcy",
"token": "Token",
"configured": "Token skonfigurowany",
"notConfigured": "Brak ustawionych tokenów",
"save": "Zapisz"
}
}
},
@@ -3236,9 +3236,9 @@
"invalidIdentifier": "Nieprawidłowy identyfikator",
"noMatchingVersion": "Nie znaleziono pasującej wersji",
"noReleasesFound": "Nie znaleziono wydań!",
"noProviderSelected": "",
"noProviderSelected": "Nie wybrano dostawcy",
"noProviderResponse": "Brak odpowiedzi od dostawcy",
"unexpected": ""
"unexpected": "Nieoczekiwany błąd dostawcy"
}
}
},
+1 -1
View File
@@ -3437,7 +3437,7 @@
"description": ""
},
"maxNowPlayingItems": {
"label": "",
"label": "Максимум елементів для відтворення",
"description": ""
},
"showLibraryCount": {
+1 -1
View File
@@ -11,7 +11,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
itemId?: string;
}
type UseWidgetInputTranslationReturnType = (key: "label" | "description") => string;
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string;
/**
* Short description why as and unknown convertions are used below:
+2
View File
@@ -17,6 +17,7 @@ import { WidgetUmamiEventInput } from "./widget-umami-event-input";
import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input";
import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input";
import { WidgetIntegrationSelectInput } from "./widget-integration-select-input";
import { WidgetIntegrationMultiSelectInput } from "./widget-integration-multi-select-input";
const mapping = {
anchorNote: WidgetAnchorNoteInput,
@@ -37,6 +38,7 @@ const mapping = {
umamiWebsite: WidgetUmamiWebsiteInput,
customWidgetSelect: WidgetCustomWidgetSelectInput,
integrationSelect: WidgetIntegrationSelectInput,
integrationMultiSelect: WidgetIntegrationMultiSelectInput,
} satisfies Record<WidgetOptionType, unknown>;
export const getInputForType = <TType extends WidgetOptionType>(type: TType) => {
@@ -0,0 +1,60 @@
"use client";
import { MultiSelect, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
const useIntegrationSelectTranslation = () => useScopedI18n("widget.integrationSelect");
export const WidgetIntegrationMultiSelectInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"integrationMultiSelect">) => {
const t = useIntegrationSelectTranslation();
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const integrationIds = form.values.integrationIds as string[];
const inputProps = form.getInputProps(`options.${property}`);
const selectedValues = (inputProps.value as string[] | undefined) ?? [];
const { data: selectData = [], isPending, isError } = options.useOptions(integrationIds);
const data = [
...selectData,
...selectedValues
.filter((selectedValue) => !selectData.some((option) => option.value === selectedValue))
.map((selectedValue) => ({ value: selectedValue, label: selectedValue })),
];
if (integrationIds.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("configureIntegrationFirst")}
</Text>
);
}
const description = options.withDescription ? tInput("description") : undefined;
const placeholder = isPending ? t("loading") : tInput("placeholder");
const loadError = isError ? t("loadError") : undefined;
return (
<MultiSelect
{...inputProps}
label={tInput("label")}
description={description}
placeholder={placeholder}
searchable
clearable
nothingFoundMessage={t("noResults")}
data={data}
disabled={isError}
error={loadError ?? inputProps.error}
/>
);
};
@@ -0,0 +1,146 @@
import { describe, expect, test } from "vitest";
import {
filterStorageVolumes,
normalizeStorageDeviceName,
toScopedStorageVolumeValue,
} from "./filter-storage-volumes";
import { matchFileSystemAndSmart } from "./health-monitoring/system-health";
describe("normalizeStorageDeviceName", () => {
test("strips partition suffixes from block device paths", () => {
expect(normalizeStorageDeviceName("/dev/sda1")).toBe("/dev/sda");
expect(normalizeStorageDeviceName("/dev/sda")).toBe("/dev/sda");
});
test("preserves Synology-style volume names", () => {
expect(normalizeStorageDeviceName("volume_1")).toBe("volume_1");
expect(normalizeStorageDeviceName("volume_2")).toBe("volume_2");
});
test("strips NVMe partition suffixes without collapsing the namespace id", () => {
expect(normalizeStorageDeviceName("/dev/nvme0n1")).toBe("/dev/nvme0n1");
expect(normalizeStorageDeviceName("/dev/nvme0n1p2")).toBe("/dev/nvme0n1");
expect(normalizeStorageDeviceName("/dev/nvme0n2p1")).toBe("/dev/nvme0n2");
});
test("does not collapse unrelated md devices", () => {
expect(normalizeStorageDeviceName("/dev/md0")).toBe("/dev/md0");
expect(normalizeStorageDeviceName("/dev/md1")).toBe("/dev/md1");
});
});
describe("toScopedStorageVolumeValue", () => {
test("scopes unscoped volume names to the query integration", () => {
expect(toScopedStorageVolumeValue("nas-a", "volume_1")).toBe("nas-a:volume_1");
});
test("re-scopes already-prefixed values using the query integration", () => {
expect(toScopedStorageVolumeValue("nas-b", "nas-a:volume_1")).toBe("nas-b:volume_1");
});
});
describe("filterStorageVolumes", () => {
const integrationId = "nas-1";
test("matches SMART entries using normalized partition identifiers", () => {
const entries = [
{ deviceName: "/dev/sda1", used: "1", available: "2", percentage: 50 },
{ deviceName: "/dev/sdb1", used: "1", available: "2", percentage: 50 },
];
const smartEntries = [
{ deviceName: "/dev/sda", temperature: 35, overallStatus: "GOOD" },
{ deviceName: "/dev/sdb", temperature: 40, overallStatus: "GOOD" },
];
const visibleVolumes = [`${integrationId}:/dev/sda1`];
expect(filterStorageVolumes(entries, visibleVolumes, integrationId)).toHaveLength(1);
expect(filterStorageVolumes(smartEntries, visibleVolumes, integrationId)).toHaveLength(1);
});
test("keeps exact Synology volume matches", () => {
const entries = [{ deviceName: "volume_1" }, { deviceName: "volume_2" }];
const visibleVolumes = [`${integrationId}:volume_1`];
expect(filterStorageVolumes(entries, visibleVolumes, integrationId)).toEqual([{ deviceName: "volume_1" }]);
});
test("does not apply another integration's scoped selection", () => {
const entries = [{ deviceName: "volume_1" }, { deviceName: "volume_2" }];
const visibleVolumes = ["nas-b:volume_1"];
expect(filterStorageVolumes(entries, visibleVolumes, integrationId)).toEqual([]);
});
test("does not treat different md devices as the same volume", () => {
const entries = [{ deviceName: "/dev/md0" }, { deviceName: "/dev/md1" }];
const visibleVolumes = [`${integrationId}:/dev/md0`];
expect(filterStorageVolumes(entries, visibleVolumes, integrationId)).toEqual([{ deviceName: "/dev/md0" }]);
});
});
describe("matchFileSystemAndSmart", () => {
test("joins partitioned filesystem entries with base SMART device names", () => {
const result = matchFileSystemAndSmart(
[{ deviceName: "/dev/sda1", used: "100", available: "900", percentage: 10 }],
[{ deviceName: "/dev/sda", temperature: 42, overallStatus: "GOOD" }],
);
expect(result).toEqual([
{
deviceName: "/dev/sda",
used: "100",
available: "900",
percentage: 10,
temperature: 42,
overallStatus: "GOOD",
},
]);
});
test("joins Synology volume names without stripping volume suffixes", () => {
const result = matchFileSystemAndSmart(
[{ deviceName: "volume_1", used: "100", available: "900", percentage: 10 }],
[{ deviceName: "volume_1", temperature: 39, overallStatus: "normal" }],
);
expect(result[0]).toMatchObject({
deviceName: "volume_1",
temperature: 39,
overallStatus: "normal",
});
});
test("joins NVMe partitions with their base device SMART data", () => {
const result = matchFileSystemAndSmart(
[{ deviceName: "/dev/nvme0n1p2", used: "100", available: "900", percentage: 10 }],
[{ deviceName: "/dev/nvme0n1", temperature: 41, overallStatus: "GOOD" }],
);
expect(result).toEqual([
{
deviceName: "/dev/nvme0n1",
used: "100",
available: "900",
percentage: 10,
temperature: 41,
overallStatus: "GOOD",
},
]);
});
test("does not join SMART data from a different md device", () => {
const result = matchFileSystemAndSmart(
[{ deviceName: "/dev/md0", used: "100", available: "900", percentage: 10 }],
[{ deviceName: "/dev/md1", temperature: 41, overallStatus: "GOOD" }],
);
expect(result[0]).toMatchObject({
deviceName: "/dev/md0",
temperature: null,
overallStatus: "",
});
});
});
@@ -0,0 +1,67 @@
interface StorageVolumeEntry {
deviceName: string;
}
const partitionSuffixPatterns: ReadonlyArray<{ pattern: RegExp; baseGroupIndex: number }> = [
// SCSI/SATA/VirtIO: /dev/sda1 -> /dev/sda
{ pattern: /^(\/dev\/(?:sd|vd|hd|xvd)[a-z]+)[0-9]+$/, baseGroupIndex: 1 },
// NVMe: /dev/nvme0n1p2 -> /dev/nvme0n1
{ pattern: /^(\/dev\/nvme[0-9]+n[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
// eMMC: /dev/mmcblk0p1 -> /dev/mmcblk0
{ pattern: /^(\/dev\/mmcblk[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
];
export const normalizeStorageDeviceName = (deviceName: string): string => {
for (const { pattern, baseGroupIndex } of partitionSuffixPatterns) {
const match = deviceName.match(pattern);
if (match) {
return match[baseGroupIndex] ?? deviceName;
}
}
return deviceName;
};
export const toScopedStorageVolumeValue = (integrationId: string, value: string): string => {
const separatorIndex = value.indexOf(":");
const volumeName = separatorIndex === -1 ? value : value.slice(separatorIndex + 1);
return `${integrationId}:${volumeName}`;
};
const storageDeviceNamesMatch = (leftDeviceName: string, rightDeviceName: string): boolean => {
return (
leftDeviceName === rightDeviceName ||
normalizeStorageDeviceName(leftDeviceName) === normalizeStorageDeviceName(rightDeviceName)
);
};
const matchesVisibleStorageVolume = (
visibleVolume: string,
integrationId: string,
deviceName: string,
): boolean => {
const separatorIndex = visibleVolume.indexOf(":");
if (separatorIndex === -1) {
return storageDeviceNamesMatch(visibleVolume, deviceName);
}
const visibleIntegrationId = visibleVolume.slice(0, separatorIndex);
const visibleVolumeName = visibleVolume.slice(separatorIndex + 1);
return visibleIntegrationId === integrationId && storageDeviceNamesMatch(visibleVolumeName, deviceName);
};
export const filterStorageVolumes = <TEntry extends StorageVolumeEntry>(
entries: TEntry[],
visibleStorageVolumes: string[] | undefined,
integrationId: string,
): TEntry[] => {
if (!visibleStorageVolumes || visibleStorageVolumes.length === 0) {
return entries;
}
return entries.filter((entry) =>
visibleStorageVolumes.some((visibleVolume) =>
matchesVisibleStorageVolume(visibleVolume, integrationId, entry.deviceName),
),
);
};
@@ -4,6 +4,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options";
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor,
@@ -28,6 +29,7 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
fileSystem: factory.switch({
defaultValue: true,
}),
visibleStorageVolumes: factory.integrationMultiSelect(createStorageVolumeMultiSelectOptions()),
visibleClusterSections: factory.multiSelect({
options: [
{
@@ -77,6 +79,11 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
return integrationKinds.every((kind) => kind === "proxmox") || integrationKinds.length === 0;
},
},
visibleStorageVolumes: {
shouldHide(_, integrationKinds) {
return integrationKinds.length === 0 || !integrationKinds.every((kind) => kind === "synology");
},
},
showUptime: {
shouldHide(_, integrationKinds) {
// Uptime is only shown on cluster health tab
@@ -36,8 +36,9 @@ import { humanFileSize } from "@homarr/common";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { CpuRing } from "./rings/cpu-ring";
import { CpuTempRing } from "./rings/cpu-temp-ring";
import { GpuRing } from "./rings/gpu-ring";
@@ -65,7 +66,13 @@ export const SystemHealthMonitoring = ({
return (
<Stack h="100%" gap="sm" className="health-monitoring">
{healthData.map(({ integrationId, integrationName, healthInfo }) => {
const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
const filteredFileSystem = filterStorageVolumes(
healthInfo.fileSystem,
options.visibleStorageVolumes,
integrationId,
);
const filteredSmart = filterStorageVolumes(healthInfo.smart, options.visibleStorageVolumes, integrationId);
const disksData = matchFileSystemAndSmart(filteredFileSystem, filteredSmart);
const memoryUsage = formatMemoryUsage(healthInfo.memAvailableInBytes, healthInfo.memUsedInBytes);
return (
<Stack
@@ -289,8 +296,12 @@ interface SmartData {
export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: SmartData[]) => {
return fileSystems
.map((fileSystem) => {
const baseDeviceName = fileSystem.deviceName.replace(/[0-9]+$/, "");
const smartDisk = smartData.find((smart) => smart.deviceName === baseDeviceName);
const normalizedFileSystemName = normalizeStorageDeviceName(fileSystem.deviceName);
const smartDisk = smartData.find(
(smart) =>
smart.deviceName === fileSystem.deviceName ||
normalizeStorageDeviceName(smart.deviceName) === normalizedFileSystemName,
);
return {
deviceName: smartDisk?.deviceName ?? fileSystem.deviceName,
+15
View File
@@ -72,6 +72,15 @@ interface IntegrationSelectInput extends CommonInput<string> {
};
}
interface IntegrationMultiSelectInput extends CommonInput<string[]> {
withDescription?: boolean;
useOptions: (integrationIds: string[]) => {
data: { value: string; label: string }[];
isPending: boolean;
isError: boolean;
};
}
interface NumberInput extends CommonInput<number> {
validate: z.ZodNumber;
step?: number;
@@ -196,6 +205,12 @@ const optionsFactory = {
searchable: input.searchable ?? true,
useOptions: input.useOptions,
}),
integrationMultiSelect: (input: IntegrationMultiSelectInput) => ({
type: "integrationMultiSelect" as const,
defaultValue: input.defaultValue ?? [],
withDescription: input.withDescription ?? false,
useOptions: input.useOptions,
}),
customWidgetSelect: (input?: CommonInput<string>) => ({
type: "customWidgetSelect" as const,
defaultValue: input?.defaultValue ?? "",
@@ -0,0 +1,39 @@
import { clientApi } from "@homarr/api/client";
import { toScopedStorageVolumeValue } from "./filter-storage-volumes";
export const createStorageVolumeMultiSelectOptions = () => ({
defaultValue: [] as string[],
withDescription: true,
useOptions: (integrationIds: string[]) => {
const queryResults = clientApi.useQueries((queryBuilder) =>
integrationIds.map((integrationId) =>
queryBuilder.widget.healthMonitoring.listStorageVolumes({
integrationId,
}),
),
);
const isPending = queryResults.some((queryResult) => queryResult.isPending);
const isError = queryResults.every((queryResult) => queryResult.isError);
const options = queryResults.flatMap((queryResult, index) => {
const integrationId = integrationIds[index];
if (!integrationId) {
return [];
}
return (queryResult.data ?? []).map((option) => ({
label: option.label,
value: toScopedStorageVolumeValue(integrationId, option.value),
}));
});
const uniqueOptions = [...new Map(options.map((option) => [option.value, option])).values()];
return {
data: uniqueOptions,
isPending,
isError: isError && uniqueOptions.length === 0,
};
},
});
@@ -10,6 +10,7 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { filterStorageVolumes } from "../filter-storage-volumes";
import { NoIntegrationDataError } from "../errors/no-data-integration";
type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
@@ -123,12 +124,25 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo
const lastItem = data.at(-1);
if (!lastItem) return <WidgetEmptyState />;
const { fileSystem, smart } = lastItem.healthInfo;
if (fileSystem.length === 0) {
const rawFileSystem = lastItem.healthInfo.fileSystem;
if (rawFileSystem.length === 0) {
throw new NoIntegrationDataError();
}
const fileSystem = filterStorageVolumes(
rawFileSystem,
options.visibleStorageVolumes,
lastItem.integrationId,
);
const smart = filterStorageVolumes(
lastItem.healthInfo.smart,
options.visibleStorageVolumes,
lastItem.integrationId,
);
if (fileSystem.length === 0) return <WidgetEmptyState />;
return (
<Stack gap="xs" p="xs" h="100%">
{fileSystem.map((item) => {
+22 -11
View File
@@ -2,21 +2,32 @@ import { IconServer2 } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options";
export const { definition, componentLoader } = createWidgetDefinition("systemDisks", {
icon: IconServer2,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid"],
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
createOptions() {
return optionsBuilder.from((factory) => ({
showTemperatureIfAvailable: factory.switch({ defaultValue: true }),
displayMode: factory.select({
options: (["percentage", "absolute", "free"] as const).map((value) => ({
value,
label: (t) => t(`widget.systemDisks.option.displayMode.option.${value}.label`),
})),
defaultValue: "percentage",
return optionsBuilder.from(
(factory) => ({
showTemperatureIfAvailable: factory.switch({ defaultValue: true }),
displayMode: factory.select({
options: (["percentage", "absolute", "free"] as const).map((value) => ({
value,
label: (t) => t(`widget.systemDisks.option.displayMode.option.${value}.label`),
})),
defaultValue: "percentage",
}),
showBackgroundBar: factory.switch({ defaultValue: true }),
visibleStorageVolumes: factory.integrationMultiSelect(createStorageVolumeMultiSelectOptions()),
}),
showBackgroundBar: factory.switch({ defaultValue: true }),
}));
{
visibleStorageVolumes: {
shouldHide(_, integrationKinds) {
return integrationKinds.length === 0 || !integrationKinds.every((kind) => kind === "synology");
},
},
},
);
},
}).withDynamicImport(() => import("./component"));
@@ -14,7 +14,7 @@ const labelDisplayModeOptions = {
export const { definition, componentLoader } = createWidgetDefinition("systemResources", {
icon: IconGraphFilled,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances"],
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
createOptions() {
return optionsBuilder.from((factory) => ({
hasShadow: factory.switch({ defaultValue: true }),
+758 -727
View File
File diff suppressed because it is too large. Load diff
+37 -33
View File
@@ -14,7 +14,7 @@ catalog:
"@dnd-kit/utilities": ^3.2.2
"@drizzle-team/brocli": ^0.12.0
"@extractus/feed-extractor": 7.2.1
'@gfazioli/mantine-onboarding-tour': ^4.0.6
"@gfazioli/mantine-onboarding-tour": ^4.0.6
"@gitbeaker/rest": ^43.8.0
"@homarr/gridstack": ^1.12.0
"@homarr/node-unifi": ^2.6.0
@@ -33,23 +33,23 @@ catalog:
"@mantine/notifications": ^9.4.1
"@mantine/spotlight": ^9.4.1
"@mantine/tiptap": ^9.4.1
"@next/eslint-plugin-next": 16.2.9
"@next/eslint-plugin-next": 16.2.10
"@octokit/auth-app": ^8.2.0
"@paralleldrive/cuid2": ^3.3.0
"@playwright/test": ^1.61.1
"@semantic-release/changelog": ^6.0.3
"@semantic-release/commit-analyzer": ^13.0.1
"@semantic-release/git": ^10.0.1
"@semantic-release/github": ^12.0.8
"@semantic-release/github": ^12.0.9
"@semantic-release/npm": ^13.1.5
"@semantic-release/release-notes-generator": ^14.1.1
"@t3-oss/env-nextjs": ^0.13.11
"@tabler/icons-react": ^3.44.0
"@tanstack/query-async-storage-persister": ^5.101.0
"@tanstack/react-query": ^5.101.0
"@tanstack/react-query-devtools": ^5.101.0
"@tanstack/react-query-next-experimental": ^5.101.0
"@tanstack/react-query-persist-client": ^5.101.0
"@tanstack/query-async-storage-persister": ^5.101.2
"@tanstack/react-query": ^5.101.2
"@tanstack/react-query-devtools": ^5.101.2
"@tanstack/react-query-next-experimental": ^5.101.2
"@tanstack/react-query-persist-client": ^5.101.2
"@testcontainers/mysql": ^12.0.4
"@testcontainers/postgresql": ^12.0.4
"@testcontainers/redis": ^12.0.4
@@ -69,12 +69,12 @@ catalog:
"@tiptap/extension-underline": 3.27.1
"@tiptap/react": ^3.27.1
"@tiptap/starter-kit": ^3.27.1
"@trpc/client": ^11.17.0
"@trpc/next": ^11.17.0
"@trpc/react-query": ^11.17.0
"@trpc/server": ^11.17.0
"@trpc/tanstack-react-query": ^11.17.0
"@turbo/gen": ^2.10.0
"@trpc/client": ^11.18.0
"@trpc/next": ^11.18.0
"@trpc/react-query": ^11.18.0
"@trpc/server": ^11.18.0
"@trpc/tanstack-react-query": ^11.18.0
"@turbo/gen": ^2.10.2
"@types/adm-zip": 0.5.8
"@types/bcrypt": 6.0.0
"@types/better-sqlite3": 7.6.13
@@ -89,29 +89,29 @@ catalog:
"@types/prismjs": ^1.26.6
"@types/react": 19.2.17
"@types/react-dom": 19.2.3
"@scalar/api-reference-react": ^0.9.49
"@scalar/api-reference-react": ^0.9.51
"@types/video.js": ^7.3.58
"@types/ws": ^8.18.1
"@types/xml2js": ^0.4.14
posthog-js: ^1.396.2
posthog-node: ^4.18.0
posthog-js: ^1.396.4
posthog-node: ^5.39.2
"@vitejs/plugin-react": ^6.0.3
"@vitest/coverage-v8": ^4.1.9
"@vitest/ui": ^4.1.9
"@xterm/addon-canvas": ^0.7.0
"@xterm/addon-fit": 0.11.0
"@xterm/xterm": ^6.0.0
adm-zip: 0.5.17
adm-zip: 0.5.18
jszip: ^3.10.1
sql.js: ^1.14.1
babel-plugin-react-compiler: ^1.0.0
bcrypt: ^6.0.0
better-sqlite3: ^12.10.0
better-sqlite3: ^12.11.1
chroma-js: ^3.2.0
clsx: ^2.1.1
concurrently: ^10.0.3
conventional-changelog-conventionalcommits: ^9.3.1
cookie: 1.1.1
conventional-changelog-conventionalcommits: ^10.2.0
cookie: 2.0.1
cookies: ^0.9.1
croner: 10.0.1
cross-env: ^10.1.0
@@ -139,20 +139,20 @@ catalog:
jsonpath-plus: ^10.4.0
ldapts: 8.1.8
lodash.clonedeep: ^4.5.0
lucide-react: ^1.22.0
lucide-react: ^1.23.0
"mantine-contextmenu": ^9.3.1
mantine-datatable: ^9.3.1
mantine-react-table: npm:mantine-react-table-open@9.0.3
mcp-handler: ^1.1.0
maria2: ^0.4.1
mysql2: 3.22.5
next: 16.2.9
next: 16.2.10
next-auth: 5.0.0-beta.31
next-intl: 4.13.0
next-intl: 4.13.1
node-loader: ^2.1.0
octokit: ^5.0.5
oxfmt: ^0.56.0
oxlint: ^1.71.0
oxfmt: ^0.57.0
oxlint: ^1.72.0
pg: ^8.22.0
postcss-preset-mantine: ^1.18.0
prismjs: ^1.30.0
@@ -164,7 +164,7 @@ catalog:
react-markdown: ^10.1.0
react-quill-new: ^3.8.3
react-simple-code-editor: ^0.14.1
recharts: ^3.9.0
recharts: ^3.9.1
sass: ^1.101.0
semantic-release: ^25.0.5
semver-parser: ^4.2.1
@@ -174,11 +174,11 @@ catalog:
timezones-ical-library: ^2.2.1
trpc-to-mcp: ^1.3.2
trpc-to-openapi: ^3.3.0
tsdav: ^2.2.2
tsdav: ^2.3.0
tsx: 4.20.4
turbo: ^2.10.0
turbo: ^2.10.2
typescript: ^6.0.3
vite: ^8.1.0
vite: ^8.1.2
undici: 7.28.0
use-deep-compare-effect: ^1.8.1
video.js: ^8.23.9
@@ -191,6 +191,7 @@ catalog:
zod: ^4.4.3
zod-form-data: ^3.0.2
zod-validation-error: ^5.0.0
catalogMode: strict
nodeLinker: hoisted
strictPeerDependencies: false
@@ -207,29 +208,32 @@ minimumReleaseAgeExclude:
- form-data@4.0.6
# Renovate security update: undici@7.28.0
- undici@7.28.0
# Renovate security update: esbuild@0.28.1
- esbuild@0.28.1
overrides:
"@babel/helpers@<7.26.10": ">=7.29.7"
"@babel/runtime@<7.26.10": ">=7.29.7"
"axios@>=1.0.0 <1.8.2": ">=1.18.1"
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.7"
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.7"
"esbuild@<=0.24.2": ">=0.28.0"
"esbuild@<=0.24.2": ">=0.28.1"
"form-data@>=4.0.0 <4.0.4": ">=4.0.6"
"hono@<4.6.5": ">=4.12.27"
"linkifyjs@<4.3.2": ">=4.3.3"
"nanoid@>=4.0.0 <5.0.9": ">=5.1.16"
"prismjs@<1.30.0": ">=1.30.0"
"proxmox-api>undici": "7.27.1"
"proxmox-api>undici": "7.28.0"
"react-is": "^19.2.7"
"rollup@>=4.0.0 <4.22.4": ">=4.62.2"
"sha.js@<=2.4.11": ">=2.4.12"
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.3"
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.3"
"tmp@<=0.2.3": ">=0.2.7"
"vite@>=5.0.0 <=5.4.18": ">=8.1.0"
"vite@>=5.0.0 <=5.4.18": ">=8.1.3"
patchedDependencies:
"@types/node-unifi": "patches/@types__node-unifi.patch"
"trpc-to-openapi": "patches/trpc-to-openapi.patch"
allowUnusedPatches: true
allowBuilds:
"@tree-sitter-grammars/tree-sitter-yaml": true