Compare commits

..
Author SHA1 Message Date
ajnart 3d358f664d fix(auth): isolate session cookies via AUTH_COOKIE_PREFIX (#5773)
Prefix all Auth.js cookies (default "homarr") so they no longer collide
with other Auth.js apps on the same hostname.
2026-06-18 09:33:01 +02:00
141 changed files with 4135 additions and 6580 deletions

No files matched your search

-2
View File
@@ -33,8 +33,6 @@ 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
- 1.64.0
@@ -66,7 +66,7 @@ jobs:
- uses: actions/setup-node@v6
if: env.SKIP_RELEASE == 'false'
with:
node-version: 24.17.0
node-version: 24.16.0
package-manager-cache: false
- run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false'
@@ -3,7 +3,6 @@ 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
View File
@@ -1,2 +1,2 @@
[tools]
node = "24.17.0"
node = "24.16.0"
+1 -1
View File
@@ -1 +1 @@
24.17.0
24.16.0
-13
View File
@@ -1,16 +1,3 @@
## [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
-5
View File
@@ -1,11 +1,6 @@
# ── 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 -15
View File
@@ -1,4 +1,4 @@
FROM node:24.17.0-alpine AS base
FROM node:24.16.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
@@ -20,17 +20,6 @@ ARG DISABLE_REDIS_LOGS='true'
RUN corepack enable pnpm && pnpm build
# The standalone output tracing omits server chunk source maps (.next/server/**/*.map), which is
# where production Node stack frames point. Copy the .map files into the standalone tree (preserving
# directory structure) so the existing standalone COPY ships them, keeping traces readable.
# See https://github.com/homarr-labs/homarr/issues/5891
RUN cd apps/nextjs/.next/server && \
find . -name '*.map' | while IFS= read -r map; do \
dest="../standalone/apps/nextjs/.next/server/$map"; \
mkdir -p "$(dirname "$dest")"; \
cp "$map" "$dest"; \
done
FROM base AS runner
WORKDIR /app
@@ -75,9 +64,6 @@ ENV DB_DRIVER='better-sqlite3'
ENV AUTH_PROVIDERS='credentials'
ENV REDIS_IS_EXTERNAL='false'
ENV NODE_ENV='production'
# Tell Node to symbolicate stack traces using the shipped .map files so production logs
# show original source locations instead of minified frames, see https://github.com/homarr-labs/homarr/issues/5891
ENV NODE_OPTIONS='--enable-source-maps'
ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD ["sh", "run.sh"]
@@ -1,52 +0,0 @@
---
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: [],
},,]} />
@@ -1,8 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 30 KiB

