Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46ae756d15 | ||
|
|
8b63799f1b | ||
|
|
60a69e31ef | ||
|
|
57ef0af4a2 | ||
|
|
597b5fbc8d | ||
|
|
5412577519 | ||
|
|
ab9aca8b60 | ||
|
|
dcde2819eb | ||
|
|
abd16354bc | ||
|
|
6ac352c520 | ||
|
|
19f9769d9e | ||
|
|
f0b2486c8a | ||
|
|
2443b89b46 | ||
|
|
915c8a3501 | ||
|
|
a2bb53a8b8 | ||
|
|
f56a05bd8e | ||
|
|
6051b17401 | ||
|
|
29eed08408 | ||
|
|
bd74fe4a61 | ||
|
|
b88e8861e1 | ||
|
|
39af3ec7e6 | ||
|
|
7f50be3dbb | ||
|
|
b6b7bea1dd | ||
|
|
9bde1dfbd6 | ||
|
|
c2712ed7f1 | ||
|
|
16be7ece33 | ||
|
|
b0a46186d1 | ||
|
|
d0385a4480 | ||
|
|
3d6a3594a3 | ||
|
|
641ee18278 | ||
|
|
2e538bf40d | ||
|
|
1c0a94a6dc | ||
|
|
3a3dc449de | ||
|
|
d6b156b8ee | ||
|
|
fc4e31fb42 | ||
|
|
b82b22431b | ||
|
|
510cba295a | ||
|
|
56d838dafc | ||
|
|
eae9f0438c | ||
|
|
b81f729bfd | ||
|
|
86b83c5425 | ||
|
|
bbc4589c9e | ||
|
|
ea9dd716f0 | ||
|
|
4383da38ef | ||
|
|
58cb65cb3a | ||
|
|
89380f81dd | ||
|
|
e01571b221 | ||
|
|
49c266374d | ||
|
|
577950d20e | ||
|
|
82bf71b073 | ||
|
|
e26883acfa | ||
|
|
8ee747cde8 | ||
|
|
faa7e55b1b | ||
|
|
ce021a89ac | ||
|
|
55fb227305 | ||
|
|
39e424563a | ||
|
|
e29c79a902 | ||
|
|
a19c9d88ca | ||
|
|
1fb97ee9c6 | ||
|
|
f69e545d64 | ||
|
|
52df56b19d | ||
|
|
0f66bfd6c1 | ||
|
|
842c5a0b4c | ||
|
|
ce9134dee2 | ||
|
|
9ce9be6b7e | ||
|
|
7ec22411a6 | ||
|
|
d3ae06e515 | ||
|
|
d09a4f8468 | ||
|
|
bcaa93f90b | ||
|
|
eef06dd5a3 | ||
|
|
84e044f427 | ||
|
|
609ab9976a | ||
|
|
28f570aa79 | ||
|
|
5b4f03da35 |
No files matched your search
@@ -33,6 +33,7 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.67.0
|
||||
- 1.66.1
|
||||
- 1.66.0
|
||||
- 1.65.0
|
||||
|
||||
@@ -163,3 +163,65 @@ 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"
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
- uses: actions/setup-node@v6
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
with:
|
||||
node-version: 24.16.0
|
||||
node-version: 24.17.0
|
||||
package-manager-cache: false
|
||||
- run: npm i -g pnpm
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
|
||||
@@ -3,6 +3,7 @@ name: "[Deployment] Automatic Weekly Release"
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5
|
||||
timezone: Europe/Zurich
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
send-notifications:
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
[tools]
|
||||
node = "24.16.0"
|
||||
node = "24.17.0"
|
||||
@@ -1,3 +1,49 @@
|
||||
## [1.68.1](https://github.com/homarr-labs/homarr/compare/v1.68.0...v1.68.1) (2026-06-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* make source maps opt-in to restore v1.67.0 memory baseline ([57ef0af](https://github.com/homarr-labs/homarr/commit/57ef0af4a252ea39727caeba7e13587dabc6254e)), closes [#6123](https://github.com/homarr-labs/homarr/issues/6123)
|
||||
* revert production source maps to restore v1.67.0 memory baseline ([60a69e3](https://github.com/homarr-labs/homarr/commit/60a69e31efc7168b8f4309daf75954d7730d883b)), closes [#6123](https://github.com/homarr-labs/homarr/issues/6123)
|
||||
|
||||
## [1.68.0](https://github.com/homarr-labs/homarr/compare/v1.67.0...v1.68.0) (2026-06-26)
|
||||
|
||||
### Features
|
||||
|
||||
* add VPN status widget ([#5812](https://github.com/homarr-labs/homarr/issues/5812)) ([a2bb53a](https://github.com/homarr-labs/homarr/commit/a2bb53a8b8fd17da3d2e23851b75d4e179f66225))
|
||||
* re-add Docker quick-access button in user menu ([#6054](https://github.com/homarr-labs/homarr/issues/6054)) ([abd1635](https://github.com/homarr-labs/homarr/commit/abd16354bc9ef3dddbb497fd16b03476535046ac)), closes [#2165](https://github.com/homarr-labs/homarr/issues/2165)
|
||||
* ship source maps for readable production stack traces ([#6031](https://github.com/homarr-labs/homarr/issues/6031)) ([89380f8](https://github.com/homarr-labs/homarr/commit/89380f81ddd132f5b1041a8f2c3b6c6b138923dd))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **beszel:** harden cache layer and prevent blank widgets ([#6052](https://github.com/homarr-labs/homarr/issues/6052)) ([49c2663](https://github.com/homarr-labs/homarr/commit/49c266374df12fea2404710232ced42e2de11dbf)), closes [#6030](https://github.com/homarr-labs/homarr/issues/6030)
|
||||
* **beszel:** improve stats date display, add click-to-stats modal, fix grid ordering ([#6026](https://github.com/homarr-labs/homarr/issues/6026)) ([55fb227](https://github.com/homarr-labs/homarr/commit/55fb227305fe51963e486ffb25c4ce6811a2f0e4))
|
||||
* **beszel:** leverage TanStack Query for stale-while-revalidate caching ([#6055](https://github.com/homarr-labs/homarr/issues/6055)) ([bbc4589](https://github.com/homarr-labs/homarr/commit/bbc4589c9e1608721fb58a6f9b03cca8d65c1c4a))
|
||||
* **beszel:** use 480m interval for 1w/30d stats views ([4383da3](https://github.com/homarr-labs/homarr/commit/4383da38ef634327c288626c58480cc1e5a2f805))
|
||||
* **beszel:** widget transparency, error handling, and cache invalidation ([#5993](https://github.com/homarr-labs/homarr/issues/5993)) ([39e4245](https://github.com/homarr-labs/homarr/commit/39e424563a3187dcf0dba5f654e2225be76aef51))
|
||||
* **icon-picker:** keep URL visible in edit mode, add SVG-first ordering ([#6051](https://github.com/homarr-labs/homarr/issues/6051)) ([e01571b](https://github.com/homarr-labs/homarr/commit/e01571b221812cd19a6d67ebe3cf44432104ec27))
|
||||
* **integrations:** prevent certificate trust button from submitting the form ([#6020](https://github.com/homarr-labs/homarr/issues/6020)) ([0f66bfd](https://github.com/homarr-labs/homarr/commit/0f66bfd6c128253ba644d80d7c908de5314cf997))
|
||||
* **manage/about:** bypass self-fetch + add manage error boundary (LLM exploration) ([#6009](https://github.com/homarr-labs/homarr/issues/6009)) ([19f9769](https://github.com/homarr-labs/homarr/commit/19f9769d9e669d56d793e9f6a0d265316113d743)), closes [#6008](https://github.com/homarr-labs/homarr/issues/6008) [#6008](https://github.com/homarr-labs/homarr/issues/6008) [#6008](https://github.com/homarr-labs/homarr/issues/6008)
|
||||
* **plex:** use series title for media releases instead of season label ([#6083](https://github.com/homarr-labs/homarr/issues/6083)) ([2443b89](https://github.com/homarr-labs/homarr/commit/2443b89b46f88fddbf41f0382a9f3bc5469c6fa8))
|
||||
* show app first in add item modal ([#6087](https://github.com/homarr-labs/homarr/issues/6087)) ([915c8a3](https://github.com/homarr-labs/homarr/commit/915c8a3501551fdccf786d0c34dc01107d873ce0))
|
||||
* **truenas:** report actual memory usage and pool free space ([#6017](https://github.com/homarr-labs/homarr/issues/6017)) ([f69e545](https://github.com/homarr-labs/homarr/commit/f69e545d6429aece0e55975c3b2426f20e91e545))
|
||||
* **unraid:** report disk free space instead of total size ([#6018](https://github.com/homarr-labs/homarr/issues/6018)) ([1fb97ee](https://github.com/homarr-labs/homarr/commit/1fb97ee9c6fbe656a7dd3a9d1e436125982533f4))
|
||||
* **widgets:** clean up health monitoring disk and size display ([#6019](https://github.com/homarr-labs/homarr/issues/6019)) ([e29c79a](https://github.com/homarr-labs/homarr/commit/e29c79a9028d9311dcb3d8323e0950a444589c7a))
|
||||
* **widgets:** hide health monitoring default tab option for non-proxmox integrations ([#6021](https://github.com/homarr-labs/homarr/issues/6021)) ([52df56b](https://github.com/homarr-labs/homarr/commit/52df56b19da8e0bcb6c2e5df47b7936ec63f3149))
|
||||
* **widgets:** hide system disk usage value when it overflows and show it in a tooltip ([#6022](https://github.com/homarr-labs/homarr/issues/6022)) ([a19c9d8](https://github.com/homarr-labs/homarr/commit/a19c9d88caacc86b42854c732818f0b5e5cc8dd5))
|
||||
|
||||
## [1.67.0](https://github.com/homarr-labs/homarr/compare/v1.66.1...v1.67.0) (2026-06-19)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** migrate from Umami to PostHog server-side analytics ([#5983](https://github.com/homarr-labs/homarr/issues/5983)) ([d059023](https://github.com/homarr-labs/homarr/commit/d059023b51a323df027c1cf033454440bd0d7e57))
|
||||
* Calculate and display total CPU and memory usage ([#5911](https://github.com/homarr-labs/homarr/issues/5911)) ([d9cbd54](https://github.com/homarr-labs/homarr/commit/d9cbd5427d0e5be3e5dbecca8d6069dc8074d810))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** isolate session cookies from other Auth.js apps on same host ([#5996](https://github.com/homarr-labs/homarr/issues/5996)) ([e1a245f](https://github.com/homarr-labs/homarr/commit/e1a245ff2d8520d08957c4a7dbe01d039607a973))
|
||||
* **deps:** update tailwindcss monorepo to ^4.3.1 ([#5988](https://github.com/homarr-labs/homarr/issues/5988)) ([78b6e52](https://github.com/homarr-labs/homarr/commit/78b6e520ac095129398d3da8f2441df408e93e9f))
|
||||
* **downloads:** sort stalled torrents last when sorting by finish time ([#6011](https://github.com/homarr-labs/homarr/issues/6011)) ([c56d9d6](https://github.com/homarr-labs/homarr/commit/c56d9d6894c717b5c70ab75d6a100d4d1071c2d3)), closes [#5416](https://github.com/homarr-labs/homarr/issues/5416)
|
||||
|
||||
## [1.65.0](https://github.com/homarr-labs/homarr/compare/v1.64.0...v1.65.0) (2026-06-12)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# ── CODEOWNERS ───────────────────────────────────────
|
||||
* @homarr-labs/maintainers
|
||||
|
||||
# Specific features / integrations
|
||||
# Glueten
|
||||
apps/docs/docs/integrations/glueten @Lok3sh-kumar
|
||||
packages/integrations/src/gluetun @Lok3sh-kumar
|
||||
|
||||
# Exempt Renovate‑managed files (no owners)
|
||||
package.json
|
||||
**/package.json
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:24.16.0-alpine AS base
|
||||
FROM node:24.17.0-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A Helm chart to deploy homarr for Kubernetes
|
||||
|
||||
@@ -383,6 +383,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
||||
| containerPorts | object | `{"http":{"port":7575,"protocol":"TCP"}}` | containerPorts defines the ports to open on the container. It is a map where each entry specifies: - `port` (int) (required): The port number to expose inside the container. - `protocol` (string) (required): The network protocol (TCP or UDP) used for the port. - `disabled` (bool) : Optional flag to disable this port (defaults to false). Can be overridden via Helm values. By default, this configuration exposes TCP port 7575 with the name `http`. |
|
||||
| database.migrationEnabled | bool | `true` | Database migration configuration. DB_MIGRATIONS_DISABLED Set to `true` to disable database migrations. Migrations are enabled by default (`false`). |
|
||||
| database.type | string | `"sqlite"` | Database type: sqlite, mysql or postgresql |
|
||||
| env.AUTH_COOKIE_PREFIX | string | `"homarr"` | Prefix used for all authentication cookies. Change if you run another Auth.js/NextAuth app on the same hostname to avoid cookie name collisions. Only letters, numbers, hyphens and underscores. |
|
||||
| env.AUTH_LDAP_BASE | string | `nil` | Base dn of your LDAP server |
|
||||
| env.AUTH_LDAP_BIND_DN | string | `nil` | User used for finding users and groups |
|
||||
| env.AUTH_LDAP_GROUP_CLASS | string | `"groupOfUniqueNames"` | Class used for querying groups |
|
||||
@@ -395,7 +396,6 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
||||
| env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG | string | `nil` | Extra arguments for user search filter (& based) |
|
||||
| env.AUTH_LDAP_USER_MAIL_ATTRIBUTE | string | `"mail"` | Attribute used for mail field |
|
||||
| env.AUTH_LOGOUT_REDIRECT_URL | string | `nil` | URL to redirect to after clicking logging out. |
|
||||
| env.AUTH_COOKIE_PREFIX | string | `"homarr"` | Prefix used for all authentication cookies. Change if you run another Auth.js/NextAuth app on the same hostname to avoid cookie name collisions. Only letters, numbers, hyphens and underscores. |
|
||||
| env.AUTH_OIDC_AUTO_LOGIN | string | `"false"` | Automatically redirect to OIDC login |
|
||||
| env.AUTH_OIDC_CLIENT_NAME | string | `"OIDC"` | Display name of provider (in login screen) |
|
||||
| env.AUTH_OIDC_GROUPS_ATTRIBUTE | string | `"groups"` | Attribute used for groups (roles) claim |
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
title: 'Gluetun'
|
||||
description: 'Connect Gluetun to Homarr as a VPN integration.'
|
||||
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 { gluetunIntegration } from '.';
|
||||
import { vpnWidget } from '@site/docs/widgets/vpn';
|
||||
|
||||
<IntegrationHeader
|
||||
integration={gluetunIntegration}
|
||||
categories={['VPN', 'Network']}
|
||||
/>
|
||||
|
||||
[Gluetun](https://github.com/qdm12/gluetun) exposes a control server that Homarr can query to report the VPN tunnel
|
||||
status, DNS status, public IP address, and active provider details in the VPN widget.
|
||||
|
||||
### Widgets & Capabilities
|
||||
|
||||
<IntegrationCapabilites
|
||||
items={[{
|
||||
widget: vpnWidget,
|
||||
}]}
|
||||
/>
|
||||
|
||||
### Setting up the Gluetun control server
|
||||
|
||||
Refer to the [Gluetun control server documentation](https://github.com/qdm12/gluetun-wiki/blob/main/setup/advanced/control-server.md#authentication) to enable and configure authentication.
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
|
||||
### Secrets
|
||||
|
||||
Gluetun's control server can be left unprotected or secured with either an API key or HTTP Basic authentication.
|
||||
Choose the option that matches how your Gluetun instance integration is set up.
|
||||
|
||||
<IntegrationSecrets secrets={[{
|
||||
credentials: [],
|
||||
steps: [],
|
||||
},{
|
||||
credentials: ['username', 'password'],
|
||||
steps: [],
|
||||
},{
|
||||
credentials: ['apiKey'],
|
||||
steps: [],
|
||||
},,]} />
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const gluetunIntegration: IntegrationDefinition = {
|
||||
name: "Gluetun",
|
||||
description: "VPN control server integration for Homarr's VPN widget.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gluetun.svg",
|
||||
path: "../../integrations/gluetun",
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: 'VPN'
|
||||
description: 'Monitor the status of your VPN integrations.'
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||
import { vpnWidget } from '.';
|
||||
import { gluetunIntegration } from '@site/docs/integrations/gluetun';
|
||||
|
||||
<WidgetHeader
|
||||
widget={vpnWidget}
|
||||
categories={['Network', 'Security']}
|
||||
/>
|
||||
|
||||
The VPN widget provides a compact overview of each configured VPN integration. It shows whether the tunnel and DNS
|
||||
services are up, the currently exposed public IP address, and the VPN provider and protocol being used.
|
||||
|
||||
When multiple VPN integrations are attached, the widget renders one card per integration so you can compare their
|
||||
current status at a glance.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import singleIntegrationImage from './img/single.png';
|
||||
|
||||
<div>
|
||||
<img src={singleIntegrationImage} alt="VPN widget showing a single integration" width={128 * 3} />
|
||||
</div>
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations items={[{
|
||||
integration: gluetunIntegration,
|
||||
}]} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
@@ -0,0 +1,9 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconShieldLock } from "@tabler/icons-react";
|
||||
|
||||
export const vpnWidget: WidgetDefinition = {
|
||||
icon: IconShieldLock,
|
||||
name: "VPN",
|
||||
description: "Monitor the connection status, public IP, and provider details of your VPN integrations.",
|
||||
path: "../../widgets/vpn",
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { headers } from "next/headers";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
Accordion,
|
||||
@@ -41,11 +40,10 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import type { PackageJsonDependencies } from "~/versions/package-reader";
|
||||
import { getPackageVersion } from "~/versions/package-reader";
|
||||
import type githubContributorsJson from "../../../../../../../static-data/contributors.json";
|
||||
import type openCollectiveContributorsJson from "../../../../../../../static-data/opencollective-contributors.json";
|
||||
import type crowdinContributorsJson from "../../../../../../../static-data/translators.json";
|
||||
import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader";
|
||||
import githubContributors from "@static-data/contributors.json";
|
||||
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
|
||||
import crowdinContributors from "@static-data/translators.json";
|
||||
import classes from "./about.module.css";
|
||||
|
||||
export async function generateMetadata() {
|
||||
@@ -56,32 +54,10 @@ export async function generateMetadata() {
|
||||
};
|
||||
}
|
||||
|
||||
const getHostAsync = async () => {
|
||||
if (process.env.HOSTNAME) {
|
||||
return `${process.env.HOSTNAME}:3000`;
|
||||
}
|
||||
|
||||
return (await headers()).get("host");
|
||||
};
|
||||
|
||||
export default async function AboutPage() {
|
||||
const baseServerUrl = `http://${await getHostAsync()}`;
|
||||
const t = await getScopedI18n("management.page.about");
|
||||
const version = getPackageVersion();
|
||||
const dependencies = (await fetch(`${baseServerUrl}/api/about/dependencies`).then((res) =>
|
||||
res.json(),
|
||||
)) as PackageJsonDependencies;
|
||||
const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) =>
|
||||
res.json(),
|
||||
)) as typeof githubContributorsJson;
|
||||
|
||||
const crowdinContributors = (await fetch(`${baseServerUrl}/api/about/contributors/crowdin`).then((res) =>
|
||||
res.json(),
|
||||
)) as typeof crowdinContributorsJson;
|
||||
|
||||
const openCollectiveContributors = (await fetch(`${baseServerUrl}/api/about/contributors/opencollective`).then(
|
||||
(res) => res.json(),
|
||||
)) as typeof openCollectiveContributorsJson;
|
||||
const dependencies = await getDependenciesAsync();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -185,7 +161,7 @@ export default async function AboutPage() {
|
||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3, lg: 4, xl: 5 }} spacing="md">
|
||||
{Object.entries(dependencies)
|
||||
.filter(([, value]) => !value.includes("workspace:"))
|
||||
.sort(([key1], [key2]) => key1.localeCompare(key2))
|
||||
.toSorted(([key1], [key2]) => key1.localeCompare(key2))
|
||||
.map(([name, version]) => (
|
||||
<UnstyledButton
|
||||
key={name}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Button, Center, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export default function ManageErrorPage({
|
||||
error,
|
||||
unstable_retry,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
unstable_retry: () => void;
|
||||
}) {
|
||||
const tError = useScopedI18n("management.error");
|
||||
const t = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconAlertTriangle size={48} stroke={1.5} />
|
||||
<Title order={3}>{tError("title")}</Title>
|
||||
<Text size="sm" c="dimmed" maw={480} ta="center">
|
||||
{tError("text")}
|
||||
</Text>
|
||||
{error.digest && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{tError("digest", { digest: error.digest })}
|
||||
</Text>
|
||||
)}
|
||||
<Button variant="light" onClick={unstable_retry}>
|
||||
{t("common.action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
+2
@@ -131,6 +131,7 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
|
||||
{(error.data.reason === "untrusted" && rootCertificate.isSelfSigned) ||
|
||||
error.data.reason === "hostnameMismatch" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
fullWidth
|
||||
onClick={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
|
||||
@@ -140,6 +141,7 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
|
||||
) : null}
|
||||
{error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
fullWidth
|
||||
onClick={() =>
|
||||
|
||||
@@ -149,16 +149,20 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
|
||||
},
|
||||
];
|
||||
|
||||
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
|
||||
interface DockerTableProps {
|
||||
initialData?: RouterOutputs["docker"]["getContainers"];
|
||||
}
|
||||
|
||||
export function DockerTable({ initialData }: DockerTableProps) {
|
||||
const t = useI18n();
|
||||
const tDocker = useScopedI18n("docker");
|
||||
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||
initialData: { containers, timestamp },
|
||||
const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||
initialData,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const relativeTime = useTimeAgo(data.timestamp);
|
||||
const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
||||
async onSuccess() {
|
||||
@@ -176,8 +180,11 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
||||
},
|
||||
});
|
||||
|
||||
const containers = data?.containers ?? [];
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data: data.containers,
|
||||
data: containers,
|
||||
state: { isLoading: isFetching },
|
||||
enableDensityToggle: false,
|
||||
enableColumnActions: false,
|
||||
enableColumnFilters: false,
|
||||
@@ -190,7 +197,7 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
||||
enableBottomToolbar: false,
|
||||
positionGlobalFilter: "right",
|
||||
mantineSearchTextInputProps: {
|
||||
placeholder: tDocker("table.search", { count: String(data.containers.length) }),
|
||||
placeholder: tDocker("table.search", { count: String(containers.length) }),
|
||||
style: { minWidth: 300 },
|
||||
autoFocus: true,
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ export default async function DockerPage() {
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tDocker("title")}</Title>
|
||||
<DockerTable containers={containers} timestamp={timestamp} />
|
||||
<DockerTable initialData={{ containers, timestamp }} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import crowdinContributors from "../../../../../../../../static-data/translators.json";
|
||||
import crowdinContributors from "@static-data/translators.json";
|
||||
|
||||
export const GET = () => {
|
||||
return NextResponse.json(crowdinContributors);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import githubContributors from "../../../../../../../../static-data/contributors.json";
|
||||
import githubContributors from "@static-data/contributors.json";
|
||||
|
||||
export const GET = () => {
|
||||
return NextResponse.json(githubContributors);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import openCollectiveContributors from "../../../../../../../../static-data/opencollective-contributors.json";
|
||||
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
|
||||
|
||||
export const GET = () => {
|
||||
return NextResponse.json(openCollectiveContributors);
|
||||
|
||||
@@ -211,7 +211,17 @@ describe("SQLite backup", () => {
|
||||
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
|
||||
expect(decryptedAfter).toBe(plaintext);
|
||||
|
||||
expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow();
|
||||
// The old key must no longer recover the plaintext. With AES-256-CBC this
|
||||
// usually fails PKCS7 padding validation (throws), but ~1/256 of the time the
|
||||
// wrong key yields valid padding and returns garbage instead of throwing, so
|
||||
// assert on the actual property (plaintext is unrecoverable) rather than throwing.
|
||||
let decryptedWithOldKey: string | null = null;
|
||||
try {
|
||||
decryptedWithOldKey = decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey);
|
||||
} catch {
|
||||
// Expected for most wrong keys: padding validation fails.
|
||||
}
|
||||
expect(decryptedWithOldKey).not.toBe(plaintext);
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
@@ -142,7 +142,8 @@ export const useBackupAnalysis = () => {
|
||||
phase: "error",
|
||||
});
|
||||
throw new Error(
|
||||
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`, { cause: migrationErr },
|
||||
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`,
|
||||
{ cause: migrationErr },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,12 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
name: t(`widget.${kind}.name`),
|
||||
description: t(`widget.${kind}.description`),
|
||||
}))
|
||||
.sort((itemA, itemB) => itemA.name.localeCompare(itemB.name)),
|
||||
.sort((itemA, itemB) => {
|
||||
if (itemA.kind === "app") return -1;
|
||||
if (itemB.kind === "app") return 1;
|
||||
|
||||
return itemA.name.localeCompare(itemB.name);
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export const categoryTranslationKeys: Record<string, string> = {
|
||||
mediaMonitoring: "integration.category.mediaMonitoring",
|
||||
speedtest: "integration.category.speedtest",
|
||||
analytics: "integration.category.analytics",
|
||||
vpn: "integration.category.vpn",
|
||||
documents: "integration.category.documents",
|
||||
mediaLibrary: "integration.category.mediaLibrary",
|
||||
uptimeMonitoring: "integration.category.uptimeMonitoring",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { createModal } from "@homarr/modals";
|
||||
|
||||
import { DockerTable } from "~/app/[locale]/manage/tools/docker/docker-table";
|
||||
|
||||
export const DockerQuickAccessModal = createModal<void>(() => <DockerTable />).withOptions({
|
||||
defaultTitle: (t) => t("docker.title"),
|
||||
size: 1400,
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { UnstyledButton } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/docker/env";
|
||||
|
||||
import { CurrentUserAvatar } from "~/components/user-avatar";
|
||||
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||
@@ -11,9 +12,10 @@ import { UpdateIndicator } from "./update";
|
||||
export const UserButton = async () => {
|
||||
const session = await auth();
|
||||
const isAdmin = session?.user.permissions.includes("admin");
|
||||
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
|
||||
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
|
||||
return (
|
||||
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise}>
|
||||
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
|
||||
<UnstyledButton>
|
||||
<Suspense fallback={<CurrentUserAvatar size="md" />}>
|
||||
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
|
||||
|
||||
@@ -5,7 +5,15 @@ import { useCallback, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
||||
import { useHotkeys, useTimeout } from "@mantine/hooks";
|
||||
import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
|
||||
import {
|
||||
IconBrandDocker,
|
||||
IconCheck,
|
||||
IconHome,
|
||||
IconLogin,
|
||||
IconLogout,
|
||||
IconSettings,
|
||||
IconTool,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { signOut, useSession } from "@homarr/auth/client";
|
||||
@@ -17,14 +25,16 @@ import { Link } from "@homarr/ui";
|
||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
|
||||
import { AvailableUpdatesMenuItem } from "./layout/header/update";
|
||||
|
||||
interface UserAvatarMenuProps {
|
||||
children: ReactNode;
|
||||
availableUpdatesPromise?: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
|
||||
isDockerEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
|
||||
export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnabled }: UserAvatarMenuProps) => {
|
||||
const t = useScopedI18n("common.userAvatar.menu");
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
||||
@@ -34,6 +44,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
|
||||
|
||||
const { logoutUrl } = useAuthContext();
|
||||
const { openModal } = useModalAction(LogoutModal);
|
||||
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
||||
|
||||
const handleSignout = useCallback(async () => {
|
||||
await signOut({
|
||||
@@ -80,6 +91,11 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
|
||||
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
||||
{t("management")}
|
||||
</Menu.Item>
|
||||
{isDockerEnabled && (
|
||||
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
|
||||
{t("docker")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"~/*": ["./src/*"],
|
||||
"@static-data/*": ["../../static-data/*"],
|
||||
"@homarr/node-unifi": ["../../node_modules/@types/node-unifi"]
|
||||
},
|
||||
"plugins": [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.16.0-trixie AS base
|
||||
FROM node:24.17.0-trixie AS base
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
COPY pnpm-lock.yaml .
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"better-sqlite3": "^12.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.16.0",
|
||||
"pnpm": ">=11.6.0"
|
||||
"node": ">=24.17.0",
|
||||
"pnpm": ">=11.8.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.6.0"
|
||||
"packageManager": "pnpm@11.8.0"
|
||||
}
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.65.0",
|
||||
"version": "1.68.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -61,10 +61,10 @@
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.16.0",
|
||||
"pnpm": ">=11.6.0"
|
||||
"node": ">=24.17.0",
|
||||
"pnpm": ">=11.8.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.7.0",
|
||||
"packageManager": "pnpm@11.8.0",
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "catalog:"
|
||||
}
|
||||
|
||||
@@ -15,20 +15,36 @@ export const iconsRouter = createTRPCRouter({
|
||||
})
|
||||
.input(iconsFindSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const term = input.searchText?.toLowerCase().trim() ?? "";
|
||||
const keywords = (input.searchText ?? "").split(" ").filter((keyword) => keyword.length > 0);
|
||||
|
||||
let whereCondition = undefined;
|
||||
if (term.length > 0) {
|
||||
whereCondition = and(...keywords.map((keyword) => like(icons.name, `%${keyword}%`)));
|
||||
}
|
||||
|
||||
return {
|
||||
icons: await ctx.db.query.iconRepositories.findMany({
|
||||
with: {
|
||||
icons: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
columns: { id: true, name: true, url: true },
|
||||
where: whereCondition,
|
||||
orderBy: (table, { asc, sql }) => {
|
||||
// ponytail: SVG first, then alphabetical; with a search term,
|
||||
// prepend a relevance tier (exact > prefix > substring).
|
||||
const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
|
||||
const nameAsc = asc(table.name);
|
||||
if (term.length === 0) {
|
||||
return [svgFirst, nameAsc];
|
||||
}
|
||||
const tier = sql`CASE
|
||||
WHEN LOWER(${table.name}) = ${term} THEN 0
|
||||
WHEN LOWER(${table.name}) LIKE ${term + "%"} THEN 1
|
||||
WHEN LOWER(${table.name}) LIKE ${`%${term}%`} THEN 2
|
||||
ELSE 3
|
||||
END`;
|
||||
return [tier, svgFirst, nameAsc];
|
||||
},
|
||||
where:
|
||||
(input.searchText?.length ?? 0) > 0
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
|
||||
: undefined,
|
||||
limit: input.limitPerGroup,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
integrationSecretKindObject,
|
||||
} from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { invalidateIntegrationCacheAsync } from "@homarr/redis";
|
||||
import { byIdSchema } from "@homarr/validation/common";
|
||||
import {
|
||||
integrationCreateSchema,
|
||||
@@ -469,6 +470,10 @@ export const integrationRouter = createTRPCRouter({
|
||||
kind: integration.kind,
|
||||
url: input.url,
|
||||
});
|
||||
|
||||
// Invalidate all cached data for this integration so that widgets pick up the
|
||||
// new configuration immediately instead of serving stale (or errored) data.
|
||||
await invalidateIntegrationCacheAsync(input.id);
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
.meta({
|
||||
@@ -490,6 +495,9 @@ export const integrationRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
|
||||
|
||||
// Clean up any cached data left behind by the deleted integration.
|
||||
await invalidateIntegrationCacheAsync(input.id);
|
||||
}),
|
||||
getIntegrationPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||
import {
|
||||
@@ -14,6 +15,8 @@ import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "beszelRouter" });
|
||||
|
||||
export const beszelRouter = createTRPCRouter({
|
||||
getSystems: publicProcedure
|
||||
.meta({
|
||||
@@ -25,7 +28,9 @@ export const beszelRouter = createTRPCRouter({
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
const integrationIds = ctx.integrations.map((i) => i.id);
|
||||
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
|
||||
const settled = await Promise.allSettled(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
|
||||
@@ -40,6 +45,29 @@ export const beszelRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
);
|
||||
const results = settled.map((result, index) => {
|
||||
if (result.status === "fulfilled") return result.value;
|
||||
const integration = ctx.integrations[index];
|
||||
logger.warn("getSystems integration failed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration?.id,
|
||||
error: result.reason,
|
||||
});
|
||||
return {
|
||||
integrationId: integration?.id ?? "unknown",
|
||||
integrationName: integration?.name ?? "unknown",
|
||||
integrationUrl: integration?.url ?? "",
|
||||
systems: [],
|
||||
updatedAt: new Date(0),
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
};
|
||||
});
|
||||
logger.debug("getSystems completed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationIds,
|
||||
resultCount: results.length,
|
||||
errorCount: results.filter((r) => "error" in r).length,
|
||||
});
|
||||
return results;
|
||||
}),
|
||||
|
||||
@@ -83,7 +111,14 @@ export const beszelRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const results = await Promise.all(
|
||||
const integrationIds = ctx.integrations.map((i) => i.id);
|
||||
logger.debug("getAlerts called", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationIds,
|
||||
includeHistory: input.includeHistory,
|
||||
maxHistoryItems: input.maxHistoryItems,
|
||||
});
|
||||
const settled = await Promise.allSettled(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
|
||||
includeHistory: input.includeHistory,
|
||||
@@ -100,6 +135,7 @@ export const beszelRouter = createTRPCRouter({
|
||||
}
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
alerts: alertsResult.data.alerts,
|
||||
history: alertsResult.data.history,
|
||||
systemNameMap,
|
||||
@@ -107,6 +143,30 @@ export const beszelRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
);
|
||||
const results = settled.map((result, index) => {
|
||||
if (result.status === "fulfilled") return result.value;
|
||||
const integration = ctx.integrations[index];
|
||||
logger.warn("getAlerts integration failed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration?.id,
|
||||
error: result.reason,
|
||||
});
|
||||
return {
|
||||
integrationId: integration?.id ?? "unknown",
|
||||
integrationName: integration?.name ?? "unknown",
|
||||
alerts: [],
|
||||
history: [],
|
||||
systemNameMap: {},
|
||||
updatedAt: new Date(0),
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
};
|
||||
});
|
||||
logger.debug("getAlerts completed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationIds,
|
||||
resultCount: results.length,
|
||||
errorCount: results.filter((r) => "error" in r).length,
|
||||
});
|
||||
return results;
|
||||
}),
|
||||
|
||||
@@ -164,13 +224,41 @@ export const beszelRouter = createTRPCRouter({
|
||||
if (!integration) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" });
|
||||
}
|
||||
const innerHandler = beszelStatsRequestHandler.handler(integration, {
|
||||
logger.debug("getSystemStats called", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
timePeriod: input.timePeriod,
|
||||
includeDocker: input.includeDocker,
|
||||
});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return { integrationId: integration.id, ...data, updatedAt: timestamp };
|
||||
try {
|
||||
const innerHandler = beszelStatsRequestHandler.handler(integration, {
|
||||
systemId: input.systemId,
|
||||
timePeriod: input.timePeriod,
|
||||
includeDocker: input.includeDocker,
|
||||
});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
logger.debug("getSystemStats completed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
});
|
||||
return { integrationId: integration.id, ...data, updatedAt: timestamp };
|
||||
} catch (error) {
|
||||
logger.warn("getSystemStats failed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
error,
|
||||
});
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
systemStats: [],
|
||||
containerStats: [],
|
||||
updatedAt: new Date(0),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
subscribeSystemStats: publicProcedure
|
||||
@@ -190,6 +278,11 @@ export const beszelRouter = createTRPCRouter({
|
||||
emit.error(new Error("No Beszel integration configured"));
|
||||
return () => {};
|
||||
}
|
||||
logger.debug("subscribeSystemStats opened", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
@@ -199,6 +292,12 @@ export const beszelRouter = createTRPCRouter({
|
||||
await instance.subscribeRealtimeMetrics(input.systemId, (data) => emit.next(data), abortController.signal);
|
||||
} catch (error) {
|
||||
if (!abortController.signal.aborted) {
|
||||
logger.warn("subscribeSystemStats error", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
error,
|
||||
});
|
||||
if (error instanceof Error) {
|
||||
emit.error(error);
|
||||
} else {
|
||||
@@ -210,6 +309,11 @@ export const beszelRouter = createTRPCRouter({
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
logger.debug("subscribeSystemStats closed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
});
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -36,6 +36,7 @@ export const widgetRouter = createTRPCRouter({
|
||||
uptimeKuma: lazy(() => import("./uptime-kuma").then((mod) => mod.uptimeKumaRouter)),
|
||||
audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)),
|
||||
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
|
||||
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
|
||||
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
|
||||
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import type { Integration } from "@homarr/db/schema";
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { VpnSummary } from "@homarr/integrations/types";
|
||||
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const createVpnIntegrationMiddleware = () =>
|
||||
createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"));
|
||||
|
||||
export const vpnRouter = createTRPCRouter({
|
||||
getSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).query(async ({ ctx }) => {
|
||||
return await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = vpnSummaryHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
summary: data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}),
|
||||
subscribeSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integration: Modify<Integration, { kind: IntegrationKindByCategory<"vpn"> }>;
|
||||
summary: VpnSummary | null;
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||
const innerHandler = vpnSummaryHandler.handler(integrationWithSecrets, {});
|
||||
const unsubscribe = innerHandler.subscribe((summary) => {
|
||||
emit.next({
|
||||
integration,
|
||||
summary,
|
||||
});
|
||||
});
|
||||
unsubscribes.push(unsubscribe);
|
||||
}
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { refreshNotificationsJob } from "./jobs/integrations/notifications";
|
||||
import { timetableJob } from "./jobs/integrations/timetable";
|
||||
import { tracearrJob } from "./jobs/integrations/tracearr";
|
||||
import { umamiActiveVisitorsJob } from "./jobs/integrations/umami";
|
||||
import { vpnJob } from "./jobs/integrations/vpn";
|
||||
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
|
||||
import { pingJob } from "./jobs/ping";
|
||||
import { rssFeedsJob } from "./jobs/rss-feeds";
|
||||
@@ -57,6 +58,7 @@ const getJobGroup = () => {
|
||||
timetable: timetableJob,
|
||||
tracearr: tracearrJob,
|
||||
umamiActiveVisitors: umamiActiveVisitorsJob,
|
||||
vpn: vpnJob,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { EVERY_30_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
||||
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
|
||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||
|
||||
import { createCronJob } from "../../lib";
|
||||
|
||||
export const vpnJob = createCronJob("vpn", EVERY_30_SECONDS).withCallback(
|
||||
createRequestIntegrationJobHandler(vpnSummaryHandler.handler, {
|
||||
widgetKinds: ["vpn"],
|
||||
getInput: {
|
||||
vpn: () => ({}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -179,6 +179,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/github"
|
||||
| "/docs/integrations/gitlab"
|
||||
| "/docs/integrations/glances"
|
||||
| "/docs/integrations/gluetun"
|
||||
| "/docs/integrations/gotify"
|
||||
| "/docs/integrations/home-assistant"
|
||||
| "/docs/integrations/ical"
|
||||
@@ -280,6 +281,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/ups"
|
||||
| "/docs/widgets/uptime-kuma"
|
||||
| "/docs/widgets/video"
|
||||
| "/docs/widgets/vpn"
|
||||
| "/docs/widgets/weather"
|
||||
| ""
|
||||
| "/sitemap.xml"
|
||||
@@ -337,6 +339,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/audiobookshelf"
|
||||
| "/docs/integrations/navidrome"
|
||||
| "/docs/integrations/umami"
|
||||
| "/docs/integrations/gluetun"
|
||||
| "/docs/integrations/peanut"
|
||||
| "/docs/integrations/beszel"
|
||||
| "/docs/widgets/clock"
|
||||
@@ -380,6 +383,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/uptime-kuma"
|
||||
| "/docs/widgets/audio-stats"
|
||||
| "/docs/widgets/umami"
|
||||
| "/docs/widgets/vpn"
|
||||
| "/docs/widgets/ups"
|
||||
| "/docs/widgets/beszel-system-table"
|
||||
| "/docs/widgets/beszel-system-grid"
|
||||
|
||||
@@ -55,6 +55,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
||||
audiobookshelf: "audiobookshelf",
|
||||
navidrome: "navidrome",
|
||||
umami: "umami",
|
||||
gluetun: "gluetun",
|
||||
peaNut: "peanut",
|
||||
beszel: "beszel",
|
||||
archiveTeamWarrior: null,
|
||||
|
||||
@@ -43,6 +43,7 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
uptimeKuma: "uptime-kuma",
|
||||
audioStats: "audio-stats",
|
||||
umami: "umami",
|
||||
vpn: "vpn",
|
||||
ups: "ups",
|
||||
archiveTeamWarrior: null,
|
||||
beszelSystemTable: "beszel-system-table",
|
||||
|
||||
@@ -489,6 +489,13 @@ export const integrationDefs = {
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
|
||||
defaultPort: 8090,
|
||||
},
|
||||
gluetun: {
|
||||
name: "Gluetun",
|
||||
secretKinds: [["username", "password"], ["apiKey"], []],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gluetun.svg",
|
||||
category: ["vpn"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/gluetun"),
|
||||
},
|
||||
archiveTeamWarrior: {
|
||||
name: "ArchiveTeam Warrior",
|
||||
secretKinds: [[], ["username", "password"]],
|
||||
@@ -604,6 +611,7 @@ export const integrationCategories = [
|
||||
"mediaMonitoring",
|
||||
"speedtest",
|
||||
"analytics",
|
||||
"vpn",
|
||||
"archiving",
|
||||
"ups",
|
||||
"documents",
|
||||
|
||||
@@ -38,6 +38,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
speedtestTracker: ["speedtestTracker"],
|
||||
uptimeKuma: ["uptimeKuma"],
|
||||
audioStats: ["navidrome", "audiobookshelf"],
|
||||
vpn: getIntegrationKindsByCategory("vpn"),
|
||||
anchorNote: ["anchor"],
|
||||
};
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ export const widgetKinds = [
|
||||
"uptimeKuma",
|
||||
"audioStats",
|
||||
"umami",
|
||||
"vpn",
|
||||
"archiveTeamWarrior",
|
||||
"ups",
|
||||
"beszelSystemTable",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { FocusEventHandler } from "react";
|
||||
import { startTransition } from "react";
|
||||
import { startTransition, useEffect, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Combobox,
|
||||
Flex,
|
||||
@@ -18,7 +21,7 @@ import {
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
|
||||
import { IconUpload } from "@tabler/icons-react";
|
||||
import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
@@ -34,7 +37,6 @@ interface IconPickerProps {
|
||||
error?: string | null;
|
||||
onFocus?: FocusEventHandler;
|
||||
onBlur?: FocusEventHandler;
|
||||
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
withAsterisk?: boolean;
|
||||
@@ -50,29 +52,30 @@ export const IconPicker = ({
|
||||
label,
|
||||
placeholder,
|
||||
}: IconPickerProps) => {
|
||||
const [value, setValue] = useUncontrolled({
|
||||
value: propsValue,
|
||||
onChange,
|
||||
});
|
||||
const [search, setSearch] = useUncontrolled({
|
||||
value,
|
||||
onChange: (value) => {
|
||||
setValue(value);
|
||||
},
|
||||
});
|
||||
const [previewUrl, setPreviewUrl] = useUncontrolled({
|
||||
value: propsValue ?? null,
|
||||
});
|
||||
const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
// ponytail: reset the typed query when the committed value changes
|
||||
// externally (form reset, parent re-render) so the input reverts to the URL.
|
||||
useEffect(() => {
|
||||
setQuery("");
|
||||
}, [value]);
|
||||
|
||||
const { data: session } = useSession();
|
||||
|
||||
const tCommon = useScopedI18n("common");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 100);
|
||||
|
||||
const [debouncedSearch] = useDebouncedValue(search, 100);
|
||||
// ponytail: only search when the typed query differs from the committed
|
||||
// value. On mount the input shows the existing URL (edit mode), which
|
||||
// would never match the bare filenames in the icon table.
|
||||
const searchText = (debouncedQuery !== (value ?? "") && debouncedQuery) || "";
|
||||
|
||||
// We use not useSuspenseQuery as it would cause an above Suspense boundary to trigger and so searching for something is bad UX.
|
||||
const { data } = clientApi.icon.findIcons.useQuery({
|
||||
searchText: debouncedSearch,
|
||||
});
|
||||
// ponytail: not useSuspenseQuery — an above Suspense boundary would
|
||||
// trigger on every keystroke and the dropdown flash is bad UX.
|
||||
const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
|
||||
{ searchText },
|
||||
{ placeholderData: (prev) => prev },
|
||||
);
|
||||
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
@@ -90,25 +93,15 @@ export const IconPicker = ({
|
||||
>
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
const value = item.url;
|
||||
startTransition(() => {
|
||||
setPreviewUrl(value);
|
||||
setSearch(value);
|
||||
setValue(value);
|
||||
setValue(item.url);
|
||||
setQuery("");
|
||||
combobox.closeDropdown();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
|
||||
<Card
|
||||
p="sm"
|
||||
pos="relative"
|
||||
style={{
|
||||
overflow: "visible",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
className={classes.iconCard}
|
||||
>
|
||||
<Card p="sm" pos="relative" style={{ overflow: "visible", cursor: "pointer" }} className={classes.iconCard}>
|
||||
<Box w={25} h={25}>
|
||||
<Image src={item.url} w={25} h={25} />
|
||||
</Box>
|
||||
@@ -130,6 +123,48 @@ export const IconPicker = ({
|
||||
);
|
||||
});
|
||||
|
||||
// ponytail: show the typed query if non-empty, else fall back to the URL.
|
||||
const inputValue = query || value || "";
|
||||
// ponytail: show the existing icon as a left-section preview.
|
||||
const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />;
|
||||
// ponytail: prop override -> real count -> loading text.
|
||||
const placeholderText =
|
||||
placeholder ||
|
||||
(data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
|
||||
tCommon("iconPicker.headerLoading");
|
||||
|
||||
const renderDropdown = () => {
|
||||
if (isError) {
|
||||
return (
|
||||
<Stack align="center" gap="xs" p="md">
|
||||
<IconSearchOff size={32} stroke={1.5} />
|
||||
<Text size="sm">{tCommon("iconPicker.error")}</Text>
|
||||
<Button size="xs" variant="default" onClick={() => refetch()}>
|
||||
{tCommon("action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (isLoading) {
|
||||
return Array(15)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
|
||||
<Skeleton height={25} visible />
|
||||
</Combobox.Option>
|
||||
));
|
||||
}
|
||||
if (totalOptions > 0) {
|
||||
return <Stack gap={4}>{groups}</Stack>;
|
||||
}
|
||||
return (
|
||||
<Stack align="center" gap="xs" p="md">
|
||||
<IconMoodEmpty size={32} stroke={1.5} />
|
||||
<Text size="sm">{tCommon("iconPicker.noResults", { search: debouncedQuery })}</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox store={combobox} withinPortal>
|
||||
<Combobox.Target>
|
||||
@@ -137,16 +172,11 @@ export const IconPicker = ({
|
||||
<InputBase
|
||||
flex={1}
|
||||
rightSection={<Combobox.Chevron />}
|
||||
leftSection={
|
||||
shouldShowPreview(previewUrl) ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null
|
||||
}
|
||||
value={search}
|
||||
leftSection={leftSection}
|
||||
value={inputValue}
|
||||
onChange={(event) => {
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
setSearch(event.currentTarget.value);
|
||||
setValue(event.currentTarget.value);
|
||||
setPreviewUrl(null);
|
||||
setQuery(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={(event) => {
|
||||
@@ -156,25 +186,21 @@ export const IconPicker = ({
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event);
|
||||
combobox.closeDropdown();
|
||||
setPreviewUrl(value);
|
||||
setSearch(value || "");
|
||||
setQuery("");
|
||||
}}
|
||||
rightSectionPointerEvents="none"
|
||||
withAsterisk={withAsterisk}
|
||||
error={error}
|
||||
label={label ?? tCommon("iconPicker.label")}
|
||||
placeholder={placeholder ?? tCommon("iconPicker.header", { countIcons: String(data?.countIcons ?? 0) })}
|
||||
placeholder={placeholderText}
|
||||
/>
|
||||
{session?.user.permissions.includes("media-upload") && (
|
||||
<UploadMedia
|
||||
onSuccess={(medias) => {
|
||||
const first = medias.at(0);
|
||||
if (!first) return;
|
||||
|
||||
startTransition(() => {
|
||||
setValue(first.url);
|
||||
setPreviewUrl(first.url);
|
||||
setSearch(first.url);
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -190,17 +216,7 @@ export const IconPicker = ({
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
||||
{totalOptions > 0 ? (
|
||||
<Stack gap={4}>{groups}</Stack>
|
||||
) : (
|
||||
Array(15)
|
||||
.fill(0)
|
||||
.map((_, index: number) => (
|
||||
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
|
||||
<Skeleton height={25} visible />
|
||||
</Combobox.Option>
|
||||
))
|
||||
)}
|
||||
{renderDropdown()}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
@@ -209,8 +225,8 @@ export const IconPicker = ({
|
||||
|
||||
// This regex is used to prevent loading a preview like en or /en which would trigger a language change
|
||||
// See https://github.com/homarr-labs/homarr/issues/3070
|
||||
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i" /* ignore casing */);
|
||||
const shouldShowPreview = (value: string | null): value is string => {
|
||||
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
|
||||
const shouldShowPreview = (value: string | null | undefined): value is string => {
|
||||
if (!value) return false;
|
||||
return !localizationPathRegex.test(value);
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import { GitHubContainerRegistryIntegration } from "../github-container-registry
|
||||
import { GithubIntegration } from "../github/github-integration";
|
||||
import { GitlabIntegration } from "../gitlab/gitlab-integration";
|
||||
import { GlancesIntegration } from "../glances/glances-integration";
|
||||
import { GluetunIntegration } from "../gluetun/gluetun-integration";
|
||||
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
||||
import { ICalIntegration } from "../ical/ical-integration";
|
||||
import { ImmichIntegration } from "../immich/immich-integration";
|
||||
@@ -137,6 +138,7 @@ export const integrationCreators = {
|
||||
audiobookshelf: AudiobookshelfIntegration,
|
||||
navidrome: NavidromeIntegration,
|
||||
umami: UmamiIntegration,
|
||||
gluetun: GluetunIntegration,
|
||||
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
|
||||
uptimeKuma: UptimeKumaIntegration,
|
||||
peaNut: PeaNutIntegration,
|
||||
|
||||
@@ -46,10 +46,13 @@ export class BeszelIntegration extends Integration {
|
||||
private async authenticateAsync(): Promise<BeszelSession> {
|
||||
const existingSession = await this.sessionStore.getAsync();
|
||||
if (existingSession) {
|
||||
logger.debug("Using stored Beszel session", { integrationId: this.integration.id });
|
||||
return existingSession;
|
||||
}
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/collections/users/auth-with-password"), {
|
||||
const authUrl = this.url("/api/collections/users/auth-with-password");
|
||||
logger.debug("Authenticating with Beszel", { integrationId: this.integration.id, url: authUrl.pathname });
|
||||
const response = await fetchWithTrustedCertificatesAsync(authUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -57,23 +60,28 @@ export class BeszelIntegration extends Integration {
|
||||
password: this.getSecretValue("password"),
|
||||
}),
|
||||
});
|
||||
logger.debug("Beszel auth response received", { integrationId: this.integration.id, status: response.status });
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn("Beszel auth failed", { integrationId: this.integration.id, status: response.status });
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as BeszelAuthResponse;
|
||||
const session: BeszelSession = { token: data.token, userId: data.record.id };
|
||||
await this.sessionStore.setAsync(session);
|
||||
logger.debug("Saved Beszel session", { integrationId: this.integration.id, userId: session.userId });
|
||||
return session;
|
||||
}
|
||||
|
||||
private async fetchWithAuthAsync(url: URL, options: { method?: string; body?: string } = {}) {
|
||||
const method = options.method ?? "GET";
|
||||
const start = performance.now();
|
||||
let session = await this.authenticateAsync();
|
||||
|
||||
const doFetch = (token: string) =>
|
||||
fetchWithTrustedCertificatesAsync(url, {
|
||||
method: options.method ?? "GET",
|
||||
method,
|
||||
headers: {
|
||||
Authorization: token,
|
||||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||||
@@ -82,14 +90,42 @@ export class BeszelIntegration extends Integration {
|
||||
});
|
||||
|
||||
let response = await doFetch(session.token);
|
||||
logger.debug("Beszel response", {
|
||||
integrationId: this.integration.id,
|
||||
method,
|
||||
path: url.pathname,
|
||||
status: response.status,
|
||||
durationMs: Math.round(performance.now() - start),
|
||||
attempt: 1,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
logger.warn("Beszel 401, clearing session and retrying", {
|
||||
integrationId: this.integration.id,
|
||||
method,
|
||||
path: url.pathname,
|
||||
});
|
||||
await this.sessionStore.clearAsync();
|
||||
session = await this.authenticateAsync();
|
||||
response = await doFetch(session.token);
|
||||
logger.debug("Beszel response (after re-auth)", {
|
||||
integrationId: this.integration.id,
|
||||
method,
|
||||
path: url.pathname,
|
||||
status: response.status,
|
||||
durationMs: Math.round(performance.now() - start),
|
||||
attempt: 2,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn("Beszel request failed", {
|
||||
integrationId: this.integration.id,
|
||||
method,
|
||||
path: url.pathname,
|
||||
status: response.status,
|
||||
durationMs: Math.round(performance.now() - start),
|
||||
});
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
@@ -257,6 +293,7 @@ export class BeszelIntegration extends Integration {
|
||||
): Promise<void> {
|
||||
const session = await this.authenticateAsync();
|
||||
const sseUrl = this.url("/api/realtime");
|
||||
logger.debug("Opening Beszel SSE connection", { integrationId: this.integration.id, systemId });
|
||||
|
||||
const sseResponse = await fetchWithTrustedCertificatesAsync(sseUrl, {
|
||||
headers: { Authorization: session.token },
|
||||
@@ -264,8 +301,14 @@ export class BeszelIntegration extends Integration {
|
||||
});
|
||||
|
||||
if (!sseResponse.ok || !sseResponse.body) {
|
||||
logger.warn("Beszel SSE connection failed", {
|
||||
integrationId: this.integration.id,
|
||||
systemId,
|
||||
status: sseResponse.status,
|
||||
});
|
||||
throw new ResponseError(sseResponse);
|
||||
}
|
||||
logger.debug("Beszel SSE connection opened", { integrationId: this.integration.id, systemId });
|
||||
|
||||
const reader = sseResponse.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
@@ -322,6 +365,10 @@ export class BeszelIntegration extends Integration {
|
||||
}
|
||||
}
|
||||
if (!signal.aborted) {
|
||||
logger.warn("Beszel SSE stream ended unexpectedly", {
|
||||
integrationId: this.integration.id,
|
||||
systemId,
|
||||
});
|
||||
throw new Error("Beszel SSE stream ended unexpectedly");
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { VpnSummaryIntegration } from "../interfaces/vpn-summary/vpn-summary-integration";
|
||||
import type { VpnSummary } from "../interfaces/vpn-summary/vpn-summary-types";
|
||||
import {
|
||||
gluetunDnsStatusSchema,
|
||||
gluetunPublicIpSchema,
|
||||
gluetunVpnSettingsSchema,
|
||||
gluetunVpnStatusSchema,
|
||||
} from "./gluetun-types";
|
||||
import type { GluetunDnsStatus, GluetunPublicIp, GluetunVpnSettings, GluetunVpnStatus } from "./gluetun-types";
|
||||
|
||||
export class GluetunIntegration extends Integration implements VpnSummaryIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const authHeaders = this.getAuthHeaders();
|
||||
const url = this.url("/v1/vpn/status");
|
||||
const response = await input.fetchAsync(url, {
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async getSummaryAsync(): Promise<VpnSummary> {
|
||||
const authHeaders = this.getAuthHeaders();
|
||||
const [vpnStatus, dnsStatus, publicIp, vpnSettings] = await Promise.all([
|
||||
this.getVpnStatusAsync(authHeaders),
|
||||
this.getDnsStatusAsync(authHeaders),
|
||||
this.getPublicIpAsync(authHeaders),
|
||||
this.getVpnSettingAsync(authHeaders),
|
||||
]);
|
||||
|
||||
return {
|
||||
vpnStatus: vpnStatus.status,
|
||||
dnsStatus: dnsStatus.status,
|
||||
publicIp: publicIp.public_ip,
|
||||
country: publicIp.country,
|
||||
city: publicIp.city,
|
||||
vpnProvider: {
|
||||
protocol: vpnSettings.type,
|
||||
provider: vpnSettings.provider.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async getVpnStatusAsync(authHeaders: Record<string, string>): Promise<GluetunVpnStatus> {
|
||||
const url = this.url("/v1/vpn/status");
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
return gluetunVpnStatusSchema.parse(await response.json());
|
||||
}
|
||||
|
||||
private async getDnsStatusAsync(authHeaders: Record<string, string>): Promise<GluetunDnsStatus> {
|
||||
const url = this.url("/v1/dns/status");
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
return gluetunDnsStatusSchema.parse(await response.json());
|
||||
}
|
||||
|
||||
private async getPublicIpAsync(authHeaders: Record<string, string>): Promise<GluetunPublicIp> {
|
||||
const url = this.url("/v1/publicip/ip");
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
return gluetunPublicIpSchema.parse(await response.json());
|
||||
}
|
||||
|
||||
private async getVpnSettingAsync(authHeaders: Record<string, string>): Promise<GluetunVpnSettings> {
|
||||
const url = this.url("/v1/vpn/settings");
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
return gluetunVpnSettingsSchema.parse(await response.json());
|
||||
}
|
||||
|
||||
private getAuthHeaders(): Record<string, string> {
|
||||
if (this.hasSecretValue("apiKey")) {
|
||||
return { "X-API-Key": this.getSecretValue("apiKey") };
|
||||
}
|
||||
|
||||
if (this.hasSecretValue("username") && this.hasSecretValue("password")) {
|
||||
const username = this.getSecretValue("username");
|
||||
const password = this.getSecretValue("password");
|
||||
|
||||
const encodedCredentials = btoa(`${username}:${password}`);
|
||||
|
||||
return { Authorization: `Basic ${encodedCredentials}` };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const gluetunVpnStatusSchema = z.object({
|
||||
status: z.string(),
|
||||
});
|
||||
|
||||
export type GluetunVpnStatus = z.infer<typeof gluetunVpnStatusSchema>;
|
||||
|
||||
export const gluetunDnsStatusSchema = gluetunVpnStatusSchema;
|
||||
|
||||
export type GluetunDnsStatus = z.infer<typeof gluetunDnsStatusSchema>;
|
||||
|
||||
export const gluetunPublicIpSchema = z.object({
|
||||
public_ip: z.string(),
|
||||
region: z.string(),
|
||||
country: z.string(),
|
||||
city: z.string(),
|
||||
location: z.string(),
|
||||
organization: z.string(),
|
||||
postal_code: z.string(),
|
||||
timezone: z.string(),
|
||||
});
|
||||
|
||||
export type GluetunPublicIp = z.infer<typeof gluetunPublicIpSchema>;
|
||||
|
||||
const gluetunServerSelectionOpenvpnSchema = z.object({
|
||||
config_file_path: z.string(),
|
||||
protocol: z.string(),
|
||||
endpoint_ip: z.string(),
|
||||
custom_port: z.number(),
|
||||
pia_encryption_preset: z.string(),
|
||||
});
|
||||
|
||||
const gluetunServerSelectionWireguardSchema = z.object({
|
||||
endpoint_ip: z.string(),
|
||||
endpoint_port: z.number(),
|
||||
public_key: z.string(),
|
||||
});
|
||||
|
||||
const gluetunServerSelectionSchema = z.object({
|
||||
vpn: z.string(),
|
||||
countries: z.array(z.string()).nullable(),
|
||||
categories: z.array(z.string()).nullable(),
|
||||
regions: z.array(z.string()).nullable(),
|
||||
cities: z.array(z.string()).nullable(),
|
||||
isps: z.array(z.string()).nullable(),
|
||||
names: z.array(z.string()).nullable(),
|
||||
numbers: z.array(z.number()).nullable(),
|
||||
hostnames: z.array(z.string()),
|
||||
owned_only: z.boolean(),
|
||||
free_only: z.boolean(),
|
||||
premium_only: z.boolean(),
|
||||
stream_only: z.boolean(),
|
||||
multi_hop_only: z.boolean(),
|
||||
port_forward_only: z.boolean(),
|
||||
secure_core_only: z.boolean(),
|
||||
tor_only: z.boolean(),
|
||||
openvpn: gluetunServerSelectionOpenvpnSchema,
|
||||
wireguard: gluetunServerSelectionWireguardSchema,
|
||||
});
|
||||
|
||||
const gluetunPortForwardingSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
provider: z.string(),
|
||||
status_file_path: z.string(),
|
||||
up_command: z.string(),
|
||||
down_command: z.string(),
|
||||
listening_port: z.number(),
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
const gluetunProviderSchema = z.object({
|
||||
name: z.string(),
|
||||
server_selection: gluetunServerSelectionSchema,
|
||||
port_forwarding: gluetunPortForwardingSchema,
|
||||
});
|
||||
|
||||
const gluetunOpenvpnSettingsSchema = z.object({
|
||||
version: z.string(),
|
||||
user: z.string(),
|
||||
password: z.string(),
|
||||
config_file_path: z.string(),
|
||||
ciphers: z.array(z.string()).nullable(),
|
||||
auth: z.string(),
|
||||
cert: z.string(),
|
||||
key: z.string(),
|
||||
encrypted_key: z.string(),
|
||||
key_passphrase: z.string(),
|
||||
pia_encryption_preset: z.string(),
|
||||
mssfix: z.number(),
|
||||
interface: z.string(),
|
||||
process_user: z.string(),
|
||||
verbosity: z.number(),
|
||||
flags: z.array(z.string()).nullable(),
|
||||
});
|
||||
|
||||
const gluetunWireguardSettingsSchema = z.object({
|
||||
private_key: z.string(),
|
||||
pre_shared_key: z.string(),
|
||||
addresses: z.array(z.string()),
|
||||
allowed_ips: z.array(z.string()),
|
||||
interface: z.string(),
|
||||
persistent_keep_alive_interval: z.number(),
|
||||
mtu: z.number(),
|
||||
implementation: z.string(),
|
||||
});
|
||||
|
||||
export const gluetunVpnSettingsSchema = z.object({
|
||||
type: z.string(),
|
||||
provider: gluetunProviderSchema,
|
||||
openvpn: gluetunOpenvpnSettingsSchema,
|
||||
wireguard: gluetunWireguardSettingsSchema,
|
||||
});
|
||||
|
||||
export type GluetunVpnSettings = z.infer<typeof gluetunVpnSettingsSchema>;
|
||||
@@ -0,0 +1,371 @@
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import { GluetunIntegration } from "../gluetun-integration";
|
||||
import {
|
||||
gluetunDnsStatusSchema,
|
||||
gluetunPublicIpSchema,
|
||||
gluetunVpnSettingsSchema,
|
||||
gluetunVpnStatusSchema,
|
||||
} from "../gluetun-types";
|
||||
|
||||
vi.mock("@homarr/db", async (importActual) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importActual<typeof import("@homarr/db")>();
|
||||
return {
|
||||
...actual,
|
||||
db: createDb(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const TEST_URL = "https://gluetun.example.com";
|
||||
const TEST_API_KEY = "test-api-key";
|
||||
const TEST_USERNAME = "user";
|
||||
const TEST_PASSWORD = "p@ss";
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
type FetchResponse = Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
|
||||
|
||||
const VPN_STATUS_PAYLOAD = { status: "running" };
|
||||
const DNS_STATUS_PAYLOAD = { status: "running" };
|
||||
const PUBLIC_IP_PAYLOAD = {
|
||||
public_ip: "203.0.113.42",
|
||||
region: "Europe",
|
||||
country: "Germany",
|
||||
city: "Berlin",
|
||||
location: "52.5200,13.4050",
|
||||
organization: "Example Org",
|
||||
postal_code: "10115",
|
||||
timezone: "Europe/Berlin",
|
||||
};
|
||||
const VPN_SETTINGS_PAYLOAD = {
|
||||
type: "wireguard",
|
||||
provider: {
|
||||
name: "mullvad",
|
||||
server_selection: {
|
||||
vpn: "wireguard",
|
||||
countries: ["Germany"],
|
||||
categories: null,
|
||||
regions: null,
|
||||
cities: ["Berlin"],
|
||||
isps: null,
|
||||
names: null,
|
||||
numbers: null,
|
||||
hostnames: ["de-ber-wg-001"],
|
||||
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: "",
|
||||
protocol: "udp",
|
||||
endpoint_ip: "",
|
||||
custom_port: 0,
|
||||
pia_encryption_preset: "",
|
||||
},
|
||||
wireguard: {
|
||||
endpoint_ip: "203.0.113.1",
|
||||
endpoint_port: 51820,
|
||||
public_key: "pubkey",
|
||||
},
|
||||
},
|
||||
port_forwarding: {
|
||||
enabled: false,
|
||||
provider: "mullvad",
|
||||
status_file_path: "",
|
||||
up_command: "",
|
||||
down_command: "",
|
||||
listening_port: 0,
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
},
|
||||
openvpn: {
|
||||
version: "",
|
||||
user: "",
|
||||
password: "",
|
||||
config_file_path: "",
|
||||
ciphers: null,
|
||||
auth: "",
|
||||
cert: "",
|
||||
key: "",
|
||||
encrypted_key: "",
|
||||
key_passphrase: "",
|
||||
pia_encryption_preset: "",
|
||||
mssfix: 0,
|
||||
interface: "tun0",
|
||||
process_user: "",
|
||||
verbosity: 0,
|
||||
flags: null,
|
||||
},
|
||||
wireguard: {
|
||||
private_key: "",
|
||||
pre_shared_key: "",
|
||||
addresses: ["10.0.0.2/32"],
|
||||
allowed_ips: ["0.0.0.0/0"],
|
||||
interface: "wg0",
|
||||
persistent_keep_alive_interval: 25,
|
||||
mtu: 1420,
|
||||
implementation: "auto",
|
||||
},
|
||||
};
|
||||
|
||||
const jsonResponse = (body: unknown, init: ResponseInit = { status: 200 }): FetchResponse =>
|
||||
new Response(JSON.stringify(body), {
|
||||
...init,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as FetchResponse;
|
||||
|
||||
const setupHappyPath = () => {
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const urlString = typeof url === "string" ? url : url.toString();
|
||||
const { pathname } = new URL(urlString);
|
||||
|
||||
switch (pathname) {
|
||||
case "/v1/vpn/status":
|
||||
return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD));
|
||||
case "/v1/dns/status":
|
||||
return Promise.resolve(jsonResponse(DNS_STATUS_PAYLOAD));
|
||||
case "/v1/publicip/ip":
|
||||
return Promise.resolve(jsonResponse(PUBLIC_IP_PAYLOAD));
|
||||
case "/v1/vpn/settings":
|
||||
return Promise.resolve(jsonResponse(VPN_SETTINGS_PAYLOAD));
|
||||
default:
|
||||
return Promise.resolve(jsonResponse({ error: "not found" }, { status: 404 }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const createIntegrationWithApiKey = () =>
|
||||
new GluetunIntegration({
|
||||
id: "gluetun-1",
|
||||
name: "Gluetun",
|
||||
url: TEST_URL,
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [{ kind: "apiKey", value: TEST_API_KEY }],
|
||||
});
|
||||
|
||||
const createIntegrationWithBasicAuth = () =>
|
||||
new GluetunIntegration({
|
||||
id: "gluetun-2",
|
||||
name: "Gluetun",
|
||||
url: TEST_URL,
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [
|
||||
{ kind: "username", value: TEST_USERNAME },
|
||||
{ kind: "password", value: TEST_PASSWORD },
|
||||
],
|
||||
});
|
||||
|
||||
describe("GluetunIntegration.getSummaryAsync", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("maps the four upstream responses into a flat VPN summary", async () => {
|
||||
setupHappyPath();
|
||||
|
||||
const result = await createIntegrationWithApiKey().getSummaryAsync();
|
||||
|
||||
expect(result).toEqual({
|
||||
vpnStatus: "running",
|
||||
dnsStatus: "running",
|
||||
publicIp: "203.0.113.42",
|
||||
country: "Germany",
|
||||
city: "Berlin",
|
||||
vpnProvider: {
|
||||
protocol: "wireguard",
|
||||
provider: "mullvad",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("hits each of the four control endpoints exactly once", async () => {
|
||||
setupHappyPath();
|
||||
|
||||
await createIntegrationWithApiKey().getSummaryAsync();
|
||||
|
||||
const calledPaths = mockFetch.mock.calls.map(([url]) => {
|
||||
const urlString = typeof url === "string" ? url : url.toString();
|
||||
return new URL(urlString).pathname;
|
||||
});
|
||||
|
||||
expect(calledPaths).toHaveLength(4);
|
||||
expect(new Set(calledPaths)).toEqual(
|
||||
new Set(["/v1/vpn/status", "/v1/dns/status", "/v1/publicip/ip", "/v1/vpn/settings"]),
|
||||
);
|
||||
});
|
||||
|
||||
test("sends X-API-Key header when an apiKey secret is configured", async () => {
|
||||
setupHappyPath();
|
||||
|
||||
await createIntegrationWithApiKey().getSummaryAsync();
|
||||
|
||||
for (const [, init] of mockFetch.mock.calls) {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers["X-API-Key"]).toBe(TEST_API_KEY);
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("sends Basic auth header when username/password are configured", async () => {
|
||||
setupHappyPath();
|
||||
|
||||
await createIntegrationWithBasicAuth().getSummaryAsync();
|
||||
|
||||
const expected = `Basic ${btoa(`${TEST_USERNAME}:${TEST_PASSWORD}`)}`;
|
||||
for (const [, init] of mockFetch.mock.calls) {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers.Authorization).toBe(expected);
|
||||
expect(headers["X-API-Key"]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("throws when any upstream endpoint returns a non-OK status", async () => {
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const urlString = typeof url === "string" ? url : url.toString();
|
||||
const { pathname } = new URL(urlString);
|
||||
|
||||
if (pathname === "/v1/publicip/ip") {
|
||||
return Promise.resolve(jsonResponse({ error: "unauthorized" }, { status: 401 }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD));
|
||||
});
|
||||
|
||||
await expect(createIntegrationWithApiKey().getSummaryAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("throws when an upstream payload fails schema validation", async () => {
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const urlString = typeof url === "string" ? url : url.toString();
|
||||
const { pathname } = new URL(urlString);
|
||||
|
||||
if (pathname === "/v1/publicip/ip") {
|
||||
return Promise.resolve(jsonResponse({ public_ip: 123 }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD));
|
||||
});
|
||||
|
||||
await expect(createIntegrationWithApiKey().getSummaryAsync()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GluetunIntegration testing endpoint", () => {
|
||||
type TestingFn = (input: IntegrationTestingInput) => Promise<{ success: boolean }>;
|
||||
|
||||
const invokeTesting = (integration: GluetunIntegration, input: IntegrationTestingInput) => {
|
||||
const testing = (integration as unknown as { testingAsync: TestingFn }).testingAsync.bind(integration);
|
||||
return testing(input);
|
||||
};
|
||||
|
||||
const makeInput = (response: Response): IntegrationTestingInput =>
|
||||
({
|
||||
fetchAsync: vi.fn().mockResolvedValue(response),
|
||||
}) as unknown as IntegrationTestingInput;
|
||||
|
||||
test("returns success when /v1/vpn/status responds 200", async () => {
|
||||
const input = makeInput(new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 }));
|
||||
|
||||
const result = await invokeTesting(createIntegrationWithApiKey(), input);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("returns a status-code failure when /v1/vpn/status responds non-OK", async () => {
|
||||
const input = makeInput(new Response("unauthorized", { status: 401 }));
|
||||
|
||||
const result = await invokeTesting(createIntegrationWithApiKey(), input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("passes the api-key header through to fetchAsync", async () => {
|
||||
const response = new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 });
|
||||
const input = makeInput(response);
|
||||
|
||||
await invokeTesting(createIntegrationWithApiKey(), input);
|
||||
|
||||
const fetchAsyncMock = vi.mocked(input.fetchAsync);
|
||||
const init = fetchAsyncMock.mock.calls[0]?.[1];
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers["X-API-Key"]).toBe(TEST_API_KEY);
|
||||
});
|
||||
|
||||
test("passes the Basic auth header through to fetchAsync when username/password are configured", async () => {
|
||||
const response = new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 });
|
||||
const input = makeInput(response);
|
||||
|
||||
await invokeTesting(createIntegrationWithBasicAuth(), input);
|
||||
|
||||
const fetchAsyncMock = vi.mocked(input.fetchAsync);
|
||||
const init = fetchAsyncMock.mock.calls[0]?.[1];
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
const expected = `Basic ${btoa(`${TEST_USERNAME}:${TEST_PASSWORD}`)}`;
|
||||
expect(headers.Authorization).toBe(expected);
|
||||
expect(headers["X-API-Key"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("gluetun schemas", () => {
|
||||
test("vpn status schema accepts a string status", () => {
|
||||
expect(gluetunVpnStatusSchema.parse({ status: "running" })).toEqual({ status: "running" });
|
||||
});
|
||||
|
||||
test("vpn status schema rejects a missing status", () => {
|
||||
expect(() => gluetunVpnStatusSchema.parse({})).toThrow();
|
||||
});
|
||||
|
||||
test("dns status schema mirrors vpn status schema", () => {
|
||||
expect(gluetunDnsStatusSchema.parse({ status: "stopped" })).toEqual({ status: "stopped" });
|
||||
expect(() => gluetunDnsStatusSchema.parse({ status: 42 })).toThrow();
|
||||
});
|
||||
|
||||
test("public ip schema parses the full payload", () => {
|
||||
expect(gluetunPublicIpSchema.parse(PUBLIC_IP_PAYLOAD)).toEqual(PUBLIC_IP_PAYLOAD);
|
||||
});
|
||||
|
||||
test("public ip schema rejects a missing required field", () => {
|
||||
const { city: _city, ...partial } = PUBLIC_IP_PAYLOAD;
|
||||
expect(() => gluetunPublicIpSchema.parse(partial)).toThrow();
|
||||
});
|
||||
|
||||
test("vpn settings schema parses a wireguard payload", () => {
|
||||
const parsed = gluetunVpnSettingsSchema.parse(VPN_SETTINGS_PAYLOAD);
|
||||
expect(parsed.type).toBe("wireguard");
|
||||
expect(parsed.provider.name).toBe("mullvad");
|
||||
});
|
||||
|
||||
test("vpn settings schema accepts nullable server-selection arrays", () => {
|
||||
const payloadWithNulls = {
|
||||
...VPN_SETTINGS_PAYLOAD,
|
||||
provider: {
|
||||
...VPN_SETTINGS_PAYLOAD.provider,
|
||||
server_selection: {
|
||||
...VPN_SETTINGS_PAYLOAD.provider.server_selection,
|
||||
countries: null,
|
||||
cities: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => gluetunVpnSettingsSchema.parse(payloadWithNulls)).not.toThrow();
|
||||
});
|
||||
|
||||
test("vpn settings schema rejects a non-string type", () => {
|
||||
expect(() => gluetunVpnSettingsSchema.parse({ ...VPN_SETTINGS_PAYLOAD, type: 1 })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -15,9 +15,9 @@ export class GotifyIntegration extends Integration implements INotificationsInte
|
||||
}
|
||||
|
||||
private getHeaders() {
|
||||
const credentials = Buffer.from(
|
||||
`${super.getSecretValue("username")}:${super.getSecretValue("password")}`,
|
||||
).toString("base64");
|
||||
const credentials = Buffer.from(`${super.getSecretValue("username")}:${super.getSecretValue("password")}`).toString(
|
||||
"base64",
|
||||
);
|
||||
return { Authorization: `Basic ${credentials}` };
|
||||
}
|
||||
|
||||
@@ -31,11 +31,13 @@ export class GotifyIntegration extends Integration implements INotificationsInte
|
||||
if (!result.success) throw new Error(`Failed to parse Gotify response: ${result.error.message}`);
|
||||
const parsed = result.data;
|
||||
|
||||
return parsed.messages.map((message): Notification => ({
|
||||
id: String(message.id),
|
||||
time: new Date(message.date),
|
||||
title: message.title,
|
||||
body: message.message,
|
||||
}));
|
||||
return parsed.messages.map(
|
||||
(message): Notification => ({
|
||||
id: String(message.id),
|
||||
time: new Date(message.date),
|
||||
title: message.title,
|
||||
body: message.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { VpnSummary } from "./vpn-summary-types";
|
||||
|
||||
export interface VpnSummaryIntegration {
|
||||
getSummaryAsync(): Promise<VpnSummary>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface VpnProviderDetails {
|
||||
protocol: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface VpnSummary {
|
||||
vpnStatus: string;
|
||||
dnsStatus: string;
|
||||
publicIp: string;
|
||||
country: string;
|
||||
city: string;
|
||||
vpnProvider: VpnProviderDetails;
|
||||
}
|
||||
@@ -127,11 +127,18 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
|
||||
|
||||
const media =
|
||||
data.MediaContainer.Metadata?.filter((item) => item.Image).map((item) => {
|
||||
const title =
|
||||
item.type === "episode"
|
||||
? (item.grandparentTitle ?? item.title)
|
||||
: item.type === "season"
|
||||
? (item.parentTitle ?? item.title)
|
||||
: item.title;
|
||||
|
||||
return {
|
||||
id: item.Media?.at(0)?.id.toString() ?? item.key,
|
||||
type: mapType(item.type),
|
||||
title: item.title,
|
||||
subtitle: item.tagline,
|
||||
title,
|
||||
subtitle: title === item.title ? item.tagline : item.title,
|
||||
description: item.summary,
|
||||
releaseDate: item.originallyAvailableAt
|
||||
? new Date(item.originallyAvailableAt)
|
||||
@@ -222,6 +229,8 @@ const recentlyAddedSchema = z.object({
|
||||
studio: z.string().optional(),
|
||||
type: z.string(), // For example "movie", "album"
|
||||
title: z.string(),
|
||||
parentTitle: z.string().optional(),
|
||||
grandparentTitle: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
duration: z.number().optional(),
|
||||
addedAt: z.number(),
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// @vitest-environment node
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationSecret } from "../../base/types";
|
||||
import { PlexIntegration } from "../plex-integration";
|
||||
|
||||
const imageProxyMocks = vi.hoisted(() => ({
|
||||
createImageAsync: vi.fn((url: string) => Promise.resolve(`proxied:${url}`)),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/image-proxy", () => ({
|
||||
ImageProxy: class {
|
||||
createImageAsync = imageProxyMocks.createImageAsync;
|
||||
},
|
||||
}));
|
||||
|
||||
const TEST_URL = "https://plex.example.com";
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
const secrets: IntegrationSecret[] = [{ kind: "apiKey", value: "plex-token" }];
|
||||
|
||||
const createIntegration = () =>
|
||||
new PlexIntegration({
|
||||
id: "test-plex",
|
||||
name: "Test Plex",
|
||||
url: TEST_URL,
|
||||
externalUrl: null,
|
||||
decryptedSecrets: secrets,
|
||||
});
|
||||
|
||||
const createMetadataItem = (key: string, overrides: Record<string, unknown>) => ({
|
||||
key: `/library/metadata/${key}`,
|
||||
type: "movie",
|
||||
title: "Movie",
|
||||
addedAt: 1_735_689_600,
|
||||
Image: [
|
||||
{
|
||||
type: "coverPoster",
|
||||
url: `/library/metadata/${key}/thumb`,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
imageProxyMocks.createImageAsync.mockClear();
|
||||
});
|
||||
|
||||
describe("PlexIntegration.getMediaReleasesAsync", () => {
|
||||
test("uses the series title for TV seasons and episodes", async () => {
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const urlString = String(url);
|
||||
|
||||
if (urlString.endsWith("/identity")) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ MediaContainer: { machineIdentifier: "server-id" } }), { status: 200 }),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
if (urlString.endsWith("/library/recentlyAdded")) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
MediaContainer: {
|
||||
Metadata: [
|
||||
createMetadataItem("season", {
|
||||
type: "season",
|
||||
title: "Season 1",
|
||||
parentTitle: "Breaking Bad",
|
||||
}),
|
||||
createMetadataItem("episode", {
|
||||
type: "episode",
|
||||
title: "Review",
|
||||
parentTitle: "Season 1",
|
||||
grandparentTitle: "The Bear",
|
||||
}),
|
||||
createMetadataItem("movie", {
|
||||
type: "movie",
|
||||
title: "Inception",
|
||||
tagline: "Dreams feel real",
|
||||
}),
|
||||
createMetadataItem("show", {
|
||||
type: "show",
|
||||
title: "The Last of Us",
|
||||
tagline: "A series tagline",
|
||||
}),
|
||||
createMetadataItem("missing-parent", {
|
||||
type: "season",
|
||||
title: "Season 3",
|
||||
}),
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected Plex request: ${urlString}`);
|
||||
});
|
||||
|
||||
const releases = await createIntegration().getMediaReleasesAsync();
|
||||
|
||||
expect(releases).toMatchObject([
|
||||
{
|
||||
title: "Breaking Bad",
|
||||
subtitle: "Season 1",
|
||||
type: "tv",
|
||||
},
|
||||
{
|
||||
title: "The Bear",
|
||||
subtitle: "Review",
|
||||
type: "tv",
|
||||
},
|
||||
{
|
||||
title: "Inception",
|
||||
subtitle: "Dreams feel real",
|
||||
type: "movie",
|
||||
},
|
||||
{
|
||||
title: "The Last of Us",
|
||||
subtitle: "A series tagline",
|
||||
type: "tv",
|
||||
},
|
||||
{
|
||||
title: "Season 3",
|
||||
subtitle: undefined,
|
||||
type: "tv",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -132,8 +132,9 @@ const happyResponder = (method: string) => {
|
||||
aggregations: emptyAggregations(),
|
||||
start: 0,
|
||||
end: 1,
|
||||
legend: [],
|
||||
data: [[1000, 8_000_000_000]],
|
||||
// The "memory" graph reports available (free) memory: legend is ["time", "available"].
|
||||
legend: ["time", "available"],
|
||||
data: [[1000, 6_000_000_000]],
|
||||
},
|
||||
{
|
||||
name: "cputemp",
|
||||
@@ -194,15 +195,17 @@ describe("TrueNasIntegration", () => {
|
||||
version: "TrueNAS-SCALE-24.10",
|
||||
cpuModelName: "Intel Xeon",
|
||||
cpuTemp: 45,
|
||||
memAvailableInBytes: 16_000_000_000,
|
||||
memUsedInBytes: 8_000_000_000,
|
||||
// physmem (16 GB) minus the reported available 6 GB leaves 10 GB used.
|
||||
memAvailableInBytes: 6_000_000_000,
|
||||
memUsedInBytes: 10_000_000_000,
|
||||
uptime: 3600,
|
||||
rebootRequired: false,
|
||||
availablePkgUpdates: 0,
|
||||
loadAverage: null,
|
||||
gpu: [],
|
||||
network: { up: 20_000, down: 10_000 },
|
||||
fileSystem: [{ deviceName: "tank", available: "1000", used: "500", percentage: 50 }],
|
||||
// `available` is the free space left on the pool (free = 500), not its total size.
|
||||
fileSystem: [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }],
|
||||
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,14 +37,19 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
||||
const upload = this.extractNetworkTrafficData(netdata, 2); // Index 2 is "sent"
|
||||
const download = this.extractNetworkTrafficData(netdata, 1); // Index 1 is "received"
|
||||
|
||||
// The "memory" reporting graph reports available (free) memory, its legend is ["time", "available"].
|
||||
// Used memory is therefore the physical total minus what is still available (clamped against timing skew).
|
||||
const memAvailableInBytes = memoryData[1] ?? 0; // Index 0 is the UNIX timestamp, Index 1 is available bytes
|
||||
const memUsedInBytes = Math.max(systemInformation.physmem - memAvailableInBytes, 0);
|
||||
|
||||
return {
|
||||
cpuUtilization: cpuData.reduce((acc, item) => acc + (item > 100 ? 0 : item), 0) / cpuData.length,
|
||||
cpuTemp: Math.max(...cpuTempData.filter((_item, index) => index > 0)),
|
||||
memAvailableInBytes: systemInformation.physmem,
|
||||
memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes
|
||||
memAvailableInBytes,
|
||||
memUsedInBytes,
|
||||
fileSystem: datasets.map((dataset) => ({
|
||||
deviceName: dataset.name,
|
||||
available: `${dataset.size}`, // TODO: can we use number instead of string here?
|
||||
available: `${dataset.free}`, // free space left on the pool
|
||||
used: `${dataset.allocated}`,
|
||||
percentage: (dataset.allocated / dataset.size) * 100,
|
||||
})),
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from "./interfaces/network-controller-summary/network-controller-summar
|
||||
export * from "./interfaces/firewall-summary/firewall-summary-types";
|
||||
export * from "./interfaces/health-monitoring/health-monitoring-types";
|
||||
export * from "./interfaces/ups-summary/ups-summary-types";
|
||||
export * from "./interfaces/vpn-summary/vpn-summary-types";
|
||||
export * from "./interfaces/indexer-manager/indexer-manager-types";
|
||||
export * from "./interfaces/media-requests/media-request-types";
|
||||
export * from "./base/searchable-integration";
|
||||
@@ -22,4 +23,5 @@ export * from "./audiobookshelf/audiobookshelf-types";
|
||||
export * from "./navidrome/navidrome-types";
|
||||
export * from "./paperless-ngx/paperless-ngx-types";
|
||||
export * from "./umami/umami-types";
|
||||
export * from "./gluetun/gluetun-types";
|
||||
export * from "./beszel/beszel-types";
|
||||
@@ -59,8 +59,8 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
|
||||
cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597
|
||||
fileSystem: systemInfo.array.disks.map((disk) => ({
|
||||
deviceName: disk.name,
|
||||
used: humanFileSize(disk.fsUsed * 1024), // API is in KiB (kibibytes), covert to bytes
|
||||
available: `${disk.size * 1024}`, // API is in KiB (kibibytes), covert to bytes
|
||||
used: humanFileSize(disk.fsUsed * 1024), // API is in KiB (kibibytes), convert to bytes
|
||||
available: `${(disk.size - disk.fsUsed) * 1024}`, // free space left on the disk, API is in KiB (kibibytes)
|
||||
percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant
|
||||
})),
|
||||
smart: systemInfo.array.disks.map((disk) => ({
|
||||
|
||||
@@ -2,15 +2,7 @@
|
||||
|
||||
import type { StartedTestContainer } from "testcontainers";
|
||||
import { GenericContainer, Wait } from "testcontainers";
|
||||
import {
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
afterAll,
|
||||
describe,
|
||||
expect,
|
||||
test,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { beforeAll, beforeEach, afterAll, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
@@ -31,15 +23,10 @@ vi.mock("@homarr/db", async (importActual) => {
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual =
|
||||
await importActual<
|
||||
typeof import("@homarr/core/infrastructure/certificates")
|
||||
>();
|
||||
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
|
||||
return {
|
||||
...actual,
|
||||
getTrustedCertificateHostnamesAsync: vi
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve([])),
|
||||
getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => Promise.resolve([])),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -90,9 +77,7 @@ const INTEGRATION_ID = "test-technitium";
|
||||
|
||||
const makeInput = (
|
||||
baseUrl: string,
|
||||
auth:
|
||||
| { kind: "credentials"; password?: string }
|
||||
| { kind: "apiKey"; value: string },
|
||||
auth: { kind: "credentials"; password?: string } | { kind: "apiKey"; value: string },
|
||||
): IntegrationInput => ({
|
||||
id: INTEGRATION_ID,
|
||||
name: "Technitium DNS",
|
||||
@@ -109,10 +94,7 @@ const makeInput = (
|
||||
|
||||
// Fetch an API token appropriate for the server version.
|
||||
// v15 has a dedicated createToken endpoint; legacy reuses the session token from login.
|
||||
async function fetchApiToken(
|
||||
baseUrl: string,
|
||||
apiVersion: TechnitiumVersion,
|
||||
): Promise<string> {
|
||||
async function fetchApiToken(baseUrl: string, apiVersion: TechnitiumVersion): Promise<string> {
|
||||
if (apiVersion === "v15") {
|
||||
// v15 exposes a dedicated endpoint for non-expiring API tokens.
|
||||
const res = await fetch(
|
||||
@@ -122,9 +104,7 @@ async function fetchApiToken(
|
||||
}
|
||||
// Legacy: /api/user/login exists on all tested versions (v11–v14) and returns a session
|
||||
// token that can be used as a long-lived API key via the ?token= query parameter.
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`,
|
||||
);
|
||||
const res = await fetch(`${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`);
|
||||
return ((await res.json()) as { token: string }).token;
|
||||
}
|
||||
|
||||
@@ -144,16 +124,12 @@ const pool: Record<string, ContainerEntry> = {};
|
||||
beforeAll(async () => {
|
||||
await Promise.all(
|
||||
VERSIONS.map(async ({ tag, apiVersion }) => {
|
||||
const container = await new GenericContainer(
|
||||
`technitium/dns-server:${tag}`,
|
||||
)
|
||||
const container = await new GenericContainer(`technitium/dns-server:${tag}`)
|
||||
.withExposedPorts(5380)
|
||||
// Explicitly set the admin password so all versions use the same credentials.
|
||||
// Without this, older versions may generate a random password on first run.
|
||||
.withEnvironment({ DNS_SERVER_ADMIN_PASSWORD: DEFAULT_PASSWORD })
|
||||
.withWaitStrategy(
|
||||
Wait.forLogMessage("Technitium DNS Server was started successfully."),
|
||||
)
|
||||
.withWaitStrategy(Wait.forLogMessage("Technitium DNS Server was started successfully."))
|
||||
.start();
|
||||
const baseUrl = `http://${container.getHost()}:${container.getMappedPort(5380)}`;
|
||||
const apiToken = await fetchApiToken(baseUrl, apiVersion);
|
||||
@@ -163,238 +139,209 @@ beforeAll(async () => {
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(
|
||||
Object.values(pool).map(({ container }) => container.stop()),
|
||||
);
|
||||
await Promise.all(Object.values(pool).map(({ container }) => container.stop()));
|
||||
});
|
||||
|
||||
// ─── version-parameterised suites ────────────────────────────────────────────
|
||||
|
||||
describe.each(VERSIONS)(
|
||||
"Technitium DNS $tag ($apiVersion)",
|
||||
({ tag, apiVersion }) => {
|
||||
let baseUrl: string;
|
||||
let apiToken: string;
|
||||
describe.each(VERSIONS)("Technitium DNS $tag ($apiVersion)", ({ tag, apiVersion }) => {
|
||||
let baseUrl: string;
|
||||
let apiToken: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const entry = pool[tag];
|
||||
if (!entry)
|
||||
throw new Error(`Container pool entry for ${tag} was not initialized`);
|
||||
({ baseUrl, apiToken } = entry);
|
||||
});
|
||||
beforeAll(() => {
|
||||
const entry = pool[tag];
|
||||
if (!entry) throw new Error(`Container pool entry for ${tag} was not initialized`);
|
||||
({ baseUrl, apiToken } = entry);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionState[INTEGRATION_ID] = null;
|
||||
});
|
||||
beforeEach(() => {
|
||||
sessionState[INTEGRATION_ID] = null;
|
||||
});
|
||||
|
||||
// ── factory ────────────────────────────────────────────────────────────────
|
||||
// ── factory ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
|
||||
test("factory creates a working integration (auto-detects version)", async () => {
|
||||
const integration = await createTechnitiumDnsIntegrationAsync(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
|
||||
test("factory creates a working integration (auto-detects version)", async () => {
|
||||
const integration = await createTechnitiumDnsIntegrationAsync(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
const result = await integration.getSummaryAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("enabled");
|
||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||
}, 20_000);
|
||||
});
|
||||
expect(result.status).toBe("enabled");
|
||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
// ── getSummaryAsync ────────────────────────────────────────────────────────
|
||||
// ── getSummaryAsync ────────────────────────────────────────────────────────
|
||||
|
||||
describe("getSummaryAsync", () => {
|
||||
test("returns valid summary with credentials", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
describe("getSummaryAsync", () => {
|
||||
test("returns valid summary with credentials", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
const result = await integration.getSummaryAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||
expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0);
|
||||
expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
|
||||
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
|
||||
expect(result.status).toBe("enabled");
|
||||
}, 20_000);
|
||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||
expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0);
|
||||
expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
|
||||
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
|
||||
expect(result.status).toBe("enabled");
|
||||
}, 20_000);
|
||||
|
||||
test("returns valid summary with API key", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
|
||||
apiVersion,
|
||||
);
|
||||
test("returns valid summary with API key", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
|
||||
apiVersion,
|
||||
);
|
||||
|
||||
const result = await integration.getSummaryAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("enabled");
|
||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||
}, 20_000);
|
||||
expect(result.status).toBe("enabled");
|
||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||
}, 20_000);
|
||||
|
||||
test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
const result = await integration.getSummaryAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(typeof result.domainsBeingBlocked).toBe("number");
|
||||
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
|
||||
}, 20_000);
|
||||
});
|
||||
expect(typeof result.domainsBeingBlocked).toBe("number");
|
||||
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
// ── session caching ────────────────────────────────────────────────────────
|
||||
// ── session caching ────────────────────────────────────────────────────────
|
||||
|
||||
describe("session caching", () => {
|
||||
test("login is performed once and token is cached for subsequent calls", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
describe("session caching", () => {
|
||||
test("login is performed once and token is cached for subsequent calls", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
await integration.getSummaryAsync();
|
||||
const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
|
||||
expect(tokenAfterFirst).toBeDefined();
|
||||
await integration.getSummaryAsync();
|
||||
const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
|
||||
expect(tokenAfterFirst).toBeDefined();
|
||||
|
||||
await integration.getSummaryAsync();
|
||||
const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
|
||||
await integration.getSummaryAsync();
|
||||
const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
|
||||
|
||||
expect(tokenAfterSecond).toBe(tokenAfterFirst);
|
||||
}, 20_000);
|
||||
expect(tokenAfterSecond).toBe(tokenAfterFirst);
|
||||
}, 20_000);
|
||||
|
||||
test("recovers from an expired/invalid stored token by re-logging in", async () => {
|
||||
// Plant a bad token with the correct version so it hits the right API path before failing
|
||||
sessionState[INTEGRATION_ID] = {
|
||||
token: "deliberately-invalid-token",
|
||||
version: apiVersion,
|
||||
};
|
||||
test("recovers from an expired/invalid stored token by re-logging in", async () => {
|
||||
// Plant a bad token with the correct version so it hits the right API path before failing
|
||||
sessionState[INTEGRATION_ID] = {
|
||||
token: "deliberately-invalid-token",
|
||||
version: apiVersion,
|
||||
};
|
||||
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
apiVersion,
|
||||
);
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }), apiVersion);
|
||||
|
||||
const result = await integration.getSummaryAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("enabled");
|
||||
expect(sessionState[INTEGRATION_ID]).not.toBeNull();
|
||||
expect(sessionState[INTEGRATION_ID]?.token).not.toBe(
|
||||
"deliberately-invalid-token",
|
||||
);
|
||||
}, 20_000);
|
||||
});
|
||||
expect(result.status).toBe("enabled");
|
||||
expect(sessionState[INTEGRATION_ID]).not.toBeNull();
|
||||
expect(sessionState[INTEGRATION_ID]?.token).not.toBe("deliberately-invalid-token");
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
// ── blocking state changes ─────────────────────────────────────────────────
|
||||
// ── blocking state changes ─────────────────────────────────────────────────
|
||||
|
||||
describe("blocking state changes", () => {
|
||||
test("disableAsync permanently disables blocking", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
describe("blocking state changes", () => {
|
||||
test("disableAsync permanently disables blocking", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
await integration.disableAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
await integration.disableAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("disabled");
|
||||
expect(result.status).toBe("disabled");
|
||||
|
||||
await integration.enableAsync(); // restore
|
||||
}, 20_000);
|
||||
await integration.enableAsync(); // restore
|
||||
}, 20_000);
|
||||
|
||||
test("enableAsync re-enables blocking after permanent disable", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
test("enableAsync re-enables blocking after permanent disable", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
await integration.disableAsync();
|
||||
await integration.enableAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
await integration.disableAsync();
|
||||
await integration.enableAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("enabled");
|
||||
}, 20_000);
|
||||
expect(result.status).toBe("enabled");
|
||||
}, 20_000);
|
||||
|
||||
test("disableAsync with duration temporarily disables blocking", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
test("disableAsync with duration temporarily disables blocking", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
await integration.disableAsync(120); // 120s → 2 min
|
||||
const result = await integration.getSummaryAsync();
|
||||
await integration.disableAsync(120); // 120s → 2 min
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("disabled");
|
||||
expect(result.status).toBe("disabled");
|
||||
|
||||
await integration.enableAsync(); // restore
|
||||
}, 20_000);
|
||||
await integration.enableAsync(); // restore
|
||||
}, 20_000);
|
||||
|
||||
test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
await integration.disableAsync(1);
|
||||
const result = await integration.getSummaryAsync();
|
||||
await integration.disableAsync(1);
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("disabled");
|
||||
expect(result.status).toBe("disabled");
|
||||
|
||||
await integration.enableAsync(); // restore
|
||||
}, 20_000);
|
||||
await integration.enableAsync(); // restore
|
||||
}, 20_000);
|
||||
|
||||
test("enableAsync cancels an active timed disable immediately", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
test("enableAsync cancels an active timed disable immediately", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
await integration.disableAsync(600); // 10 minutes
|
||||
await integration.enableAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
await integration.disableAsync(600); // 10 minutes
|
||||
await integration.enableAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("enabled");
|
||||
}, 20_000);
|
||||
});
|
||||
expect(result.status).toBe("enabled");
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
// ── testConnectionAsync ────────────────────────────────────────────────────
|
||||
// ── testConnectionAsync ────────────────────────────────────────────────────
|
||||
|
||||
describe("testConnectionAsync", () => {
|
||||
test("succeeds with valid credentials", async () => {
|
||||
const result = await new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
).testConnectionAsync();
|
||||
expect(result.success).toBe(true);
|
||||
}, 20_000);
|
||||
describe("testConnectionAsync", () => {
|
||||
test("succeeds with valid credentials", async () => {
|
||||
const result = await new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
).testConnectionAsync();
|
||||
expect(result.success).toBe(true);
|
||||
}, 20_000);
|
||||
|
||||
test("succeeds with valid API key", async () => {
|
||||
const result = await new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
|
||||
apiVersion,
|
||||
).testConnectionAsync();
|
||||
expect(result.success).toBe(true);
|
||||
}, 20_000);
|
||||
test("succeeds with valid API key", async () => {
|
||||
const result = await new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
|
||||
apiVersion,
|
||||
).testConnectionAsync();
|
||||
expect(result.success).toBe(true);
|
||||
}, 20_000);
|
||||
|
||||
test("fails with wrong password — returns authorization error", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, {
|
||||
kind: "credentials",
|
||||
password: "wrong-password",
|
||||
}),
|
||||
);
|
||||
test("fails with wrong password — returns authorization error", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, {
|
||||
kind: "credentials",
|
||||
password: "wrong-password",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await integration.testConnectionAsync();
|
||||
const result = await integration.testConnectionAsync();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error).toBeInstanceOf(TestConnectionError);
|
||||
expect(result.error.type).toBe("authorization");
|
||||
}, 20_000);
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error).toBeInstanceOf(TestConnectionError);
|
||||
expect(result.error.type).toBe("authorization");
|
||||
}, 20_000);
|
||||
|
||||
test("fails with invalid API key — returns authorization error", async () => {
|
||||
const result = await new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
|
||||
apiVersion,
|
||||
).testConnectionAsync();
|
||||
test("fails with invalid API key — returns authorization error", async () => {
|
||||
const result = await new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
|
||||
apiVersion,
|
||||
).testConnectionAsync();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error).toBeInstanceOf(TestConnectionError);
|
||||
expect(result.error.type).toBe("authorization");
|
||||
}, 20_000);
|
||||
});
|
||||
},
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error).toBeInstanceOf(TestConnectionError);
|
||||
expect(result.error.type).toBe("authorization");
|
||||
}, 20_000);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
createGetSetChannel,
|
||||
createQueryCacheChannel,
|
||||
getAllQueryCacheAsync,
|
||||
invalidateIntegrationCacheAsync,
|
||||
} from "./lib/channel";
|
||||
|
||||
export { createIntegrationHistoryChannel } from "./lib/channels/history-channel";
|
||||
|
||||
@@ -261,6 +261,36 @@ export const createIntegrationOptionsChannel = <TData>(
|
||||
return createChannelWithLatestAndEvents<TData>(channelName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Invalidates all cached data for a given integration by deleting every Redis key
|
||||
* that starts with `integration:${integrationId}:` as well as the integration's
|
||||
* session store entry. This ensures that stale data from a misconfigured or
|
||||
* updated integration is never served from cache.
|
||||
*/
|
||||
export const invalidateIntegrationCacheAsync = async (integrationId: string): Promise<void> => {
|
||||
const client = getSetClient as typeof getSetClient | null;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
const patterns = [`integration:${integrationId}:*`, `session-store:${integrationId}`];
|
||||
let deletedCount = 0;
|
||||
for (const pattern of patterns) {
|
||||
let cursor = "0";
|
||||
do {
|
||||
const [nextCursor, keys] = await client.scan(cursor, "MATCH", pattern, "COUNT", 200);
|
||||
cursor = nextCursor;
|
||||
if (keys.length > 0) {
|
||||
await client.del(...keys);
|
||||
deletedCount += keys.length;
|
||||
}
|
||||
} while (cursor !== "0");
|
||||
}
|
||||
if (deletedCount > 0) {
|
||||
logger.info("Invalidated integration cache", { integrationId, deletedKeys: deletedCount });
|
||||
}
|
||||
};
|
||||
|
||||
export const createWidgetOptionsChannel = <TData>(
|
||||
widgetKind: WidgetKind,
|
||||
queryKey: string,
|
||||
@@ -385,8 +415,18 @@ export const createChannelWithLatestAndEvents = <TData>(channelName: string) =>
|
||||
});
|
||||
},
|
||||
publishAndUpdateLastStateAsync: async (data: TData) => {
|
||||
await publisher.publish(channelName, superjson.stringify(data));
|
||||
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
||||
// Set first, then publish. If publish fails the storage is still consistent
|
||||
// and subscribers will catch up on the next event.
|
||||
const payload = superjson.stringify({ data, timestamp: new Date() });
|
||||
await getSetClient.set(channelName, payload);
|
||||
try {
|
||||
await publisher.publish(channelName, superjson.stringify(data));
|
||||
} catch (error) {
|
||||
logger.warn("Failed to publish cache update (storage is correct, subscribers will catch up)", {
|
||||
channel: channelName,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
setAsync: async (data: TData) => {
|
||||
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type {
|
||||
BeszelAlert,
|
||||
@@ -13,6 +14,8 @@ import type {
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
|
||||
const logger = createLogger({ module: "beszelRequestHandler" });
|
||||
|
||||
export type { BeszelSystemRow } from "@homarr/integrations/types";
|
||||
|
||||
/**
|
||||
@@ -56,6 +59,7 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
|
||||
Record<string, never>
|
||||
>({
|
||||
async requestAsync(integration) {
|
||||
const start = performance.now();
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
const systems = await instance.getSystemsAsync();
|
||||
const enriched = await Promise.all(
|
||||
@@ -64,9 +68,16 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
|
||||
return mapToSystemRow(system, details);
|
||||
}),
|
||||
);
|
||||
logger.debug("beszelSystems fetch completed", {
|
||||
integrationId: integration.id,
|
||||
durationMs: Math.round(performance.now() - start),
|
||||
count: enriched.length,
|
||||
});
|
||||
return enriched;
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "seconds"),
|
||||
cacheDuration: dayjs.duration(1, "second"),
|
||||
fallbackToStaleOnError: true,
|
||||
retry: { attempts: 2, delayMs: 500 },
|
||||
queryKey: "beszelSystems",
|
||||
});
|
||||
|
||||
@@ -81,22 +92,31 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
{ includeHistory: boolean; maxHistoryItems: number }
|
||||
>({
|
||||
async requestAsync(integration, input) {
|
||||
const start = performance.now();
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
const alerts = await instance.getAlertsAsync();
|
||||
const history = input.includeHistory ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) : [];
|
||||
logger.debug("beszelAlerts fetch completed", {
|
||||
integrationId: integration.id,
|
||||
durationMs: Math.round(performance.now() - start),
|
||||
alertsCount: alerts.length,
|
||||
historyCount: history.length,
|
||||
});
|
||||
return { alerts, history };
|
||||
},
|
||||
cacheDuration: dayjs.duration(15, "seconds"),
|
||||
cacheDuration: dayjs.duration(1, "second"),
|
||||
fallbackToStaleOnError: true,
|
||||
retry: { attempts: 2, delayMs: 500 },
|
||||
queryKey: "beszelAlerts",
|
||||
});
|
||||
|
||||
const timePeriodConfig: Record<string, { type: string; perPage: number; cacheSeconds: number }> = {
|
||||
"1m": { type: "1m", perPage: 60, cacheSeconds: 5 },
|
||||
"1h": { type: "1m", perPage: 60, cacheSeconds: 60 },
|
||||
"12h": { type: "10m", perPage: 72, cacheSeconds: 300 },
|
||||
"24h": { type: "20m", perPage: 72, cacheSeconds: 600 },
|
||||
"1w": { type: "120m", perPage: 84, cacheSeconds: 1800 },
|
||||
"30d": { type: "480m", perPage: 90, cacheSeconds: 3600 },
|
||||
const timePeriodConfig: Record<string, { type: string; perPage: number }> = {
|
||||
"1m": { type: "1m", perPage: 60 },
|
||||
"1h": { type: "1m", perPage: 60 },
|
||||
"12h": { type: "10m", perPage: 72 },
|
||||
"24h": { type: "20m", perPage: 72 },
|
||||
"1w": { type: "480m", perPage: 21 },
|
||||
"30d": { type: "480m", perPage: 90 },
|
||||
};
|
||||
|
||||
export interface BeszelStatsData {
|
||||
@@ -110,19 +130,25 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
{ systemId: string; timePeriod: string; includeDocker: boolean }
|
||||
>({
|
||||
async requestAsync(integration, input) {
|
||||
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 60 };
|
||||
const start = performance.now();
|
||||
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60 };
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
|
||||
const containerStats = input.includeDocker
|
||||
? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => [])
|
||||
: [];
|
||||
logger.debug("beszelStats fetch completed", {
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
timePeriod: input.timePeriod,
|
||||
durationMs: Math.round(performance.now() - start),
|
||||
systemStatsCount: systemStats.length,
|
||||
containerStatsCount: containerStats.length,
|
||||
});
|
||||
return { systemStats, containerStats };
|
||||
},
|
||||
cacheDuration: dayjs.duration(60, "seconds"),
|
||||
cacheDuration: dayjs.duration(1, "second"),
|
||||
fallbackToStaleOnError: true,
|
||||
retry: { attempts: 2, delayMs: 500 },
|
||||
queryKey: "beszelStats",
|
||||
cacheDurationForInput(input) {
|
||||
const config = timePeriodConfig[input.timePeriod];
|
||||
if (!config) return undefined;
|
||||
return dayjs.duration(config.cacheSeconds, "seconds");
|
||||
},
|
||||
});
|
||||
@@ -18,6 +18,9 @@ interface Options<TData, TKind extends IntegrationKind, TInput extends Record<st
|
||||
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
|
||||
cacheDuration: Duration;
|
||||
cacheDurationForInput?: (input: TInput) => Duration | undefined;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
retry?: { attempts?: number; delayMs?: number };
|
||||
isValid?: (data: TData) => boolean;
|
||||
}
|
||||
|
||||
export const createCachedIntegrationRequestHandler = <
|
||||
@@ -35,6 +38,9 @@ export const createCachedIntegrationRequestHandler = <
|
||||
return await options.requestAsync(input.integration, input.options);
|
||||
},
|
||||
cacheDuration: options.cacheDurationForInput?.(itemOptions) ?? options.cacheDuration,
|
||||
fallbackToStaleOnError: options.fallbackToStaleOnError,
|
||||
retry: options.retry,
|
||||
isValid: options.isValid,
|
||||
createRedisChannel(input, handlerOptions) {
|
||||
return createIntegrationOptionsChannel<TData>(input.integration.id, handlerOptions.queryKey, input.options);
|
||||
},
|
||||
|
||||
@@ -7,6 +7,16 @@ import type { createChannelWithLatestAndEvents } from "@homarr/redis";
|
||||
|
||||
const logger = createLogger({ module: "cachedRequestHandler" });
|
||||
|
||||
type FetchResult<TData> = { data: TData; timestamp: Date };
|
||||
type InFlightEntry = Promise<FetchResult<unknown>>;
|
||||
|
||||
const inFlightFetches = new Map<string, InFlightEntry>();
|
||||
|
||||
const isAbortedError = (error: unknown): boolean => {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return error.name === "AbortError" || error.name === "TimeoutError";
|
||||
};
|
||||
|
||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
// Unique key for this request handler
|
||||
queryKey: string;
|
||||
@@ -17,28 +27,70 @@ interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
) => ReturnType<typeof createChannelWithLatestAndEvents<TData>>;
|
||||
cacheDuration: Duration;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
// Retry the upstream call on failure. attempts=1 means no retry (default).
|
||||
// AbortError / TimeoutError are never retried.
|
||||
retry?: { attempts?: number; delayMs?: number };
|
||||
// Predicate to decide whether a fetched response is worth caching.
|
||||
// Return false to skip the cache write (next call will refetch).
|
||||
isValid?: (data: TData) => boolean;
|
||||
}
|
||||
|
||||
const defaultRetry = { attempts: 1, delayMs: 0 };
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export const createCachedRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||
options: Options<TData, TInput>,
|
||||
) => {
|
||||
return {
|
||||
handler: (input: TInput) => {
|
||||
const channel = options.createRedisChannel(input, options);
|
||||
const retryConfig = { ...defaultRetry, ...options.retry };
|
||||
|
||||
return {
|
||||
async getCachedOrUpdatedDataAsync({ forceUpdate = false }) {
|
||||
const channelData = await channel.getAsync();
|
||||
|
||||
const requestNewDataAsync = async () => {
|
||||
try {
|
||||
const data = await options.requestAsync(input);
|
||||
await channel.publishAndUpdateLastStateAsync(data);
|
||||
return {
|
||||
data,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
const requestNewDataAsync = (): Promise<FetchResult<TData>> => {
|
||||
const inflight = inFlightFetches.get(channel.name);
|
||||
if (inflight) {
|
||||
logger.debug("Cached request handler joining in-flight fetch", {
|
||||
channel: channel.name,
|
||||
queryKey: options.queryKey,
|
||||
});
|
||||
return inflight as Promise<FetchResult<TData>>;
|
||||
}
|
||||
|
||||
const promise = (async (): Promise<FetchResult<TData>> => {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= retryConfig.attempts; attempt++) {
|
||||
try {
|
||||
const data = await options.requestAsync(input);
|
||||
if (options.isValid && !options.isValid(data)) {
|
||||
logger.warn("Cached request handler received invalid data, not caching", {
|
||||
channel: channel.name,
|
||||
queryKey: options.queryKey,
|
||||
attempt,
|
||||
});
|
||||
return { data, timestamp: new Date() };
|
||||
}
|
||||
await channel.publishAndUpdateLastStateAsync(data);
|
||||
return { data, timestamp: new Date() };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (isAbortedError(error) || attempt === retryConfig.attempts) {
|
||||
break;
|
||||
}
|
||||
logger.warn("Cached request handler fetch failed, retrying", {
|
||||
channel: channel.name,
|
||||
queryKey: options.queryKey,
|
||||
attempt,
|
||||
nextDelayMs: retryConfig.delayMs,
|
||||
error: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
|
||||
});
|
||||
await sleep(retryConfig.delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.fallbackToStaleOnError && channelData) {
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
@@ -46,15 +98,21 @@ export const createCachedRequestHandler = <TData, TInput extends Record<string,
|
||||
{
|
||||
channel: channel.name,
|
||||
queryKey: options.queryKey,
|
||||
attempts: retryConfig.attempts,
|
||||
},
|
||||
{ cause: error },
|
||||
{ cause: lastError },
|
||||
),
|
||||
);
|
||||
return channelData;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
throw lastError;
|
||||
})().finally(() => {
|
||||
inFlightFetches.delete(channel.name);
|
||||
});
|
||||
|
||||
inFlightFetches.set(channel.name, promise as InFlightEntry);
|
||||
return promise;
|
||||
};
|
||||
|
||||
if (forceUpdate) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { VpnSummary } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
|
||||
const logger = createLogger({ module: "vpnRequestHandler" });
|
||||
|
||||
export const vpnSummaryHandler = createCachedIntegrationRequestHandler<
|
||||
VpnSummary | null,
|
||||
IntegrationKindByCategory<"vpn">,
|
||||
Record<string, never>
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
try {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getSummaryAsync();
|
||||
} catch (error) {
|
||||
// The VPN control server is unreachable (e.g. the container is down).
|
||||
// Return null so the widget can render an "unavailable" status instead of
|
||||
// surfacing a hard error, and so it recovers automatically once it is back up.
|
||||
logger.warn("Failed to fetch VPN summary, marking as unavailable", {
|
||||
integrationId: integration.id,
|
||||
integrationKind: integration.kind,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
queryKey: "vpnSummary",
|
||||
});
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"usersData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "",
|
||||
"search": "",
|
||||
"selected": "",
|
||||
"footer": ""
|
||||
"footer": "",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "发送匿名分析",
|
||||
"text": "Homarr 将使用开源软件 Umami 发送匿名分析数据。启用后,我们会收集:Homarr 版本、数据库类型(MySQL/PostgreSQL/SQLite)、Docker/Kubernetes 状态、已启用的身份验证方法以及各项功能的数量。所有数据均为匿名且汇总收集——绝不收集任何个人信息、URL、名称或可识别数据。这有助于我们的开源团队了解使用模式并确定开发优先级。"
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "组件数据",
|
||||
"text": "发送您已配置的组件(及其数量)。不包括URL、名称或任何其他数据。"
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "集成数据",
|
||||
"text": "发送您已配置的集成(及其数量)。不包括URL、名称或任何其他数据。"
|
||||
},
|
||||
"usersData": {
|
||||
"title": "用户数据",
|
||||
"text": "发送用户数量以及您是否激活了SSO"
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "已更新 {when}",
|
||||
"search": "搜索 {count} 容器",
|
||||
"selected": "{totalCount} 容器的 {selectCount}",
|
||||
"footer": "总计 {count} 容器"
|
||||
"footer": "总计 {count} 容器",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4413,20 +4413,8 @@
|
||||
"analytics": {
|
||||
"title": "crwdns1764:0crwdne1764:0",
|
||||
"general": {
|
||||
"title": "crwdns1766:0crwdne1766:0",
|
||||
"text": "crwdns3878:0crwdne3878:0"
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "crwdns1770:0crwdne1770:0",
|
||||
"text": "crwdns1772:0crwdne1772:0"
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "crwdns1774:0crwdne1774:0",
|
||||
"text": "crwdns1776:0crwdne1776:0"
|
||||
},
|
||||
"usersData": {
|
||||
"title": "crwdns1778:0crwdne1778:0",
|
||||
"text": "crwdns1780:0crwdne1780:0"
|
||||
"title": "crwdns5673:0crwdne5673:0",
|
||||
"text": "crwdns5675:0crwdne5675:0"
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "crwdns1894:0{when}crwdne1894:0",
|
||||
"search": "crwdns3432:0{count}crwdne3432:0",
|
||||
"selected": "crwdns1898:0{selectCount}crwdnd1898:0{totalCount}crwdne1898:0",
|
||||
"footer": "crwdns3434:0{count}crwdne3434:0"
|
||||
"footer": "crwdns3434:0{count}crwdne3434:0",
|
||||
"totalCpu": "crwdns5669:0{cpu}crwdne5669:0",
|
||||
"totalMemory": "crwdns5671:0{memory}crwdne5671:0"
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"usersData": {
|
||||
"title": "Uživatelská data",
|
||||
"text": ""
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "",
|
||||
"search": "",
|
||||
"selected": "",
|
||||
"footer": ""
|
||||
"footer": "",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "Indsend anonyme analysedata",
|
||||
"text": "Homarr vil sende anonymiserede analyser ved hjælp af open source-softwaren Umami. Den indsamler aldrig personlige oplysninger og er derfor fuldt ud GDPR & CCPA kompatibel. Vi opfordrer dig til at aktivere analyser, fordi det hjælper vores open source-team til at identificere problemer og prioritere vores backlog."
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "Widget data",
|
||||
"text": "Send hvilke widgets (og deres antal) du har konfigureret. Indeholder ikke URL'er, navne eller andre data."
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "Integrationsdata",
|
||||
"text": "Send hvilke integrationer (og deres antal) du har konfigureret. Indeholder ikke URL'er, navne eller andre data."
|
||||
},
|
||||
"usersData": {
|
||||
"title": "Brugerdata",
|
||||
"text": "Send mængden af brugere, og om du har aktiveret SSO"
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "Opdateret {when}",
|
||||
"search": "Søg {count} containere",
|
||||
"selected": "{selectCount} af {totalCount} containere valgt",
|
||||
"footer": "Total {count} containere"
|
||||
"footer": "Total {count} containere",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "Anonymisierte Nutzungsdaten senden",
|
||||
"text": "Homarr schickt anonymisierte Nutzungsdaten mit der Open-Source-Software Umami. Es sammelt zu keinem Zeitpunkt personenbezogene Daten und ist daher vollständig DPR & CCPA konform. Wir empfehlen diese Nutzungsdaten zu aktivieren, da es unserem Open-Source-Team hilft Probleme zu identifizieren und unserem Backlog ermöglicht Priorität einzuräumen."
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "Widget Daten",
|
||||
"text": "Senden Sie die Widgets (und deren Anzahl) die Sie konfiguriert haben. Enthält keine URLs, Namen oder andere Daten."
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "Integrationsdaten",
|
||||
"text": "Senden Sie die Intregrationen (und deren Anzahl) die Sie konfiguriert haben. Enthält keine URLs, Namen oder andere Daten."
|
||||
},
|
||||
"usersData": {
|
||||
"title": "Benutzerdaten",
|
||||
"text": "Senden Sie die Anzahl der Benutzer und ob Sie SSO aktiviert haben"
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "Aktualisiert {when}",
|
||||
"search": "{count} Container durchsuchen",
|
||||
"selected": "{selectCount} von {totalCount} ausgewählten Containern",
|
||||
"footer": ""
|
||||
"footer": "",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -3374,27 +3374,27 @@
|
||||
"pausedCount": "Pausiert"
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "Fehler beim Abrufen der Laufzeit Daten"
|
||||
}
|
||||
},
|
||||
"audioStats": {
|
||||
"name": "Audiostatistiken",
|
||||
"description": "",
|
||||
"description": "Bibliotheks- und Wiedergabestatistiken von Navidrome oder Audiobookshelf",
|
||||
"option": {
|
||||
"showArtists": {
|
||||
"label": "Künstler anzeigen",
|
||||
"description": ""
|
||||
"description": "Zeige die Gesamtzahl der Künstler in deiner Musikbibliothek (Navidrome)"
|
||||
},
|
||||
"showAlbums": {
|
||||
"label": "",
|
||||
"label": "Alben anzeigen",
|
||||
"description": "Zeigt die Gesamtzahl der Alben in deiner Musikbibliothek an (Navidrome)"
|
||||
},
|
||||
"showSongs": {
|
||||
"label": "Songs anzeigen",
|
||||
"description": ""
|
||||
"description": "Zeigt die Gesamtzahl der Titel in deiner Musikbibliothek an (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"label": "\"Sie hören\" anzeigen",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
@@ -3720,7 +3720,7 @@
|
||||
},
|
||||
"chart": {
|
||||
"cpu": {
|
||||
"title": "CPU-Auslastung",
|
||||
"title": "CPU Auslastung",
|
||||
"subtitle": "",
|
||||
"series": "CPU"
|
||||
},
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "Anonymisierte Nutzungsdaten senden",
|
||||
"text": "Homarr sendet anonymisierte Analysen mit der Open-Source-Software Umami. Wenn aktiviert, sammeln wir die Homarr-Version, den Datenbanktyp (MySQL/PostgreSQL/SQLite), den Docker/Kubernetes Status, die Authentifizierungsmethoden und die Anzahl der einzelnen Features. Alle Daten sind anonym und aggregiert - es werden keine persönlichen Informationen, URLs, Namen oder identifizierbare Daten gesammelt. Dies hilft unserem Open-Source-Team, Nutzungsmuster zu verstehen und der Entwicklung Priorität einzuräumen."
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "Widget Daten",
|
||||
"text": "Senden Sie die Widgets (und deren Anzahl) die Sie konfiguriert haben. Enthält keine URLs, Namen oder andere Daten."
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "Integrationsdaten",
|
||||
"text": "Senden Sie die Intregrationen (und deren Anzahl) die Sie konfiguriert haben. Enthält keine URLs, Namen oder andere Daten."
|
||||
},
|
||||
"usersData": {
|
||||
"title": "Benutzerdaten",
|
||||
"text": "Senden Sie die Anzahl der Benutzer und ob Sie SSO aktiviert haben"
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "Aktualisiert {when}",
|
||||
"search": "{count} Container durchsuchen",
|
||||
"selected": "{selectCount} von {totalCount} ausgewählten Containern",
|
||||
"footer": "Insgesamt {count} Container"
|
||||
"footer": "Insgesamt {count} Container",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"usersData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "",
|
||||
"search": "",
|
||||
"selected": "",
|
||||
"footer": ""
|
||||
"footer": "",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Password",
|
||||
"suggestionsTitle": "",
|
||||
"suggestionsTitle": "Password suggestions",
|
||||
"suggestion": {
|
||||
"length": "Includes a minimum of 8 characters",
|
||||
"lowercase": "Includes a lower-case letter",
|
||||
@@ -687,28 +687,28 @@
|
||||
"customWidget": {
|
||||
"page": {
|
||||
"list": {
|
||||
"title": "",
|
||||
"noResults": "",
|
||||
"loading": ""
|
||||
"title": "Custom Widgets",
|
||||
"noResults": "No custom widgets yet. Create your first one!",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"create": {
|
||||
"title": "",
|
||||
"subtitle": ""
|
||||
"title": "Create Custom Widget",
|
||||
"subtitle": "Define an API URL, authentication, and how to display the response data."
|
||||
},
|
||||
"edit": {
|
||||
"title": ""
|
||||
"title": "Edit Custom Widget"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "",
|
||||
"import": "",
|
||||
"save": "",
|
||||
"delete": "",
|
||||
"deleteConfirm": "",
|
||||
"export": "",
|
||||
"addMapping": "",
|
||||
"addColumn": "",
|
||||
"edit": "",
|
||||
"create": "Create",
|
||||
"import": "Import",
|
||||
"save": "Save",
|
||||
"delete": "Delete Custom Widget",
|
||||
"deleteConfirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
|
||||
"export": "Export",
|
||||
"addMapping": "Add Mapping",
|
||||
"addColumn": "Add Column",
|
||||
"edit": "Edit",
|
||||
"removeMapping": "",
|
||||
"removeColumn": "",
|
||||
"addItem": "",
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"usersData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "",
|
||||
"search": "",
|
||||
"selected": "",
|
||||
"footer": ""
|
||||
"footer": "",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
@@ -5609,71 +5599,71 @@
|
||||
"label": "",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Select your colour scheme"
|
||||
}
|
||||
}
|
||||
},
|
||||
"locale": {
|
||||
"label": "",
|
||||
"label": "Language",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Select your preferred language"
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultSearchEngineId": {
|
||||
"label": "",
|
||||
"label": "Default search engine",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Select your default search engine"
|
||||
}
|
||||
}
|
||||
},
|
||||
"openSearchInNewTab": {
|
||||
"label": "",
|
||||
"label": "Open search results in new tab",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Open search results in new tab"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ddgBangs": {
|
||||
"label": "",
|
||||
"label": "Enable DuckDuckGo bangs",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Enable DuckDuckGo bangs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"firstDayOfWeek": {
|
||||
"label": "",
|
||||
"label": "First day of the week",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Select the first day of the week"
|
||||
}
|
||||
}
|
||||
},
|
||||
"homeBoardId": {
|
||||
"label": "",
|
||||
"label": "Home board",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Select your home board"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mobileHomeBoardId": {
|
||||
"label": "",
|
||||
"label": "Mobile home board",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Select your mobile home board"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pingIconsEnabled": {
|
||||
"label": "",
|
||||
"label": "Use icons for pings",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Use icons for pings"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "Monitoring",
|
||||
"speedtest": "Speed Test",
|
||||
"analytics": "Analytics",
|
||||
"vpn": "VPN",
|
||||
"mediaLibrary": "Media Library",
|
||||
"uptimeMonitoring": "Uptime Monitoring"
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "here",
|
||||
"iconPicker": {
|
||||
"label": "Icon URL",
|
||||
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you."
|
||||
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
|
||||
"headerLoading": "Type to search icons…",
|
||||
"noResults": "No icons match \"{search}\".",
|
||||
"error": "Couldn't load icons."
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "Switch to dark mode",
|
||||
"switchToLightMode": "Switch to light mode",
|
||||
"management": "Management",
|
||||
"docker": "Docker",
|
||||
"preferences": "Your preferences",
|
||||
"logout": "Logout",
|
||||
"login": "Login",
|
||||
@@ -2405,6 +2410,12 @@
|
||||
"title": "Instances"
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "VPN",
|
||||
"description": "Overview of your VPN connections",
|
||||
"serviceUnavailable": "VPN unavailable",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "UPS",
|
||||
"description": "Monitor the status of your UPS devices through a NUT server",
|
||||
@@ -3639,6 +3650,9 @@
|
||||
"error": {
|
||||
"internalServerError": "Unable to connect to Beszel. Check your integration settings."
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": "No systems found"
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
@@ -3712,6 +3726,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "Select system",
|
||||
"empty": {
|
||||
"noSystems": "No systems found"
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Unable to connect to Beszel. Check your integration settings.",
|
||||
"liveConnectionFailed": "Live connection failed. Your Beszel instance may not support real-time streaming.",
|
||||
@@ -4133,6 +4150,11 @@
|
||||
"title": "Not Found",
|
||||
"text": "Could not find requested resource"
|
||||
},
|
||||
"error": {
|
||||
"title": "This page couldn't load",
|
||||
"text": "An unexpected error occurred while loading this page. Your navigation is still available — try reloading, or use another section from the sidebar.",
|
||||
"digest": "Error reference: {digest}"
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Home",
|
||||
@@ -4286,7 +4308,7 @@
|
||||
"notStarted": "Not started"
|
||||
},
|
||||
"name": {
|
||||
"management": "Management",
|
||||
"management": "Management",
|
||||
"dashboard": "Dashboard"
|
||||
}
|
||||
}
|
||||
@@ -4579,6 +4601,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": "Umami Active Visitors"
|
||||
},
|
||||
"vpn": {
|
||||
"label": "VPN Status"
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "Enviar estadísticas anónimas",
|
||||
"text": "Homarr enviará estadísticas anónimas usando el software de código abierto Umami. Nunca recopila información personal y por lo tanto cumple plenamente con GDPR y CCPA. Te animamos a habilitar esta característica ya que ayuda a nuestro equipo de código abierto a identificar problemas y priorizar su resolución."
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "Datos del widget",
|
||||
"text": "Enviar qué widgets (y su cantidad) has configurado. No incluye URLs, nombres ni ningún otro dato."
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "Datos de integración",
|
||||
"text": "Enviar qué integraciones (y su cantidad) has configurado. No incluye URLs, nombres ni ningún otro dato."
|
||||
},
|
||||
"usersData": {
|
||||
"title": "Datos de usuarios",
|
||||
"text": "Enviar la cantidad de usuarios y si has activado SSO"
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "Actualizado {when}",
|
||||
"search": "Buscar {count} contenedores",
|
||||
"selected": "{selectCount} de {totalCount} contenedores seleccionados",
|
||||
"footer": "{count} contenedores en total"
|
||||
"footer": "{count} contenedores en total",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"usersData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "",
|
||||
"search": "",
|
||||
"selected": "",
|
||||
"footer": ""
|
||||
"footer": "",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "Lähetä anonyymejä käyttötietoja",
|
||||
"text": "Homarr lähettää anonymisoitua analytiikkaa avoimen lähdekoodin Umami‑järjestelmän avulla. Kun analytiikka on käytössä, keräämme seuraavat tiedot: Homarrin version, tietokannan tyypin (MySQL/PostgreSQL/SQLite), Docker‑/Kubernetes‑tilan, käytössä olevat tunnistautumismenetelmät sekä eri ominaisuuksien lukumäärät. Kaikki tiedot on anonymisoitu ja koottu tilastomuotoon – mitään henkilötietoja, URL‑osoitteita, nimiä tai muita tunnistettavia tietoja ei koskaan kerätä. Tämä auttaa avoimen lähdekoodin tiimiämme ymmärtämään käyttötapoja ja priorisoimaan kehitystä."
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "Integraatiotiedot",
|
||||
"text": "Lähetä mitä ja miten monta integraatiota olet määrittänyt. Ei sisällä URL-osoitteita, nimiä tai mitään muita tietoja."
|
||||
},
|
||||
"usersData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "Päivitetty {when}",
|
||||
"search": "Suodata {count} konttia...",
|
||||
"selected": "{selectCount}/{totalCount} kontista valittu",
|
||||
"footer": "Yhteensä {count} konttia"
|
||||
"footer": "Yhteensä {count} konttia",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "Envoyer des statistiques anonymes",
|
||||
"text": "Homarr enverra des statistiques anonymes en utilisant le logiciel open source Umami. Il ne recueille jamais aucune information personnelle et est donc entièrement conforme au RGPD & CCPA. Nous vous encourageons à activer les statistiques parce qu'elles aident notre équipe open source à identifier les problèmes et à prioriser notre backlog."
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "Données du widget",
|
||||
"text": "Envoie quels widgets (et leur quantité) vous avez configurés. N'inclut pas les URLs, les noms ou toute autre donnée."
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "Données d'intégration",
|
||||
"text": "Envoie quelles intégrations (et leur quantité) vous avez configurées. N'inclut pas les URLs, les noms ou toute autre donnée."
|
||||
},
|
||||
"usersData": {
|
||||
"title": "Données utilisateur",
|
||||
"text": "Envoie le nombre d'utilisateurs et si vous avez activé le SSO"
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "Mis à jour {when}",
|
||||
"search": "Rechercher dans {count} conteneurs",
|
||||
"selected": "{selectCount} sur {totalCount} conteneurs sélectionnés",
|
||||
"footer": "Total des conteneurs {count}"
|
||||
"footer": "Total des conteneurs {count}",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "שליחת ניתוח נתונים אנונימי",
|
||||
"text": "Homarr ישלח ניתוחים אנונימיים באמצעות התוכנה בקוד פתוח Umami. הוא לעולם אינו אוסף מידע אישי ולכן תואם לחלוטין לדרישות ה-GDPR וה-CCPA. אנו מעודדים אותך להפעיל ניתוחים, כיוון שזה מסייע לצוות הקוד הפתוח שלנו לזהות בעיות ולהעניק עדיפות למשימות שברשימת העדיפויות שלנו."
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "נתוני ווידג׳ט",
|
||||
"text": "שלח אילו ווידג'טים (וכמותם) הוגדרו. אינו כולל כתובות רשת, שמות או כל מידע נוסף אחר."
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "נתוני אינטגרציה",
|
||||
"text": "שלח אילו אינטגרציות (וכמותם) הוגדרו. לא כולל כתובות רשת, שמות או כל מידע נוסף אחר."
|
||||
},
|
||||
"usersData": {
|
||||
"title": "נתוני משתמשים",
|
||||
"text": "שלח את כמות המשתמשים והאם הופעל SSO"
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "עודכן ב- {when}",
|
||||
"search": "חפש {count} קונטיינרים",
|
||||
"selected": "{selectCount} מ- {totalCount} קונטיינרים נבחרו",
|
||||
"footer": "סה״כ {count} קונטיינרים"
|
||||
"footer": "סה״כ {count} קונטיינרים",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"usersData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "",
|
||||
"search": "",
|
||||
"selected": "",
|
||||
"footer": ""
|
||||
"footer": "",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "",
|
||||
"text": "A Homarr az Umami nevű nyílt forráskódú szoftver segítségével névtelen elemzési adatokat küld. Ha ez az opció engedélyezve van, a következő adatokat gyűjtjük: a Homarr verziója, az adatbázis típusa (MySQL/PostgreSQL/SQLite), a Docker/Kubernetes állapota, az engedélyezett hitelesítési módszerek, valamint az egyes funkciók száma. Minden adat névtelen és összesített formában kerül rögzítésre – személyes adatokat, URL-eket, neveket vagy bármilyen azonosítható adatot soha nem gyűjtünk. Ez segít nyílt forráskódú fejlesztői csapatunknak a használati szokások megértésében és a fejlesztési feladatok fontossági sorrendjének meghatározásában."
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"usersData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "",
|
||||
"search": "",
|
||||
"selected": "",
|
||||
"footer": ""
|
||||
"footer": "",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "Invia analytics anonimi",
|
||||
"text": "Homarr invierà analisi anonime utilizzando il software open source Umami. Non raccoglie mai informazioni personali ed è quindi pienamente conforme al GDPR e al CCPA. Ti invitiamo ad abilitare l'analisi perché aiuta il nostro team open source a identificare i problemi e a dare priorità al nostro arretrato."
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "Dati widget",
|
||||
"text": "Invia quali widget (e la loro quantità) hai configurato. Non include URL, nomi o altri dati."
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "Dati di integrazione",
|
||||
"text": "Invia quali integrazioni (e la loro quantità) hai configurato. Non include URL, nomi o altri dati."
|
||||
},
|
||||
"usersData": {
|
||||
"title": "Dati utenti",
|
||||
"text": "Invia il numero di utenti e se hai attivato SSO"
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "Aggiornato {when}",
|
||||
"search": "Cerca {count} container",
|
||||
"selected": "{selectCount} di {totalCount} containers selezionati",
|
||||
"footer": "Totale container {count}"
|
||||
"footer": "Totale container {count}",
|
||||
"totalCpu": "CPU Totale: {cpu}%",
|
||||
"totalMemory": "Mem Totale: {memory}"
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "匿名の解析を送信",
|
||||
"text": "Homarrは、オープンソースソフトウェアUmamiを使用して匿名化された分析を送信します。 有効にすると、以下が収集されます。Homarrバージョン、データベースタイプ(MySQL/PostgreSQL/SQLite)、Docker/Kubernetesステータス、認証方法、各機能の数。 すべてのデータは匿名で集計されます。個人情報、URL、名前、または識別可能なデータは収集されません。 これにより、オープンソースチームは利用パターンを理解し、開発に優先順位を付けることができます。"
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "ウィジェットのデータ",
|
||||
"text": "あなたが設定したウィジェット(およびその数量)を送信します。URL・名前・その他のデータは含まれません。"
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "連携情報",
|
||||
"text": "あなたが設定した連携機能(およびその数量)を送信します。URL・名前・その他のデータは含まれません。"
|
||||
},
|
||||
"usersData": {
|
||||
"title": "ユーザー情報",
|
||||
"text": "SSOをアクティベートしたユーザーの数を送信します"
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "{when} に更新しました",
|
||||
"search": "{count} 個のコンテナを検索",
|
||||
"selected": "{totalCount} 個のうち、{selectCount} 個のコンテナが選択されています",
|
||||
"footer": "合計 {count} 個のコンテナ"
|
||||
"footer": "合計 {count} 個のコンテナ",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"usersData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "",
|
||||
"search": "",
|
||||
"selected": "",
|
||||
"footer": ""
|
||||
"footer": "",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"usersData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "",
|
||||
"search": "",
|
||||
"selected": "",
|
||||
"footer": ""
|
||||
"footer": "",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "Anonieme analyses sturen",
|
||||
"text": "Homarr verstuurt geanonimiseerde analyses met behulp van de open source software Umami. Het verzamelt nooit persoonlijke informatie en is daarom volledig GDPR & CCPA compliant. We moedigen je aan om analytics in te schakelen omdat het ons open source team helpt bij het identificeren van problemen en het prioriteren van onze backlog."
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "Widgetgegevens",
|
||||
"text": "Stuur welke widgets (en hun aantal) je hebt geconfigureerd. Bevat geen URL's, namen of andere gegevens."
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "Integratiegegevens",
|
||||
"text": "Stuur welke integraties (en het aantal) je hebt geconfigureerd. Bevat geen URL's, namen of andere gegevens."
|
||||
},
|
||||
"usersData": {
|
||||
"title": "Gebruikersgegevens",
|
||||
"text": "Stuur het aantal gebruikers en of je SSO hebt geactiveerd"
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "{when} bijgewerkt",
|
||||
"search": "{count} containers zoeken",
|
||||
"selected": "{selectCount} van {totalCount} containers geselecteerd",
|
||||
"footer": "Totaal {count} containers"
|
||||
"footer": "Totaal {count} containers",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "Send anonym brukerdata",
|
||||
"text": "Homarr vil sende anonymiserte analyser ved bruk av den åpne programvaren Umami. Når dette er aktivert, samler vi inn: Homarr-versjon, databastype (MySQL/PostgreSQL/SQLite), Docker/Kubernetes-status, aktiverte autentiseringsmetoder, og antallet av hver funksjon. Alle data er anonyme og aggregerte – ingen personlig informasjon, URL-er, navn eller identifiserbare data samles inn. Dette hjelper vårt open source-team med å forstå bruks‑mønstre og prioritere utvikling."
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "Widget data",
|
||||
"text": "Send hvilke widgeter (og deres antall) du har konfigurert. Inkluderer ikke nettadresser, navn eller andre data."
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "Integrasjonsdata",
|
||||
"text": "Send hvilke integrasjoner (og deres antall) du har konfigurert. Inkluderer ikke nettadresser, navn eller andre data."
|
||||
},
|
||||
"usersData": {
|
||||
"title": "Brukeres data",
|
||||
"text": "Send antall brukere og om du har aktivert SSO"
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "Oppdatert {when}",
|
||||
"search": "Søk {count} containere",
|
||||
"selected": "{selectCount} av {totalCount} containere valgt",
|
||||
"footer": "Totalt {count} containere"
|
||||
"footer": "Totalt {count} containere",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "Wysyłaj anonimowe dane analityczne",
|
||||
"text": "Homarr będzie wysyłał anonimowe dane analityczne przy użyciu oprogramowania open source Umami. Nigdy nie zbiera żadnych danych osobowych, dlatego jest w pełni zgodny z RODO i CCPA. Zachęcamy do włączenia analityki, ponieważ pomaga to naszemu zespołowi open source w identyfikowaniu problemów i ustalaniu priorytetów w backlogu."
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "Dane widżetu",
|
||||
"text": "Wyślij informacje o tym, które widżety (i w jakiej ilości) masz skonfigurowane. Nie obejmuje to adresów URL, nazw ani żadnych innych danych."
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "Informacje o integracjach",
|
||||
"text": "Wyślij informacje o tym, które integracje (i w jakiej ilości) masz skonfigurowane. Nie obejmuje to adresów URL, nazw ani żadnych innych danych."
|
||||
},
|
||||
"usersData": {
|
||||
"title": "Dane użytkowników",
|
||||
"text": "Wyślij liczbę użytkowników oraz informację, czy SSO jest aktywne."
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "Zaktualizowano {when}",
|
||||
"search": "Szukaj wśród {count} kontenerów",
|
||||
"selected": "Wybrano {selectCount} z {totalCount} kontenerów",
|
||||
"footer": "Łącznie {count} kontenerów"
|
||||
"footer": "Łącznie {count} kontenerów",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "Enviar análises anônimas",
|
||||
"text": "O Homarr enviará análises anonimizadas usando o software de código aberto Umami. Quando habilitado, coletamos: versão do Homarr, tipo de banco de dados (MySQL/PostgreSQL/SQLite), status Docker/Kubernetes, métodos de autenticação habilitados e a quantidade de cada recurso. Todos os dados são anônimos e agregados - nenhuma informação pessoal, URLs, nomes ou dados identificáveis são coletados. Isso ajuda nossa equipe de código aberto a entender padrões de uso e priorizar o desenvolvimento."
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "Dados de widgets",
|
||||
"text": "Envia quais widgets (e suas quantidades) você configurou. Não inclui URLs, nomes ou quaisquer outros dados."
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "Dados de integrações",
|
||||
"text": "Envia quais integrações (e suas quantidades) você configurou. Não inclui URLs, nomes ou quaisquer outros dados."
|
||||
},
|
||||
"usersData": {
|
||||
"title": "Dados dos usuários",
|
||||
"text": "Envia a quantidade de usuários e se você ativou SSO"
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "Atualizado em {when}",
|
||||
"search": "Buscar {count} contêineres",
|
||||
"selected": "{selectCount} de {totalCount} contêineres selecionados",
|
||||
"footer": "Total de contêineres: {count}"
|
||||
"footer": "Total de contêineres: {count}",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"usersData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "",
|
||||
"search": "",
|
||||
"selected": "",
|
||||
"footer": ""
|
||||
"footer": "",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"usersData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "",
|
||||
"search": "",
|
||||
"selected": "",
|
||||
"footer": ""
|
||||
"footer": "",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "Отправлять анонимную аналитику",
|
||||
"text": "Homarr отправляет обезличенную аналитику с помощью открытого ПО Umami. При включении собираются: версия Homarr, тип базы данных (MySQL/PostgreSQL/SQLite), статус Docker/Kubernetes, включённые методы аутентификации и количество используемых функций. Все данные анонимны и агрегированы - личная информация, URL-адреса, имена и другие идентифицирующие данные никогда не собираются. Это помогает нашей команде понимать типичные сценарии использования и определять приоритеты в разработке."
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "Данные о виджетах",
|
||||
"text": "Отправлять информацию о настроенных виджетах (и их количестве). Не включает URL-адреса, названия или другие данные."
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "Данные об интеграциях",
|
||||
"text": "Отправлять информацию о настроенных интеграциях (и их количестве). Не включает URL-адреса, названия или другие данные."
|
||||
},
|
||||
"usersData": {
|
||||
"title": "Данные о пользователях",
|
||||
"text": "Отправлять количество пользователей и информацию об использовании единого входа"
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "Обновлено {when}",
|
||||
"search": "Поиск среди {count} контейнеров",
|
||||
"selected": "Выбрано {selectCount} из {totalCount} контейнеров",
|
||||
"footer": "Всего {count} контейнеров"
|
||||
"footer": "Всего {count} контейнеров",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "Odoslať anonymné analýzy",
|
||||
"text": "Homarr bude posielať anonymizované analýzy pomocou open source softvéru Umami. Nikdy nezhromažďuje žiadne osobné údaje, a preto je plne v súlade s GDPR a CCPA. Odporúčame vám povoliť analýzu, pretože pomáha nášmu tímu s otvoreným zdrojom identifikovať problémy a uprednostňovať naše nevybavené veci."
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "Údaje widgetu",
|
||||
"text": "Pošlite, ktoré widgety (a ich množstvo) ste nakonfigurovali. Nezahŕňa adresy URL, mená ani iné údaje."
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "Integračné údaje",
|
||||
"text": "Pošlite, ktoré integrácie (a ich množstvo) ste nakonfigurovali. Nezahŕňa adresy URL, mená ani iné údaje."
|
||||
},
|
||||
"usersData": {
|
||||
"title": "Údaje o používateľoch",
|
||||
"text": "Pošlite počet používateľov a či ste aktivovali jednotné prihlásenie"
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "Aktualizované {when}",
|
||||
"search": "Vyhľadajte {count} kontajnery",
|
||||
"selected": "{selectCount} z {totalCount} vybratých kontajnerov",
|
||||
"footer": "Celkový počet kontajnerov: {count}"
|
||||
"footer": "Celkový počet kontajnerov: {count}",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
@@ -4415,18 +4415,6 @@
|
||||
"general": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"usersData": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -4954,7 +4942,9 @@
|
||||
"updated": "",
|
||||
"search": "",
|
||||
"selected": "",
|
||||
"footer": ""
|
||||
"footer": "",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user