Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cecd58626 | ||
|
|
e4b061920c | ||
|
|
65ba866239 | ||
|
|
260b167134 | ||
|
|
7a4de03c0e | ||
|
|
68f8e8146b | ||
|
|
1297e6c2ac | ||
|
|
31288dcbfe | ||
|
|
fc2c251557 | ||
|
|
d067261171 | ||
|
|
ae2f05c834 | ||
|
|
c7fa7cdb50 | ||
|
|
0c28224e82 | ||
|
|
4e6272dbe2 | ||
|
|
f5bc090d36 | ||
|
|
f23e06dff1 | ||
|
|
24188ce51f | ||
|
|
e31f60fdec | ||
|
|
6bb547f3c5 | ||
|
|
b07cc14d0e | ||
|
|
0dc93c1a02 | ||
|
|
00661c9b3d | ||
|
|
2e8fddd29f | ||
|
|
302d5a0d04 | ||
|
|
a9ed95856a | ||
|
|
4d42eb65e4 | ||
|
|
82858ecd75 | ||
|
|
0e365ed53c | ||
|
|
a55dc98487 | ||
|
|
5b91687912 | ||
|
|
a3df31ff05 | ||
|
|
c5c9daf5a5 | ||
|
|
ab0e878fbf | ||
|
|
885be1b9a1 | ||
|
|
0f8a86fdde |
No files matched your search
+8
-1
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -163,65 +163,3 @@ jobs:
|
||||
run: cp .env.example .env
|
||||
- name: Build
|
||||
run: pnpm turbo build --affected
|
||||
|
||||
memory-regression:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: homarr-memory-test
|
||||
network: host
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
- name: Start container and measure memory
|
||||
run: |
|
||||
docker run -d --name homarr-mem-test -p 17575:7575 homarr-memory-test
|
||||
# Wait for container to be healthy (up to 60s)
|
||||
healthy=false
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:17575/api/health 2>/dev/null; then
|
||||
echo "Container healthy after $((i * 2))s"
|
||||
healthy=true
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$healthy" != "true" ]; then
|
||||
echo "ERROR: Container never became healthy"
|
||||
docker logs homarr-mem-test --tail 50
|
||||
exit 1
|
||||
fi
|
||||
# Warm up: hit the dashboard page a few times
|
||||
for i in $(seq 1 5); do
|
||||
curl -sf http://localhost:17575/ > /dev/null 2>&1 || true
|
||||
sleep 2
|
||||
done
|
||||
# Measure RSS after warmup
|
||||
MEM_KB=$(docker stats --no-stream --format '{{.MemUsage}}' homarr-mem-test | sed 's| /.*||')
|
||||
echo "Raw memory: $MEM_KB"
|
||||
# Convert to numeric MB (handles GiB and MiB suffixes)
|
||||
MEM_NUM=$(echo "$MEM_KB" | awk '{
|
||||
if ($0 ~ /GiB/) { gsub(/GiB/,""); print $1 * 1024 }
|
||||
else if ($0 ~ /MiB/) { gsub(/MiB/,""); print $1 }
|
||||
else if ($0 ~ /KiB/) { gsub(/KiB/,""); print $1 / 1024 }
|
||||
else { print $1 / 1048576 }
|
||||
}')
|
||||
echo "Memory usage: ${MEM_NUM} MB"
|
||||
MAX_MB=700
|
||||
if awk "BEGIN {exit !($MEM_NUM > $MAX_MB)}"; then
|
||||
echo "ERROR: Memory usage ${MEM_NUM} MB exceeds ${MAX_MB} MB threshold"
|
||||
docker logs homarr-mem-test --tail 50
|
||||
exit 1
|
||||
fi
|
||||
echo "Memory check passed: ${MEM_NUM} MB <= ${MAX_MB} MB"
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Obtain token
|
||||
id: obtainToken
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -39,13 +39,26 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats';
|
||||
'Scroll down to "API Keys"',
|
||||
'Click "New API Key"',
|
||||
'Enter a name, e.g. "Homarr"',
|
||||
(<span>
|
||||
Select the following permissions:{" "}
|
||||
<code>album.read</code>,{" "}
|
||||
<code>user.read</code>,{" "}
|
||||
<code>server.statistics</code>,{" "}
|
||||
<code>asset.download</code>
|
||||
</span>),
|
||||
(
|
||||
|
||||
<span>
|
||||
|
||||
Select the following permissions:{" "}
|
||||
<code>album.statistics</code>,{" "}
|
||||
<code>album.read</code>,{" "}
|
||||
<code>album.create</code>,{" "}
|
||||
<code>user.read</code>,{" "}
|
||||
<code>user.update</code>,{" "}
|
||||
<code>asset.read</code>,{" "}
|
||||
<code>asset.download</code>,{" "}
|
||||
<code>asset.statistics</code>,{" "}
|
||||
<code>server.statistics</code>,{" "}
|
||||
<code>server.about</code>.
|
||||
<br />
|
||||
Additional permissions may be required depending on your Immich version. If you encounter authorization errors, grant any missing permissions requested by the server.
|
||||
</span>
|
||||
|
||||
),
|
||||
'Create the integration in Homarr and copy the API key to there'
|
||||
]
|
||||
}]} />
|
||||
}]} />
|
||||
@@ -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 > Tools > Certificates</a> so Homarr can connect securely.
|
||||
</Alert>
|
||||
|
||||
### Secrets
|
||||
|
||||
<IntegrationSecrets
|
||||
secrets={[
|
||||
{
|
||||
credentials: ["username", "password"],
|
||||
steps: [
|
||||
"Create a dedicated DSM user with a strong password for Homarr.",
|
||||
"Add the user to the default Administrators group so Homarr can read `SYNO.Core.System` metrics.",
|
||||
"Under User Groups and Permissions, deny access to shared folders and most applications.",
|
||||
"Allow login to DSM only, optionally restricted by source IP address.",
|
||||
"Log in once with the service account in DSM to accept terms and conditions.",
|
||||
],
|
||||
body: (
|
||||
<>
|
||||
<Alert type="caution" title="Administrators group required">
|
||||
Homarr reads system, storage, and utilization data from APIs that require membership in the default
|
||||
Administrators group.
|
||||
</Alert>
|
||||
<Alert type="warning" title="Two-factor authentication is not supported">
|
||||
Accounts with 2-step verification enabled cannot authenticate with this integration. Use a dedicated service
|
||||
account without 2FA.
|
||||
</Alert>
|
||||
<Alert type="tip" title="First-time setup">
|
||||
If login fails during testing, temporarily disable automatic blocking for invalid logins under Control Panel
|
||||
> Security > Protection, then re-enable it after Homarr connects successfully.
|
||||
</Alert>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Storage volume selection
|
||||
|
||||
Health Monitoring and System Disks widgets can filter Synology storage volumes when **every** selected integration is a Synology DiskStation. Leave **Visible storage volumes** empty to show all volumes, or pick specific volumes from the list. When multiple Synology integrations are connected, volumes are labeled with the integration name.
|
||||
|
||||
<Alert type="note" title="Synology-only option">
|
||||
The volume filter is hidden when the widget also uses non-Synology integrations such as TrueNAS or Unraid.
|
||||
</Alert>
|
||||
|
||||
<Alert type="note" title="Volume ordering">
|
||||
When no volume filter is configured, the first volume returned by DSM is not guaranteed to be `volume_1`.
|
||||
</Alert>
|
||||
@@ -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} />
|
||||
@@ -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 } },
|
||||
});
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@scalar/api-reference-react": "catalog:",
|
||||
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
"clsx": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"docusaurus-plugin-image-zoom": "^3.0.1",
|
||||
@@ -43,8 +43,8 @@
|
||||
"random-word-slugs": "^0.1.7",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"swiper": "^12.2.0",
|
||||
"tailwindcss": "^4.3.1"
|
||||
"swiper": "^14.0.1",
|
||||
"tailwindcss": "^4.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.10.1",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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
@@ -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
@@ -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:",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE `widget_secret` (
|
||||
`item_id` varchar(64) NOT NULL,
|
||||
`kind` varchar(64) NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`updated_at` timestamp NOT NULL,
|
||||
CONSTRAINT `widget_secret_item_id_kind_pk` PRIMARY KEY(`item_id`,`kind`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `widget_secret` ADD CONSTRAINT `widget_secret_item_id_item_id_fk` FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON DELETE cascade ON UPDATE no action;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -295,6 +295,13 @@
|
||||
"when": 1780764500000,
|
||||
"tag": "0041_merge_custom_widget_url",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 42,
|
||||
"version": "5",
|
||||
"when": 1783176100870,
|
||||
"tag": "0042_add_widget_secret",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE "widget_secret" (
|
||||
"item_id" varchar(64) NOT NULL,
|
||||
"kind" varchar(64) NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"updated_at" timestamp NOT NULL,
|
||||
CONSTRAINT "widget_secret_item_id_kind_pk" PRIMARY KEY("item_id","kind")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "widget_secret" ADD CONSTRAINT "widget_secret_item_id_item_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."item"("id") ON DELETE cascade ON UPDATE no action;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -57,6 +57,13 @@
|
||||
"when": 1780764500000,
|
||||
"tag": "0007_merge_custom_widget_url",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1783176100870,
|
||||
"tag": "0008_add_widget_secret",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE `widget_secret` (
|
||||
`item_id` text NOT NULL,
|
||||
`kind` text NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
PRIMARY KEY(`item_id`, `kind`),
|
||||
FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -281,6 +281,13 @@
|
||||
"when": 1780764500000,
|
||||
"tag": "0039_merge_custom_widget_url",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 40,
|
||||
"version": "6",
|
||||
"when": 1783176100870,
|
||||
"tag": "0040_add_widget_secret",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -72,7 +72,14 @@ export const AppForm = ({
|
||||
}
|
||||
|
||||
form.initialize(toFormValues(initialValues));
|
||||
}, [initialValuesKey, form, initialValues]);
|
||||
// `form` is a new object every render (Mantine useForm returns a fresh literal),
|
||||
// so it must not be a dep here — it would re-run every render and, since
|
||||
// form.initialize() always calls clearErrors() (new {} object) internally,
|
||||
// trigger an infinite update loop. initialValuesKey (a primitive string)
|
||||
// already captures the only case this effect exists for: re-initializing
|
||||
// when initialValues actually changes (e.g. navigating between edit pages).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialValuesKey]);
|
||||
|
||||
// Debounce the name value with 200ms delay
|
||||
const [debouncedName] = useDebouncedValue(form.values.name, 200);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -732,8 +732,8 @@
|
||||
"displayType": "",
|
||||
"jsonPath": "",
|
||||
"jsonPathHint": "",
|
||||
"label": "",
|
||||
"unit": "",
|
||||
"label": "Étiquette",
|
||||
"unit": "Unité",
|
||||
"mappings": "",
|
||||
"tablePath": "",
|
||||
"tablePathHint": "",
|
||||
@@ -755,14 +755,14 @@
|
||||
"striped": "",
|
||||
"compact": "",
|
||||
"maxHeight": "",
|
||||
"color": "",
|
||||
"color": "Couleur",
|
||||
"buttonLabel": "",
|
||||
"buttonColor": "",
|
||||
"confirmText": "",
|
||||
"confirmText": "Texte de confirmation",
|
||||
"confirmTextHint": "",
|
||||
"successMessage": "",
|
||||
"successMessage": "Message de réussite",
|
||||
"successMessageHint": "",
|
||||
"enabled": "",
|
||||
"enabled": "Activé",
|
||||
"template": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -1017,13 +1017,13 @@
|
||||
"timetable": "",
|
||||
"photoService": "",
|
||||
"documents": "",
|
||||
"notes": "",
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"notes": "Notes",
|
||||
"mediaMonitoring": "Supervision",
|
||||
"speedtest": "Test de débit",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
"mediaLibrary": "Bibliothèque de médias",
|
||||
"uptimeMonitoring": "Surveillance des disponibilités"
|
||||
},
|
||||
"testConnection": {
|
||||
"action": {
|
||||
@@ -1298,7 +1298,7 @@
|
||||
"newLabel": "Nouvelle clé privée"
|
||||
},
|
||||
"slug": {
|
||||
"label": "",
|
||||
"label": "Slug de la page de statut",
|
||||
"newLabel": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1599,7 +1599,7 @@
|
||||
"menu": {
|
||||
"label": {
|
||||
"settings": "Impostazioni",
|
||||
"refresh": "",
|
||||
"refresh": "Aggiorna",
|
||||
"options": "Opzioni",
|
||||
"actions": "Azioni"
|
||||
}
|
||||
@@ -1904,7 +1904,7 @@
|
||||
"noStation": "Nessuna stazione selezionata",
|
||||
"option": {
|
||||
"baseUrl": {
|
||||
"label": ""
|
||||
"label": "Url Search.ch"
|
||||
},
|
||||
"station": {
|
||||
"label": "Stazione"
|
||||
@@ -2791,8 +2791,8 @@
|
||||
"description": "Visualizza la data dell'immagine e l'indice corrente nella parte inferiore della presentazione"
|
||||
},
|
||||
"randomizePhotos": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Immagini casuali",
|
||||
"description": "Mostra le foto in ordine casuale invece di iniziare sempre con lo stesso ordine degli album."
|
||||
}
|
||||
},
|
||||
"description": "Presentazione delle immagini nel tuo album Immich",
|
||||
@@ -3158,7 +3158,7 @@
|
||||
"label": "Provider"
|
||||
},
|
||||
"providerUrl": {
|
||||
"label": ""
|
||||
"label": "URL Provider"
|
||||
},
|
||||
"identifier": {
|
||||
"label": "Identificatore",
|
||||
@@ -3209,10 +3209,10 @@
|
||||
"tooltip": "Impossibile analizzare il provider, impostarlo manualmente dopo aver importato le immagini"
|
||||
},
|
||||
"tokens": {
|
||||
"label": "",
|
||||
"token": "",
|
||||
"configured": "",
|
||||
"notConfigured": "",
|
||||
"label": "Token Provider",
|
||||
"token": "Token",
|
||||
"configured": "Token configurato",
|
||||
"notConfigured": "Nessun token impostato",
|
||||
"save": "Salva"
|
||||
}
|
||||
}
|
||||
@@ -3236,9 +3236,9 @@
|
||||
"invalidIdentifier": "Identificatore non valido",
|
||||
"noMatchingVersion": "Nessuna versione corrispondente trovata",
|
||||
"noReleasesFound": "Nessun rilascio trovato",
|
||||
"noProviderSelected": "",
|
||||
"noProviderSelected": "Nessun provider selezionato",
|
||||
"noProviderResponse": "Nessuna risposta dal provider",
|
||||
"unexpected": ""
|
||||
"unexpected": "Errore provider inaspettato"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3132,7 +3132,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"showOnlyIcon": {
|
||||
"label": ""
|
||||
"label": "Показати тільки іконку"
|
||||
},
|
||||
"topReleases": {
|
||||
"label": "",
|
||||
@@ -3229,7 +3229,7 @@
|
||||
"openReleasePage": "",
|
||||
"releaseDescription": "",
|
||||
"projectDescription": "",
|
||||
"created": "",
|
||||
"created": "Створено",
|
||||
"error": {
|
||||
"label": "",
|
||||
"messages": {
|
||||
@@ -3350,7 +3350,7 @@
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"latestResult": "",
|
||||
"latestResult": "Останній результат",
|
||||
"averages": "",
|
||||
"tests": "",
|
||||
"recentResults": "",
|
||||
@@ -3437,7 +3437,7 @@
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"label": "Максимум елементів для відтворення",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from "@homarr/definitions";
|
||||
import { findBestIconMatch, IconPicker } from "@homarr/forms-collection";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { MaskedImage } from "@homarr/ui";
|
||||
|
||||
@@ -269,8 +270,24 @@ const providersWithAuth: ReleaseProviderKind[] = [
|
||||
const ProviderTokensSection = ({ itemId, repositories }: { itemId: string; repositories: ReleasesRepository[] }) => {
|
||||
const tRepository = useScopedI18n("widget.releases.option.repositories");
|
||||
const { data: configuredKinds = [], refetch } = clientApi.widget.secrets.getConfiguredKinds.useQuery({ itemId });
|
||||
const setSecret = clientApi.widget.secrets.setSecret.useMutation({ onSuccess: () => refetch() });
|
||||
const deleteSecret = clientApi.widget.secrets.deleteSecret.useMutation({ onSuccess: () => refetch() });
|
||||
const setSecret = clientApi.widget.secrets.setSecret.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
onError: (error) => {
|
||||
showErrorNotification({
|
||||
title: "Failed to save token",
|
||||
message: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
const deleteSecret = clientApi.widget.secrets.deleteSecret.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
onError: (error) => {
|
||||
showErrorNotification({
|
||||
title: "Failed to delete token",
|
||||
message: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const authProviders = useMemo(() => {
|
||||
const usedProviders = repositories.map((r) => r.provider).filter((p): p is ReleaseProviderKind => p !== undefined);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Generated
+850
-812
File diff suppressed because it is too large.
Load diff
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user