-40
View File
@@ -1,40 +0,0 @@
---
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 />
-9
View File
@@ -1,9 +0,0 @@
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",
};
+2 -2
View File
@@ -32,7 +32,7 @@
"@scalar/api-reference-react": "catalog:",
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
"@tabler/icons-react": "catalog:",
"@tailwindcss/postcss": "^4.3.1",
"@tailwindcss/postcss": "^4.3.0",
"clsx": "catalog:",
"dayjs": "catalog:",
"docusaurus-plugin-image-zoom": "^3.0.1",
@@ -44,7 +44,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"swiper": "^12.2.0",
"tailwindcss": "^4.3.1"
"tailwindcss": "^4.3.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.10.1",
-4
View File
@@ -31,8 +31,6 @@ const nextConfig: NextConfig = {
},
output: "standalone",
reactStrictMode: true,
// Ship browser source maps so production client stack traces are readable, see https://github.com/homarr-labs/homarr/issues/5891
productionBrowserSourceMaps: true,
// react compiler breaks mantine-react-table, so disabled for now
//reactCompiler: true,
/** We already do typechecking as separate tasks in CI */
@@ -43,8 +41,6 @@ const nextConfig: NextConfig = {
*/
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
experimental: {
// Ship server source maps so production Node stack traces in logs reference original source, see https://github.com/homarr-labs/homarr/issues/5891
serverSourceMaps: true,
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true,
preloadEntriesOnStart: false,
-1
View File
@@ -89,7 +89,6 @@
"mcp-handler": "catalog:",
"next": "catalog:",
"postcss-preset-mantine": "catalog:",
"posthog-js": "catalog:",
"prismjs": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
+1 -1
View File
@@ -139,11 +139,11 @@ export default async function Layout(props: {
suppressHydrationWarning
>
<head>
<Analytics />
<SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} />
</head>
<body className={["font-sans", fontSans.variable].join(" ")}>
<Analytics enabled={serverSettings.analytics.enableGeneral} />
<StackedProvider>
<Notifications pauseResetOnHover="notification" />
<ServiceWorkerRegistration />
@@ -131,7 +131,6 @@ 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}
@@ -141,7 +140,6 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
) : null}
{error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? (
<Button
type="button"
variant="default"
fullWidth
onClick={() =>
@@ -17,10 +17,9 @@ interface AnalyticsSettingsProps {
export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
const t = useScopedI18n("management.page.settings.section.analytics");
const { instanceId: _, ...formDefaults } = initialData;
const form = useForm({
initialValues: formDefaults,
onValuesChange: (updatedValues, _prev) => {
initialValues: initialData,
onValuesChange: (updatedValues, _) => {
if (!form.isValid()) {
return;
}
@@ -211,17 +211,7 @@ describe("SQLite backup", () => {
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
expect(decryptedAfter).toBe(plaintext);
// 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);
expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow();
sqlite.close();
});
@@ -142,8 +142,7 @@ 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,12 +41,7 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
name: t(`widget.${kind}.name`),
description: t(`widget.${kind}.description`),
}))
.sort((itemA, itemB) => {
if (itemA.kind === "app") return -1;
if (itemB.kind === "app") return 1;
return itemA.name.localeCompare(itemB.name);
}),
.sort((itemA, itemB) => itemA.name.localeCompare(itemB.name)),
[t],
);
@@ -57,7 +57,6 @@ 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",
+11 -19
View File
@@ -1,24 +1,16 @@
"use client";
import Script from "next/script";
import { useEffect } from "react";
import { UMAMI_WEBSITE_ID } from "@homarr/analytics";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
const POSTHOG_KEY = "phc_vYBmGWNbRshvfeC7EHfeSmUm2pD2Neg5nGqzJuGvS8Hs";
const POSTHOG_HOST = "https://hog.homarr.dev";
export const Analytics = async () => {
// For static pages it will not find any analytics data so we do not include the script on them
const analytics = await getServerSettingByKeyAsync(db, "analytics").catch(() => null);
export const Analytics = ({ enabled }: { enabled: boolean }) => {
useEffect(() => {
if (!enabled) return;
if (analytics?.enableGeneral) {
return <Script src="https://umami.homarr.dev/script.js" data-website-id={UMAMI_WEBSITE_ID} defer />;
}
void import("posthog-js").then(({ default: posthog }) => {
posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_HOST,
capture_pageview: false,
capture_pageleave: false,
autocapture: false,
persistence: "memory",
});
});
}, [enabled]);
return null;
return <></>;
};
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.17.0-trixie AS base
FROM node:24.16.0-trixie AS base
WORKDIR /app
COPY package.json .
COPY pnpm-lock.yaml .
+3 -3
View File
@@ -5,8 +5,8 @@
"better-sqlite3": "^12.10.0"
},
"engines": {
"node": ">=24.17.0",
"pnpm": ">=11.8.0"
"node": ">=24.16.0",
"pnpm": ">=11.6.0"
},
"packageManager": "pnpm@11.8.0"
"packageManager": "pnpm@11.6.0"
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.67.0",
"version": "1.65.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -61,10 +61,10 @@
"vitest": "catalog:"
},
"engines": {
"node": ">=24.17.0",
"pnpm": ">=11.8.0"
"node": ">=24.16.0",
"pnpm": ">=11.6.0"
},
"packageManager": "pnpm@11.8.0",
"packageManager": "pnpm@11.6.0",
"dependencies": {
"jsonpath-plus": "catalog:"
}
+3 -1
View File
@@ -26,7 +26,9 @@
"@homarr/core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0",
"posthog-node": "catalog:"
"@homarr/server-settings": "workspace:^0.1.0",
"@umami/node": "catalog:",
"superjson": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
-23
View File
@@ -1,23 +0,0 @@
import { PostHog } from "posthog-node";
export const POSTHOG_API_KEY = "phc_vYBmGWNbRshvfeC7EHfeSmUm2pD2Neg5nGqzJuGvS8Hs";
export const POSTHOG_HOST = "https://hog.homarr.dev";
let instance: PostHog | undefined;
export const getPostHogClient = (): PostHog => {
instance ??= new PostHog(POSTHOG_API_KEY, {
host: POSTHOG_HOST,
flushAt: 10,
flushInterval: 30_000,
});
return instance;
};
export const trackEvent = (instanceId: string, event: string, properties?: Record<string, unknown>) => {
getPostHogClient().capture({
distinctId: instanceId,
event,
properties,
});
};
+2
View File
@@ -0,0 +1,2 @@
export const UMAMI_HOST_URL = "https://umami.homarr.dev";
export const UMAMI_WEBSITE_ID = "ff7dc470-a84f-4779-b1ab-66a5bb16a94b";
+2 -2
View File
@@ -1,2 +1,2 @@
export { trackEvent } from "./client";
export { sendServerAnalyticsAsync } from "./send-server-analytics";
export * from "./constants";
export * from "./send-server-analytics";
+63 -136
View File
@@ -1,16 +1,17 @@
import type { UmamiEventData } from "@umami/node";
import { Umami } from "@umami/node";
import { isProviderEnabled } from "@homarr/auth/server";
import { createId, Stopwatch } from "@homarr/common";
import { Stopwatch } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { count, db } from "@homarr/db";
import { isMysql, isPostgresql } from "@homarr/db/collection";
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import {
accounts,
apiKeys,
apps,
boards,
cronJobConfigurations,
customWidgetDefinitions,
groups,
iconRepositories,
icons,
@@ -23,160 +24,86 @@ import {
searchEngines,
sectionLayouts,
sections,
sessions,
trustedCertificateHostnames,
users,
} from "@homarr/db/schema";
import { env as dockerEnv } from "@homarr/docker/env";
import packageJson from "../../../package.json";
import { getPostHogClient } from "./client";
import { UMAMI_HOST_URL, UMAMI_WEBSITE_ID } from "./constants";
const logger = createLogger({ module: "analytics" });
type AnalyticsResult = "sent" | "disabled" | "failed";
// ponytail: no DB-level lock; concurrent calls may both generate an ID, but the last write wins
// and the ID stabilizes after the first successful run. Upgrade path: use DB upsert with WHERE instanceId IS NULL.
const getOrCreateInstanceId = async (
analyticsSettings: Awaited<ReturnType<typeof getServerSettingByKeyAsync<"analytics">>>,
): Promise<string> => {
if (analyticsSettings.instanceId) return analyticsSettings.instanceId;
const instanceId = createId();
await updateServerSettingByKeyAsync(db, "analytics", { ...analyticsSettings, instanceId });
const verified = await getServerSettingByKeyAsync(db, "analytics");
return verified.instanceId ?? instanceId;
const getDatabaseType = (): "mysql" | "postgresql" | "sqlite" => {
if (isMysql()) return "mysql";
if (isPostgresql()) return "postgresql";
return "sqlite";
};
const sumGroupedCounts = (rows: { count: number }[]): number => rows.reduce((sum, row) => sum + row.count, 0);
const getEnabledAuthProviders = (): string[] => {
const providers: string[] = [];
if (isProviderEnabled("credentials")) providers.push("credentials");
if (isProviderEnabled("oidc")) providers.push("oidc");
if (isProviderEnabled("ldap")) providers.push("ldap");
return providers;
};
export const sendServerAnalyticsAsync = async (): Promise<AnalyticsResult> => {
export const sendServerAnalyticsAsync = async () => {
const stopWatch = new Stopwatch();
const analyticsSettings = await getServerSettingByKeyAsync(db, "analytics");
if (!analyticsSettings.enableGeneral) {
logger.info("Analytics are disabled. No data will be sent. Enable analytics in the settings");
return "disabled";
return;
}
const client = getPostHogClient();
const umamiInstance = new Umami();
umamiInstance.init({
hostUrl: UMAMI_HOST_URL,
websiteId: UMAMI_WEBSITE_ID,
});
try {
const instanceId = await getOrCreateInstanceId(analyticsSettings);
const enabledAuthProviders = getEnabledAuthProviders();
const [
cultureSettings,
countBoards,
countGroups,
countApps,
countSections,
countSearchEngines,
countIconRepositories,
countIcons,
countLayouts,
countItemLayouts,
countSectionLayouts,
countCronJobConfigs,
countCustomWidgets,
countTrustedCertificates,
countUsers,
countApiKeys,
countInvites,
countMedias,
countSessions,
countAccounts,
integrationKinds,
widgetKinds,
] = await Promise.all([
getServerSettingByKeyAsync(db, "culture"),
db.$count(boards),
db.$count(groups),
db.$count(apps),
db.$count(sections),
db.$count(searchEngines),
db.$count(iconRepositories),
db.$count(icons),
db.$count(layouts),
db.$count(itemLayouts),
db.$count(sectionLayouts),
db.$count(cronJobConfigurations),
db.$count(customWidgetDefinitions),
db.$count(trustedCertificateHostnames),
db.$count(users),
db.$count(apiKeys),
db.$count(invites),
db.$count(medias),
db.$count(sessions),
db.$count(accounts),
db
.select({ kind: integrations.kind, count: count(integrations.id) })
.from(integrations)
.groupBy(integrations.kind),
db
.select({ kind: items.kind, count: count(items.id) })
.from(items)
.groupBy(items.kind),
]);
const analyticsData: UmamiEventData = {
homarrVersion: packageJson.version,
databaseType: getDatabaseType(),
dockerEnabled: dockerEnv.ENABLE_DOCKER ? 1 : 0,
kubernetesEnabled: dockerEnv.ENABLE_KUBERNETES ? 1 : 0,
authCredentials: enabledAuthProviders.includes("credentials") ? 1 : 0,
authOidc: enabledAuthProviders.includes("oidc") ? 1 : 0,
authLdap: enabledAuthProviders.includes("ldap") ? 1 : 0,
countUsers: await db.$count(users),
countBoards: await db.$count(boards),
countGroups: await db.$count(groups),
countApps: await db.$count(apps),
countWidgets: await db.$count(items),
countSections: await db.$count(sections),
countIntegrations: await db.$count(integrations),
countSearchEngines: await db.$count(searchEngines),
countIconRepositories: await db.$count(iconRepositories),
countIcons: await db.$count(icons),
countApiKeys: await db.$count(apiKeys),
countInvites: await db.$count(invites),
countMedias: await db.$count(medias),
countLayouts: await db.$count(layouts),
countItemLayouts: await db.$count(itemLayouts),
countSectionLayouts: await db.$count(sectionLayouts),
countCronJobConfigs: await db.$count(cronJobConfigurations),
};
const enabledAuthProviders = (["credentials", "oidc", "ldap"] as const).filter(isProviderEnabled);
const integrationKinds = await db
.select({ kind: integrations.kind, count: count(integrations.id) })
.from(integrations)
.groupBy(integrations.kind);
const properties: Record<string, unknown> = {
homarrVersion: packageJson.version,
databaseType: isMysql() ? "mysql" : isPostgresql() ? "postgresql" : "sqlite",
dockerEnabled: Boolean(dockerEnv.ENABLE_DOCKER),
kubernetesEnabled: Boolean(dockerEnv.ENABLE_KUBERNETES),
authProviders: enabledAuthProviders,
integrationKinds.forEach((integrationKind) => {
analyticsData[`integration_${integrationKind.kind}`] = integrationKind.count;
});
osPlatform: process.platform,
osArch: process.arch,
uptimeSeconds: Math.floor(process.uptime()),
defaultLocale: cultureSettings.defaultLocale,
countBoards,
countGroups,
countApps,
countSections,
countSearchEngines,
countIconRepositories,
countIcons,
countLayouts,
countItemLayouts,
countSectionLayouts,
countCronJobConfigs,
countCustomWidgets,
countTrustedCertificates,
countUsers,
countApiKeys,
countInvites,
countMedias,
countSessions,
countAccounts,
countIntegrations: sumGroupedCounts(integrationKinds),
countWidgets: sumGroupedCounts(widgetKinds),
};
for (const row of integrationKinds) {
properties[`integration_${row.kind}`] = row.count;
}
for (const row of widgetKinds) {
properties[`widget_${row.kind}`] = row.count;
}
client.capture({
distinctId: instanceId,
event: "server-analytics",
properties,
});
await client.flush();
logger.info(`Sent analytics to PostHog in ${stopWatch.getElapsedInHumanWords()}`);
return "sent";
} catch (error) {
logger.warn("Failed to send analytics to PostHog", { error });
return "failed";
const response = await umamiInstance.track("server-analytics-v2", analyticsData);
if (!response.ok) {
logger.warn("Unable to send analytics to Umami instance");
}
logger.info(`Sent analytics in ${stopWatch.getElapsedInHumanWords()}`);
};
-1
View File
@@ -24,7 +24,6 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
-1
View File
@@ -28,7 +28,6 @@ export const appRouter = createTRPCRouter({
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
analytics: lazy(() => import("./router/analytics").then((mod) => mod.analyticsRouter)),
info: lazy(() => import("./router/info").then((mod) => mod.infoRouter)),
customWidget: lazy(() => import("./router/custom-widget/custom-widget-router").then((mod) => mod.customWidgetRouter)),
});
-51
View File
@@ -1,51 +0,0 @@
import { TRPCError } from "@trpc/server";
import z from "zod/v4";
import { sendServerAnalyticsAsync, trackEvent } from "@homarr/analytics";
import { env } from "@homarr/common/env";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
const getInstanceId = async (): Promise<string | null> => {
if (env.NO_EXTERNAL_CONNECTION) return null;
const settings = await getServerSettingByKeyAsync(db, "analytics");
if (!settings.enableGeneral) return null;
return settings.instanceId;
};
export const analyticsRouter = createTRPCRouter({
sendAnalytics: permissionRequiredProcedure.requiresPermission("admin").mutation(async () => {
if (env.NO_EXTERNAL_CONNECTION) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "External connections are disabled" });
}
return { status: await sendServerAnalyticsAsync() };
}),
trackFeature: protectedProcedure
.input(
z.object({
feature: z.string().max(128),
properties: z.record(z.string(), z.unknown()).optional(),
}),
)
.mutation(async ({ input, ctx }) => {
const instanceId = await getInstanceId();
if (!instanceId) return;
trackEvent(instanceId, `feature:${input.feature}`, {
...input.properties,
userId: ctx.session.user.id,
});
}),
heartbeat: protectedProcedure.mutation(async ({ ctx }) => {
const instanceId = await getInstanceId();
if (!instanceId) return;
trackEvent(instanceId, "user-active", {
userId: ctx.session.user.id,
});
}),
});
+9 -25
View File
@@ -15,36 +15,20 @@ 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 },
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];
columns: {
id: true,
name: true,
url: true,
},
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,7 +27,6 @@ import {
integrationSecretKindObject,
} from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { invalidateIntegrationCacheAsync } from "@homarr/redis";
import { byIdSchema } from "@homarr/validation/common";
import {
integrationCreateSchema,
@@ -470,10 +469,6 @@ 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({
@@ -495,9 +490,6 @@ 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");
+6 -7
View File
@@ -24,18 +24,17 @@ export const serverSettingsRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
...current,
...input.value,
} as ServerSettings[keyof ServerSettings]);
await updateServerSettingByKeyAsync(
ctx.db,
input.settingsKey,
input.value as ServerSettings[keyof ServerSettings],
);
}),
initSettings: onboardingProcedure
.requiresStep("settings")
.input(settingsInitSchema)
.mutation(async ({ ctx, input }) => {
const currentAnalytics = await getServerSettingByKeyAsync(ctx.db, "analytics");
await updateServerSettingByKeyAsync(ctx.db, "analytics", { ...currentAnalytics, ...input.analytics });
await updateServerSettingByKeyAsync(ctx.db, "analytics", input.analytics);
await updateServerSettingByKeyAsync(ctx.db, "crawlingAndIndexing", input.crawlingAndIndexing);
await nextOnboardingStepAsync(ctx.db, undefined);
}),
@@ -75,6 +75,9 @@ describe("saveSettings", () => {
settingsKey: "analytics",
value: {
enableGeneral: true,
enableWidgetData: true,
enableIntegrationData: true,
enableUserData: true,
},
});
@@ -84,7 +87,9 @@ describe("saveSettings", () => {
settingKey: "analytics",
value: SuperJSON.stringify({
enableGeneral: true,
instanceId: null,
enableWidgetData: true,
enableIntegrationData: true,
enableUserData: true,
}),
},
]);
+5 -109
View File
@@ -2,7 +2,6 @@ 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 {
@@ -15,8 +14,6 @@ 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({
@@ -28,9 +25,7 @@ export const beszelRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.query(async ({ ctx }) => {
const integrationIds = ctx.integrations.map((i) => i.id);
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
const settled = await Promise.allSettled(
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
@@ -45,29 +40,6 @@ 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;
}),
@@ -111,14 +83,7 @@ export const beszelRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
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(
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
includeHistory: input.includeHistory,
@@ -135,7 +100,6 @@ export const beszelRouter = createTRPCRouter({
}
return {
integrationId: integration.id,
integrationName: integration.name,
alerts: alertsResult.data.alerts,
history: alertsResult.data.history,
systemNameMap,
@@ -143,30 +107,6 @@ 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;
}),
@@ -224,41 +164,13 @@ export const beszelRouter = createTRPCRouter({
if (!integration) {
throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" });
}
logger.debug("getSystemStats called", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
const innerHandler = beszelStatsRequestHandler.handler(integration, {
systemId: input.systemId,
timePeriod: input.timePeriod,
includeDocker: input.includeDocker,
});
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),
};
}
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { integrationId: integration.id, ...data, updatedAt: timestamp };
}),
subscribeSystemStats: publicProcedure
@@ -278,11 +190,6 @@ 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();
@@ -292,12 +199,6 @@ 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 {
@@ -309,11 +210,6 @@ export const beszelRouter = createTRPCRouter({
return () => {
abortController.abort();
logger.debug("subscribeSystemStats closed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
});
};
});
}),
-1
View File
@@ -36,7 +36,6 @@ 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)),
});
-59
View File
@@ -1,59 +0,0 @@
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();
});
};
});
}),
});
-2
View File
@@ -20,7 +20,6 @@ interface CreateCronJobOptions {
runOnStart?: boolean;
preventManualExecution?: boolean;
preventCustomInterval?: boolean;
hidden?: boolean;
expectedMaximumDurationInMillis?: number;
beforeStart?: () => MaybePromise<void>;
}
@@ -122,7 +121,6 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
},
preventManualExecution: options.preventManualExecution ?? false,
preventCustomInterval: options.preventCustomInterval ?? false,
hidden: options.hidden ?? false,
timezone: creatorOptions.timezone,
};
};
-2
View File
@@ -21,7 +21,6 @@ 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";
@@ -58,7 +57,6 @@ const getJobGroup = () => {
timetable: timetableJob,
tracearr: tracearrJob,
umamiActiveVisitors: umamiActiveVisitorsJob,
vpn: vpnJob,
});
};
+10 -12
View File
@@ -122,17 +122,15 @@ export class JobManager {
> {
const configurations = await this.db.query.cronJobConfigurations.findMany();
return [...this.jobGroup.getJobRegistry().entries()]
.filter(([_, job]) => !job.hidden)
.map(([name, job]) => {
const config = configurations.find((config) => config.name === name);
return {
name,
cron: config?.cronExpression ?? job.cronExpression,
preventManualExecution: job.preventManualExecution,
preventCustomInterval: job.preventCustomInterval,
isEnabled: config?.isEnabled ?? true,
};
});
return [...this.jobGroup.getJobRegistry().entries()].map(([name, job]) => {
const config = configurations.find((config) => config.name === name);
return {
name,
cron: config?.cronExpression ?? job.cronExpression,
preventManualExecution: job.preventManualExecution,
preventCustomInterval: job.preventCustomInterval,
isEnabled: config?.isEnabled ?? true,
};
});
}
}
+8 -1
View File
@@ -1,6 +1,8 @@
import { sendServerAnalyticsAsync } from "@homarr/analytics";
import { env } from "@homarr/common/env";
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { createCronJob } from "../lib";
@@ -8,8 +10,13 @@ export const analyticsJob = createCronJob("analytics", EVERY_WEEK, {
runOnStart: true,
preventManualExecution: true,
preventCustomInterval: true,
hidden: true,
}).withCallback(async () => {
if (env.NO_EXTERNAL_CONNECTION) return;
const analyticSetting = await getServerSettingByKeyAsync(db, "analytics");
if (!analyticSetting.enableGeneral) {
return;
}
await sendServerAnalyticsAsync();
});
@@ -1,14 +0,0 @@
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: () => ({}),
},
}),
);
+14 -10
View File
@@ -647,13 +647,9 @@ const seedDemoUserAsync = async (db: Database) => {
const seedDefaultCustomWidgetsAsync = async (db: Database) => {
const seedIds = CUSTOM_WIDGET_SEEDS.map((s) => s.id);
const existing = await db.query.customWidgetDefinitions.findMany({
columns: { id: true },
where: inArray(customWidgetDefinitions.id, seedIds),
});
const existingIds = new Set(existing.map((row) => row.id));
const beforeCount = await db.$count(customWidgetDefinitions, inArray(customWidgetDefinitions.id, seedIds));
const seedValues = CUSTOM_WIDGET_SEEDS.filter((seed) => !existingIds.has(seed.id)).map((seed) => {
const seedValues = CUSTOM_WIDGET_SEEDS.map((seed) => {
const parsed = customWidgetImportSchema.parse(seed.data);
return {
id: seed.id,
@@ -672,14 +668,22 @@ const seedDefaultCustomWidgetsAsync = async (db: Database) => {
};
});
if (seedValues.length === 0) {
if (seedValues.length === 0) return;
await db
.insert(customWidgetDefinitions)
.values(seedValues)
.onConflictDoNothing({ target: customWidgetDefinitions.id });
const afterCount = await db.$count(customWidgetDefinitions, inArray(customWidgetDefinitions.id, seedIds));
const createdCount = afterCount - beforeCount;
if (createdCount === 0) {
console.log("Skipping seeding of default custom widgets as they already exist");
return;
}
await db.insert(customWidgetDefinitions).values(seedValues);
console.log(`Created ${seedValues.length} default custom widgets through seeding process`);
console.log(`Created ${createdCount} default custom widgets through seeding process`);
};
const seedBoardWidgetsAsync = async (db: Database) => {
@@ -179,7 +179,6 @@ 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"
@@ -281,7 +280,6 @@ export type HomarrDocumentationPath =
| "/docs/widgets/ups"
| "/docs/widgets/uptime-kuma"
| "/docs/widgets/video"
| "/docs/widgets/vpn"
| "/docs/widgets/weather"
| ""
| "/sitemap.xml"
@@ -339,7 +337,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/audiobookshelf"
| "/docs/integrations/navidrome"
| "/docs/integrations/umami"
| "/docs/integrations/gluetun"
| "/docs/integrations/peanut"
| "/docs/integrations/beszel"
| "/docs/widgets/clock"
@@ -383,7 +380,6 @@ 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,7 +55,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
audiobookshelf: "audiobookshelf",
navidrome: "navidrome",
umami: "umami",
gluetun: "gluetun",
peaNut: "peanut",
beszel: "beszel",
archiveTeamWarrior: null,
@@ -43,7 +43,6 @@ 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",
-8
View File
@@ -489,13 +489,6 @@ 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"]],
@@ -611,7 +604,6 @@ export const integrationCategories = [
"mediaMonitoring",
"speedtest",
"analytics",
"vpn",
"archiving",
"ups",
"documents",
@@ -38,7 +38,6 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
speedtestTracker: ["speedtestTracker"],
uptimeKuma: ["uptimeKuma"],
audioStats: ["navidrome", "audiobookshelf"],
vpn: getIntegrationKindsByCategory("vpn"),
anchorNote: ["anchor"],
};
-1
View File
@@ -41,7 +41,6 @@ export const widgetKinds = [
"uptimeKuma",
"audioStats",
"umami",
"vpn",
"archiveTeamWarrior",
"ups",
"beszelSystemTable",
@@ -1,11 +1,8 @@
"use client";
import type { FocusEventHandler } from "react";
import { startTransition, useEffect, useState } from "react";
import { startTransition } from "react";
import {
ActionIcon,
Box,
Button,
Card,
Combobox,
Flex,
@@ -21,7 +18,7 @@ import {
useCombobox,
} from "@mantine/core";
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
import { IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
@@ -37,6 +34,7 @@ interface IconPickerProps {
error?: string | null;
onFocus?: FocusEventHandler;
onBlur?: FocusEventHandler;
label?: string;
placeholder?: string;
withAsterisk?: boolean;
@@ -52,30 +50,29 @@ export const IconPicker = ({
label,
placeholder,
}: IconPickerProps) => {
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 [value, setValue] = useUncontrolled({
value: propsValue,
onChange,
});
const [search, setSearch] = useUncontrolled({
value,
onChange: (value) => {
setValue(value);
},
});
const [previewUrl, setPreviewUrl] = useUncontrolled({
value: propsValue ?? null,
});
const { data: session } = useSession();
const tCommon = useScopedI18n("common");
const [debouncedQuery] = useDebouncedValue(query, 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) || "";
const [debouncedSearch] = useDebouncedValue(search, 100);
// 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 },
);
// 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,
});
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
@@ -93,15 +90,25 @@ export const IconPicker = ({
>
<UnstyledButton
onClick={() => {
const value = item.url;
startTransition(() => {
setValue(item.url);
setQuery("");
setPreviewUrl(value);
setSearch(value);
setValue(value);
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>
@@ -123,48 +130,6 @@ 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>
@@ -172,11 +137,16 @@ export const IconPicker = ({
<InputBase
flex={1}
rightSection={<Combobox.Chevron />}
leftSection={leftSection}
value={inputValue}
leftSection={
shouldShowPreview(previewUrl) ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null
}
value={search}
onChange={(event) => {
combobox.openDropdown();
setQuery(event.currentTarget.value);
combobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
setValue(event.currentTarget.value);
setPreviewUrl(null);
}}
onClick={() => combobox.openDropdown()}
onFocus={(event) => {
@@ -186,21 +156,25 @@ export const IconPicker = ({
onBlur={(event) => {
onBlur?.(event);
combobox.closeDropdown();
setQuery("");
setPreviewUrl(value);
setSearch(value || "");
}}
rightSectionPointerEvents="none"
withAsterisk={withAsterisk}
error={error}
label={label ?? tCommon("iconPicker.label")}
placeholder={placeholderText}
placeholder={placeholder ?? tCommon("iconPicker.header", { countIcons: String(data?.countIcons ?? 0) })}
/>
{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);
});
}}
>
@@ -216,7 +190,17 @@ export const IconPicker = ({
<Combobox.Dropdown>
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
{renderDropdown()}
{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>
))
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
@@ -225,8 +209,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");
const shouldShowPreview = (value: string | null | undefined): value is string => {
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i" /* ignore casing */);
const shouldShowPreview = (value: string | null): value is string => {
if (!value) return false;
return !localizationPathRegex.test(value);
};
@@ -21,7 +21,6 @@ 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";
@@ -138,7 +137,6 @@ export const integrationCreators = {
audiobookshelf: AudiobookshelfIntegration,
navidrome: NavidromeIntegration,
umami: UmamiIntegration,
gluetun: GluetunIntegration,
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration,
@@ -46,13 +46,10 @@ 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 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, {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/collections/users/auth-with-password"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -60,28 +57,23 @@ 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,
method: options.method ?? "GET",
headers: {
Authorization: token,
...(options.body ? { "Content-Type": "application/json" } : {}),
@@ -90,42 +82,14 @@ 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);
}
@@ -293,7 +257,6 @@ 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 },
@@ -301,14 +264,8 @@ 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();
@@ -365,10 +322,6 @@ 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 {
@@ -38,7 +38,7 @@ const itemSchema = z.object({
ServerId: z.string(),
Name: z.string(),
Taglines: z.array(z.string()),
Studios: z.array(z.object({ Name: z.string() })).optional(),
Studios: z.array(z.object({ Name: z.string() })),
Overview: z.string().optional(),
PremiereDate: z
.string()
@@ -172,7 +172,7 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
poster: super.externalUrl(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
backdrop: super.externalUrl(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
},
producer: item.Studios?.at(0)?.Name,
producer: item.Studios.at(0)?.Name,
rating: item.CommunityRating?.toFixed(1),
tags: item.Genres,
href: super.externalUrl(`/web/index.html#!/item?id=${item.Id}&serverId=${item.ServerId}`).toString(),
@@ -1,115 +0,0 @@
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 {};
}
}
@@ -1,116 +0,0 @@
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>;
@@ -1,371 +0,0 @@
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,13 +31,11 @@ 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,
}));
}
}
@@ -1,5 +0,0 @@
import type { VpnSummary } from "./vpn-summary-types";
export interface VpnSummaryIntegration {
getSummaryAsync(): Promise<VpnSummary>;
}
@@ -1,13 +0,0 @@
export interface VpnProviderDetails {
protocol: string;
provider: string;
}
export interface VpnSummary {
vpnStatus: string;
dnsStatus: string;
publicIp: string;
country: string;
city: string;
vpnProvider: VpnProviderDetails;
}
@@ -132,9 +132,8 @@ const happyResponder = (method: string) => {
aggregations: emptyAggregations(),
start: 0,
end: 1,
// The "memory" graph reports available (free) memory: legend is ["time", "available"].
legend: ["time", "available"],
data: [[1000, 6_000_000_000]],
legend: [],
data: [[1000, 8_000_000_000]],
},
{
name: "cputemp",
@@ -195,17 +194,15 @@ describe("TrueNasIntegration", () => {
version: "TrueNAS-SCALE-24.10",
cpuModelName: "Intel Xeon",
cpuTemp: 45,
// physmem (16 GB) minus the reported available 6 GB leaves 10 GB used.
memAvailableInBytes: 6_000_000_000,
memUsedInBytes: 10_000_000_000,
memAvailableInBytes: 16_000_000_000,
memUsedInBytes: 8_000_000_000,
uptime: 3600,
rebootRequired: false,
availablePkgUpdates: 0,
loadAverage: null,
gpu: [],
network: { up: 20_000, down: 10_000 },
// `available` is the free space left on the pool (free = 500), not its total size.
fileSystem: [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }],
fileSystem: [{ deviceName: "tank", available: "1000", used: "500", percentage: 50 }],
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
});
});
@@ -37,19 +37,14 @@ 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,
memUsedInBytes,
memAvailableInBytes: systemInformation.physmem,
memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes
fileSystem: datasets.map((dataset) => ({
deviceName: dataset.name,
available: `${dataset.free}`, // free space left on the pool
available: `${dataset.size}`, // TODO: can we use number instead of string here?
used: `${dataset.allocated}`,
percentage: (dataset.allocated / dataset.size) * 100,
})),
-2
View File
@@ -4,7 +4,6 @@ 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";
@@ -23,5 +22,4 @@ 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), convert to bytes
available: `${(disk.size - disk.fsUsed) * 1024}`, // free space left on the disk, API is in KiB (kibibytes)
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
percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant
})),
smart: systemInfo.array.disks.map((disk) => ({
+211 -158
View File
@@ -2,7 +2,15 @@
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";
@@ -23,10 +31,15 @@ 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([])),
};
});
@@ -77,7 +90,9 @@ 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",
@@ -94,7 +109,10 @@ 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(
@@ -104,7 +122,9 @@ async function fetchApiToken(baseUrl: string, apiVersion: TechnitiumVersion): Pr
}
// 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;
}
@@ -124,12 +144,16 @@ 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);
@@ -139,209 +163,238 @@ 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);
});
},
);
-1
View File
@@ -15,7 +15,6 @@ export {
createGetSetChannel,
createQueryCacheChannel,
getAllQueryCacheAsync,
invalidateIntegrationCacheAsync,
} from "./lib/channel";
export { createIntegrationHistoryChannel } from "./lib/channels/history-channel";
+2 -42
View File
@@ -261,36 +261,6 @@ 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,
@@ -415,18 +385,8 @@ export const createChannelWithLatestAndEvents = <TData>(channelName: string) =>
});
},
publishAndUpdateLastStateAsync: async (data: TData) => {
// 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),
});
}
await publisher.publish(channelName, superjson.stringify(data));
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
},
setAsync: async (data: TData) => {
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
+16 -42
View File
@@ -1,6 +1,5 @@
import dayjs from "dayjs";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations";
import type {
BeszelAlert,
@@ -14,8 +13,6 @@ import type {
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
const logger = createLogger({ module: "beszelRequestHandler" });
export type { BeszelSystemRow } from "@homarr/integrations/types";
/**
@@ -59,7 +56,6 @@ 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(
@@ -68,16 +64,9 @@ 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(1, "second"),
fallbackToStaleOnError: true,
retry: { attempts: 2, delayMs: 500 },
cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "beszelSystems",
});
@@ -92,31 +81,22 @@ 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(1, "second"),
fallbackToStaleOnError: true,
retry: { attempts: 2, delayMs: 500 },
cacheDuration: dayjs.duration(15, "seconds"),
queryKey: "beszelAlerts",
});
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 },
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 },
};
export interface BeszelStatsData {
@@ -130,25 +110,19 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
{ systemId: string; timePeriod: string; includeDocker: boolean }
>({
async requestAsync(integration, input) {
const start = performance.now();
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60 };
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 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(1, "second"),
fallbackToStaleOnError: true,
retry: { attempts: 2, delayMs: 500 },
cacheDuration: dayjs.duration(60, "seconds"),
queryKey: "beszelStats",
cacheDurationForInput(input) {
const config = timePeriodConfig[input.timePeriod];
if (!config) return undefined;
return dayjs.duration(config.cacheSeconds, "seconds");
},
});
@@ -18,9 +18,6 @@ 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 = <
@@ -38,9 +35,6 @@ 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,16 +7,6 @@ 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;
@@ -27,70 +17,28 @@ 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 = (): 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);
}
}
const requestNewDataAsync = async () => {
try {
const data = await options.requestAsync(input);
await channel.publishAndUpdateLastStateAsync(data);
return {
data,
timestamp: new Date(),
};
} catch (error) {
if (options.fallbackToStaleOnError && channelData) {
logger.warn(
new ErrorWithMetadata(
@@ -98,21 +46,15 @@ export const createCachedRequestHandler = <TData, TInput extends Record<string,
{
channel: channel.name,
queryKey: options.queryKey,
attempts: retryConfig.attempts,
},
{ cause: lastError },
{ cause: error },
),
);
return channelData;
}
throw lastError;
})().finally(() => {
inFlightFetches.delete(channel.name);
});
inFlightFetches.set(channel.name, promise as InFlightEntry);
return promise;
throw error;
}
};
if (forceUpdate) {
-35
View File
@@ -1,35 +0,0 @@
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",
});
+3 -1
View File
@@ -16,7 +16,9 @@ export type ServerSettingsRecord = Record<(typeof defaultServerSettingsKeys)[num
export const defaultServerSettings = {
analytics: {
enableGeneral: true,
instanceId: null as string | null,
enableWidgetData: false,
enableIntegrationData: false,
enableUserData: false,
},
crawlingAndIndexing: {
noIndex: true,
+13 -29
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "",
"loading": "",
"placeholder": "",
"noResults": "",
"loadError": ""
},
"app": {
"name": "",
"description": "",
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "",
"error": {
"internalServerError": ""
},
"column": {
"system": "",
"cpu": "",
@@ -3636,9 +3626,6 @@
"label": ""
}
},
"error": {
"internalServerError": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3665,9 +3652,6 @@
}
},
"empty": "",
"error": {
"internalServerError": ""
},
"history": "",
"resolved": "",
"status": {
@@ -3679,10 +3663,6 @@
"name": "",
"description": "",
"option": {
"systemId": {
"label": "",
"description": ""
},
"timePeriod": {
"label": ""
},
@@ -3712,12 +3692,6 @@
}
},
"selectSystem": "",
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
"switchToHistorical": "",
"systemNotFound": ""
},
"chart": {
"cpu": {
"title": "",
@@ -4415,6 +4389,18 @@
"general": {
"title": "",
"text": ""
},
"widgetData": {
"title": "",
"text": ""
},
"integrationData": {
"title": "",
"text": ""
},
"usersData": {
"title": "",
"text": ""
}
},
"crawlingAndIndexing": {
@@ -4942,9 +4928,7 @@
"updated": "",
"search": "",
"selected": "",
"footer": "",
"totalCpu": "",
"totalMemory": ""
"footer": ""
},
"field": {
"name": {
+13 -29
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "",
"loading": "",
"placeholder": "",
"noResults": "",
"loadError": ""
},
"app": {
"name": "应用",
"description": "将应用嵌入到面板中。",
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "",
"error": {
"internalServerError": ""
},
"column": {
"system": "",
"cpu": "",
@@ -3636,9 +3626,6 @@
"label": ""
}
},
"error": {
"internalServerError": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3665,9 +3652,6 @@
}
},
"empty": "",
"error": {
"internalServerError": ""
},
"history": "",
"resolved": "",
"status": {
@@ -3679,10 +3663,6 @@
"name": "",
"description": "",
"option": {
"systemId": {
"label": "",
"description": ""
},
"timePeriod": {
"label": ""
},
@@ -3712,12 +3692,6 @@
}
},
"selectSystem": "",
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
"switchToHistorical": "",
"systemNotFound": ""
},
"chart": {
"cpu": {
"title": "",
@@ -4415,6 +4389,18 @@
"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": {
@@ -4942,9 +4928,7 @@
"updated": "已更新 {when}",
"search": "搜索 {count} 容器",
"selected": "{totalCount} 容器的 {selectCount}",
"footer": "总计 {count} 容器",
"totalCpu": "",
"totalMemory": ""
"footer": "总计 {count} 容器"
},
"field": {
"name": {
+15 -31
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "crwdns5641:0crwdne5641:0",
"loading": "crwdns5643:0crwdne5643:0",
"placeholder": "crwdns5645:0crwdne5645:0",
"noResults": "crwdns5647:0crwdne5647:0",
"loadError": "crwdns5649:0crwdne5649:0"
},
"app": {
"name": "crwdns732:0crwdne732:0",
"description": "crwdns734:0crwdne734:0",
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "crwdns4811:0crwdne4811:0",
"error": {
"internalServerError": "crwdns5651:0crwdne5651:0"
},
"column": {
"system": "crwdns4813:0crwdne4813:0",
"cpu": "crwdns4815:0crwdne4815:0",
@@ -3636,9 +3626,6 @@
"label": "crwdns4863:0crwdne4863:0"
}
},
"error": {
"internalServerError": "crwdns5653:0crwdne5653:0"
},
"metric": {
"cpu": "crwdns4865:0crwdne4865:0",
"memory": "crwdns4867:0crwdne4867:0",
@@ -3665,9 +3652,6 @@
}
},
"empty": "crwdns4895:0crwdne4895:0",
"error": {
"internalServerError": "crwdns5655:0crwdne5655:0"
},
"history": "crwdns4897:0crwdne4897:0",
"resolved": "crwdns4899:0crwdne4899:0",
"status": {
@@ -3679,10 +3663,6 @@
"name": "crwdns4905:0crwdne4905:0",
"description": "crwdns4907:0crwdne4907:0",
"option": {
"systemId": {
"label": "crwdns5657:0crwdne5657:0",
"description": "crwdns5659:0crwdne5659:0"
},
"timePeriod": {
"label": "crwdns4909:0crwdne4909:0"
},
@@ -3712,12 +3692,6 @@
}
},
"selectSystem": "crwdns4927:0crwdne4927:0",
"error": {
"internalServerError": "crwdns5661:0crwdne5661:0",
"liveConnectionFailed": "crwdns5663:0crwdne5663:0",
"switchToHistorical": "crwdns5665:0crwdne5665:0",
"systemNotFound": "crwdns5667:0crwdne5667:0"
},
"chart": {
"cpu": {
"title": "crwdns4929:0crwdne4929:0",
@@ -4413,8 +4387,20 @@
"analytics": {
"title": "crwdns1764:0crwdne1764:0",
"general": {
"title": "crwdns5673:0crwdne5673:0",
"text": "crwdns5675:0crwdne5675:0"
"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"
}
},
"crawlingAndIndexing": {
@@ -4942,9 +4928,7 @@
"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",
"totalCpu": "crwdns5669:0{cpu}crwdne5669:0",
"totalMemory": "crwdns5671:0{memory}crwdne5671:0"
"footer": "crwdns3434:0{count}crwdne3434:0"
},
"field": {
"name": {
+13 -29
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "",
"loading": "",
"placeholder": "",
"noResults": "",
"loadError": ""
},
"app": {
"name": "",
"description": "",
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "",
"error": {
"internalServerError": ""
},
"column": {
"system": "",
"cpu": "",
@@ -3636,9 +3626,6 @@
"label": ""
}
},
"error": {
"internalServerError": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3665,9 +3652,6 @@
}
},
"empty": "",
"error": {
"internalServerError": ""
},
"history": "",
"resolved": "",
"status": {
@@ -3679,10 +3663,6 @@
"name": "",
"description": "",
"option": {
"systemId": {
"label": "",
"description": ""
},
"timePeriod": {
"label": ""
},
@@ -3712,12 +3692,6 @@
}
},
"selectSystem": "",
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
"switchToHistorical": "",
"systemNotFound": ""
},
"chart": {
"cpu": {
"title": "",
@@ -4415,6 +4389,18 @@
"general": {
"title": "",
"text": ""
},
"widgetData": {
"title": "",
"text": ""
},
"integrationData": {
"title": "",
"text": ""
},
"usersData": {
"title": "Uživatelská data",
"text": ""
}
},
"crawlingAndIndexing": {
@@ -4942,9 +4928,7 @@
"updated": "",
"search": "",
"selected": "",
"footer": "",
"totalCpu": "",
"totalMemory": ""
"footer": ""
},
"field": {
"name": {
+13 -29
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "",
"loading": "",
"placeholder": "",
"noResults": "",
"loadError": ""
},
"app": {
"name": "App",
"description": "Indlejrer en app i tavlen.",
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "",
"error": {
"internalServerError": ""
},
"column": {
"system": "",
"cpu": "",
@@ -3636,9 +3626,6 @@
"label": ""
}
},
"error": {
"internalServerError": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3665,9 +3652,6 @@
}
},
"empty": "",
"error": {
"internalServerError": ""
},
"history": "",
"resolved": "",
"status": {
@@ -3679,10 +3663,6 @@
"name": "",
"description": "",
"option": {
"systemId": {
"label": "",
"description": ""
},
"timePeriod": {
"label": ""
},
@@ -3712,12 +3692,6 @@
}
},
"selectSystem": "",
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
"switchToHistorical": "",
"systemNotFound": ""
},
"chart": {
"cpu": {
"title": "",
@@ -4415,6 +4389,18 @@
"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": {
@@ -4942,9 +4928,7 @@
"updated": "Opdateret {when}",
"search": "Søg {count} containere",
"selected": "{selectCount} af {totalCount} containere valgt",
"footer": "Total {count} containere",
"totalCpu": "",
"totalMemory": ""
"footer": "Total {count} containere"
},
"field": {
"name": {
+13 -29
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "",
"loading": "",
"placeholder": "",
"noResults": "",
"loadError": ""
},
"app": {
"name": "",
"description": "Bettet eine App in das Board ein.",
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "",
"error": {
"internalServerError": ""
},
"column": {
"system": "",
"cpu": "",
@@ -3636,9 +3626,6 @@
"label": ""
}
},
"error": {
"internalServerError": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3665,9 +3652,6 @@
}
},
"empty": "",
"error": {
"internalServerError": ""
},
"history": "",
"resolved": "",
"status": {
@@ -3679,10 +3663,6 @@
"name": "",
"description": "",
"option": {
"systemId": {
"label": "",
"description": ""
},
"timePeriod": {
"label": ""
},
@@ -3712,12 +3692,6 @@
}
},
"selectSystem": "",
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
"switchToHistorical": "",
"systemNotFound": ""
},
"chart": {
"cpu": {
"title": "",
@@ -4415,6 +4389,18 @@
"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": {
@@ -4942,9 +4928,7 @@
"updated": "Aktualisiert {when}",
"search": "{count} Container durchsuchen",
"selected": "{selectCount} von {totalCount} ausgewählten Containern",
"footer": "",
"totalCpu": "",
"totalMemory": ""
"footer": ""
},
"field": {
"name": {
+147 -163
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "Verbinde zuerst eine Integration",
"loading": "Wird geladen...",
"placeholder": "Wählen Sie eine Option",
"noResults": "Keine Ergebnisse gefunden",
"loadError": "Fehler beim Laden der Optionen"
},
"app": {
"name": "App",
"description": "Bettet eine App in das Board ein.",
@@ -1747,11 +1740,11 @@
}
},
"archiveTeamWarrior": {
"name": "ArchiveTeam Warrior",
"description": "ArchiveTeam Warrior Status, Projekte und aktive Archivaufträge überwachen.",
"name": "",
"description": "",
"option": {
"showBroadcastMessage": {
"label": "Broadcast Nachrichten anzeigen"
"label": ""
}
}
},
@@ -3339,62 +3332,62 @@
},
"uptimeKuma": {
"name": "Uptime Kuma",
"description": "Laufzeit Statistiken von der Uptime Kuma Statusseite überwachen",
"description": "",
"option": {
"showAverageUptime": {
"label": "Durchschnittliche Laufzeit anzeigen",
"description": "Zeige den durchschnittlichen Prozentsatz der 24 Stunden Laufzeit an"
"label": "",
"description": ""
},
"showUptimeRing": {
"label": "Uptime Ring anzeigen",
"description": "Einen Fortschrittsring neben dem durchschnittlichen Laufzeit Wert anzeigen"
"label": "",
"description": ""
},
"showTotalMonitors": {
"label": "Alle Monitore anzeigen",
"description": "Zeigt die Gesamtzahl der konfigurierten Monitore an"
"label": "",
"description": ""
},
"showUpCount": {
"label": "Aktive anzeigen",
"description": "Zeigt an, wie viele Monitore derzeit aktiv sind"
"label": "",
"description": ""
},
"showDownCount": {
"label": "Inaktive anzeigen",
"description": "Zeigt an, wie viele Monitore derzeit nicht verfügbar sind"
"label": "",
"description": ""
},
"showPausedCount": {
"label": "Pausierte anzeigen",
"description": "Zeigt an, wie viele Monitore derzeit pausiert sind"
"label": "",
"description": ""
}
},
"averageUptime": "Durchschnittliche Laufzeit (24h)",
"averageUptime": "",
"stats": {
"totalMonitors": "Alle Monitore",
"upCount": "Aktiv",
"downCount": "Inaktiv",
"pausedCount": "Pausiert"
"totalMonitors": "",
"upCount": "",
"downCount": "",
"pausedCount": ""
},
"error": {
"internalServerError": "Fehler beim Abrufen der Laufzeit Daten"
"internalServerError": ""
}
},
"audioStats": {
"name": "Audiostatistiken",
"description": "Bibliotheks- und Wiedergabestatistiken von Navidrome oder Audiobookshelf",
"name": "",
"description": "",
"option": {
"showArtists": {
"label": "Künstler anzeigen",
"description": "Zeige die Gesamtzahl der Künstler in deiner Musikbibliothek (Navidrome)"
"label": "",
"description": ""
},
"showAlbums": {
"label": "Alben anzeigen",
"description": "Zeigt die Gesamtzahl der Alben in deiner Musikbibliothek an (Navidrome)"
"label": "",
"description": ""
},
"showSongs": {
"label": "Songs anzeigen",
"description": "Zeigt die Gesamtzahl der Titel in deiner Musikbibliothek an (Navidrome)"
"label": "",
"description": ""
},
"showNowPlaying": {
"label": "\"Sie hören\" anzeigen",
"label": "",
"description": ""
},
"showNowPlayingList": {
@@ -3419,10 +3412,10 @@
},
"showListeningTime": {
"label": "",
"description": "Zeigt deine gesamte Hörzeit (Audiobookshelf)"
"description": ""
},
"showActiveSessions": {
"label": "Aktive Streams anzeigen",
"label": "",
"description": ""
},
"compactMode": {
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "",
"error": {
"internalServerError": ""
},
"column": {
"system": "",
"cpu": "",
@@ -3606,13 +3596,13 @@
"label": ""
},
"showMemory": {
"label": "Arbeitsspeicher anzeigen"
"label": ""
},
"showDisk": {
"label": ""
},
"showGpu": {
"label": "GPU anzeigen"
"label": ""
},
"showLoadAvg": {
"label": ""
@@ -3621,13 +3611,13 @@
"label": ""
},
"showTemp": {
"label": "Temperatur anzeigen"
"label": ""
},
"showBattery": {
"label": ""
},
"showServices": {
"label": "Dienste anzeigen"
"label": ""
},
"showUptime": {
"label": ""
@@ -3636,21 +3626,18 @@
"label": ""
}
},
"error": {
"internalServerError": ""
},
"metric": {
"cpu": "CPU",
"memory": "Arbeitsspeicher",
"cpu": "",
"memory": "",
"disk": "",
"gpu": "GPU",
"gpu": "",
"loadAvg": "",
"net": "",
"temp": "",
"battery": "",
"services": "Dienste",
"services": "",
"uptime": "",
"agent": "Agent"
"agent": ""
}
},
"beszelAlerts": {
@@ -3665,24 +3652,17 @@
}
},
"empty": "",
"error": {
"internalServerError": ""
},
"history": "Verlauf",
"history": "",
"resolved": "",
"status": {
"triggered": "",
"ok": "Ok"
"ok": ""
}
},
"beszelSystemStats": {
"name": "",
"description": "",
"option": {
"systemId": {
"label": "",
"description": ""
},
"timePeriod": {
"label": ""
},
@@ -3711,27 +3691,21 @@
"label": ""
}
},
"selectSystem": "System auswählen",
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
"switchToHistorical": "",
"systemNotFound": ""
},
"selectSystem": "",
"chart": {
"cpu": {
"title": "CPU Auslastung",
"title": "",
"subtitle": "",
"series": "CPU"
"series": ""
},
"memory": {
"title": "",
"subtitle": "",
"series": "Belegt",
"series": "",
"cache": ""
},
"disk": {
"title": "Festplattenbelegung",
"title": "",
"subtitle": "",
"series": ""
},
@@ -4282,12 +4256,12 @@
"message": ""
},
"status": {
"completed": "Abgeschlossen",
"notStarted": "Nicht gestartet"
"completed": "",
"notStarted": ""
},
"name": {
"management": "",
"dashboard": "Dashboard"
"dashboard": ""
}
}
}
@@ -4415,6 +4389,18 @@
"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": {
@@ -4692,15 +4678,15 @@
"subtitle": "",
"description": "",
"noApiKey": "",
"createApiKeyButton": "API Key erstellen",
"createApiKeyButton": "",
"endpoint": {
"title": ""
},
"authentication": {
"title": "Authentifizierung",
"title": "",
"intro": "",
"apiKey": {
"title": "API Key (manuell)",
"title": "",
"description": ""
},
"oauth": {
@@ -4713,12 +4699,12 @@
"clientConfig": {
"title": "",
"streamableHttp": {
"recommended": "Empfohlen",
"recommended": "",
"title": "",
"worksWith": "",
"addToConfig": "",
"cursor": "",
"claudeDesktop": "{name}: {file}",
"claudeDesktop": "",
"claudeCode": "",
"vsCodeCopilot": "",
"replaceHint": ""
@@ -4729,16 +4715,16 @@
}
},
"testConnection": {
"title": "Verbindung testen",
"title": "",
"description": "",
"hint": ""
},
"availableTools": {
"title": "Verfügbare Tools",
"title": "",
"description": "",
"columnType": "Typ",
"columnName": "Tool",
"columnDescription": "Beschreibung"
"columnType": "",
"columnName": "",
"columnDescription": ""
},
"troubleshooting": {
"title": "",
@@ -4767,65 +4753,65 @@
"oneClick": {
"title": "",
"subtitle": "",
"vsCode": "In VS Code installieren",
"cursor": "In Cursor installieren",
"copyUrl": "MCP URL kopieren",
"copied": "Kopiert!",
"copyHint": "Für ChatGPT, Claude Code oder weitere MCP-Client",
"manualConfig": "Manuelle Konfiguration"
"vsCode": "",
"cursor": "",
"copyUrl": "",
"copied": "",
"copyHint": "",
"manualConfig": ""
}
}
}
},
"backup": {
"title": "Komplette Datenbanksicherung",
"metaTitle": "Sichern & Wiederherstellen",
"title": "",
"metaTitle": "",
"export": {
"title": "Datenbank exportieren",
"description": "Laden Sie eine vollständige Sicherung Ihrer SQLite-Datenbank herunter, inklusive aller Einstellungen, Benutzer, Boards und verschlüsselten Integrationsgeheimnissen. Das Backup wird als ZIP-Datei mit dem Verschlüsselungsschlüssel und den Metadaten gebündelt.",
"button": "Sicherung herunterladen",
"error": "Export fehlgeschlagen",
"title": "",
"description": "",
"button": "",
"error": "",
"confirm": {
"title": "",
"warningTitle": "Dieser Export enthält Ihre gesamte Datenbank.",
"warningBody": "Das Backup umfasst alle Benutzer, Integrationsgeheimnisse (verschlüsselt), API-Keys, Board-Konfigurationen und App-Daten. Speichern Sie die exportierte Datei sicher ab und teilen Sie diese nicht.",
"typePrompt": "Geben Sie \"{phrase}\" ein, um fortzufahren",
"submit": "Datenbank exportieren",
"cancel": "Abbrechen"
"warningTitle": "",
"warningBody": "",
"typePrompt": "",
"submit": "",
"cancel": ""
}
},
"import": {
"title": "Datenbank wiederherstellen",
"title": "",
"description": "",
"warning": "",
"dropzone": {
"title": "Ziehen Sie Ihre Backup-ZIP hierher oder klicken Sie zum Durchsuchen",
"description": "Nur .zip Dateien aus einer Homarr SQLite-Sicherung werden akzeptiert"
"title": "",
"description": ""
},
"changeFile": "Datei ändern",
"button": "Datenbank wiederherstellen",
"changeFile": "",
"button": "",
"success": {
"title": "Wiederherstellung abgeschlossen",
"message": "Datenbank wurde erfolgreich wiederhergestellt. Die Anwendung wird neu gestartet..."
"title": "",
"message": ""
},
"error": "Importieren fehlgeschlagen"
"error": ""
},
"preview": {
"title": "",
"exported": "",
"loading": "Analysiere Backup-Datei...",
"loading": "",
"errorTitle": "",
"entity": "",
"count": "",
"boards": "Boards",
"boards": "",
"entities": {
"board": "Boards",
"user": "Benutzer",
"app": "Apps",
"integration": "Integrationen",
"item": "Widgets",
"media": "Medien",
"search_engine": "Suchmaschinen"
"board": "",
"user": "",
"app": "",
"integration": "",
"item": "",
"media": "",
"search_engine": ""
},
"analyzeError": "",
"invalidMissingMetadata": "",
@@ -4833,11 +4819,11 @@
},
"restore": {
"dropzone": {
"title": "Backup-ZIP hierher ziehen",
"description": "Akzeptiert .zip-Dateien von einem Homarr SQLite-Backup (max. 256 MB)"
"title": "",
"description": ""
},
"analyzing": "Analysiere Backup-Datei...",
"changeFile": "Datei ändern",
"analyzing": "",
"changeFile": "",
"continueToRestore": "",
"tryAgain": "",
"analysisError": "",
@@ -4889,7 +4875,7 @@
"message": ""
},
"failed": {
"title": "Wiederherstellung fehlgeschlagen"
"title": ""
},
"invalidMissingMetadata": "",
"invalidMissingDb": "",
@@ -4942,9 +4928,7 @@
"updated": "Aktualisiert {when}",
"search": "{count} Container durchsuchen",
"selected": "{selectCount} von {totalCount} ausgewählten Containern",
"footer": "Insgesamt {count} Container",
"totalCpu": "",
"totalMemory": ""
"footer": "Insgesamt {count} Container"
},
"field": {
"name": {
@@ -4977,15 +4961,15 @@
"label": "Ports"
},
"host": {
"label": "Host"
"label": ""
}
},
"action": {
"title": "Aktionen",
"logs": {
"label": "Logs anzeigen",
"label": "",
"page": {
"title": "{name} Logs"
"title": ""
}
},
"start": {
@@ -5380,12 +5364,12 @@
}
},
"custom-widgets": {
"label": "Benutzerdefinierte Widgets",
"label": "",
"new": {
"label": "Neu"
"label": ""
},
"edit": {
"label": "Bearbeiten",
"label": "",
"flow": {
"label": ""
}
@@ -5414,7 +5398,7 @@
"docker": {
"label": "Docker",
"logs": {
"label": "Logs"
"label": ""
}
},
"kubernetes": {
@@ -5566,26 +5550,26 @@
}
},
"preferences": {
"title": "Einstellungen",
"title": "",
"children": {
"detail": {
"title": "",
"backHint": "",
"backAction": "Zurück"
"backAction": ""
}
},
"notification": {
"error": {
"message": "Einstellung konnte nicht aktualisiert werden"
"message": ""
}
},
"option": {
"preferences": {
"label": "Einstellungen öffnen"
"label": ""
},
"boolean": {
"enabled": "Aktiviert",
"disabled": "Deaktiviert"
"enabled": "",
"disabled": ""
},
"board": {
"none": "",
@@ -5596,34 +5580,34 @@
"unavailable": ""
},
"colorScheme": {
"label": "Farbschema",
"label": "",
"children": {
"detail": {
"title": "Farbschema auswählen"
"title": ""
}
}
},
"locale": {
"label": "Sprache",
"label": "",
"children": {
"detail": {
"title": "Bevorzugte Sprache auswählen"
"title": ""
}
}
},
"defaultSearchEngineId": {
"label": "Standard Suchmaschine",
"label": "",
"children": {
"detail": {
"title": "Standard Suchmaschine auswählen"
"title": ""
}
}
},
"openSearchInNewTab": {
"label": "Suchergebnisse in neuem Tab öffnen",
"label": "",
"children": {
"detail": {
"title": "Suchergebnisse in neuem Tab öffnen"
"title": ""
}
}
},
@@ -5636,10 +5620,10 @@
}
},
"firstDayOfWeek": {
"label": "Erster Tag der Woche",
"label": "",
"children": {
"detail": {
"title": "Ersten Tag der Woche auswählen"
"title": ""
}
}
},
@@ -5668,23 +5652,23 @@
}
},
"fullPreferencesPage": {
"label": "Alle Einstellungen öffnen"
"label": ""
}
}
}
}
},
"media": {
"help": "Suche nach Filmen oder Serien zum Abfragen",
"help": "",
"requestMovie": "Einen Film anfordern",
"requestSeries": "Eine Serie anfordern",
"openIn": "In {kind} öffnen",
"group": {
"title": "Filme und Serien"
"title": ""
},
"action": {
"search": {
"label": "Suche nach Filmen oder Serien",
"label": "",
"description": "",
"disabled": {
"noIntegration": "",
@@ -6114,23 +6098,23 @@
"description": ""
},
"appsCreate": {
"title": "App hinzufügen",
"title": "",
"description": ""
},
"integrationsList": {
"title": "Ihre Integrationen",
"title": "",
"description": ""
},
"integrationsCreate": {
"title": "Integration hinzufügen",
"title": "",
"description": ""
},
"usersList": {
"title": "Ihre Benutzer",
"title": "",
"description": ""
},
"usersCreate": {
"title": "Benutzer erstellen",
"title": "",
"description": ""
}
},
@@ -6140,7 +6124,7 @@
"description": ""
},
"editMode": {
"title": "Bearbeitungsmodus",
"title": "",
"description": ""
},
"settings": {
+13 -29
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "",
"loading": "",
"placeholder": "",
"noResults": "",
"loadError": ""
},
"app": {
"name": "Εφαρμογή",
"description": "",
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "",
"error": {
"internalServerError": ""
},
"column": {
"system": "",
"cpu": "",
@@ -3636,9 +3626,6 @@
"label": ""
}
},
"error": {
"internalServerError": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3665,9 +3652,6 @@
}
},
"empty": "",
"error": {
"internalServerError": ""
},
"history": "",
"resolved": "",
"status": {
@@ -3679,10 +3663,6 @@
"name": "",
"description": "",
"option": {
"systemId": {
"label": "",
"description": ""
},
"timePeriod": {
"label": ""
},
@@ -3712,12 +3692,6 @@
}
},
"selectSystem": "",
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
"switchToHistorical": "",
"systemNotFound": ""
},
"chart": {
"cpu": {
"title": "",
@@ -4415,6 +4389,18 @@
"general": {
"title": "",
"text": ""
},
"widgetData": {
"title": "",
"text": ""
},
"integrationData": {
"title": "",
"text": ""
},
"usersData": {
"title": "",
"text": ""
}
},
"crawlingAndIndexing": {
@@ -4942,9 +4928,7 @@
"updated": "",
"search": "",
"selected": "",
"footer": "",
"totalCpu": "",
"totalMemory": ""
"footer": ""
},
"field": {
"name": {
+46 -62
View File
@@ -177,7 +177,7 @@
},
"password": {
"label": "Password",
"suggestionsTitle": "Password suggestions",
"suggestionsTitle": "",
"suggestion": {
"length": "Includes a minimum of 8 characters",
"lowercase": "Includes a lower-case letter",
@@ -687,28 +687,28 @@
"customWidget": {
"page": {
"list": {
"title": "Custom Widgets",
"noResults": "No custom widgets yet. Create your first one!",
"loading": "Loading..."
"title": "",
"noResults": "",
"loading": ""
},
"create": {
"title": "Create Custom Widget",
"subtitle": "Define an API URL, authentication, and how to display the response data."
"title": "",
"subtitle": ""
},
"edit": {
"title": "Edit Custom Widget"
"title": ""
}
},
"action": {
"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",
"create": "",
"import": "",
"save": "",
"delete": "",
"deleteConfirm": "",
"export": "",
"addMapping": "",
"addColumn": "",
"edit": "",
"removeMapping": "",
"removeColumn": "",
"addItem": "",
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "",
"loading": "",
"placeholder": "",
"noResults": "",
"loadError": ""
},
"app": {
"name": "App",
"description": "Embeds an app into the board.",
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "",
"error": {
"internalServerError": ""
},
"column": {
"system": "",
"cpu": "",
@@ -3636,9 +3626,6 @@
"label": ""
}
},
"error": {
"internalServerError": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3665,9 +3652,6 @@
}
},
"empty": "",
"error": {
"internalServerError": ""
},
"history": "",
"resolved": "",
"status": {
@@ -3679,10 +3663,6 @@
"name": "",
"description": "",
"option": {
"systemId": {
"label": "",
"description": ""
},
"timePeriod": {
"label": ""
},
@@ -3712,12 +3692,6 @@
}
},
"selectSystem": "",
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
"switchToHistorical": "",
"systemNotFound": ""
},
"chart": {
"cpu": {
"title": "",
@@ -4415,6 +4389,18 @@
"general": {
"title": "",
"text": ""
},
"widgetData": {
"title": "",
"text": ""
},
"integrationData": {
"title": "",
"text": ""
},
"usersData": {
"title": "",
"text": ""
}
},
"crawlingAndIndexing": {
@@ -4942,9 +4928,7 @@
"updated": "",
"search": "",
"selected": "",
"footer": "",
"totalCpu": "",
"totalMemory": ""
"footer": ""
},
"field": {
"name": {
@@ -5599,71 +5583,71 @@
"label": "",
"children": {
"detail": {
"title": "Select your colour scheme"
"title": ""
}
}
},
"locale": {
"label": "Language",
"label": "",
"children": {
"detail": {
"title": "Select your preferred language"
"title": ""
}
}
},
"defaultSearchEngineId": {
"label": "Default search engine",
"label": "",
"children": {
"detail": {
"title": "Select your default search engine"
"title": ""
}
}
},
"openSearchInNewTab": {
"label": "Open search results in new tab",
"label": "",
"children": {
"detail": {
"title": "Open search results in new tab"
"title": ""
}
}
},
"ddgBangs": {
"label": "Enable DuckDuckGo bangs",
"label": "",
"children": {
"detail": {
"title": "Enable DuckDuckGo bangs"
"title": ""
}
}
},
"firstDayOfWeek": {
"label": "First day of the week",
"label": "",
"children": {
"detail": {
"title": "Select the first day of the week"
"title": ""
}
}
},
"homeBoardId": {
"label": "Home board",
"label": "",
"children": {
"detail": {
"title": "Select your home board"
"title": ""
}
}
},
"mobileHomeBoardId": {
"label": "Mobile home board",
"label": "",
"children": {
"detail": {
"title": "Select your mobile home board"
"title": ""
}
}
},
"pingIconsEnabled": {
"label": "Use icons for pings",
"label": "",
"children": {
"detail": {
"title": "Use icons for pings"
"title": ""
}
}
},
+16 -25
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "Monitoring",
"speedtest": "Speed Test",
"analytics": "Analytics",
"vpn": "VPN",
"mediaLibrary": "Media Library",
"uptimeMonitoring": "Uptime Monitoring"
},
@@ -1413,10 +1412,7 @@
"here": "here",
"iconPicker": {
"label": "Icon URL",
"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."
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you."
},
"colorScheme": {
"options": {
@@ -2409,12 +2405,6 @@
"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",
@@ -3649,9 +3639,6 @@
"error": {
"internalServerError": "Unable to connect to Beszel. Check your integration settings."
},
"empty": {
"noSystems": "No systems found"
},
"metric": {
"cpu": "CPU",
"memory": "Memory",
@@ -3725,9 +3712,6 @@
}
},
"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.",
@@ -4429,8 +4413,20 @@
"analytics": {
"title": "Analytics",
"general": {
"title": "Help us improve Homarr",
"text": "This helps us figure out what people actually use so we know where to spend our time. We see stuff like how many widgets or integrations you have, your Homarr version, and what database you're on. That's it — no personal info, no URLs, no names, nothing that could identify you or your setup."
"title": "Send anonymous analytics",
"text": "Homarr will send anonymized analytics using the open source software Umami. When enabled, we collect: Homarr version, database type (MySQL/PostgreSQL/SQLite), Docker/Kubernetes status, authentication methods enabled, and the number of each feature. All data is anonymous and aggregated - no personal information, URLs, names, or identifiable data is ever collected. This helps our open source team understand usage patterns and prioritize development."
},
"widgetData": {
"title": "Widget data",
"text": "Send which widgets (and their quantity) you have configured. Does not include URLs, names or any other data."
},
"integrationData": {
"title": "Integration data",
"text": "Send which integrations (and their quantity) you have configured. Does not include URLs, names or any other data."
},
"usersData": {
"title": "Users data",
"text": "Send the amount of users and whether you've activated SSO"
}
},
"crawlingAndIndexing": {
@@ -4595,9 +4591,6 @@
},
"umamiActiveVisitors": {
"label": "Umami Active Visitors"
},
"vpn": {
"label": "VPN Status"
}
},
"interval": {
@@ -4961,9 +4954,7 @@
"updated": "Updated {when}",
"search": "Seach {count} containers",
"selected": "{selectCount} of {totalCount} containers selected",
"footer": "Total {count} containers",
"totalCpu": "Total CPU: {cpu}%",
"totalMemory": "Total Mem: {memory}"
"footer": "Total {count} containers"
},
"field": {
"name": {
+13 -29
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "",
"loading": "",
"placeholder": "",
"noResults": "",
"loadError": ""
},
"app": {
"name": "Aplicación",
"description": "Insertar una aplicación en el tablero.",
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "",
"error": {
"internalServerError": ""
},
"column": {
"system": "",
"cpu": "",
@@ -3636,9 +3626,6 @@
"label": ""
}
},
"error": {
"internalServerError": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3665,9 +3652,6 @@
}
},
"empty": "",
"error": {
"internalServerError": ""
},
"history": "",
"resolved": "",
"status": {
@@ -3679,10 +3663,6 @@
"name": "",
"description": "",
"option": {
"systemId": {
"label": "",
"description": ""
},
"timePeriod": {
"label": ""
},
@@ -3712,12 +3692,6 @@
}
},
"selectSystem": "",
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
"switchToHistorical": "",
"systemNotFound": ""
},
"chart": {
"cpu": {
"title": "",
@@ -4415,6 +4389,18 @@
"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": {
@@ -4942,9 +4928,7 @@
"updated": "Actualizado {when}",
"search": "Buscar {count} contenedores",
"selected": "{selectCount} de {totalCount} contenedores seleccionados",
"footer": "{count} contenedores en total",
"totalCpu": "",
"totalMemory": ""
"footer": "{count} contenedores en total"
},
"field": {
"name": {
+13 -29
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "",
"loading": "",
"placeholder": "",
"noResults": "",
"loadError": ""
},
"app": {
"name": "",
"description": "",
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "",
"error": {
"internalServerError": ""
},
"column": {
"system": "",
"cpu": "",
@@ -3636,9 +3626,6 @@
"label": ""
}
},
"error": {
"internalServerError": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3665,9 +3652,6 @@
}
},
"empty": "",
"error": {
"internalServerError": ""
},
"history": "",
"resolved": "",
"status": {
@@ -3679,10 +3663,6 @@
"name": "",
"description": "",
"option": {
"systemId": {
"label": "",
"description": ""
},
"timePeriod": {
"label": ""
},
@@ -3712,12 +3692,6 @@
}
},
"selectSystem": "",
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
"switchToHistorical": "",
"systemNotFound": ""
},
"chart": {
"cpu": {
"title": "",
@@ -4415,6 +4389,18 @@
"general": {
"title": "",
"text": ""
},
"widgetData": {
"title": "",
"text": ""
},
"integrationData": {
"title": "",
"text": ""
},
"usersData": {
"title": "",
"text": ""
}
},
"crawlingAndIndexing": {
@@ -4942,9 +4928,7 @@
"updated": "",
"search": "",
"selected": "",
"footer": "",
"totalCpu": "",
"totalMemory": ""
"footer": ""
},
"field": {
"name": {
+13 -29
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "",
"loading": "",
"placeholder": "",
"noResults": "",
"loadError": ""
},
"app": {
"name": "Sovellus",
"description": "",
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "",
"error": {
"internalServerError": ""
},
"column": {
"system": "",
"cpu": "",
@@ -3636,9 +3626,6 @@
"label": ""
}
},
"error": {
"internalServerError": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3665,9 +3652,6 @@
}
},
"empty": "",
"error": {
"internalServerError": ""
},
"history": "",
"resolved": "",
"status": {
@@ -3679,10 +3663,6 @@
"name": "",
"description": "",
"option": {
"systemId": {
"label": "",
"description": ""
},
"timePeriod": {
"label": ""
},
@@ -3712,12 +3692,6 @@
}
},
"selectSystem": "",
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
"switchToHistorical": "",
"systemNotFound": ""
},
"chart": {
"cpu": {
"title": "",
@@ -4415,6 +4389,18 @@
"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": {
@@ -4942,9 +4928,7 @@
"updated": "Päivitetty {when}",
"search": "Suodata {count} konttia...",
"selected": "{selectCount}/{totalCount} kontista valittu",
"footer": "Yhteensä {count} konttia",
"totalCpu": "",
"totalMemory": ""
"footer": "Yhteensä {count} konttia"
},
"field": {
"name": {
+13 -29
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "",
"loading": "",
"placeholder": "",
"noResults": "",
"loadError": ""
},
"app": {
"name": "App",
"description": "Intègre une application dans le tableau.",
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "",
"error": {
"internalServerError": ""
},
"column": {
"system": "",
"cpu": "",
@@ -3636,9 +3626,6 @@
"label": ""
}
},
"error": {
"internalServerError": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3665,9 +3652,6 @@
}
},
"empty": "",
"error": {
"internalServerError": ""
},
"history": "",
"resolved": "",
"status": {
@@ -3679,10 +3663,6 @@
"name": "",
"description": "",
"option": {
"systemId": {
"label": "",
"description": ""
},
"timePeriod": {
"label": ""
},
@@ -3712,12 +3692,6 @@
}
},
"selectSystem": "",
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
"switchToHistorical": "",
"systemNotFound": ""
},
"chart": {
"cpu": {
"title": "",
@@ -4415,6 +4389,18 @@
"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": {
@@ -4942,9 +4928,7 @@
"updated": "Mis à jour {when}",
"search": "Rechercher dans {count} conteneurs",
"selected": "{selectCount} sur {totalCount} conteneurs sélectionnés",
"footer": "Total des conteneurs {count}",
"totalCpu": "",
"totalMemory": ""
"footer": "Total des conteneurs {count}"
},
"field": {
"name": {
+13 -29
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "",
"loading": "",
"placeholder": "",
"noResults": "",
"loadError": ""
},
"app": {
"name": "אפליקציה",
"description": "הטמעת אפליקציה בלוח.",
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "",
"error": {
"internalServerError": ""
},
"column": {
"system": "",
"cpu": "",
@@ -3636,9 +3626,6 @@
"label": ""
}
},
"error": {
"internalServerError": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3665,9 +3652,6 @@
}
},
"empty": "",
"error": {
"internalServerError": ""
},
"history": "",
"resolved": "",
"status": {
@@ -3679,10 +3663,6 @@
"name": "",
"description": "",
"option": {
"systemId": {
"label": "",
"description": ""
},
"timePeriod": {
"label": ""
},
@@ -3712,12 +3692,6 @@
}
},
"selectSystem": "",
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
"switchToHistorical": "",
"systemNotFound": ""
},
"chart": {
"cpu": {
"title": "",
@@ -4415,6 +4389,18 @@
"general": {
"title": "שליחת ניתוח נתונים אנונימי",
"text": "Homarr ישלח ניתוחים אנונימיים באמצעות התוכנה בקוד פתוח Umami. הוא לעולם אינו אוסף מידע אישי ולכן תואם לחלוטין לדרישות ה-GDPR וה-CCPA. אנו מעודדים אותך להפעיל ניתוחים, כיוון שזה מסייע לצוות הקוד הפתוח שלנו לזהות בעיות ולהעניק עדיפות למשימות שברשימת העדיפויות שלנו."
},
"widgetData": {
"title": "נתוני ווידג׳ט",
"text": "שלח אילו ווידג'טים (וכמותם) הוגדרו. אינו כולל כתובות רשת, שמות או כל מידע נוסף אחר."
},
"integrationData": {
"title": "נתוני אינטגרציה",
"text": "שלח אילו אינטגרציות (וכמותם) הוגדרו. לא כולל כתובות רשת, שמות או כל מידע נוסף אחר."
},
"usersData": {
"title": "נתוני משתמשים",
"text": "שלח את כמות המשתמשים והאם הופעל SSO"
}
},
"crawlingAndIndexing": {
@@ -4942,9 +4928,7 @@
"updated": "עודכן ב- {when}",
"search": "חפש {count} קונטיינרים",
"selected": "{selectCount} מ- {totalCount} קונטיינרים נבחרו",
"footer": "סה״כ {count} קונטיינרים",
"totalCpu": "",
"totalMemory": ""
"footer": "סה״כ {count} קונטיינרים"
},
"field": {
"name": {
+13 -29
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "",
"loading": "",
"placeholder": "",
"noResults": "",
"loadError": ""
},
"app": {
"name": "",
"description": "",
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "",
"error": {
"internalServerError": ""
},
"column": {
"system": "",
"cpu": "",
@@ -3636,9 +3626,6 @@
"label": ""
}
},
"error": {
"internalServerError": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3665,9 +3652,6 @@
}
},
"empty": "",
"error": {
"internalServerError": ""
},
"history": "",
"resolved": "",
"status": {
@@ -3679,10 +3663,6 @@
"name": "",
"description": "",
"option": {
"systemId": {
"label": "",
"description": ""
},
"timePeriod": {
"label": ""
},
@@ -3712,12 +3692,6 @@
}
},
"selectSystem": "",
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
"switchToHistorical": "",
"systemNotFound": ""
},
"chart": {
"cpu": {
"title": "",
@@ -4415,6 +4389,18 @@
"general": {
"title": "",
"text": ""
},
"widgetData": {
"title": "",
"text": ""
},
"integrationData": {
"title": "",
"text": ""
},
"usersData": {
"title": "",
"text": ""
}
},
"crawlingAndIndexing": {
@@ -4942,9 +4928,7 @@
"updated": "",
"search": "",
"selected": "",
"footer": "",
"totalCpu": "",
"totalMemory": ""
"footer": ""
},
"field": {
"name": {
+13 -29
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "",
"loading": "",
"placeholder": "",
"noResults": "",
"loadError": ""
},
"app": {
"name": "Alkalmazás",
"description": "Alkalmazást ágyaz be a táblába.",
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "",
"error": {
"internalServerError": ""
},
"column": {
"system": "",
"cpu": "",
@@ -3636,9 +3626,6 @@
"label": ""
}
},
"error": {
"internalServerError": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3665,9 +3652,6 @@
}
},
"empty": "",
"error": {
"internalServerError": ""
},
"history": "",
"resolved": "",
"status": {
@@ -3679,10 +3663,6 @@
"name": "",
"description": "",
"option": {
"systemId": {
"label": "",
"description": ""
},
"timePeriod": {
"label": ""
},
@@ -3712,12 +3692,6 @@
}
},
"selectSystem": "",
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
"switchToHistorical": "",
"systemNotFound": ""
},
"chart": {
"cpu": {
"title": "",
@@ -4415,6 +4389,18 @@
"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": {
@@ -4942,9 +4928,7 @@
"updated": "",
"search": "",
"selected": "",
"footer": "",
"totalCpu": "",
"totalMemory": ""
"footer": ""
},
"field": {
"name": {
+16 -32
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "Connetti prima un'integrazione",
"loading": "Caricamento...",
"placeholder": "Seleziona un'opzione",
"noResults": "Nessun risultato trovato",
"loadError": "Impossibile caricare le opzioni"
},
"app": {
"name": "App",
"description": "Incorpora un'app nella board.",
@@ -1747,11 +1740,11 @@
}
},
"archiveTeamWarrior": {
"name": "ArchiveTeam Warrior",
"description": "Monitora lo stato di ArchiveTeam Warrior e i lavori di archiviazione attivi.",
"name": "",
"description": "",
"option": {
"showBroadcastMessage": {
"label": "Mostra messaggi broadcast"
"label": ""
}
}
},
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "Nessun sistema trovato",
"error": {
"internalServerError": "Impossibile connettersi a Beszel. Controlla le tue impostazioni di integrazione."
},
"column": {
"system": "Sistema",
"cpu": "CPU",
@@ -3636,9 +3626,6 @@
"label": "Mostra versione agente"
}
},
"error": {
"internalServerError": "Impossibile connettersi a Beszel. Controlla le tue impostazioni di integrazione."
},
"metric": {
"cpu": "CPU",
"memory": "Memoria",
@@ -3665,9 +3652,6 @@
}
},
"empty": "Nessun avviso configurato",
"error": {
"internalServerError": "Impossibile connettersi a Beszel. Controlla le tue impostazioni di integrazione."
},
"history": "Cronologia",
"resolved": "Risolto",
"status": {
@@ -3679,10 +3663,6 @@
"name": "Statistiche Sistema Beszel",
"description": "Grafici a serie temporali per CPU, memoria, disco, rete e metriche dei container Docker",
"option": {
"systemId": {
"label": "Sistema",
"description": "Seleziona un sistema Beszel per visualizzare le statistiche per"
},
"timePeriod": {
"label": "Periodo di tempo"
},
@@ -3712,12 +3692,6 @@
}
},
"selectSystem": "Seleziona sistema",
"error": {
"internalServerError": "Impossibile connettersi a Beszel. Controlla le tue impostazioni di integrazione.",
"liveConnectionFailed": "Connessione Live non riuscita. L'istanza di Beszel potrebbe non supportare lo streaming in tempo reale.",
"switchToHistorical": "Passa alla visualizzazione storica",
"systemNotFound": "Il sistema selezionato non è stato trovato. Scegli un sistema diverso."
},
"chart": {
"cpu": {
"title": "Utilizzo CPU",
@@ -4415,6 +4389,18 @@
"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": {
@@ -4942,9 +4928,7 @@
"updated": "Aggiornato {when}",
"search": "Cerca {count} container",
"selected": "{selectCount} di {totalCount} containers selezionati",
"footer": "Totale container {count}",
"totalCpu": "CPU Totale: {cpu}%",
"totalMemory": "Mem Totale: {memory}"
"footer": "Totale container {count}"
},
"field": {
"name": {
+13 -29
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "",
"loading": "",
"placeholder": "",
"noResults": "",
"loadError": ""
},
"app": {
"name": "アプリ",
"description": "アプリをボードに埋め込みます。",
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "",
"error": {
"internalServerError": ""
},
"column": {
"system": "",
"cpu": "",
@@ -3636,9 +3626,6 @@
"label": ""
}
},
"error": {
"internalServerError": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3665,9 +3652,6 @@
}
},
"empty": "",
"error": {
"internalServerError": ""
},
"history": "",
"resolved": "",
"status": {
@@ -3679,10 +3663,6 @@
"name": "",
"description": "",
"option": {
"systemId": {
"label": "",
"description": ""
},
"timePeriod": {
"label": ""
},
@@ -3712,12 +3692,6 @@
}
},
"selectSystem": "",
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
"switchToHistorical": "",
"systemNotFound": ""
},
"chart": {
"cpu": {
"title": "",
@@ -4415,6 +4389,18 @@
"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": {
@@ -4942,9 +4928,7 @@
"updated": "{when} に更新しました",
"search": "{count} 個のコンテナを検索",
"selected": "{totalCount} 個のうち、{selectCount} 個のコンテナが選択されています",
"footer": "合計 {count} 個のコンテナ",
"totalCpu": "",
"totalMemory": ""
"footer": "合計 {count} 個のコンテナ"
},
"field": {
"name": {
File diff suppressed because it is too large. Load diff
+13 -29
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "",
"loading": "",
"placeholder": "",
"noResults": "",
"loadError": ""
},
"app": {
"name": "",
"description": "",
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "",
"error": {
"internalServerError": ""
},
"column": {
"system": "",
"cpu": "",
@@ -3636,9 +3626,6 @@
"label": ""
}
},
"error": {
"internalServerError": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3665,9 +3652,6 @@
}
},
"empty": "",
"error": {
"internalServerError": ""
},
"history": "",
"resolved": "",
"status": {
@@ -3679,10 +3663,6 @@
"name": "",
"description": "",
"option": {
"systemId": {
"label": "",
"description": ""
},
"timePeriod": {
"label": ""
},
@@ -3712,12 +3692,6 @@
}
},
"selectSystem": "",
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
"switchToHistorical": "",
"systemNotFound": ""
},
"chart": {
"cpu": {
"title": "",
@@ -4415,6 +4389,18 @@
"general": {
"title": "",
"text": ""
},
"widgetData": {
"title": "",
"text": ""
},
"integrationData": {
"title": "",
"text": ""
},
"usersData": {
"title": "",
"text": ""
}
},
"crawlingAndIndexing": {
@@ -4942,9 +4928,7 @@
"updated": "",
"search": "",
"selected": "",
"footer": "",
"totalCpu": "",
"totalMemory": ""
"footer": ""
},
"field": {
"name": {
+13 -29
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "",
"loading": "",
"placeholder": "",
"noResults": "",
"loadError": ""
},
"app": {
"name": "",
"description": "",
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "",
"error": {
"internalServerError": ""
},
"column": {
"system": "",
"cpu": "",
@@ -3636,9 +3626,6 @@
"label": ""
}
},
"error": {
"internalServerError": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3665,9 +3652,6 @@
}
},
"empty": "",
"error": {
"internalServerError": ""
},
"history": "",
"resolved": "",
"status": {
@@ -3679,10 +3663,6 @@
"name": "",
"description": "",
"option": {
"systemId": {
"label": "",
"description": ""
},
"timePeriod": {
"label": ""
},
@@ -3712,12 +3692,6 @@
}
},
"selectSystem": "",
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
"switchToHistorical": "",
"systemNotFound": ""
},
"chart": {
"cpu": {
"title": "",
@@ -4415,6 +4389,18 @@
"general": {
"title": "",
"text": ""
},
"widgetData": {
"title": "",
"text": ""
},
"integrationData": {
"title": "",
"text": ""
},
"usersData": {
"title": "",
"text": ""
}
},
"crawlingAndIndexing": {
@@ -4942,9 +4928,7 @@
"updated": "",
"search": "",
"selected": "",
"footer": "",
"totalCpu": "",
"totalMemory": ""
"footer": ""
},
"field": {
"name": {
+13 -29
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "",
"loading": "",
"placeholder": "",
"noResults": "",
"loadError": ""
},
"app": {
"name": "App",
"description": "Een app insluiten op het bord.",
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "",
"error": {
"internalServerError": ""
},
"column": {
"system": "",
"cpu": "",
@@ -3636,9 +3626,6 @@
"label": ""
}
},
"error": {
"internalServerError": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3665,9 +3652,6 @@
}
},
"empty": "",
"error": {
"internalServerError": ""
},
"history": "",
"resolved": "",
"status": {
@@ -3679,10 +3663,6 @@
"name": "",
"description": "",
"option": {
"systemId": {
"label": "",
"description": ""
},
"timePeriod": {
"label": ""
},
@@ -3712,12 +3692,6 @@
}
},
"selectSystem": "",
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
"switchToHistorical": "",
"systemNotFound": ""
},
"chart": {
"cpu": {
"title": "",
@@ -4415,6 +4389,18 @@
"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": {
@@ -4942,9 +4928,7 @@
"updated": "{when} bijgewerkt",
"search": "{count} containers zoeken",
"selected": "{selectCount} van {totalCount} containers geselecteerd",
"footer": "Totaal {count} containers",
"totalCpu": "",
"totalMemory": ""
"footer": "Totaal {count} containers"
},
"field": {
"name": {
+13 -29
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "",
"loading": "",
"placeholder": "",
"noResults": "",
"loadError": ""
},
"app": {
"name": "App",
"description": "Bygger inn en app i tavlen.",
@@ -3577,9 +3570,6 @@
}
},
"noRecords": "",
"error": {
"internalServerError": ""
},
"column": {
"system": "",
"cpu": "",
@@ -3636,9 +3626,6 @@
"label": ""
}
},
"error": {
"internalServerError": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3665,9 +3652,6 @@
}
},
"empty": "",
"error": {
"internalServerError": ""
},
"history": "",
"resolved": "",
"status": {
@@ -3679,10 +3663,6 @@
"name": "",
"description": "",
"option": {
"systemId": {
"label": "",
"description": ""
},
"timePeriod": {
"label": ""
},
@@ -3712,12 +3692,6 @@
}
},
"selectSystem": "",
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
"switchToHistorical": "",
"systemNotFound": ""
},
"chart": {
"cpu": {
"title": "",
@@ -4415,6 +4389,18 @@
"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": {
@@ -4942,9 +4928,7 @@
"updated": "Oppdatert {when}",
"search": "Søk {count} containere",
"selected": "{selectCount} av {totalCount} containere valgt",
"footer": "Totalt {count} containere",
"totalCpu": "",
"totalMemory": ""
"footer": "Totalt {count} containere"
},
"field": {
"name": {
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more