diff --git a/.github/renovate.json b/.github/renovate.json
index 1f5acc772..69fb4cdad 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -1,15 +1,30 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
- "extends": ["config:recommended"],
+ "extends": [
+ "config:recommended"
+ ],
"packageRules": [
{
- "matchPackagePatterns": ["^@homarr/"],
+ "matchPackagePatterns": [
+ "^@homarr/"
+ ],
"enabled": false
+ },
+ {
+ "matchUpdateTypes": [
+ "minor",
+ "patch",
+ "pin",
+ "digest"
+ ],
+ "automerge": true
}
],
"updateInternalDeps": true,
"rangeStrategy": "bump",
"automerge": false,
- "baseBranches": ["dev"],
+ "baseBranches": [
+ "dev"
+ ],
"dependencyDashboard": false
-}
+}
\ No newline at end of file
diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml
index 44846a959..228dd9989 100644
--- a/.github/workflows/code-quality.yml
+++ b/.github/workflows/code-quality.yml
@@ -66,3 +66,9 @@ jobs:
- name: Test
run: pnpm test
+
+ - name: 'Report Coverage'
+ # Set if: always() to also generate the report if tests are failing
+ # Only works if you set `reportOnFailure: true` in your vite config as specified above
+ if: always()
+ uses: davelosert/vitest-coverage-report-action@v2
diff --git a/.github/workflows/conventional-commits.yml b/.github/workflows/conventional-commits.yml
new file mode 100644
index 000000000..8f2216bd7
--- /dev/null
+++ b/.github/workflows/conventional-commits.yml
@@ -0,0 +1,16 @@
+
+# https://github.com/webiny/action-conventional-commits?tab=readme-ov-file
+
+name: Conventional Commits
+
+on:
+ pull_request:
+ branches: [ dev ]
+
+jobs:
+ build:
+ name: Conventional Commits
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: webiny/action-conventional-commits@v1.3.0
\ No newline at end of file
diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml
index 94bfba67a..046523358 100644
--- a/.github/workflows/docker-image.yml
+++ b/.github/workflows/docker-image.yml
@@ -30,8 +30,20 @@ jobs:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
- args: "Deployment of an image has been triggered"
+ args: "Deployment of an image has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
- uses: actions/checkout@v4
+ - name: Get Next Version
+ id: semver
+ uses: ietf-tools/semver-action@v1
+ with:
+ token: ${{ github.token }}
+ branch: master
+ - name: Discord notification
+ env:
+ DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
+ uses: Ilshidur/action-discord@master
+ with:
+ args: "Semver computed next tag to be ${{ steps.semver.outputs.next }}. Current is ${{ steps.semver.outputs.current }}"
- uses: pnpm/action-setup@v2
with:
version: 8
@@ -44,23 +56,16 @@ jobs:
run: pnpm install
- name: Build artifacts
run: pnpm build
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
- - name: Bump version and push tag
- id: githubTagAction
- uses: anothrNick/github-tag-action@1.69.0
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- WITH_V: false
- DRY_RUN: true
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
- args: "Image has been tagged as ${{ steps.githubTagAction.outputs.new_tag }}"
+ args: "Built application artifacts. Building images..."
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
@@ -68,7 +73,7 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest
- type=raw,value=${{ steps.githubTagAction.outputs.new_tag }}
+ type=raw,value=${{ steps.semver.outputs.next }}
- name: Build and push
id: buildPushAction
uses: docker/build-push-action@v5
@@ -86,4 +91,4 @@ jobs:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
- args: "Image built with ID ${{ steps.buildPushAction.outputs.imageid }}"
+ args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'. This was a dry run."
diff --git a/.github/workflows/pr-conventional-commits.yml b/.github/workflows/pr-conventional-commits.yml
new file mode 100644
index 000000000..e3dfb587b
--- /dev/null
+++ b/.github/workflows/pr-conventional-commits.yml
@@ -0,0 +1,20 @@
+name: "Lint PR"
+
+on:
+ pull_request_target:
+ types:
+ - opened
+ - edited
+ - synchronize
+
+permissions:
+ pull-requests: read
+
+jobs:
+ main:
+ name: Validate PR title
+ runs-on: ubuntu-latest
+ steps:
+ - uses: amannn/action-semantic-pull-request@v5
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
diff --git a/.github/workflows/renovate-automatic-approval b/.github/workflows/renovate-automatic-approval
new file mode 100644
index 000000000..b4701ed96
--- /dev/null
+++ b/.github/workflows/renovate-automatic-approval
@@ -0,0 +1,23 @@
+name: Approve Renovate PRs
+on:
+ pull_request:
+ types: [opened, synchronize]
+
+jobs:
+ approve-renovate-prs:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+
+ - name: Install GitHub CLI
+ run: sudo apt-get install -y gh
+
+ - name: Approve Renovate PRs
+ env:
+ GITHUB_TOKEN: ${{ secrets.RENOVATE_APPROVE_TOKEN }}
+ run: |
+ for pr in $(gh pr list --author homarr-renovate[bot] --json number --jq .[].number); do
+ gh pr review $pr --approve --body "Automatically approved by GitHub Action"
+ done
diff --git a/.nvmrc b/.nvmrc
index f203ab89b..48b14e6b2 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-20.13.1
+20.14.0
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 53c760db0..b9a963aad 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -10,6 +10,7 @@
"cSpell.words": [
"superjson",
"homarr",
- "trpc"
+ "trpc",
+ "Umami"
]
}
\ No newline at end of file
diff --git a/README.md b/README.md
index 2eb9f8222..3a88af411 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# THIS PROJECT IS STILL UNSTABLE AND WE DO NOT PROVIDE ANY SUPPORT FOR ISSUES THAT OCCURE.
+
## PLEASE DO NOT OPEN ANY ISSUES OR DISCUSSIONS
+
### EVERYTHING IS SUBJECT TO CHANGE
Please use [this](https://github.com/ajnart/homarr) version of Homarr when you want to use it
diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json
index 5c78e7643..d82c1decd 100644
--- a/apps/nextjs/package.json
+++ b/apps/nextjs/package.json
@@ -14,6 +14,7 @@
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
+ "@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
@@ -21,6 +22,7 @@
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/gridstack": "^1.0.0",
+ "@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
"@homarr/modals": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
@@ -29,16 +31,16 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
- "@mantine/colors-generator": "^7.9.2",
- "@mantine/hooks": "^7.9.2",
- "@mantine/modals": "^7.9.2",
- "@mantine/tiptap": "^7.9.2",
+ "@mantine/colors-generator": "^7.10.0",
+ "@mantine/hooks": "^7.10.0",
+ "@mantine/modals": "^7.10.0",
+ "@mantine/tiptap": "^7.10.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@t3-oss/env-nextjs": "^0.10.1",
- "@tanstack/react-query": "^5.37.1",
- "@tanstack/react-query-devtools": "^5.37.1",
- "@tanstack/react-query-next-experimental": "5.37.1",
- "@trpc/client": "11.0.0-rc.374",
+ "@tanstack/react-query": "^5.40.0",
+ "@tanstack/react-query-devtools": "^5.40.0",
+ "@tanstack/react-query-next-experimental": "5.40.0",
+ "@trpc/client": "11.0.0-rc.377",
"@trpc/next": "next",
"@trpc/react-query": "next",
"@trpc/server": "next",
@@ -49,12 +51,13 @@
"dayjs": "^1.11.11",
"dotenv": "^16.4.5",
"flag-icons": "^7.2.2",
- "glob": "^10.3.15",
- "jotai": "^2.8.1",
+ "glob": "^10.4.1",
+ "jotai": "^2.8.2",
"next": "^14.2.3",
"postcss-preset-mantine": "^1.15.0",
"react": "18.3.1",
"react-dom": "18.3.1",
+ "react-error-boundary": "^4.0.13",
"sass": "^1.77.2",
"superjson": "2.2.1",
"use-deep-compare-effect": "^1.8.1"
@@ -65,12 +68,12 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "2.4.4",
"@types/node": "^20.12.12",
- "@types/react": "^18.3.2",
+ "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"concurrently": "^8.2.2",
"eslint": "^8.57.0",
"prettier": "^3.2.5",
- "tsx": "4.10.5",
+ "tsx": "4.11.0",
"typescript": "^5.4.5"
},
"eslintConfig": {
diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx
index 35414e252..86db1c66c 100644
--- a/apps/nextjs/src/app/[locale]/layout.tsx
+++ b/apps/nextjs/src/app/[locale]/layout.tsx
@@ -11,6 +11,7 @@ import { auth } from "@homarr/auth/next";
import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications";
+import { Analytics } from "~/components/layout/analytics";
import { JotaiProvider } from "./_client-providers/jotai";
import { NextInternationalProvider } from "./_client-providers/next-international";
import { AuthProvider } from "./_client-providers/session";
@@ -76,6 +77,7 @@ export default function Layout(props: { children: React.ReactNode; params: { loc
+
diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx
index dfe86b363..7ac93b917 100644
--- a/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx
@@ -96,18 +96,23 @@ const SwitchSetting = ({
title: string;
text: ReactNode;
}) => {
+ const disabled = formKey !== "enableGeneral" && !form.values.enableGeneral;
const handleClick = React.useCallback(() => {
+ if (disabled) {
+ return;
+ }
form.setFieldValue(formKey, !form.values[formKey]);
- }, [form, formKey]);
+ }, [form, formKey, disabled]);
+
return (
-
-
+
+
{title}
{text}
-
-
-
+
+
+
);
};
diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx
index 9a2a1f100..d38c868a2 100644
--- a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx
+++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx
@@ -3,21 +3,24 @@
import { useCallback, useMemo, useState } from "react";
import { ActionIcon, Affix, Card } from "@mantine/core";
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
+import { QueryErrorResetBoundary } from "@tanstack/react-query";
+import { ErrorBoundary } from "react-error-boundary";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
-import type { BoardItemAdvancedOptions, BoardItemIntegration } from "@homarr/validation";
+import type { BoardItemAdvancedOptions } from "@homarr/validation";
import {
loadWidgetDynamic,
reduceWidgetOptionsWithDefaultValues,
WidgetEditModal,
widgetImports,
} from "@homarr/widgets";
+import { WidgetError } from "@homarr/widgets/errors";
-import { PreviewDimensionsModal } from "./_dimension-modal";
import type { Dimensions } from "./_dimension-modal";
+import { PreviewDimensionsModal } from "./_dimension-modal";
interface WidgetPreviewPageContentProps {
kind: WidgetKind;
@@ -41,11 +44,11 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
});
const [state, setState] = useState<{
options: Record;
- integrations: BoardItemIntegration[];
+ integrationIds: string[];
advancedOptions: BoardItemAdvancedOptions;
}>({
options: reduceWidgetOptionsWithDefaultValues(kind, {}),
- integrations: [],
+ integrationIds: [],
advancedOptions: {
customCssClasses: [],
},
@@ -86,17 +89,26 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
return (
<>
= 96 ? undefined : 4}>
- integrationData.find((integration) => integration.id === stateIntegration.id)!,
+
+ {({ reset }) => (
+ (
+
+ )}
+ >
+
+
)}
- width={dimensions.width}
- height={dimensions.height}
- isEditMode={editMode}
- boardId={undefined}
- itemId={undefined}
- />
+
diff --git a/apps/nextjs/src/components/board/items/item-actions.tsx b/apps/nextjs/src/components/board/items/item-actions.tsx
index 309dab8bd..2246853ea 100644
--- a/apps/nextjs/src/components/board/items/item-actions.tsx
+++ b/apps/nextjs/src/components/board/items/item-actions.tsx
@@ -2,7 +2,7 @@ import { useCallback } from "react";
import { createId } from "@homarr/db/client";
import type { WidgetKind } from "@homarr/definitions";
-import type { BoardItemAdvancedOptions, BoardItemIntegration } from "@homarr/validation";
+import type { BoardItemAdvancedOptions } from "@homarr/validation";
import type { EmptySection, Item } from "~/app/[locale]/boards/_types";
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
@@ -38,7 +38,7 @@ interface UpdateItemAdvancedOptions {
interface UpdateItemIntegrations {
itemId: string;
- newIntegrations: BoardItemIntegration[];
+ newIntegrations: string[];
}
interface CreateItem {
@@ -63,7 +63,7 @@ export const useItemActions = () => {
options: {},
width: 1,
height: 1,
- integrations: [],
+ integrationIds: [],
advancedOptions: {
customCssClasses: [],
},
@@ -157,7 +157,7 @@ export const useItemActions = () => {
if (item.id !== itemId) return item;
return {
...item,
- ...("integrations" in item ? { integrations: newIntegrations } : {}),
+ ...("integrationIds" in item ? { integrationIds: newIntegrations } : {}),
};
}),
};
diff --git a/apps/nextjs/src/components/board/sections/content.tsx b/apps/nextjs/src/components/board/sections/content.tsx
index cdd71266f..487207eb4 100644
--- a/apps/nextjs/src/components/board/sections/content.tsx
+++ b/apps/nextjs/src/components/board/sections/content.tsx
@@ -2,11 +2,13 @@
// Ignored because of gridstack attributes
import type { RefObject } from "react";
-import { useMemo } from "react";
+import { useEffect, useMemo, useRef } from "react";
import { ActionIcon, Card, Menu } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
+import { QueryErrorResetBoundary } from "@tanstack/react-query";
import combineClasses from "clsx";
+import { ErrorBoundary } from "react-error-boundary";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
@@ -18,6 +20,7 @@ import {
WidgetEditModal,
widgetImports,
} from "@homarr/widgets";
+import { WidgetError } from "@homarr/widgets/errors";
import type { Item } from "~/app/[locale]/boards/_types";
import { useEditMode, useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
@@ -104,22 +107,43 @@ const BoardItemContent = ({ item, ...dimensions }: ItemContentProps) => {
if (!serverData?.isReady) return null;
return (
- <>
-
-
- >
+
+ {({ reset }) => (
+ (
+ <>
+
+
+ >
+ )}
+ >
+
+
+
+ )}
+
);
};
-const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
+const ItemMenu = ({
+ offset,
+ item,
+ resetErrorBoundary,
+}: {
+ offset: number;
+ item: Item;
+ resetErrorBoundary?: () => void;
+}) => {
+ const refResetErrorBoundaryOnNextRender = useRef(false);
const tItem = useScopedI18n("item");
const t = useI18n();
const { openModal } = useModalAction(WidgetEditModal);
@@ -129,6 +153,14 @@ const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
+ // Reset error boundary on next render if item has been edited
+ useEffect(() => {
+ if (refResetErrorBoundaryOnNextRender.current) {
+ resetErrorBoundary?.();
+ refResetErrorBoundaryOnNextRender.current = false;
+ }
+ }, [item, resetErrorBoundary]);
+
if (!isEditMode || isPending) return null;
const openEditModal = () => {
@@ -137,9 +169,9 @@ const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
value: {
advancedOptions: item.advancedOptions,
options: item.options,
- integrations: item.integrations,
+ integrationIds: item.integrationIds,
},
- onSuccessfulEdit: ({ options, integrations, advancedOptions }) => {
+ onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => {
updateItemOptions({
itemId: item.id,
newOptions: options,
@@ -150,8 +182,9 @@ const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
});
updateItemIntegrations({
itemId: item.id,
- newIntegrations: integrations,
+ newIntegrations: integrationIds,
});
+ refResetErrorBoundaryOnNextRender.current = true;
},
integrationData: (integrationData ?? []).filter(
(integration) =>
diff --git a/apps/nextjs/src/components/layout/analytics.tsx b/apps/nextjs/src/components/layout/analytics.tsx
new file mode 100644
index 000000000..322d7a0a6
--- /dev/null
+++ b/apps/nextjs/src/components/layout/analytics.tsx
@@ -0,0 +1,15 @@
+import Script from "next/script";
+
+import { UMAMI_WEBSITE_ID } from "@homarr/analytics";
+import { api } from "@homarr/api/server";
+
+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 api.serverSettings.getAnalytics().catch(() => null);
+
+ if (analytics?.enableGeneral) {
+ return ;
+ }
+
+ return <>>;
+};
diff --git a/apps/tasks/package.json b/apps/tasks/package.json
index 7133523b8..da7d67760 100644
--- a/apps/tasks/package.json
+++ b/apps/tasks/package.json
@@ -27,6 +27,7 @@
"@homarr/redis": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
+ "@homarr/analytics": "workspace:^0.1.0",
"dotenv": "^16.4.5",
"node-cron": "^3.0.3",
"superjson": "2.2.1"
@@ -40,7 +41,7 @@
"dotenv-cli": "^7.4.2",
"eslint": "^8.57.0",
"prettier": "^3.2.5",
- "tsx": "4.10.5",
+ "tsx": "4.11.0",
"typescript": "^5.4.5"
},
"eslintConfig": {
diff --git a/apps/tasks/src/jobs.ts b/apps/tasks/src/jobs.ts
index 39fcb0113..181ce4590 100644
--- a/apps/tasks/src/jobs.ts
+++ b/apps/tasks/src/jobs.ts
@@ -1,4 +1,5 @@
import { iconsUpdaterJob } from "~/jobs/icons-updater";
+import { analyticsJob } from "./jobs/analytics";
import { queuesJob } from "./jobs/queue";
import { createJobGroup } from "./lib/cron-job/group";
@@ -8,4 +9,5 @@ export const jobs = createJobGroup({
// This job is used to process queues.
queues: queuesJob,
iconsUpdater: iconsUpdaterJob,
+ analytics: analyticsJob,
});
diff --git a/apps/tasks/src/jobs/analytics.ts b/apps/tasks/src/jobs/analytics.ts
new file mode 100644
index 000000000..bbdd89927
--- /dev/null
+++ b/apps/tasks/src/jobs/analytics.ts
@@ -0,0 +1,29 @@
+import SuperJSON from "superjson";
+
+import { sendServerAnalyticsAsync } from "@homarr/analytics";
+import { db, eq } from "@homarr/db";
+import { serverSettings } from "@homarr/db/schema/sqlite";
+
+import { EVERY_WEEK } from "~/lib/cron-job/constants";
+import { createCronJob } from "~/lib/cron-job/creator";
+import type { defaultServerSettings } from "../../../../packages/server-settings";
+
+export const analyticsJob = createCronJob(EVERY_WEEK, {
+ runOnStart: true,
+}).withCallback(async () => {
+ const analyticSetting = await db.query.serverSettings.findFirst({
+ where: eq(serverSettings.settingKey, "analytics"),
+ });
+
+ if (!analyticSetting) {
+ return;
+ }
+
+ const value = SuperJSON.parse<(typeof defaultServerSettings)["analytics"]>(analyticSetting.value);
+
+ if (!value.enableGeneral) {
+ return;
+ }
+
+ await sendServerAnalyticsAsync();
+});
diff --git a/package.json b/package.json
index f45201e39..5ffe3cff8 100644
--- a/package.json
+++ b/package.json
@@ -2,9 +2,9 @@
"name": "homarr",
"private": true,
"engines": {
- "node": ">=20.13.1"
+ "node": ">=20.14.0"
},
- "packageManager": "pnpm@9.1.1",
+ "packageManager": "pnpm@9.1.3",
"scripts": {
"build": "turbo build",
"clean": "git clean -xdf node_modules",
@@ -29,11 +29,11 @@
"devDependencies": {
"@homarr/prettier-config": "workspace:^0.1.0",
"@turbo/gen": "^1.13.3",
- "@vitejs/plugin-react": "^4.2.1",
+ "@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^1.6.0",
"@vitest/ui": "^1.6.0",
"cross-env": "^7.0.3",
- "jsdom": "^24.0.0",
+ "jsdom": "^24.1.0",
"prettier": "^3.2.5",
"turbo": "^1.13.3",
"typescript": "^5.4.5",
@@ -41,8 +41,8 @@
"vitest": "^1.6.0"
},
"dependencies": {
- "@mantine/core": "^7.9.2",
- "@mantine/dates": "^7.9.2",
+ "@mantine/core": "^7.10.0",
+ "@mantine/dates": "^7.10.0",
"@tabler/icons-react": "^3.5.0",
"mantine-react-table": "2.0.0-beta.3"
},
diff --git a/packages/analytics/index.ts b/packages/analytics/index.ts
new file mode 100644
index 000000000..3bd16e178
--- /dev/null
+++ b/packages/analytics/index.ts
@@ -0,0 +1 @@
+export * from "./src";
diff --git a/packages/analytics/package.json b/packages/analytics/package.json
new file mode 100644
index 000000000..7d2c49d17
--- /dev/null
+++ b/packages/analytics/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@homarr/analytics",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "exports": {
+ ".": "./index.ts"
+ },
+ "typesVersions": {
+ "*": {
+ "*": [
+ "src/*"
+ ]
+ }
+ },
+ "license": "MIT",
+ "scripts": {
+ "clean": "rm -rf .turbo node_modules",
+ "lint": "eslint .",
+ "format": "prettier --check . --ignore-path ../../.gitignore",
+ "typecheck": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@homarr/db": "workspace:^0.1.0",
+ "@homarr/eslint-config": "workspace:^0.2.0",
+ "@homarr/log": "workspace:^0.1.0",
+ "@homarr/prettier-config": "workspace:^0.1.0",
+ "@homarr/server-settings": "workspace:^0.1.0",
+ "@homarr/tsconfig": "workspace:^0.1.0",
+ "eslint": "^8.57.0",
+ "typescript": "^5.4.5"
+ },
+ "eslintConfig": {
+ "extends": [
+ "@homarr/eslint-config/base"
+ ]
+ },
+ "prettier": "@homarr/prettier-config",
+ "dependencies": {
+ "@umami/node": "^0.3.0",
+ "superjson": "2.2.1"
+ }
+}
diff --git a/packages/analytics/src/constants.ts b/packages/analytics/src/constants.ts
new file mode 100644
index 000000000..6316180ad
--- /dev/null
+++ b/packages/analytics/src/constants.ts
@@ -0,0 +1,2 @@
+export const UMAMI_HOST_URL = "https://umami.homarr.dev";
+export const UMAMI_WEBSITE_ID = "ff7dc470-a84f-4779-b1ab-66a5bb16a94b";
diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts
new file mode 100644
index 000000000..c3c0fd550
--- /dev/null
+++ b/packages/analytics/src/index.ts
@@ -0,0 +1,2 @@
+export * from "./constants";
+export * from "./send-server-analytics";
diff --git a/packages/analytics/src/send-server-analytics.ts b/packages/analytics/src/send-server-analytics.ts
new file mode 100644
index 000000000..532bd5c4c
--- /dev/null
+++ b/packages/analytics/src/send-server-analytics.ts
@@ -0,0 +1,102 @@
+import type { UmamiEventData } from "@umami/node";
+import { Umami } from "@umami/node";
+import SuperJSON from "superjson";
+
+import { count, db, eq } from "@homarr/db";
+import { integrations, items, serverSettings, users } from "@homarr/db/schema/sqlite";
+import { logger } from "@homarr/log";
+import type { defaultServerSettings } from "@homarr/server-settings";
+
+import { Stopwatch } from "../../common/src";
+import { UMAMI_HOST_URL, UMAMI_WEBSITE_ID } from "./constants";
+
+export const sendServerAnalyticsAsync = async () => {
+ const stopWatch = new Stopwatch();
+ const setting = await db.query.serverSettings.findFirst({
+ where: eq(serverSettings.settingKey, "analytics"),
+ });
+
+ if (!setting) {
+ logger.info(
+ "Server does not know the configured state of analytics. No data will be sent. Enable analytics in the settings",
+ );
+ return;
+ }
+
+ const analyticsSettings = SuperJSON.parse(setting.value);
+
+ if (!analyticsSettings.enableGeneral) {
+ logger.info("Analytics are disabled. No data will be sent. Enable analytics in the settings");
+ return;
+ }
+
+ const umamiInstance = new Umami();
+ umamiInstance.init({
+ hostUrl: UMAMI_HOST_URL,
+ websiteId: UMAMI_WEBSITE_ID,
+ });
+
+ await sendIntegrationDataAsync(umamiInstance, analyticsSettings);
+ await sendWidgetDataAsync(umamiInstance, analyticsSettings);
+ await sendUserDataAsync(umamiInstance, analyticsSettings);
+
+ logger.info(`Sent all analytics in ${stopWatch.getElapsedInHumanWords()}`);
+};
+
+const sendWidgetDataAsync = async (umamiInstance: Umami, analyticsSettings: typeof defaultServerSettings.analytics) => {
+ if (!analyticsSettings.enableWidgetData) {
+ return;
+ }
+ const widgetCount = (await db.select({ count: count(items.id) }).from(items))[0]?.count ?? 0;
+
+ const response = await umamiInstance.track("server-widget-data", {
+ countWidgets: widgetCount,
+ });
+ if (response.ok) {
+ return;
+ }
+
+ logger.warn("Unable to send track event data to Umami instance");
+};
+
+const sendUserDataAsync = async (umamiInstance: Umami, analyticsSettings: typeof defaultServerSettings.analytics) => {
+ if (!analyticsSettings.enableUserData) {
+ return;
+ }
+ const userCount = (await db.select({ count: count(users.id) }).from(users))[0]?.count ?? 0;
+
+ const response = await umamiInstance.track("server-user-data", {
+ countUsers: userCount,
+ });
+ if (response.ok) {
+ return;
+ }
+
+ logger.warn("Unable to send track event data to Umami instance");
+};
+
+const sendIntegrationDataAsync = async (
+ umamiInstance: Umami,
+ analyticsSettings: typeof defaultServerSettings.analytics,
+) => {
+ if (!analyticsSettings.enableIntegrationData) {
+ return;
+ }
+ const integrationKinds = await db
+ .select({ kind: integrations.kind, count: count(integrations.id) })
+ .from(integrations)
+ .groupBy(integrations.kind);
+
+ const map: UmamiEventData = {};
+
+ integrationKinds.forEach((integrationKind) => {
+ map[integrationKind.kind] = integrationKind.count;
+ });
+
+ const response = await umamiInstance.track("server-integration-data-kind", map);
+ if (response.ok) {
+ return;
+ }
+
+ logger.warn("Unable to send track event data to Umami instance");
+};
diff --git a/packages/analytics/tsconfig.json b/packages/analytics/tsconfig.json
new file mode 100644
index 000000000..cbe8483d9
--- /dev/null
+++ b/packages/analytics/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@homarr/tsconfig/base.json",
+ "compilerOptions": {
+ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
+ },
+ "include": ["*.ts", "src"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/api/package.json b/packages/api/package.json
index 52fd9fb21..3fad704f3 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -23,6 +23,7 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
+ "@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/tasks": "workspace:^0.1.0",
diff --git a/packages/api/src/middlewares/integration.ts b/packages/api/src/middlewares/integration.ts
new file mode 100644
index 000000000..8f23903fc
--- /dev/null
+++ b/packages/api/src/middlewares/integration.ts
@@ -0,0 +1,76 @@
+import { TRPCError } from "@trpc/server";
+
+import { and, eq, inArray } from "@homarr/db";
+import { integrations } from "@homarr/db/schema/sqlite";
+import type { IntegrationKind } from "@homarr/definitions";
+import { z } from "@homarr/validation";
+
+import { decryptSecret } from "../router/integration";
+import { publicProcedure } from "../trpc";
+
+export const createOneIntegrationMiddleware = (...kinds: TKind[]) => {
+ return publicProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => {
+ const integration = await ctx.db.query.integrations.findFirst({
+ where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, kinds)),
+ with: {
+ secrets: true,
+ },
+ });
+
+ if (!integration) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Integration with id ${input.integrationId} not found or not of kinds ${kinds.join(",")}`,
+ });
+ }
+
+ const { secrets, kind, ...rest } = integration;
+
+ return next({
+ ctx: {
+ integration: {
+ ...rest,
+ kind: kind as TKind,
+ decryptedSecrets: secrets.map((secret) => ({
+ ...secret,
+ value: decryptSecret(secret.value),
+ })),
+ },
+ },
+ });
+ });
+};
+
+export const createManyIntegrationMiddleware = (...kinds: TKind[]) => {
+ return publicProcedure
+ .input(z.object({ integrationIds: z.array(z.string()).min(1) }))
+ .use(async ({ ctx, input, next }) => {
+ const dbIntegrations = await ctx.db.query.integrations.findMany({
+ where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
+ with: {
+ secrets: true,
+ },
+ });
+
+ const offset = input.integrationIds.length - dbIntegrations.length;
+ if (offset !== 0) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}`,
+ });
+ }
+
+ return next({
+ ctx: {
+ integrations: dbIntegrations.map(({ secrets, kind, ...rest }) => ({
+ ...rest,
+ kind: kind as TKind,
+ decryptedSecrets: secrets.map((secret) => ({
+ ...secret,
+ value: decryptSecret(secret.value),
+ })),
+ })),
+ },
+ });
+ });
+};
diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts
index c37e92c57..0776fcb41 100644
--- a/packages/api/src/router/board.ts
+++ b/packages/api/src/router/board.ts
@@ -236,15 +236,15 @@ export const boardRouter = createTRPCRouter({
);
}
- const inputIntegrationRelations = inputItems.flatMap(({ integrations, id: itemId }) =>
- integrations.map((integration) => ({
- integrationId: integration.id,
+ const inputIntegrationRelations = inputItems.flatMap(({ integrationIds, id: itemId }) =>
+ integrationIds.map((integrationId) => ({
+ integrationId,
itemId,
})),
);
- const dbIntegrationRelations = dbItems.flatMap(({ integrations, id: itemId }) =>
- integrations.map((integration) => ({
- integrationId: integration.id,
+ const dbIntegrationRelations = dbItems.flatMap(({ integrationIds, id: itemId }) =>
+ integrationIds.map((integrationId) => ({
+ integrationId,
itemId,
})),
);
@@ -277,6 +277,7 @@ export const boardRouter = createTRPCRouter({
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
+ advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})
.where(eq(items.id, item.id));
@@ -514,9 +515,9 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL, use
sections: sections.map((section) =>
parseSection({
...section,
- items: section.items.map((item) => ({
+ items: section.items.map(({ integrations: itemIntegrations, ...item }) => ({
...item,
- integrations: item.integrations.map((item) => item.integration),
+ integrationIds: itemIntegrations.map((item) => item.integration.id),
advancedOptions: superjson.parse(item.advancedOptions),
options: superjson.parse>(item.options),
})),
diff --git a/packages/api/src/router/integration.ts b/packages/api/src/router/integration.ts
index ae807ef97..92fb2fcb9 100644
--- a/packages/api/src/router/integration.ts
+++ b/packages/api/src/router/integration.ts
@@ -210,7 +210,6 @@ export const integrationRouter = createTRPCRouter({
const algorithm = "aes-256-cbc"; //Using AES encryption
const key = Buffer.from("1d71cceced68159ba59a277d056a66173613052cbeeccbfbd15ab1c909455a4d", "hex"); // TODO: generate with const data = crypto.randomBytes(32).toString('hex')
-//Encrypting text
export function encryptSecret(text: string): `${string}.${string}` {
const initializationVector = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), initializationVector);
@@ -219,8 +218,7 @@ export function encryptSecret(text: string): `${string}.${string}` {
return `${encrypted.toString("hex")}.${initializationVector.toString("hex")}`;
}
-// Decrypting text
-function decryptSecret(value: `${string}.${string}`) {
+export function decryptSecret(value: `${string}.${string}`) {
const [data, dataIv] = value.split(".") as [string, string];
const initializationVector = Buffer.from(dataIv, "hex");
const encryptedText = Buffer.from(data, "hex");
diff --git a/packages/api/src/router/serverSettings.ts b/packages/api/src/router/serverSettings.ts
index 8766836ca..b1f00f057 100644
--- a/packages/api/src/router/serverSettings.ts
+++ b/packages/api/src/router/serverSettings.ts
@@ -2,13 +2,34 @@ import SuperJSON from "superjson";
import { eq } from "@homarr/db";
import { serverSettings } from "@homarr/db/schema/sqlite";
-import type { ServerSettings } from "@homarr/server-settings";
+import { logger } from "@homarr/log";
+import type { defaultServerSettings, ServerSettings } from "@homarr/server-settings";
import { defaultServerSettingsKeys } from "@homarr/server-settings";
import { z } from "@homarr/validation";
-import { createTRPCRouter, protectedProcedure } from "../trpc";
+import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
export const serverSettingsRouter = createTRPCRouter({
+ // this must be public so anonymous users also get analytics
+ getAnalytics: publicProcedure.query(async ({ ctx }) => {
+ const setting = await ctx.db.query.serverSettings.findFirst({
+ where: eq(serverSettings.settingKey, "analytics"),
+ });
+
+ if (!setting) {
+ logger.info(
+ "Server settings for analytics is currently undefined. Using default values instead. If this persists, there may be an issue with the server settings",
+ );
+ return {
+ enableGeneral: true,
+ enableIntegrationData: false,
+ enableUserData: false,
+ enableWidgetData: false,
+ } as (typeof defaultServerSettings)["analytics"];
+ }
+
+ return SuperJSON.parse<(typeof defaultServerSettings)["analytics"]>(setting.value);
+ }),
getAll: protectedProcedure.query(async ({ ctx }) => {
const settings = await ctx.db.query.serverSettings.findMany();
diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts
index 11ac8c913..e5afca76e 100644
--- a/packages/api/src/router/test/board.spec.ts
+++ b/packages/api/src/router/test/board.spec.ts
@@ -659,7 +659,7 @@ describe("saveBoard should save full board", () => {
id: createId(),
kind: "clock",
options: { is24HourFormat: true },
- integrations: [],
+ integrationIds: [],
height: 1,
width: 1,
xOffset: 0,
@@ -720,7 +720,7 @@ describe("saveBoard should save full board", () => {
id: itemId,
kind: "clock",
options: { is24HourFormat: true },
- integrations: [anotherIntegration],
+ integrationIds: [anotherIntegration.id],
height: 1,
width: 1,
xOffset: 0,
@@ -834,7 +834,7 @@ describe("saveBoard should save full board", () => {
id: newItemId,
kind: "clock",
options: { is24HourFormat: true },
- integrations: [],
+ integrationIds: [],
height: 1,
width: 1,
xOffset: 3,
@@ -903,7 +903,7 @@ describe("saveBoard should save full board", () => {
id: itemId,
kind: "clock",
options: { is24HourFormat: true },
- integrations: [integration],
+ integrationIds: [integration.id],
height: 1,
width: 1,
xOffset: 0,
@@ -1017,7 +1017,7 @@ describe("saveBoard should save full board", () => {
id: itemId,
kind: "clock",
options: { is24HourFormat: false },
- integrations: [],
+ integrationIds: [],
height: 3,
width: 2,
xOffset: 7,
@@ -1245,10 +1245,9 @@ const expectInputToBeFullBoardWithName = (
if (firstItem.kind === "clock") {
expect(firstItem.options.is24HourFormat).toBe(true);
}
- expect(firstItem.integrations.length).toBe(1);
- const firstIntegration = expectToBeDefined(firstItem.integrations[0]);
- expect(firstIntegration.id).toBe(props.integrationId);
- expect(firstIntegration.kind).toBe("adGuardHome");
+ expect(firstItem.integrationIds.length).toBe(1);
+ const firstIntegration = expectToBeDefined(firstItem.integrationIds[0]);
+ expect(firstIntegration).toBe(props.integrationId);
};
const createFullBoardAsync = async (db: Database, name: string) => {
diff --git a/packages/api/src/router/widgets/dns-hole.ts b/packages/api/src/router/widgets/dns-hole.ts
new file mode 100644
index 000000000..a163713c3
--- /dev/null
+++ b/packages/api/src/router/widgets/dns-hole.ts
@@ -0,0 +1,32 @@
+import { TRPCError } from "@trpc/server";
+
+import { PiHoleIntegration } from "@homarr/integrations";
+import type { DnsHoleSummary } from "@homarr/integrations/types";
+import { logger } from "@homarr/log";
+import { createCacheChannel } from "@homarr/redis";
+
+import { createOneIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+export const dnsHoleRouter = createTRPCRouter({
+ summary: publicProcedure.unstable_concat(createOneIntegrationMiddleware("piHole")).query(async ({ ctx }) => {
+ const cache = createCacheChannel(`dns-hole-summary:${ctx.integration.id}`);
+
+ const { data } = await cache.consumeAsync(async () => {
+ const client = new PiHoleIntegration(ctx.integration);
+
+ return await client.getSummaryAsync().catch((err) => {
+ logger.error("dns-hole router - ", err);
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: `Failed to fetch DNS Hole summary for ${ctx.integration.name} (${ctx.integration.id})`,
+ });
+ });
+ });
+
+ return {
+ ...data,
+ integrationId: ctx.integration.id,
+ };
+ }),
+});
diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts
index ecd39a0a8..f2bd45078 100644
--- a/packages/api/src/router/widgets/index.ts
+++ b/packages/api/src/router/widgets/index.ts
@@ -1,8 +1,10 @@
import { createTRPCRouter } from "../../trpc";
+import { dnsHoleRouter } from "./dns-hole";
import { notebookRouter } from "./notebook";
import { weatherRouter } from "./weather";
export const widgetRouter = createTRPCRouter({
notebook: notebookRouter,
weather: weatherRouter,
+ dnsHole: dnsHoleRouter,
});
diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts
index 1089f3537..5ee205da0 100644
--- a/packages/common/src/index.ts
+++ b/packages/common/src/index.ts
@@ -3,3 +3,4 @@ export * from "./string";
export * from "./cookie";
export * from "./array";
export * from "./stopwatch";
+export * from "./number";
diff --git a/packages/common/src/number.ts b/packages/common/src/number.ts
new file mode 100644
index 000000000..5341e5004
--- /dev/null
+++ b/packages/common/src/number.ts
@@ -0,0 +1,17 @@
+const ranges = [
+ { divider: 1e18, suffix: "E" },
+ { divider: 1e15, suffix: "P" },
+ { divider: 1e12, suffix: "T" },
+ { divider: 1e9, suffix: "G" },
+ { divider: 1e6, suffix: "M" },
+ { divider: 1e3, suffix: "k" },
+];
+
+export const formatNumber = (value: number, decimalPlaces: number) => {
+ for (const range of ranges) {
+ if (value < range.divider) continue;
+
+ return (value / range.divider).toFixed(decimalPlaces) + range.suffix;
+ }
+ return value.toFixed(decimalPlaces);
+};
diff --git a/packages/db/package.json b/packages/db/package.json
index 0f83d7466..bd5000793 100644
--- a/packages/db/package.json
+++ b/packages/db/package.json
@@ -35,8 +35,8 @@
"@paralleldrive/cuid2": "^2.2.2",
"better-sqlite3": "^10.0.0",
"drizzle-orm": "^0.30.10",
- "mysql2": "3.9.7",
- "drizzle-kit": "^0.21.2"
+ "mysql2": "3.9.8",
+ "drizzle-kit": "^0.21.4"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts
index a095f01f1..6c50ba23b 100644
--- a/packages/definitions/src/widget.ts
+++ b/packages/definitions/src/widget.ts
@@ -1,2 +1,2 @@
-export const widgetKinds = ["clock", "weather", "app", "iframe", "video", "notebook"] as const;
+export const widgetKinds = ["clock", "weather", "app", "iframe", "video", "notebook", "dnsHoleSummary"] as const;
export type WidgetKind = (typeof widgetKinds)[number];
diff --git a/packages/form/package.json b/packages/form/package.json
index b964a10ea..d670b76b6 100644
--- a/packages/form/package.json
+++ b/packages/form/package.json
@@ -33,7 +33,7 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
- "@mantine/form": "^7.9.2",
+ "@mantine/form": "^7.10.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0"
}
diff --git a/packages/integrations/index.ts b/packages/integrations/index.ts
new file mode 100644
index 000000000..3bd16e178
--- /dev/null
+++ b/packages/integrations/index.ts
@@ -0,0 +1 @@
+export * from "./src";
diff --git a/packages/integrations/package.json b/packages/integrations/package.json
new file mode 100644
index 000000000..5df87673e
--- /dev/null
+++ b/packages/integrations/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@homarr/integrations",
+ "private": true,
+ "version": "0.1.0",
+ "exports": {
+ ".": "./index.ts",
+ "./types": "./src/types.ts"
+ },
+ "typesVersions": {
+ "*": {
+ "*": [
+ "src/*"
+ ]
+ }
+ },
+ "license": "MIT",
+ "type": "module",
+ "scripts": {
+ "clean": "rm -rf .turbo node_modules",
+ "lint": "eslint .",
+ "format": "prettier --check . --ignore-path ../../.gitignore",
+ "typecheck": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@homarr/eslint-config": "workspace:^0.2.0",
+ "@homarr/prettier-config": "workspace:^0.1.0",
+ "@homarr/tsconfig": "workspace:^0.1.0",
+ "eslint": "^8.57.0",
+ "typescript": "^5.4.5"
+ },
+ "dependencies": {
+ "@homarr/definitions": "workspace:^0.1.0",
+ "@homarr/validation": "workspace:^0.1.0"
+ },
+ "eslintConfig": {
+ "extends": [
+ "@homarr/eslint-config/base"
+ ]
+ },
+ "prettier": "@homarr/prettier-config"
+}
diff --git a/packages/integrations/src/base/integration.ts b/packages/integrations/src/base/integration.ts
new file mode 100644
index 000000000..e8612736a
--- /dev/null
+++ b/packages/integrations/src/base/integration.ts
@@ -0,0 +1,22 @@
+import type { IntegrationSecretKind } from "@homarr/definitions";
+
+import type { IntegrationSecret } from "./types";
+
+export abstract class Integration {
+ constructor(
+ protected integration: {
+ id: string;
+ name: string;
+ url: string;
+ decryptedSecrets: IntegrationSecret[];
+ },
+ ) {}
+
+ protected getSecretValue(kind: IntegrationSecretKind) {
+ const secret = this.integration.decryptedSecrets.find((secret) => secret.kind === kind);
+ if (!secret) {
+ throw new Error(`No secret of kind ${kind} was found`);
+ }
+ return secret.value;
+ }
+}
diff --git a/packages/integrations/src/base/types.ts b/packages/integrations/src/base/types.ts
new file mode 100644
index 000000000..f466508ca
--- /dev/null
+++ b/packages/integrations/src/base/types.ts
@@ -0,0 +1,6 @@
+import type { IntegrationSecretKind } from "@homarr/definitions";
+
+export interface IntegrationSecret {
+ kind: IntegrationSecretKind;
+ value: string;
+}
diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts
new file mode 100644
index 000000000..4740f40bd
--- /dev/null
+++ b/packages/integrations/src/index.ts
@@ -0,0 +1 @@
+export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
diff --git a/packages/integrations/src/interfaces/dns-hole-summary/dns-hole-summary-integration.ts b/packages/integrations/src/interfaces/dns-hole-summary/dns-hole-summary-integration.ts
new file mode 100644
index 000000000..d25be7fd0
--- /dev/null
+++ b/packages/integrations/src/interfaces/dns-hole-summary/dns-hole-summary-integration.ts
@@ -0,0 +1,5 @@
+import type { DnsHoleSummary } from "./dns-hole-summary-types";
+
+export interface DnsHoleSummaryIntegration {
+ getSummaryAsync(): Promise;
+}
diff --git a/packages/integrations/src/interfaces/dns-hole-summary/dns-hole-summary-types.ts b/packages/integrations/src/interfaces/dns-hole-summary/dns-hole-summary-types.ts
new file mode 100644
index 000000000..2295f9079
--- /dev/null
+++ b/packages/integrations/src/interfaces/dns-hole-summary/dns-hole-summary-types.ts
@@ -0,0 +1,6 @@
+export interface DnsHoleSummary {
+ domainsBeingBlocked: number;
+ adsBlockedToday: number;
+ adsBlockedTodayPercentage: number;
+ dnsQueriesToday: number;
+}
diff --git a/packages/integrations/src/pi-hole/pi-hole-integration.ts b/packages/integrations/src/pi-hole/pi-hole-integration.ts
new file mode 100644
index 000000000..4b309c07b
--- /dev/null
+++ b/packages/integrations/src/pi-hole/pi-hole-integration.ts
@@ -0,0 +1,31 @@
+import { Integration } from "../base/integration";
+import type { DnsHoleSummaryIntegration } from "../interfaces/dns-hole-summary/dns-hole-summary-integration";
+import type { DnsHoleSummary } from "../interfaces/dns-hole-summary/dns-hole-summary-types";
+import { summaryResponseSchema } from "./pi-hole-types";
+
+export class PiHoleIntegration extends Integration implements DnsHoleSummaryIntegration {
+ async getSummaryAsync(): Promise {
+ const apiKey = super.getSecretValue("apiKey");
+ const response = await fetch(`${this.integration.url}/admin/api.php?summaryRaw&auth=${apiKey}`);
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch summary for ${this.integration.name} (${this.integration.id}): ${response.statusText}`,
+ );
+ }
+
+ const result = summaryResponseSchema.safeParse(await response.json());
+
+ if (!result.success) {
+ throw new Error(
+ `Failed to parse summary for ${this.integration.name} (${this.integration.id}), most likely your api key is wrong: ${result.error.message}`,
+ );
+ }
+
+ return {
+ adsBlockedToday: result.data.ads_blocked_today,
+ adsBlockedTodayPercentage: result.data.ads_percentage_today,
+ domainsBeingBlocked: result.data.domains_being_blocked,
+ dnsQueriesToday: result.data.dns_queries_today,
+ };
+ }
+}
diff --git a/packages/integrations/src/pi-hole/pi-hole-types.ts b/packages/integrations/src/pi-hole/pi-hole-types.ts
new file mode 100644
index 000000000..6b2a28a95
--- /dev/null
+++ b/packages/integrations/src/pi-hole/pi-hole-types.ts
@@ -0,0 +1,9 @@
+import { z } from "@homarr/validation";
+
+export const summaryResponseSchema = z.object({
+ status: z.enum(["enabled", "disabled"]),
+ domains_being_blocked: z.number(),
+ ads_blocked_today: z.number(),
+ dns_queries_today: z.number(),
+ ads_percentage_today: z.number(),
+});
diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts
new file mode 100644
index 000000000..981a5fcb0
--- /dev/null
+++ b/packages/integrations/src/types.ts
@@ -0,0 +1 @@
+export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
diff --git a/packages/integrations/tsconfig.json b/packages/integrations/tsconfig.json
new file mode 100644
index 000000000..cbe8483d9
--- /dev/null
+++ b/packages/integrations/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@homarr/tsconfig/base.json",
+ "compilerOptions": {
+ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
+ },
+ "include": ["*.ts", "src"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/notifications/package.json b/packages/notifications/package.json
index 73f13e79e..211c3d1bc 100644
--- a/packages/notifications/package.json
+++ b/packages/notifications/package.json
@@ -28,7 +28,7 @@
"typescript": "^5.4.5"
},
"dependencies": {
- "@mantine/notifications": "^7.9.2",
+ "@mantine/notifications": "^7.10.0",
"@homarr/ui": "workspace:^0.1.0"
},
"eslintConfig": {
diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts
index 06ec8006b..beb5f1c70 100644
--- a/packages/redis/src/index.ts
+++ b/packages/redis/src/index.ts
@@ -1,5 +1,7 @@
import { createQueueChannel, createSubPubChannel } from "./lib/channel";
+export { createCacheChannel } from "./lib/channel";
+
export const exampleChannel = createSubPubChannel<{ message: string }>("example");
export const queueChannel = createQueueChannel<{
name: string;
diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts
index eeb4d13ba..270b3c6ce 100644
--- a/packages/redis/src/lib/channel.ts
+++ b/packages/redis/src/lib/channel.ts
@@ -58,23 +58,70 @@ const cacheClient = createRedisConnection();
* @param name name of the channel
* @returns cache channel object
*/
-export const createCacheChannel = (name: string) => {
+export const createCacheChannel = (name: string, cacheDurationSeconds: number = 5 * 60 * 1000) => {
const cacheChannelName = `cache:${name}`;
+
return {
/**
* Get the data from the cache channel.
- * @returns data or undefined if not found
+ * @returns data or null if not found or expired
*/
getAsync: async () => {
const data = await cacheClient.get(cacheChannelName);
- return data ? superjson.parse(data) : undefined;
+ if (!data) return null;
+
+ const parsedData = superjson.parse<{ data: TData; timestamp: Date }>(data);
+ const now = new Date();
+ const diff = now.getTime() - parsedData.timestamp.getTime();
+ if (diff > cacheDurationSeconds) return null;
+
+ return parsedData;
+ },
+ /**
+ * Consume the data from the cache channel, if not present or expired, it will call the callback to get new data.
+ * @param callback callback function to get new data if not present or expired
+ * @returns data or new data if not present or expired
+ */
+ consumeAsync: async (callback: () => Promise) => {
+ const data = await cacheClient.get(cacheChannelName);
+
+ const getNewDataAsync = async () => {
+ logger.debug(`Cache miss for channel '${cacheChannelName}'`);
+ const newData = await callback();
+ const result = { data: newData, timestamp: new Date() };
+ await cacheClient.set(cacheChannelName, superjson.stringify(result));
+ logger.debug(`Cache updated for channel '${cacheChannelName}'`);
+ return result;
+ };
+
+ if (!data) {
+ return await getNewDataAsync();
+ }
+
+ const parsedData = superjson.parse<{ data: TData; timestamp: Date }>(data);
+ const now = new Date();
+ const diff = now.getTime() - parsedData.timestamp.getTime();
+
+ if (diff > cacheDurationSeconds) {
+ return await getNewDataAsync();
+ }
+
+ logger.debug(`Cache hit for channel '${cacheChannelName}'`);
+
+ return parsedData;
+ },
+ /**
+ * Invalidate the cache channels data.
+ */
+ invalidateAsync: async () => {
+ await cacheClient.del(cacheChannelName);
},
/**
* Set the data in the cache channel.
* @param data data to be stored in the cache channel
*/
setAsync: async (data: TData) => {
- await cacheClient.set(cacheChannelName, superjson.stringify(data));
+ await cacheClient.set(cacheChannelName, superjson.stringify({ data, timestamp: new Date() }));
},
};
};
diff --git a/packages/spotlight/package.json b/packages/spotlight/package.json
index 743850739..286f3c1a6 100644
--- a/packages/spotlight/package.json
+++ b/packages/spotlight/package.json
@@ -34,7 +34,7 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
- "@mantine/spotlight": "^7.9.2",
+ "@mantine/spotlight": "^7.10.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0"
}
diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts
index 3ba679d51..fa5d56756 100644
--- a/packages/translation/src/lang/en.ts
+++ b/packages/translation/src/lang/en.ts
@@ -447,6 +447,7 @@ export default {
previous: "Previous",
next: "Next",
checkoutDocs: "Check out the documentation",
+ tryAgain: "Try again",
},
iconPicker: {
header: "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
@@ -531,7 +532,6 @@ export default {
custom: {
passwordsDoNotMatch: "Passwords do not match",
boardAlreadyExists: "A board with this name already exists",
- // TODO: Add custom error messages
},
},
},
@@ -641,6 +641,38 @@ export default {
},
},
},
+ dnsHoleSummary: {
+ name: "DNS Hole Summary",
+ description: "Displays the summary of your DNS Hole",
+ option: {
+ layout: {
+ label: "Layout",
+ option: {
+ row: {
+ label: "Horizontal",
+ },
+ column: {
+ label: "Vertical",
+ },
+ grid: {
+ label: "Grid",
+ },
+ },
+ },
+ usePiHoleColors: {
+ label: "Use Pi-Hole colors",
+ },
+ },
+ error: {
+ internalServerError: "Failed to fetch DNS Hole Summary",
+ },
+ data: {
+ adsBlockedToday: "blocked today",
+ adsBlockedTodayPercentage: "blocked today",
+ dnsQueriesToday: "Queries today",
+ domainsBeingBlocked: "Domains on blocklist",
+ },
+ },
clock: {
name: "Date and time",
description: "Displays the current date and time.",
@@ -834,6 +866,12 @@ export default {
},
},
},
+ error: {
+ action: {
+ logs: "Check logs for more details",
+ },
+ noIntegration: "No integration selected",
+ },
},
video: {
name: "Video Stream",
diff --git a/packages/validation/src/shared.ts b/packages/validation/src/shared.ts
index 46b2de2c1..b524f3716 100644
--- a/packages/validation/src/shared.ts
+++ b/packages/validation/src/shared.ts
@@ -25,7 +25,7 @@ export const sharedItemSchema = z.object({
yOffset: z.number(),
height: z.number(),
width: z.number(),
- integrations: z.array(integrationSchema),
+ integrationIds: z.array(z.string()),
advancedOptions: itemAdvancedOptionsSchema,
});
diff --git a/packages/widgets/package.json b/packages/widgets/package.json
index a31e97954..42ffa2132 100644
--- a/packages/widgets/package.json
+++ b/packages/widgets/package.json
@@ -3,7 +3,8 @@
"private": true,
"version": "0.1.0",
"exports": {
- ".": "./index.ts"
+ ".": "./index.ts",
+ "./errors": "./src/errors/component.tsx"
},
"typesVersions": {
"*": {
@@ -39,6 +40,7 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
+ "@homarr/integrations": "workspace:^0.1.0",
"@homarr/modals": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
diff --git a/packages/widgets/src/_inputs/widget-multiselect-input.tsx b/packages/widgets/src/_inputs/widget-multiselect-input.tsx
index 282ae5c2f..9f3db9e92 100644
--- a/packages/widgets/src/_inputs/widget-multiselect-input.tsx
+++ b/packages/widgets/src/_inputs/widget-multiselect-input.tsx
@@ -2,10 +2,11 @@
import { MultiSelect } from "@mantine/core";
+import { translateIfNecessary } from "@homarr/translation";
+
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
-import type { SelectOption } from "./widget-select-input";
export const WidgetMultiSelectInput = ({ property, kind, options }: CommonWidgetInputProps<"multiSelect">) => {
const t = useWidgetInputTranslation(kind, property);
@@ -14,7 +15,14 @@ export const WidgetMultiSelectInput = ({ property, kind, options }: CommonWidget
return (
+ typeof option === "string"
+ ? option
+ : {
+ value: option.value,
+ label: translateIfNecessary(t, option.label)!,
+ },
+ )}
description={options.withDescription ? t("description") : undefined}
searchable={options.searchable}
{...form.getInputProps(`options.${property}`)}
diff --git a/packages/widgets/src/_inputs/widget-select-input.tsx b/packages/widgets/src/_inputs/widget-select-input.tsx
index aa855dbf8..fd1df1d8f 100644
--- a/packages/widgets/src/_inputs/widget-select-input.tsx
+++ b/packages/widgets/src/_inputs/widget-select-input.tsx
@@ -2,6 +2,10 @@
import { Select } from "@mantine/core";
+import { translateIfNecessary } from "@homarr/translation";
+import type { stringOrTranslation } from "@homarr/translation";
+import { useI18n } from "@homarr/translation/client";
+
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
@@ -9,7 +13,7 @@ import { useFormContext } from "./form";
export type SelectOption =
| {
value: string;
- label: string;
+ label: stringOrTranslation;
}
| string;
@@ -20,14 +24,22 @@ export type inferSelectOptionValue = TOption exten
: TOption;
export const WidgetSelectInput = ({ property, kind, options }: CommonWidgetInputProps<"select">) => {
- const t = useWidgetInputTranslation(kind, property);
+ const t = useI18n();
+ const tWidget = useWidgetInputTranslation(kind, property);
const form = useFormContext();
return (