Compare commits

..
Author SHA1 Message Date
ajnart 6c3b584bd2 style: auto-fix lint and format 2026-07-21 08:08:53 +00:00
ajnart e3cf680cbd fix(integrations): support UniFi controller default ports 2026-07-21 10:07:25 +02:00
ajnart 24989ddd16 style: auto-fix lint and format 2026-07-06 00:07:03 +00:00
ajnart 8cd9e8c826 fix(integrations): use Unifi default port 8443 when no port is given
The node-unifi library hardcodes https:// in its base URL and the
Unifi controller API only speaks HTTPS. When a user adds the
integration with a bare http:// or https:// URL, getPortFromUrl
returned 80 or 443, and the library attempted a TLS handshake on
the wrong port. The server's plain HTTP response caused
'EPROTO ... wrong version number'.

Derive the port from the user's explicit port, or fall back to 8443
(the integration's defaultPort). Closes #6207.
2026-07-06 02:05:44 +02:00
55 changed files with 1195 additions and 1232 deletions

No files matched your search

+1 -8
View File
@@ -59,11 +59,4 @@ TURBO_TELEMETRY_DISABLED=1
# ENABLE_KUBERNETES=true # ENABLE_KUBERNETES=true
# Enable mock integration # Enable mock integration
UNSAFE_ENABLE_MOCK_INTEGRATION=true UNSAFE_ENABLE_MOCK_INTEGRATION=true
# Enable demo mode (seeds a demo user "demo"/"demo", a sample board and a mock integration)
# DEMO_MODE=true
# Whether demo mode is read-only. Defaults to true (the public demo blocks all mutations).
# Set to false for writable preview deployments so the demo user can modify the dashboard.
# DEMO_READ_ONLY=false
-1
View File
@@ -33,7 +33,6 @@ body:
options: options:
# The below comment is used to insert a new version with on-release.yml # The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION# #NEXT_VERSION#
- 1.69.2
- 1.69.0 - 1.69.0
- 1.68.0 - 1.68.0
- 1.67.0 - 1.67.0
+1 -1
View File
@@ -11,7 +11,7 @@ outputs:
runs: runs:
using: "composite" using: "composite"
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Docker Buildx - name: Set up Docker Buildx
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175486441 if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175486441
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v7 uses: actions/checkout@v6
- name: Obtain token - name: Obtain token
id: obtainToken id: obtainToken
uses: actions/create-github-app-token@v3 uses: actions/create-github-app-token@v3
+70 -8
View File
@@ -25,13 +25,13 @@ env:
jobs: jobs:
lint-format-autofix: lint-format-autofix:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id if: github.event_name == 'pull_request'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
permissions: permissions:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.head_ref }} ref: ${{ github.head_ref }}
@@ -60,7 +60,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -78,7 +78,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -92,7 +92,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -106,7 +106,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Setup - name: Setup
uses: ./tooling/github/setup uses: ./tooling/github/setup
@@ -124,7 +124,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Setup - name: Setup
uses: ./tooling/github/setup uses: ./tooling/github/setup
- name: Set up Docker Buildx - name: Set up Docker Buildx
@@ -153,7 +153,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup - name: Setup
@@ -163,3 +163,65 @@ jobs:
run: cp .env.example .env run: cp .env.example .env
- name: Build - name: Build
run: pnpm turbo build --affected 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: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v6
- name: Obtain token - name: Obtain token
id: obtainToken id: obtainToken
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v6
- name: Upload Crowdin translations - name: Upload Crowdin translations
uses: crowdin/github-action@v2 uses: crowdin/github-action@v2
@@ -26,7 +26,7 @@ jobs:
with: with:
args: "Beta release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" args: "Beta release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Get Next Version - name: Get Next Version
@@ -57,7 +57,7 @@ jobs:
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # required to commit package.json & changelog changes, merge them to dev / next and publish the release permission-contents: write # required to commit package.json & changelog changes, merge them to dev / next and publish the release
- uses: actions/checkout@v7 - uses: actions/checkout@v6
if: env.SKIP_RELEASE == 'false' if: env.SKIP_RELEASE == 'false'
with: with:
persist-credentials: false persist-credentials: false
@@ -119,7 +119,7 @@ jobs:
outputs: outputs:
digest: ${{ steps.build.outputs.digest }} digest: ${{ steps.build.outputs.digest }}
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
ref: ${{ needs.release.outputs.git_ref }} ref: ${{ needs.release.outputs.git_ref }}
@@ -158,7 +158,7 @@ jobs:
outputs: outputs:
digest: ${{ steps.build.outputs.digest }} digest: ${{ steps.build.outputs.digest }}
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
ref: ${{ needs.release.outputs.git_ref }} ref: ${{ needs.release.outputs.git_ref }}
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Setup - name: Setup
uses: ./tooling/github/setup uses: ./tooling/github/setup
- name: Build docs - name: Build docs
@@ -29,7 +29,7 @@ jobs:
with: with:
args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Get Next Version - name: Get Next Version
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Setup - name: Setup
uses: ./tooling/github/setup uses: ./tooling/github/setup
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
ref: dev ref: dev
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
ref: dev ref: dev
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Validate prebuilt dependencies for amd64 - name: Validate prebuilt dependencies for amd64
id: validate-amd64 id: validate-amd64
uses: ./.github/actions/prebuilt-debian uses: ./.github/actions/prebuilt-debian
@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-24.04-arm runs-on: ubuntu-24.04-arm
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Validate prebuilt dependencies for arm64 - name: Validate prebuilt dependencies for arm64
id: validate-arm64 id: validate-arm64
uses: ./.github/actions/prebuilt-debian uses: ./.github/actions/prebuilt-debian
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 2 timeout-minutes: 2
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- run: | - run: |
npx --yes --package renovate -- \ npx --yes --package renovate -- \
renovate-config-validator --strict .github/renovate.json5 renovate-config-validator --strict .github/renovate.json5
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
permission-contents: write # required to commit to branch permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout code - name: Checkout code
uses: actions/checkout@v7 uses: actions/checkout@v6
with: with:
token: ${{ steps.obtainToken.outputs.token }} token: ${{ steps.obtainToken.outputs.token }}
ref: dev ref: dev
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
permission-pull-requests: write # required to create pr & enable automerge permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v6
env: env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
@@ -28,7 +28,7 @@ jobs:
permission-contents: write # required to commit to branch permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout code - name: Checkout code
uses: actions/checkout@v7 uses: actions/checkout@v6
env: env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
- name: Setup - name: Setup
-6
View File
@@ -1,9 +1,3 @@
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
### Bug Fixes
* **layout:** raise AppShellHeader z-index above Indicator dots ([687edb9](https://github.com/homarr-labs/homarr/commit/687edb9226307cb83986097abb24fe7f672e1778)), closes [#6194](https://github.com/homarr-labs/homarr/issues/6194)
## [1.69.1](https://github.com/homarr-labs/homarr/compare/v1.69.0...v1.69.1) (2026-07-04) ## [1.69.1](https://github.com/homarr-labs/homarr/compare/v1.69.0...v1.69.1) (2026-07-04)
### Bug Fixes ### Bug Fixes
+1 -1
View File
@@ -58,7 +58,7 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats';
Additional permissions may be required depending on your Immich version. If you encounter authorization errors, grant any missing permissions requested by the server. Additional permissions may be required depending on your Immich version. If you encounter authorization errors, grant any missing permissions requested by the server.
</span> </span>
), ),Ï
'Create the integration in Homarr and copy the API key to there' 'Create the integration in Homarr and copy the API key to there'
] ]
}]} /> }]} />
@@ -1,40 +1,48 @@
--- ---
title: 'Unifi Controller' title: "Unifi Controller"
description: "Unifi Controller is a network management platform for Unifi devices." description: "Unifi Controller is a network management platform for Unifi devices."
hide_title: true hide_title: true
--- ---
import { IntegrationHeader } from '@site/src/components/integrations/header'; import { IntegrationHeader } from "@site/src/components/integrations/header";
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets'; import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
import { AddingIntegration } from '@site/src/components/integrations/adding'; import { AddingIntegration } from "@site/src/components/integrations/adding";
import {IntegrationSecrets} from '@site/src/components/integrations/secrets'; import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { IconTopologyFull } from "@tabler/icons-react"; import { IconTopologyFull } from "@tabler/icons-react";
import { unifiControllerIntegration } from '.'; import { unifiControllerIntegration } from ".";
import { networkControllerSummaryWidget } from '@site/docs/widgets/network-controller-summary'; import { networkControllerSummaryWidget } from "@site/docs/widgets/network-controller-summary";
import { networkControllerStatusWidget } from '@site/docs/widgets/network-controller-status'; import { networkControllerStatusWidget } from "@site/docs/widgets/network-controller-status";
<IntegrationHeader integration={unifiControllerIntegration} categories={["Network controller"]} />
<IntegrationHeader
integration={unifiControllerIntegration}
categories={['Network controller']}
/>
This integration is using the [node-unifi](https://www.npmjs.com/package/node-unifi) library under the hood to connect to the Unifi Controller. This integration is using the [node-unifi](https://www.npmjs.com/package/node-unifi) library under the hood to connect to the Unifi Controller.
Therfore it should support CloudKey Gen1, CloudKey Gen2, UnifiOS-based UDM-Pro controllers as well as self-hosted UniFi controllers. Therfore it should support CloudKey Gen1, CloudKey Gen2, UnifiOS-based UDM-Pro controllers as well as self-hosted UniFi controllers.
### Widgets & Capabilities ### Widgets & Capabilities
<IntegrationCapabilites <IntegrationCapabilites
items={[ items={[{ widget: networkControllerSummaryWidget }, { widget: networkControllerStatusWidget }]}
{ widget: networkControllerSummaryWidget },
{ widget: networkControllerStatusWidget}
]}
/> />
### Adding the integration ### Adding the integration
<AddingIntegration /> <AddingIntegration />
Use the local HTTPS address of your controller:
- UniFi OS consoles such as Dream Router, Dream Machine, Cloud Gateway, and CloudKey use `https://<host>` (port 443).
- Self-hosted UniFi Network Servers normally use `https://<host>:8443`.
- If you omit the port, Homarr tries port 443 first and then port 8443 when the first port cannot be reached.
Use a local administrator account. Cloud-only accounts and accounts with multi-factor authentication are not supported by the underlying client.
### Secrets ### Secrets
<IntegrationSecrets secrets={[{
credentials: ['username', 'password'], <IntegrationSecrets
steps: [] secrets={[
}]} /> {
credentials: ["username", "password"],
steps: [],
},
]}
/>
+6 -18
View File
@@ -1,25 +1,18 @@
// Algolia DocSearch crawler for homarr.dev // Algolia DocSearch crawler for homarr.dev
// Crawler ID: cd77a285-2756-4557-bf21-ee703748df15 // Crawler ID: cd77a285-2756-4557-bf21-ee703748df15
// Paste this in the Algolia crawler editor. // DocSearch-managed crawlers: paste this in the Algolia crawler editor or send to DocSearch support.
// Template: https://docsearch.algolia.com/docs/templates/#docusaurus-v3-template
// Markdown indexing: https://www.algolia.com/doc/guides/algolia-ai/askai/guides/markdown-indexing
new Crawler({ new Crawler({
appId: "N69WSPZTID", appId: "N69WSPZTID",
apiKey: "YOUR_CRAWLER_ADMIN_API_KEY", apiKey: "YOUR_CRAWLER_ADMIN_API_KEY",
indexPrefix: "", rateLimit: 8,
rateLimit: 32, maxDepth: 10,
maxDepth: 4,
startUrls: ["https://homarr.dev/"], startUrls: ["https://homarr.dev/"],
sitemaps: ["https://homarr.dev/sitemap.xml"], sitemaps: ["https://homarr.dev/sitemap.xml"],
ignoreCanonicalTo: true, ignoreCanonicalTo: true,
discoveryPatterns: ["https://homarr.dev/**"], discoveryPatterns: ["https://homarr.dev/**"],
exclusionPatterns: [
"https://homarr.dev/docs/tags/**",
"https://homarr.dev/blog/tags/**",
"https://homarr.dev/blog/authors/**",
"https://homarr.dev/blog/archive/**",
"https://homarr.dev/search/**",
"https://homarr.dev/docs/category/**",
],
actions: [ actions: [
{ {
indexName: "Docusaurus", indexName: "Docusaurus",
@@ -112,6 +105,7 @@ new Crawler({
advancedSyntax: true, advancedSyntax: true,
attributeCriteriaComputedByMinProximity: true, attributeCriteriaComputedByMinProximity: true,
removeWordsIfNoResults: "allOptional", removeWordsIfNoResults: "allOptional",
separatorsToIndex: "_",
}, },
markdown: { markdown: {
attributesForFaceting: ["lang", "language", "version", "docusaurus_tag"], attributesForFaceting: ["lang", "language", "version", "docusaurus_tag"],
@@ -125,10 +119,4 @@ new Crawler({
advancedSyntax: false, advancedSyntax: false,
}, },
}, },
apiKey: "YOUR_CRAWLER_ADMIN_API_KEY",
schedule: "on saturday",
renderJavaScript: false,
maxUrls: null,
cache: { enabled: false },
safetyChecks: { beforeIndexPublishing: { maxLostRecordsPercentage: 99 } },
}); });
+2 -21
View File
@@ -8,7 +8,7 @@ const config: Config = {
tagline: "A simple yet powerful dashboard for your server.", tagline: "A simple yet powerful dashboard for your server.",
url: "https://homarr.dev", url: "https://homarr.dev",
baseUrl: "/", baseUrl: "/",
trailingSlash: undefined, trailingSlash: true,
favicon: "img/logo.png", favicon: "img/logo.png",
organizationName: "homarr-labs", organizationName: "homarr-labs",
projectName: "homarr", projectName: "homarr",
@@ -79,27 +79,8 @@ const config: Config = {
sitemap: { sitemap: {
changefreq: "weekly", changefreq: "weekly",
priority: 0.5, priority: 0.5,
ignorePatterns: ["/tags/**", "/docs/category/**"], ignorePatterns: ["/tags/**"],
filename: "sitemap.xml", filename: "sitemap.xml",
createSitemapItems: async ({ routes, siteConfig, defaultCreateSitemapItems }) => {
const items = await defaultCreateSitemapItems({ routes, siteConfig });
return items.map((item) => {
const path = new URL(item.url).pathname;
if (path === "/" || path === "") {
return { ...item, priority: 1.0, changefreq: "weekly" };
}
if (path.startsWith("/docs/")) {
return { ...item, priority: 0.7, changefreq: "monthly" };
}
if (path.startsWith("/blog/")) {
return { ...item, priority: 0.3, changefreq: "never" };
}
if (path.startsWith("/about-us")) {
return { ...item, priority: 0.4, changefreq: "yearly" };
}
return item;
});
},
}, },
} satisfies Preset.Options, } satisfies Preset.Options,
], ],
+1 -1
View File
@@ -43,7 +43,7 @@
"random-word-slugs": "^0.1.7", "random-word-slugs": "^0.1.7",
"react": "catalog:", "react": "catalog:",
"react-dom": "catalog:", "react-dom": "catalog:",
"swiper": "^14.0.1", "swiper": "^12.2.0",
"tailwindcss": "^4.3.2" "tailwindcss": "^4.3.2"
}, },
"devDependencies": { "devDependencies": {
+2 -1
View File
@@ -1,7 +1,8 @@
@import url("https://api.fontshare.com/v2/css?f[]=work-sans@2,1&display=swap");
@import "tailwindcss/theme" layer(theme); @import "tailwindcss/theme" layer(theme);
@import "tailwindcss/utilities" layer(utilities); @import "tailwindcss/utilities" layer(utilities);
@import url(https://api.fontshare.com/v2/css?f[]=work-sans@2,1&display=swap);
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
@theme { @theme {
+4 -5
View File
@@ -1,6 +1,9 @@
{ {
"extends": "@docusaurus/tsconfig",
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "ignoreDeprecations": "6.0",
"baseUrl": ".",
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
@@ -9,14 +12,10 @@
"noEmit": true, "noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,
"module": "esnext", "module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "preserve",
"incremental": true, "incremental": true,
"paths": {
"@site/*": ["./*"]
},
}, },
"include": [ "include": [
"**/*.ts", "**/*.ts",
+14 -12
View File
@@ -5,9 +5,21 @@ import "@homarr/common/env";
import "@homarr/core/infrastructure/logs/env"; import "@homarr/core/infrastructure/logs/env";
import "@homarr/docker/env"; import "@homarr/docker/env";
import { readFileSync } from "fs";
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin"; import createNextIntlPlugin from "next-intl/plugin";
const homarrVersion = (() => {
for (const candidate of ["../../package.json", "./package.json"]) {
try {
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
if (pkg.name === "homarr") return pkg.version as string;
} catch {}
}
return "unknown";
})();
// Package path does not work... so we need to use relative path // Package path does not work... so we need to use relative path
const withNextIntl = createNextIntlPlugin({ const withNextIntl = createNextIntlPlugin({
requestConfig: "../../packages/translation/src/request.ts", requestConfig: "../../packages/translation/src/request.ts",
@@ -15,7 +27,7 @@ const withNextIntl = createNextIntlPlugin({
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
env: { env: {
HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown", HOMARR_VERSION: homarrVersion,
}, },
output: "standalone", output: "standalone",
reactStrictMode: true, reactStrictMode: true,
@@ -31,19 +43,9 @@ const nextConfig: NextConfig = {
experimental: { experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"], optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true, turbopackFileSystemCacheForDev: true,
preloadEntriesOnStart: false,
webpackMemoryOptimizations: true, webpackMemoryOptimizations: true,
}, },
turbopack: {
// ponytail: known Turbopack NFT warning from path.join(process.cwd(), …) in
// src/app/api/backup/{route,shared}.ts. No working placement in 16.2.x
// (see vercel/next.js#95125). Suppress until upstream fix lands.
ignoreIssue: [
{
path: "**/*",
title: "Encountered unexpected file in NFT list",
},
],
},
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"], transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
images: { images: {
localPatterns: [ localPatterns: [
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "HOMARR_VERSION=$(node -p \"require('../../package.json').version\") pnpm with-env next build", "build": "pnpm with-env next build",
"clean": "git clean -xdf .next .turbo node_modules", "clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev", "dev": "pnpm with-env next dev",
"format": "oxfmt .", "format": "oxfmt .",
@@ -123,9 +123,9 @@ async function QueryCacheHydration({ userId, boardId }: { userId: string; boardI
const serialized = await getQueryCacheAsync(userId, boardId); const serialized = await getQueryCacheAsync(userId, boardId);
if (!serialized) return null; if (!serialized) return null;
const persisted = superjson.parse<PersistedClient | undefined>(serialized); const persisted = superjson.parse<PersistedClient>(serialized);
if (!persisted || persisted.buster !== queryCacheBuster) return null; if (persisted.buster !== queryCacheBuster) return null;
if (!persisted.clientState?.queries?.length) return null; if (!persisted?.clientState?.queries?.length) return null;
return <HydrationBoundary state={persisted.clientState} />; return <HydrationBoundary state={persisted.clientState} />;
} catch (error) { } catch (error) {
@@ -45,9 +45,7 @@ export const BoardContentHeaderActions = () => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board); const { hasChangeAccess } = useBoardPermissions(board);
// Fall back to read-only if the query has no data (e.g. errored) so we never expose const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
// edit/save/settings UI that would then fail server-side.
const { data: demoReadOnly = true, isLoading } = clientApi.info.isDemoReadOnly.useQuery();
if (!hasChangeAccess || isLoading) { if (!hasChangeAccess || isLoading) {
return <SelectBoardsMenu />; return <SelectBoardsMenu />;
@@ -57,9 +55,9 @@ export const BoardContentHeaderActions = () => {
<> <>
{isEditMode && <AddMenu />} {isEditMode && <AddMenu />}
<EditModeMenu demoReadOnly={demoReadOnly} /> <EditModeMenu demoMode={demoMode ?? false} />
{!demoReadOnly && ( {!demoMode && (
<OnboardingTour.Target id="board-settings"> <OnboardingTour.Target id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`}> <HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} /> <IconSettings stroke={1.5} />
@@ -160,7 +158,7 @@ const AddMenu = () => {
); );
}; };
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
const [isEditMode, { open, close }] = useEditMode(); const [isEditMode, { open, close }] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
@@ -190,11 +188,11 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const toggle = useCallback(() => { const toggle = useCallback(() => {
if (isEditMode) { if (isEditMode) {
if (demoReadOnly) return discardDemoChanges(); if (demoMode) return discardDemoChanges();
return saveBoard(board); return saveBoard(board);
} }
open(); open();
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]); }, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]); useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
usePreventLeaveWithDirty(isEditMode); usePreventLeaveWithDirty(isEditMode);
+8 -10
View File
@@ -1,13 +1,11 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
export const findMigrationsFolder = () => { const migrationsFolderCandidates = [
const cwd = process.cwd(); path.join(process.cwd(), "db/migrations/sqlite"),
const migrationsFolderCandidates = [ path.join(process.cwd(), "packages/db/migrations/sqlite"),
path.join(cwd, "db/migrations/sqlite"), path.resolve(process.cwd(), "../../db/migrations/sqlite"),
path.join(cwd, "packages/db/migrations/sqlite"), "/app/db/migrations/sqlite",
path.resolve(cwd, "../../db/migrations/sqlite"), ];
"/app/db/migrations/sqlite",
]; export const findMigrationsFolder = () => migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
return migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
};
@@ -2,10 +2,10 @@
import type { MutableRefObject, ReactNode } from "react"; import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text } from "@mantine/core"; import { Group, Menu, Switch, Text } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react"; import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
@@ -50,9 +50,8 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const widgetQueryKey = useMemo(() => [["widget", item.kind]], [item.kind]); const widgetQueryKey = useMemo(() => [["widget", item.kind]], [item.kind]);
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
const handleRefetch = useCallback(() => { const handleRefetch = useCallback(() => {
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" }); void queryClient.invalidateQueries({ queryKey: widgetQueryKey });
}, [queryClient, widgetQueryKey]); }, [queryClient, widgetQueryKey]);
const options = useMemo( const options = useMemo(
@@ -224,15 +223,11 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
<> <>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />} {(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
<Menu.Item <Menu.Item closeMenuOnClick leftSection={<IconRefresh size={16} />} onClick={handleRefetch}>
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
onClick={handleRefetch}
disabled={isWidgetFetching}
>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
{tMenu("refresh")} {tMenu("refresh")}
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} /> <WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} />
</Text> </Text>
</Group> </Group>
</Menu.Item> </Menu.Item>
@@ -270,24 +265,13 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
); );
}; };
const WidgetCacheAge = ({ const WidgetCacheAge = ({ queryClient, queryKey }: { queryClient: QueryClient; queryKey: unknown[] }) => {
queryClient,
queryKey,
isFetching,
}: {
queryClient: QueryClient;
queryKey: unknown[];
isFetching: boolean;
}) => {
// 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity
const [, setTick] = useState(0); const [, setTick] = useState(0);
useEffect(() => { useEffect(() => {
const id = setInterval(() => setTick((n) => n + 1), 1000); const id = setInterval(() => setTick((n) => n + 1), 1000);
return () => clearInterval(id); return () => clearInterval(id);
}, []); }, []);
if (isFetching) return <Loader size={12} />;
const timestamps = queryClient const timestamps = queryClient
.getQueryCache() .getQueryCache()
.findAll({ queryKey }) .findAll({ queryKey })
+1 -1
View File
@@ -18,7 +18,7 @@ interface Props {
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => { export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
return ( return (
<AppShellHeader maw="100vw" zIndex={201} style={{ overflowX: "hidden" }}> <AppShellHeader maw="100vw" style={{ overflowX: "hidden" }}>
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap"> <Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap"> <Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
{hasNavigation && <ClientBurger />} {hasNavigation && <ClientBurger />}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "homarr-prebuilt-debian", "name": "homarr-prebuilt-debian",
"private": true, "private": true,
"dependencies": { "dependencies": {
"better-sqlite3": "^12.11.1" "better-sqlite3": "^12.10.0"
}, },
"engines": { "engines": {
"node": ">=24.18.0", "node": ">=24.18.0",
+5 -5
View File
@@ -9,16 +9,16 @@ importers:
.: .:
dependencies: dependencies:
better-sqlite3: better-sqlite3:
specifier: ^12.11.1 specifier: ^12.10.0
version: 12.11.1 version: 12.10.0
packages: packages:
base64-js@1.5.1: base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
better-sqlite3@12.11.1: better-sqlite3@12.10.0:
resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==} resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==}
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x}
bindings@1.5.0: bindings@1.5.0:
@@ -148,7 +148,7 @@ snapshots:
base64-js@1.5.1: {} base64-js@1.5.1: {}
better-sqlite3@12.11.1: better-sqlite3@12.10.0:
dependencies: dependencies:
bindings: 1.5.0 bindings: 1.5.0
prebuild-install: 7.1.3 prebuild-install: 7.1.3
+1 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "homarr", "name": "homarr",
"version": "1.69.2", "version": "1.69.1",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "cross-env CI=true turbo build", "build": "cross-env CI=true turbo build",
@@ -43,7 +43,6 @@
"@semantic-release/release-notes-generator": "catalog:", "@semantic-release/release-notes-generator": "catalog:",
"@testcontainers/redis": "catalog:", "@testcontainers/redis": "catalog:",
"@turbo/gen": "catalog:", "@turbo/gen": "catalog:",
"@vitejs/plugin-react": "catalog:", "@vitejs/plugin-react": "catalog:",
"@vitest/coverage-v8": "catalog:", "@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:", "@vitest/ui": "catalog:",
-2
View File
@@ -6,11 +6,9 @@ export const env = createEnv({
server: { server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(), KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
DEMO_MODE: createBooleanSchema(false), DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),
}, },
runtimeEnv: { runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME, KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
DEMO_MODE: process.env.DEMO_MODE, DEMO_MODE: process.env.DEMO_MODE,
DEMO_READ_ONLY: process.env.DEMO_READ_ONLY,
}, },
}); });
+1 -2
View File
@@ -1,7 +1,7 @@
import z from "zod/v4"; import z from "zod/v4";
import packageJson from "../../../../package.json"; import packageJson from "../../../../package.json";
import { createTRPCRouter, isDemoMode, isDemoReadOnly, publicProcedure, protectedProcedure } from "../trpc"; import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc";
export const infoRouter = createTRPCRouter({ export const infoRouter = createTRPCRouter({
getInfo: protectedProcedure getInfo: protectedProcedure
@@ -20,5 +20,4 @@ export const infoRouter = createTRPCRouter({
}; };
}), }),
isDemoMode: publicProcedure.query(() => isDemoMode), isDemoMode: publicProcedure.query(() => isDemoMode),
isDemoReadOnly: publicProcedure.query(() => isDemoReadOnly),
}); });
+2 -6
View File
@@ -95,12 +95,8 @@ export const createTRPCRouter = t.router;
export const isDemoMode = env.DEMO_MODE; export const isDemoMode = env.DEMO_MODE;
// The public "real demo" (demo + mock integrations) stays read-only, while preview
// deployments run with DEMO_MODE + DEMO_READ_ONLY=false to allow mutations.
export const isDemoReadOnly = env.DEMO_MODE && env.DEMO_READ_ONLY;
const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => { const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
if (isDemoReadOnly && type === "mutation") { if (env.DEMO_MODE && type === "mutation") {
throw new TRPCError({ throw new TRPCError({
code: "FORBIDDEN", code: "FORBIDDEN",
message: "Mutations are disabled in demo mode", message: "Mutations are disabled in demo mode",
@@ -109,7 +105,7 @@ const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
return next({ ctx }); return next({ ctx });
}); });
const baseProcedure = isDemoReadOnly ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure; const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
/** /**
* Public (unauthed) procedure * Public (unauthed) procedure
+1 -1
View File
@@ -6,5 +6,5 @@ export function parseCookies(cookieString: string) {
} }
export function setClientCookie(name: string, value: string, options: SerializeOptions = {}) { export function setClientCookie(name: string, value: string, options: SerializeOptions = {}) {
document.cookie = stringifySetCookie({ name, value, ...options }); document.cookie = stringifySetCookie(name, value, options);
} }
@@ -25,7 +25,7 @@ export class AxiosHttpErrorHandler extends HttpErrorHandler {
return new RequestError(requestErrorInput, { return new RequestError(requestErrorInput, {
cause: error, cause: error,
}) as AnyRequestError; });
} }
handleResponseError(error: unknown): ResponseError | undefined { handleResponseError(error: unknown): ResponseError | undefined {
if (!(error instanceof AxiosError)) return undefined; if (!(error instanceof AxiosError)) return undefined;
@@ -20,7 +20,7 @@ export class FetchHttpErrorHandler extends HttpErrorHandler {
const result = matchErrorCode(error.cause.code); const result = matchErrorCode(error.cause.code);
if (!result) return undefined; if (!result) return undefined;
return new RequestError(result, { cause: error }) as AnyRequestError; return new RequestError(result, { cause: error });
} }
/** /**
@@ -59,7 +59,7 @@ export const matchErrorCode = (code: string): AnyRequestErrorInput | undefined =
type: key, type: key,
reason: found[0] as RequestErrorReason<typeof key>, reason: found[0] as RequestErrorReason<typeof key>,
code: code as RequestErrorCode, code: code as RequestErrorCode,
} as AnyRequestErrorInput; };
} }
return undefined; return undefined;
@@ -30,7 +30,7 @@ export class NodeFetchHttpErrorHandler extends HttpErrorHandler {
return new RequestError(requestErrorInput, { return new RequestError(requestErrorInput, {
cause: error, cause: error,
}) as AnyRequestError; });
} }
/** /**
@@ -159,6 +159,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/archiveteam-warrior" | "/docs/integrations/archiveteam-warrior"
| "/docs/integrations/aria2" | "/docs/integrations/aria2"
| "/docs/integrations/audiobookshelf" | "/docs/integrations/audiobookshelf"
| "/docs/integrations/bazarr"
| "/docs/integrations/beszel" | "/docs/integrations/beszel"
| "/docs/integrations/coolify" | "/docs/integrations/coolify"
| "/docs/integrations/dash-dot" | "/docs/integrations/dash-dot"
@@ -183,6 +184,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/opnsense" | "/docs/integrations/opnsense"
| "/docs/integrations/overseerr" | "/docs/integrations/overseerr"
| "/docs/integrations/paperless-ngx" | "/docs/integrations/paperless-ngx"
| "/docs/integrations/patchmon"
| "/docs/integrations/peanut" | "/docs/integrations/peanut"
| "/docs/integrations/pi-hole" | "/docs/integrations/pi-hole"
| "/docs/integrations/plex" | "/docs/integrations/plex"
@@ -196,9 +198,11 @@ export type HomarrDocumentationPath =
| "/docs/integrations/slskd" | "/docs/integrations/slskd"
| "/docs/integrations/sonarr" | "/docs/integrations/sonarr"
| "/docs/integrations/speedtest-tracker" | "/docs/integrations/speedtest-tracker"
| "/docs/integrations/synology"
| "/docs/integrations/tdarr" | "/docs/integrations/tdarr"
| "/docs/integrations/technitium-dns" | "/docs/integrations/technitium-dns"
| "/docs/integrations/tracearr" | "/docs/integrations/tracearr"
| "/docs/integrations/traefik"
| "/docs/integrations/transmission" | "/docs/integrations/transmission"
| "/docs/integrations/truenas" | "/docs/integrations/truenas"
| "/docs/integrations/umami" | "/docs/integrations/umami"
@@ -221,6 +225,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/anchor-note" | "/docs/widgets/anchor-note"
| "/docs/widgets/app" | "/docs/widgets/app"
| "/docs/widgets/audio-stats" | "/docs/widgets/audio-stats"
| "/docs/widgets/bazarr"
| "/docs/widgets/beszel-alerts" | "/docs/widgets/beszel-alerts"
| "/docs/widgets/beszel-system-grid" | "/docs/widgets/beszel-system-grid"
| "/docs/widgets/beszel-system-stats" | "/docs/widgets/beszel-system-stats"
@@ -240,6 +245,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/immich-album-carousel" | "/docs/widgets/immich-album-carousel"
| "/docs/widgets/immich-server-stats" | "/docs/widgets/immich-server-stats"
| "/docs/widgets/indexer-manager" | "/docs/widgets/indexer-manager"
| "/docs/widgets/media-missing"
| "/docs/widgets/media-releases" | "/docs/widgets/media-releases"
| "/docs/widgets/media-request-list" | "/docs/widgets/media-request-list"
| "/docs/widgets/media-request-stats" | "/docs/widgets/media-request-stats"
@@ -251,15 +257,18 @@ export type HomarrDocumentationPath =
| "/docs/widgets/notebook" | "/docs/widgets/notebook"
| "/docs/widgets/notifications" | "/docs/widgets/notifications"
| "/docs/widgets/paperless-ngx" | "/docs/widgets/paperless-ngx"
| "/docs/widgets/patchmon"
| "/docs/widgets/releases" | "/docs/widgets/releases"
| "/docs/widgets/rss-feed" | "/docs/widgets/rss-feed"
| "/docs/widgets/smart-home-entity-state" | "/docs/widgets/smart-home-entity-state"
| "/docs/widgets/smart-home-execute-automation" | "/docs/widgets/smart-home-execute-automation"
| "/docs/widgets/speedtest-tracker" | "/docs/widgets/speedtest-tracker"
| "/docs/widgets/stock-price" | "/docs/widgets/stock-price"
| "/docs/widgets/system-disks"
| "/docs/widgets/system-resources" | "/docs/widgets/system-resources"
| "/docs/widgets/timetable" | "/docs/widgets/timetable"
| "/docs/widgets/tracearr" | "/docs/widgets/tracearr"
| "/docs/widgets/traefik"
| "/docs/widgets/umami" | "/docs/widgets/umami"
| "/docs/widgets/ups" | "/docs/widgets/ups"
| "/docs/widgets/uptime-kuma" | "/docs/widgets/uptime-kuma"
@@ -72,14 +72,7 @@ export const AppForm = ({
} }
form.initialize(toFormValues(initialValues)); form.initialize(toFormValues(initialValues));
// `form` is a new object every render (Mantine useForm returns a fresh literal), }, [initialValuesKey, form, initialValues]);
// so it must not be a dep here — it would re-run every render and, since
// form.initialize() always calls clearErrors() (new {} object) internally,
// trigger an infinite update loop. initialValuesKey (a primitive string)
// already captures the only case this effect exists for: re-initializing
// when initialValues actually changes (e.g. navigating between edit pages).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValuesKey]);
// Debounce the name value with 200ms delay // Debounce the name value with 200ms delay
const [debouncedName] = useDebouncedValue(form.values.name, 200); const [debouncedName] = useDebouncedValue(form.values.name, 200);
@@ -46,7 +46,7 @@ const gluetunServerSelectionSchema = z.object({
isps: z.array(z.string()).nullable(), isps: z.array(z.string()).nullable(),
names: z.array(z.string()).nullable(), names: z.array(z.string()).nullable(),
numbers: z.array(z.number()).nullable(), numbers: z.array(z.number()).nullable(),
hostnames: z.array(z.string()).nullable(), hostnames: z.array(z.string()),
owned_only: z.boolean(), owned_only: z.boolean(),
free_only: z.boolean(), free_only: z.boolean(),
premium_only: z.boolean(), premium_only: z.boolean(),
@@ -65,7 +65,7 @@ const gluetunPortForwardingSchema = z.object({
status_file_path: z.string(), status_file_path: z.string(),
up_command: z.string(), up_command: z.string(),
down_command: z.string(), down_command: z.string(),
listening_port: z.union([z.number(), z.array(z.number())]), listening_port: z.number(),
username: z.string(), username: z.string(),
password: z.string(), password: z.string(),
}); });
@@ -98,7 +98,7 @@ const gluetunOpenvpnSettingsSchema = z.object({
const gluetunWireguardSettingsSchema = z.object({ const gluetunWireguardSettingsSchema = z.object({
private_key: z.string(), private_key: z.string(),
pre_shared_key: z.string(), pre_shared_key: z.string(),
addresses: z.array(z.string()).nullable(), addresses: z.array(z.string()),
allowed_ips: z.array(z.string()), allowed_ips: z.array(z.string()),
interface: z.string(), interface: z.string(),
persistent_keep_alive_interval: z.number(), persistent_keep_alive_interval: z.number(),
@@ -124,126 +124,6 @@ const VPN_SETTINGS_PAYLOAD = {
}, },
}; };
const ALT_VPN_SETTINGS_PAYLOAD = {
type: "openvpn",
provider: {
name: "custom",
server_selection: {
vpn: "openvpn",
countries: null,
categories: null,
regions: null,
cities: null,
isps: null,
names: null,
numbers: null,
hostnames: null,
owned_only: false,
free_only: false,
premium_only: false,
stream_only: false,
multi_hop_only: false,
port_forward_only: false,
secure_core_only: false,
tor_only: false,
openvpn: {
config_file_path: "/gluetun/custom.conf",
protocol: "tcp",
endpoint_ip: "0.0.0.0",
custom_port: 0,
pia_encryption_preset: "",
},
wireguard: {
endpoint_ip: "0.0.0.0",
endpoint_port: 0,
public_key: "",
},
},
port_forwarding: {
enabled: false,
provider: "",
status_file_path: "/tmp/gluetun/forwarded_port",
up_command: "",
down_command: "",
listening_port: [0],
ports_count: 0,
username: "",
password: "",
},
},
amneziawg: {
wireguard: {
private_key: "",
pre_shared_key: "",
addresses: null,
allowed_ips: ["0.0.0.0/0", "::/0"],
interface: "tun0",
persistent_keep_alive_interval: 0,
mtu: 0,
implementation: "userspace",
},
junk_packet_count: 0,
junk_packet_min: 0,
junk_packet_max: 0,
padding_s1: 0,
padding_s2: 0,
padding_s3: 0,
padding_s4: 0,
header_h1: "",
header_h2: "",
header_h3: "",
header_h4: "",
init_packet_i1: "",
init_packet_i2: "",
init_packet_i3: "",
init_packet_i4: "",
init_packet_i5: "",
},
openvpn: {
version: "2.6",
user: "",
password: "",
config_file_path: "/gluetun/custom.conf",
ciphers: null,
auth: "",
cert: "",
key: "",
encrypted_key: "",
key_passphrase: "",
pia_encryption_preset: "",
mssfix: 0,
interface: "tun0",
process_user: "nonrootuser",
verbosity: 1,
flags: null,
},
wireguard: {
private_key: "",
pre_shared_key: "",
addresses: null,
allowed_ips: ["0.0.0.0/0", "::/0"],
interface: "tun0",
persistent_keep_alive_interval: 0,
mtu: 0,
implementation: "auto",
},
pmtud: {
icmp_addresses: ["1.1.1.1", "8.8.8.8"],
tcp_addresses: [
"1.1.1.1:443",
"8.8.8.8:443",
"1.1.1.1:53",
"8.8.8.8:53",
"[2606:4700:4700::1111]:53",
"[2001:4860:4860::8888]:53",
"[2606:4700:4700::1111]:443",
"[2001:4860:4860::8888]:443",
],
},
up_command: "",
down_command: "",
};
const jsonResponse = (body: unknown, init: ResponseInit = { status: 200 }): FetchResponse => const jsonResponse = (body: unknown, init: ResponseInit = { status: 200 }): FetchResponse =>
new Response(JSON.stringify(body), { new Response(JSON.stringify(body), {
...init, ...init,
@@ -488,54 +368,4 @@ describe("gluetun schemas", () => {
test("vpn settings schema rejects a non-string type", () => { test("vpn settings schema rejects a non-string type", () => {
expect(() => gluetunVpnSettingsSchema.parse({ ...VPN_SETTINGS_PAYLOAD, type: 1 })).toThrow(); expect(() => gluetunVpnSettingsSchema.parse({ ...VPN_SETTINGS_PAYLOAD, type: 1 })).toThrow();
}); });
test("vpn settings schema parses the live gluetun-latest custom/OpenVPN payload", () => {
const parsed = gluetunVpnSettingsSchema.parse(ALT_VPN_SETTINGS_PAYLOAD);
expect(parsed.type).toBe("openvpn");
expect(parsed.provider.name).toBe("custom");
});
test("vpn settings schema accepts null server-selection hostnames", () => {
const payload = {
...VPN_SETTINGS_PAYLOAD,
provider: {
...VPN_SETTINGS_PAYLOAD.provider,
server_selection: {
...VPN_SETTINGS_PAYLOAD.provider.server_selection,
hostnames: null,
},
},
};
expect(() => gluetunVpnSettingsSchema.parse(payload)).not.toThrow();
});
test("vpn settings schema accepts listening_port as a number or a list", () => {
const asList = {
...VPN_SETTINGS_PAYLOAD,
provider: {
...VPN_SETTINGS_PAYLOAD.provider,
port_forwarding: {
...VPN_SETTINGS_PAYLOAD.provider.port_forwarding,
listening_port: [0],
},
},
};
// The happy-path fixture already exercises the scalar form (listening_port: 0).
expect(() => gluetunVpnSettingsSchema.parse(VPN_SETTINGS_PAYLOAD)).not.toThrow();
expect(() => gluetunVpnSettingsSchema.parse(asList)).not.toThrow();
});
test("vpn settings schema accepts null wireguard addresses", () => {
const payload = {
...VPN_SETTINGS_PAYLOAD,
wireguard: {
...VPN_SETTINGS_PAYLOAD.wireguard,
addresses: null,
},
};
expect(() => gluetunVpnSettingsSchema.parse(payload)).not.toThrow();
});
}); });
@@ -0,0 +1,141 @@
import { describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
process.env.ENABLE_DNS_CACHING = "false";
});
const controllerCtor = vi.fn();
const controllerLogin = vi.fn().mockResolvedValue(true);
vi.mock("@homarr/redis", () => ({
createGetSetChannel: () => ({
getAsync: vi.fn().mockResolvedValue(null),
setAsync: vi.fn().mockResolvedValue(undefined),
removeAsync: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
ErrorWithMetadata: class extends Error {},
}));
vi.mock("@homarr/core/infrastructure/http", () => ({
createCustomCheckServerIdentity: () => (() => undefined) as never,
fetchWithTrustedCertificatesAsync: vi.fn(),
createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}),
createCertificateAgentAsync: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@homarr/core/infrastructure/certificates", () => ({
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
}));
vi.mock("axios", () => ({
AxiosError: Error,
default: {
create: vi.fn().mockReturnValue({}),
},
}));
vi.mock("http-cookie-agent/http", () => ({
HttpCookieAgent: class {},
HttpsCookieAgent: class {},
}));
vi.mock("@homarr/node-unifi", () => {
class FakeController {
public options: Record<string, unknown>;
constructor(options: Record<string, unknown>) {
this.options = options;
controllerCtor(options);
}
public async login(): Promise<true> {
return await controllerLogin(this.options.port);
}
public async getSitesStats(): Promise<unknown[]> {
return [];
}
}
return { default: { Controller: FakeController } };
});
import { UnifiControllerIntegration } from "../unifi-controller-integration";
const createIntegration = (url: string) =>
new UnifiControllerIntegration({
id: "test-unifi",
name: "Test Unifi",
url,
externalUrl: null,
decryptedSecrets: [
{ kind: "username", value: "admin" },
{ kind: "password", value: "secret" },
],
});
describe("UnifiControllerIntegration port resolution", () => {
test("a URL without a port uses the UniFi OS port 443", async () => {
controllerCtor.mockClear();
controllerLogin.mockClear().mockResolvedValue(true);
await createIntegration("http://192.168.1.1").getNetworkSummaryAsync();
expect(controllerCtor).toHaveBeenCalledTimes(1);
expect(controllerCtor).toHaveBeenCalledWith(expect.objectContaining({ host: "192.168.1.1", port: 443 }));
});
test("a connection failure on port 443 falls back to the self-hosted controller port 8443", async () => {
controllerCtor.mockClear();
controllerLogin
.mockClear()
.mockRejectedValueOnce(Object.assign(new Error("connect ECONNREFUSED"), { isAxiosError: true }))
.mockResolvedValueOnce(true);
await createIntegration("http://controller.lan").getNetworkSummaryAsync();
expect(controllerCtor).toHaveBeenNthCalledWith(1, expect.objectContaining({ port: 443 }));
expect(controllerCtor).toHaveBeenNthCalledWith(2, expect.objectContaining({ port: 8443 }));
});
test("https://controller.lan:8443 keeps the user-specified port", async () => {
controllerCtor.mockClear();
controllerLogin.mockClear().mockResolvedValue(true);
await createIntegration("https://controller.lan:8443").getNetworkSummaryAsync();
expect(controllerCtor).toHaveBeenCalledWith(expect.objectContaining({ host: "controller.lan", port: 8443 }));
});
test("https://192.168.1.1:8443 keeps the user-specified port", async () => {
controllerCtor.mockClear();
controllerLogin.mockClear().mockResolvedValue(true);
await createIntegration("https://192.168.1.1:8443").getNetworkSummaryAsync();
expect(controllerCtor).toHaveBeenCalledWith(expect.objectContaining({ host: "192.168.1.1", port: 8443 }));
});
test("an unusual port like 10443 is honored verbatim", async () => {
controllerCtor.mockClear();
controllerLogin.mockClear().mockResolvedValue(true);
await createIntegration("https://192.168.1.1:10443").getNetworkSummaryAsync();
expect(controllerCtor).toHaveBeenCalledWith(expect.objectContaining({ host: "192.168.1.1", port: 10443 }));
});
test("an authentication failure on port 443 does not try another port", async () => {
controllerCtor.mockClear();
controllerLogin.mockClear().mockRejectedValueOnce(
Object.assign(new Error("Request failed with status code 401"), {
isAxiosError: true,
response: { status: 401 },
}),
);
await expect(createIntegration("https://controller.lan").getNetworkSummaryAsync()).rejects.toThrow();
expect(controllerCtor).toHaveBeenCalledTimes(1);
expect(controllerCtor).toHaveBeenCalledWith(expect.objectContaining({ port: 443 }));
});
});
@@ -2,7 +2,6 @@ import type tls from "node:tls";
import axios from "axios"; import axios from "axios";
import { HttpCookieAgent, HttpsCookieAgent } from "http-cookie-agent/http"; import { HttpCookieAgent, HttpsCookieAgent } from "http-cookie-agent/http";
import { getPortFromUrl } from "@homarr/common";
import { import {
getAllTrustedCertificatesAsync, getAllTrustedCertificatesAsync,
getTrustedCertificateHostnamesAsync, getTrustedCertificateHostnamesAsync,
@@ -67,26 +66,53 @@ export class UnifiControllerIntegration extends Integration implements NetworkCo
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()), checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
}; };
const client = new Unifi.Controller({ // ponytail: node-unifi always connects over HTTPS, regardless of the protocol in the
host: url.hostname, // integration URL. UniFi OS consoles use port 443 while self-hosted controllers normally
port: getPortFromUrl(url), // use 8443. If no port was provided, try both without hiding authentication errors.
username: this.getSecretValue("username"), const ports = url.port ? [Number(url.port)] : [443, 8443];
password: this.getSecretValue("password"), const createClientForPortAsync = async (port: number) => {
createAxiosInstance({ cookies }) { const client = new Unifi.Controller({
return axios.create({ host: url.hostname,
adapter: "http", port,
httpAgent: new HttpCookieAgent({ cookies }), username: this.getSecretValue("username"),
httpsAgent: new HttpsCookieAgent({ password: this.getSecretValue("password"),
cookies, createAxiosInstance({ cookies }) {
requestCert: true, return axios.create({
...certificateOptions, adapter: "http",
}), httpAgent: new HttpCookieAgent({ cookies }),
}); httpsAgent: new HttpsCookieAgent({
}, cookies,
}); requestCert: true,
...certificateOptions,
}),
});
},
});
await client.login(this.getSecretValue("username"), this.getSecretValue("password"), null); await client.login(this.getSecretValue("username"), this.getSecretValue("password"), null);
return client; return client;
};
for (const [index, port] of ports.entries()) {
try {
return await createClientForPortAsync(port);
} catch (error) {
const isLastPort = index === ports.length - 1;
if (isLastPort || !this.isPortFallbackError(error)) {
throw error;
}
}
}
throw new Error("Unable to connect to the UniFi controller");
}
private isPortFallbackError(error: unknown) {
if (typeof error === "object" && error !== null && "isAxiosError" in error && error.isAxiosError === true) {
return !("response" in error) || error.response === undefined;
}
return error instanceof Error && error.message === "failed to detect UniFiOS status";
} }
private getStatusValueOverAllSites<S extends HealthSubsystem>( private getStatusValueOverAllSites<S extends HealthSubsystem>(
+33 -33
View File
@@ -6,9 +6,9 @@
"subtitle": "", "subtitle": "",
"description": "", "description": "",
"action": { "action": {
"scratch": "Sākt no jauna", "scratch": "",
"importOldmarr": "", "importOldmarr": "",
"restoreSqlite": "SQLite rezerves kopija", "restoreSqlite": "",
"restoreSqliteDescription": "", "restoreSqliteDescription": "",
"backToOptions": "" "backToOptions": ""
} }
@@ -26,19 +26,19 @@
} }
}, },
"importSettings": { "importSettings": {
"title": "Importēt iestatījumus", "title": "",
"description": "" "description": ""
}, },
"boardSelection": { "boardSelection": {
"title": "", "title": "",
"description": "", "description": "",
"action": { "action": {
"selectAll": "Atzīmēt visu", "selectAll": "",
"unselectAll": "" "unselectAll": ""
} }
}, },
"summary": { "summary": {
"title": "Importēšanas kopsavilkums", "title": "",
"description": "", "description": "",
"action": { "action": {
"import": "" "import": ""
@@ -46,7 +46,7 @@
"entities": { "entities": {
"apps": "Lietotnes", "apps": "Lietotnes",
"boards": "Dēļi", "boards": "Dēļi",
"integrations": "Integrācijas", "integrations": "",
"credentialUsers": "" "credentialUsers": ""
} }
}, },
@@ -67,15 +67,15 @@
} }
}, },
"user": { "user": {
"title": "Administratora lietotājs", "title": "",
"subtitle": "", "subtitle": "",
"notification": { "notification": {
"success": { "success": {
"title": "Lietotājs izveidots", "title": "",
"message": "" "message": ""
}, },
"error": { "error": {
"title": "Lietotāja izveide neizdevās" "title": ""
} }
} }
}, },
@@ -94,19 +94,19 @@
"subtitle": "" "subtitle": ""
}, },
"integrations": { "integrations": {
"title": "Integrācijas", "title": "",
"subtitle": "", "subtitle": "",
"description": "", "description": "",
"action": { "action": {
"continue": "Turpināt", "continue": "",
"skip": "Pagaidām izlaist šo soli" "skip": ""
}, },
"configure": { "configure": {
"progress": "{current} no {total}", "progress": "",
"skip": "Izlaist šo integrāciju", "skip": "",
"finishing": "", "finishing": "",
"error": { "error": {
"title": "Neizdevās iestatīt!", "title": "",
"message": "" "message": ""
}, },
"helpers": { "helpers": {
@@ -115,7 +115,7 @@
} }
}, },
"urlTemplate": { "urlTemplate": {
"label": "Bāzes URL", "label": "",
"placeholder": "", "placeholder": "",
"hint": "", "hint": "",
"warning": "", "warning": "",
@@ -136,14 +136,14 @@
} }
}, },
"finish": { "finish": {
"title": "Pabeigt Iestatīšanu", "title": "",
"subtitle": "", "subtitle": "",
"description": "", "description": "",
"action": { "action": {
"goToBoard": "", "goToBoard": "",
"createBoard": "", "createBoard": "",
"inviteUser": "Ielūgt citus lietotājus", "inviteUser": "",
"docs": "Lasīt dokumentāciju" "docs": ""
} }
} }
}, },
@@ -158,7 +158,7 @@
"subtitle": "" "subtitle": ""
}, },
"invite": { "invite": {
"title": "Pievienoties Homarr", "title": "",
"subtitle": "", "subtitle": "",
"description": "" "description": ""
}, },
@@ -177,20 +177,20 @@
}, },
"password": { "password": {
"label": "Parole", "label": "Parole",
"suggestionsTitle": "Paroles ieteikumi", "suggestionsTitle": "",
"suggestion": { "suggestion": {
"length": "Vismaz 8 rakstzīmes", "length": "",
"lowercase": "Ietver mazo burtu", "lowercase": "Ietver mazo burtu",
"uppercase": "Ietver lielo burtu", "uppercase": "Ietver lielo burtu",
"number": "Ietver numuru", "number": "Ietver numuru",
"special": "Iekļauj speciālo simbolu" "special": ""
} }
}, },
"passwordConfirm": { "passwordConfirm": {
"label": "Apstipriniet paroli" "label": "Apstipriniet paroli"
}, },
"previousPassword": { "previousPassword": {
"label": "Iepriekšējā parole" "label": ""
}, },
"homeBoard": { "homeBoard": {
"label": "" "label": ""
@@ -218,10 +218,10 @@
"notification": { "notification": {
"success": { "success": {
"title": "", "title": "",
"message": "Jūs esat veiksmīgi pieteicies" "message": ""
}, },
"error": { "error": {
"title": "Pieteikšanās neizdevās", "title": "",
"message": "" "message": ""
} }
}, },
@@ -245,13 +245,13 @@
}, },
"create": "Izveidot lietotāju", "create": "Izveidot lietotāju",
"changePassword": { "changePassword": {
"label": "Mainīt paroli", "label": "",
"notification": { "notification": {
"success": { "success": {
"message": "Parole veiksmīgi nomainīta." "message": ""
}, },
"error": { "error": {
"message": "Nav iespējams nomainīt paroli" "message": ""
} }
} }
}, },
@@ -595,12 +595,12 @@
} }
}, },
"defaultGroup": { "defaultGroup": {
"name": "Noklusētā grupa", "name": "",
"description": "" "description": ""
} }
}, },
"app": { "app": {
"search": "Atrast aplikāciju", "search": "",
"page": { "page": {
"list": { "list": {
"title": "Lietotnes", "title": "Lietotnes",
@@ -689,7 +689,7 @@
"list": { "list": {
"title": "", "title": "",
"noResults": "", "noResults": "",
"loading": "Notiek ielāde..." "loading": ""
}, },
"create": { "create": {
"title": "", "title": "",
@@ -700,7 +700,7 @@
} }
}, },
"action": { "action": {
"create": "Izveidot", "create": "",
"import": "", "import": "",
"save": "", "save": "",
"delete": "", "delete": "",
+12 -12
View File
@@ -1599,7 +1599,7 @@
"menu": { "menu": {
"label": { "label": {
"settings": "Ustawienia", "settings": "Ustawienia",
"refresh": "Odśwież", "refresh": "",
"options": "Opcje", "options": "Opcje",
"actions": "Akcje" "actions": "Akcje"
} }
@@ -1904,7 +1904,7 @@
"noStation": "Nie wybrano stacji", "noStation": "Nie wybrano stacji",
"option": { "option": {
"baseUrl": { "baseUrl": {
"label": "Szukaj adresu URL" "label": ""
}, },
"station": { "station": {
"label": "Stacja" "label": "Stacja"
@@ -2791,8 +2791,8 @@
"description": "Wyświetl datę zdjęcia oraz bieżący indeks na dole pokazu slajdów" "description": "Wyświetl datę zdjęcia oraz bieżący indeks na dole pokazu slajdów"
}, },
"randomizePhotos": { "randomizePhotos": {
"label": "Losowe zdjęcia", "label": "",
"description": "Pokaż zdjęcia w losowej kolejności, zamiast zawsze rozpoczynać się od tej samej kolejności albumu." "description": ""
} }
}, },
"description": "Pokaz slajdów zdjęć z Twojego albumu w Immich", "description": "Pokaz slajdów zdjęć z Twojego albumu w Immich",
@@ -3158,7 +3158,7 @@
"label": "Dostawca" "label": "Dostawca"
}, },
"providerUrl": { "providerUrl": {
"label": "URL dostawcy" "label": ""
}, },
"identifier": { "identifier": {
"label": "Identyfikator", "label": "Identyfikator",
@@ -3209,11 +3209,11 @@
"tooltip": "Nie udało się rozpoznać dostawcy. Ustaw go ręcznie po zaimportowaniu obrazów." "tooltip": "Nie udało się rozpoznać dostawcy. Ustaw go ręcznie po zaimportowaniu obrazów."
}, },
"tokens": { "tokens": {
"label": "Tokeny dostawcy", "label": "",
"token": "Token", "token": "",
"configured": "Token skonfigurowany", "configured": "",
"notConfigured": "Brak ustawionych tokenów", "notConfigured": "",
"save": "Zapisz" "save": ""
} }
} }
}, },
@@ -3236,9 +3236,9 @@
"invalidIdentifier": "Nieprawidłowy identyfikator", "invalidIdentifier": "Nieprawidłowy identyfikator",
"noMatchingVersion": "Nie znaleziono pasującej wersji", "noMatchingVersion": "Nie znaleziono pasującej wersji",
"noReleasesFound": "Nie znaleziono wydań!", "noReleasesFound": "Nie znaleziono wydań!",
"noProviderSelected": "Nie wybrano dostawcy", "noProviderSelected": "",
"noProviderResponse": "Brak odpowiedzi od dostawcy", "noProviderResponse": "Brak odpowiedzi od dostawcy",
"unexpected": "Nieoczekiwany błąd dostawcy" "unexpected": ""
} }
} }
}, },
+1 -1
View File
@@ -3437,7 +3437,7 @@
"description": "" "description": ""
}, },
"maxNowPlayingItems": { "maxNowPlayingItems": {
"label": "Максимум елементів для відтворення", "label": "",
"description": "" "description": ""
}, },
"showLibraryCount": { "showLibraryCount": {
+727 -758
View File
File diff suppressed because it is too large. Load diff
+33 -37
View File
@@ -14,7 +14,7 @@ catalog:
"@dnd-kit/utilities": ^3.2.2 "@dnd-kit/utilities": ^3.2.2
"@drizzle-team/brocli": ^0.12.0 "@drizzle-team/brocli": ^0.12.0
"@extractus/feed-extractor": 7.2.1 "@extractus/feed-extractor": 7.2.1
"@gfazioli/mantine-onboarding-tour": ^4.0.6 '@gfazioli/mantine-onboarding-tour': ^4.0.6
"@gitbeaker/rest": ^43.8.0 "@gitbeaker/rest": ^43.8.0
"@homarr/gridstack": ^1.12.0 "@homarr/gridstack": ^1.12.0
"@homarr/node-unifi": ^2.6.0 "@homarr/node-unifi": ^2.6.0
@@ -33,23 +33,23 @@ catalog:
"@mantine/notifications": ^9.4.1 "@mantine/notifications": ^9.4.1
"@mantine/spotlight": ^9.4.1 "@mantine/spotlight": ^9.4.1
"@mantine/tiptap": ^9.4.1 "@mantine/tiptap": ^9.4.1
"@next/eslint-plugin-next": 16.2.10 "@next/eslint-plugin-next": 16.2.9
"@octokit/auth-app": ^8.2.0 "@octokit/auth-app": ^8.2.0
"@paralleldrive/cuid2": ^3.3.0 "@paralleldrive/cuid2": ^3.3.0
"@playwright/test": ^1.61.1 "@playwright/test": ^1.61.1
"@semantic-release/changelog": ^6.0.3 "@semantic-release/changelog": ^6.0.3
"@semantic-release/commit-analyzer": ^13.0.1 "@semantic-release/commit-analyzer": ^13.0.1
"@semantic-release/git": ^10.0.1 "@semantic-release/git": ^10.0.1
"@semantic-release/github": ^12.0.9 "@semantic-release/github": ^12.0.8
"@semantic-release/npm": ^13.1.5 "@semantic-release/npm": ^13.1.5
"@semantic-release/release-notes-generator": ^14.1.1 "@semantic-release/release-notes-generator": ^14.1.1
"@t3-oss/env-nextjs": ^0.13.11 "@t3-oss/env-nextjs": ^0.13.11
"@tabler/icons-react": ^3.44.0 "@tabler/icons-react": ^3.44.0
"@tanstack/query-async-storage-persister": ^5.101.2 "@tanstack/query-async-storage-persister": ^5.101.0
"@tanstack/react-query": ^5.101.2 "@tanstack/react-query": ^5.101.0
"@tanstack/react-query-devtools": ^5.101.2 "@tanstack/react-query-devtools": ^5.101.0
"@tanstack/react-query-next-experimental": ^5.101.2 "@tanstack/react-query-next-experimental": ^5.101.0
"@tanstack/react-query-persist-client": ^5.101.2 "@tanstack/react-query-persist-client": ^5.101.0
"@testcontainers/mysql": ^12.0.4 "@testcontainers/mysql": ^12.0.4
"@testcontainers/postgresql": ^12.0.4 "@testcontainers/postgresql": ^12.0.4
"@testcontainers/redis": ^12.0.4 "@testcontainers/redis": ^12.0.4
@@ -69,12 +69,12 @@ catalog:
"@tiptap/extension-underline": 3.27.1 "@tiptap/extension-underline": 3.27.1
"@tiptap/react": ^3.27.1 "@tiptap/react": ^3.27.1
"@tiptap/starter-kit": ^3.27.1 "@tiptap/starter-kit": ^3.27.1
"@trpc/client": ^11.18.0 "@trpc/client": ^11.17.0
"@trpc/next": ^11.18.0 "@trpc/next": ^11.17.0
"@trpc/react-query": ^11.18.0 "@trpc/react-query": ^11.17.0
"@trpc/server": ^11.18.0 "@trpc/server": ^11.17.0
"@trpc/tanstack-react-query": ^11.18.0 "@trpc/tanstack-react-query": ^11.17.0
"@turbo/gen": ^2.10.2 "@turbo/gen": ^2.10.0
"@types/adm-zip": 0.5.8 "@types/adm-zip": 0.5.8
"@types/bcrypt": 6.0.0 "@types/bcrypt": 6.0.0
"@types/better-sqlite3": 7.6.13 "@types/better-sqlite3": 7.6.13
@@ -89,29 +89,29 @@ catalog:
"@types/prismjs": ^1.26.6 "@types/prismjs": ^1.26.6
"@types/react": 19.2.17 "@types/react": 19.2.17
"@types/react-dom": 19.2.3 "@types/react-dom": 19.2.3
"@scalar/api-reference-react": ^0.9.51 "@scalar/api-reference-react": ^0.9.49
"@types/video.js": ^7.3.58 "@types/video.js": ^7.3.58
"@types/ws": ^8.18.1 "@types/ws": ^8.18.1
"@types/xml2js": ^0.4.14 "@types/xml2js": ^0.4.14
posthog-js: ^1.396.4 posthog-js: ^1.396.2
posthog-node: ^5.39.2 posthog-node: ^4.18.0
"@vitejs/plugin-react": ^6.0.3 "@vitejs/plugin-react": ^6.0.3
"@vitest/coverage-v8": ^4.1.9 "@vitest/coverage-v8": ^4.1.9
"@vitest/ui": ^4.1.9 "@vitest/ui": ^4.1.9
"@xterm/addon-canvas": ^0.7.0 "@xterm/addon-canvas": ^0.7.0
"@xterm/addon-fit": 0.11.0 "@xterm/addon-fit": 0.11.0
"@xterm/xterm": ^6.0.0 "@xterm/xterm": ^6.0.0
adm-zip: 0.5.18 adm-zip: 0.5.17
jszip: ^3.10.1 jszip: ^3.10.1
sql.js: ^1.14.1 sql.js: ^1.14.1
babel-plugin-react-compiler: ^1.0.0 babel-plugin-react-compiler: ^1.0.0
bcrypt: ^6.0.0 bcrypt: ^6.0.0
better-sqlite3: ^12.11.1 better-sqlite3: ^12.10.0
chroma-js: ^3.2.0 chroma-js: ^3.2.0
clsx: ^2.1.1 clsx: ^2.1.1
concurrently: ^10.0.3 concurrently: ^10.0.3
conventional-changelog-conventionalcommits: ^10.2.0 conventional-changelog-conventionalcommits: ^9.3.1
cookie: 2.0.1 cookie: 1.1.1
cookies: ^0.9.1 cookies: ^0.9.1
croner: 10.0.1 croner: 10.0.1
cross-env: ^10.1.0 cross-env: ^10.1.0
@@ -139,20 +139,20 @@ catalog:
jsonpath-plus: ^10.4.0 jsonpath-plus: ^10.4.0
ldapts: 8.1.8 ldapts: 8.1.8
lodash.clonedeep: ^4.5.0 lodash.clonedeep: ^4.5.0
lucide-react: ^1.23.0 lucide-react: ^1.22.0
"mantine-contextmenu": ^9.3.1 "mantine-contextmenu": ^9.3.1
mantine-datatable: ^9.3.1 mantine-datatable: ^9.3.1
mantine-react-table: npm:mantine-react-table-open@9.0.3 mantine-react-table: npm:mantine-react-table-open@9.0.3
mcp-handler: ^1.1.0 mcp-handler: ^1.1.0
maria2: ^0.4.1 maria2: ^0.4.1
mysql2: 3.22.5 mysql2: 3.22.5
next: 16.2.10 next: 16.2.9
next-auth: 5.0.0-beta.31 next-auth: 5.0.0-beta.31
next-intl: 4.13.1 next-intl: 4.13.0
node-loader: ^2.1.0 node-loader: ^2.1.0
octokit: ^5.0.5 octokit: ^5.0.5
oxfmt: ^0.57.0 oxfmt: ^0.56.0
oxlint: ^1.72.0 oxlint: ^1.71.0
pg: ^8.22.0 pg: ^8.22.0
postcss-preset-mantine: ^1.18.0 postcss-preset-mantine: ^1.18.0
prismjs: ^1.30.0 prismjs: ^1.30.0
@@ -164,7 +164,7 @@ catalog:
react-markdown: ^10.1.0 react-markdown: ^10.1.0
react-quill-new: ^3.8.3 react-quill-new: ^3.8.3
react-simple-code-editor: ^0.14.1 react-simple-code-editor: ^0.14.1
recharts: ^3.9.1 recharts: ^3.9.0
sass: ^1.101.0 sass: ^1.101.0
semantic-release: ^25.0.5 semantic-release: ^25.0.5
semver-parser: ^4.2.1 semver-parser: ^4.2.1
@@ -174,11 +174,11 @@ catalog:
timezones-ical-library: ^2.2.1 timezones-ical-library: ^2.2.1
trpc-to-mcp: ^1.3.2 trpc-to-mcp: ^1.3.2
trpc-to-openapi: ^3.3.0 trpc-to-openapi: ^3.3.0
tsdav: ^2.3.0 tsdav: ^2.2.2
tsx: 4.20.4 tsx: 4.20.4
turbo: ^2.10.2 turbo: ^2.10.0
typescript: ^6.0.3 typescript: ^6.0.3
vite: ^8.1.2 vite: ^8.1.0
undici: 7.28.0 undici: 7.28.0
use-deep-compare-effect: ^1.8.1 use-deep-compare-effect: ^1.8.1
video.js: ^8.23.9 video.js: ^8.23.9
@@ -191,7 +191,6 @@ catalog:
zod: ^4.4.3 zod: ^4.4.3
zod-form-data: ^3.0.2 zod-form-data: ^3.0.2
zod-validation-error: ^5.0.0 zod-validation-error: ^5.0.0
catalogMode: strict catalogMode: strict
nodeLinker: hoisted nodeLinker: hoisted
strictPeerDependencies: false strictPeerDependencies: false
@@ -208,32 +207,29 @@ minimumReleaseAgeExclude:
- form-data@4.0.6 - form-data@4.0.6
# Renovate security update: undici@7.28.0 # Renovate security update: undici@7.28.0
- undici@7.28.0 - undici@7.28.0
# Renovate security update: esbuild@0.28.1
- esbuild@0.28.1
overrides: overrides:
"@babel/helpers@<7.26.10": ">=7.29.7" "@babel/helpers@<7.26.10": ">=7.29.7"
"@babel/runtime@<7.26.10": ">=7.29.7" "@babel/runtime@<7.26.10": ">=7.29.7"
"axios@>=1.0.0 <1.8.2": ">=1.18.1" "axios@>=1.0.0 <1.8.2": ">=1.18.1"
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.7" "brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.7"
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.7" "brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.7"
"esbuild@<=0.24.2": ">=0.28.1" "esbuild@<=0.24.2": ">=0.28.0"
"form-data@>=4.0.0 <4.0.4": ">=4.0.6" "form-data@>=4.0.0 <4.0.4": ">=4.0.6"
"hono@<4.6.5": ">=4.12.27" "hono@<4.6.5": ">=4.12.27"
"linkifyjs@<4.3.2": ">=4.3.3" "linkifyjs@<4.3.2": ">=4.3.3"
"nanoid@>=4.0.0 <5.0.9": ">=5.1.16" "nanoid@>=4.0.0 <5.0.9": ">=5.1.16"
"prismjs@<1.30.0": ">=1.30.0" "prismjs@<1.30.0": ">=1.30.0"
"proxmox-api>undici": "7.28.0" "proxmox-api>undici": "7.27.1"
"react-is": "^19.2.7" "react-is": "^19.2.7"
"rollup@>=4.0.0 <4.22.4": ">=4.62.2" "rollup@>=4.0.0 <4.22.4": ">=4.62.2"
"sha.js@<=2.4.11": ">=2.4.12" "sha.js@<=2.4.11": ">=2.4.12"
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.3" "tar-fs@>=3.0.0 <3.0.9": ">=3.1.3"
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.3" "tar-fs@>=2.0.0 <2.1.3": ">=3.1.3"
"tmp@<=0.2.3": ">=0.2.7" "tmp@<=0.2.3": ">=0.2.7"
"vite@>=5.0.0 <=5.4.18": ">=8.1.3" "vite@>=5.0.0 <=5.4.18": ">=8.1.0"
patchedDependencies: patchedDependencies:
"@types/node-unifi": "patches/@types__node-unifi.patch" "@types/node-unifi": "patches/@types__node-unifi.patch"
"trpc-to-openapi": "patches/trpc-to-openapi.patch" "trpc-to-openapi": "patches/trpc-to-openapi.patch"
allowUnusedPatches: true allowUnusedPatches: true
allowBuilds: allowBuilds:
"@tree-sitter-grammars/tree-sitter-yaml": true "@tree-sitter-grammars/tree-sitter-yaml": true