mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-28 01:10:54 +01:00
23
.github/renovate.json
vendored
23
.github/renovate.json
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
6
.github/workflows/code-quality.yml
vendored
6
.github/workflows/code-quality.yml
vendored
@@ -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
|
||||
|
||||
16
.github/workflows/conventional-commits.yml
vendored
Normal file
16
.github/workflows/conventional-commits.yml
vendored
Normal file
@@ -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
|
||||
35
.github/workflows/docker-image.yml
vendored
35
.github/workflows/docker-image.yml
vendored
@@ -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."
|
||||
|
||||
20
.github/workflows/pr-conventional-commits.yml
vendored
Normal file
20
.github/workflows/pr-conventional-commits.yml
vendored
Normal file
@@ -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 }}
|
||||
23
.github/workflows/renovate-automatic-approval
vendored
Normal file
23
.github/workflows/renovate-automatic-approval
vendored
Normal file
@@ -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
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -10,6 +10,7 @@
|
||||
"cSpell.words": [
|
||||
"superjson",
|
||||
"homarr",
|
||||
"trpc"
|
||||
"trpc",
|
||||
"Umami"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<ColorSchemeScript defaultColorScheme={colorScheme} />
|
||||
<Analytics />
|
||||
</head>
|
||||
<body className={["font-sans", fontSans.variable].join(" ")}>
|
||||
<StackedProvider>
|
||||
|
||||
@@ -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 (
|
||||
<UnstyledButton onClick={handleClick}>
|
||||
<Group ms={ms} justify="space-between" gap="lg" align="center" wrap="nowrap">
|
||||
<Group ms={ms} justify="space-between" gap="lg" align="center" wrap="nowrap">
|
||||
<UnstyledButton style={{ flexGrow: 1 }} onClick={handleClick}>
|
||||
<Stack gap={0}>
|
||||
<Text fw="bold">{title}</Text>
|
||||
<Text c="gray.5">{text}</Text>
|
||||
</Stack>
|
||||
<Switch {...form.getInputProps(formKey, { type: "checkbox" })} />
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</UnstyledButton>
|
||||
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
integrations: BoardItemIntegration[];
|
||||
integrationIds: string[];
|
||||
advancedOptions: BoardItemAdvancedOptions;
|
||||
}>({
|
||||
options: reduceWidgetOptionsWithDefaultValues(kind, {}),
|
||||
integrations: [],
|
||||
integrationIds: [],
|
||||
advancedOptions: {
|
||||
customCssClasses: [],
|
||||
},
|
||||
@@ -86,17 +89,26 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
return (
|
||||
<>
|
||||
<Card withBorder w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
|
||||
<Comp
|
||||
options={state.options as never}
|
||||
integrations={state.integrations.map(
|
||||
(stateIntegration) => integrationData.find((integration) => integration.id === stateIntegration.id)!,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<WidgetError kind={kind} error={error as unknown} resetErrorBoundary={resetErrorBoundary} />
|
||||
)}
|
||||
>
|
||||
<Comp
|
||||
options={state.options as never}
|
||||
integrationIds={state.integrationIds}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
isEditMode={editMode}
|
||||
boardId={undefined}
|
||||
itemId={undefined}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
isEditMode={editMode}
|
||||
boardId={undefined}
|
||||
itemId={undefined}
|
||||
/>
|
||||
</QueryErrorResetBoundary>
|
||||
</Card>
|
||||
<Affix bottom={12} right={72}>
|
||||
<ActionIcon size={48} variant="default" radius="xl" onClick={handleOpenEditWidgetModal}>
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<ItemMenu offset={4} item={newItem} />
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrations={item.integrations}
|
||||
serverData={serverData?.data as never}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
{...dimensions}
|
||||
/>
|
||||
</>
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
<ItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
|
||||
<WidgetError kind={item.kind} error={error as unknown} resetErrorBoundary={resetErrorBoundary} />
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<ItemMenu offset={4} item={newItem} />
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
serverData={serverData?.data as never}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
{...dimensions}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
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) =>
|
||||
|
||||
15
apps/nextjs/src/components/layout/analytics.tsx
Normal file
15
apps/nextjs/src/components/layout/analytics.tsx
Normal file
@@ -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 <Script src="https://umami.homarr.dev/script.js" data-website-id={UMAMI_WEBSITE_ID} defer />;
|
||||
}
|
||||
|
||||
return <></>;
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
29
apps/tasks/src/jobs/analytics.ts
Normal file
29
apps/tasks/src/jobs/analytics.ts
Normal file
@@ -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();
|
||||
});
|
||||
12
package.json
12
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"
|
||||
},
|
||||
|
||||
1
packages/analytics/index.ts
Normal file
1
packages/analytics/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
43
packages/analytics/package.json
Normal file
43
packages/analytics/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
2
packages/analytics/src/constants.ts
Normal file
2
packages/analytics/src/constants.ts
Normal 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
packages/analytics/src/index.ts
Normal file
2
packages/analytics/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./constants";
|
||||
export * from "./send-server-analytics";
|
||||
102
packages/analytics/src/send-server-analytics.ts
Normal file
102
packages/analytics/src/send-server-analytics.ts
Normal file
@@ -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<typeof defaultServerSettings.analytics>(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");
|
||||
};
|
||||
8
packages/analytics/tsconfig.json
Normal file
8
packages/analytics/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
76
packages/api/src/middlewares/integration.ts
Normal file
76
packages/api/src/middlewares/integration.ts
Normal file
@@ -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 = <TKind extends IntegrationKind>(...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 = <TKind extends IntegrationKind>(...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),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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<unknown>, 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<BoardItemAdvancedOptions>(item.advancedOptions),
|
||||
options: superjson.parse<Record<string, unknown>>(item.options),
|
||||
})),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
32
packages/api/src/router/widgets/dns-hole.ts
Normal file
32
packages/api/src/router/widgets/dns-hole.ts
Normal file
@@ -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<DnsHoleSummary>(`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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from "./string";
|
||||
export * from "./cookie";
|
||||
export * from "./array";
|
||||
export * from "./stopwatch";
|
||||
export * from "./number";
|
||||
|
||||
17
packages/common/src/number.ts
Normal file
17
packages/common/src/number.ts
Normal file
@@ -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);
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
1
packages/integrations/index.ts
Normal file
1
packages/integrations/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
41
packages/integrations/package.json
Normal file
41
packages/integrations/package.json
Normal file
@@ -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"
|
||||
}
|
||||
22
packages/integrations/src/base/integration.ts
Normal file
22
packages/integrations/src/base/integration.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
6
packages/integrations/src/base/types.ts
Normal file
6
packages/integrations/src/base/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
|
||||
export interface IntegrationSecret {
|
||||
kind: IntegrationSecretKind;
|
||||
value: string;
|
||||
}
|
||||
1
packages/integrations/src/index.ts
Normal file
1
packages/integrations/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { DnsHoleSummary } from "./dns-hole-summary-types";
|
||||
|
||||
export interface DnsHoleSummaryIntegration {
|
||||
getSummaryAsync(): Promise<DnsHoleSummary>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface DnsHoleSummary {
|
||||
domainsBeingBlocked: number;
|
||||
adsBlockedToday: number;
|
||||
adsBlockedTodayPercentage: number;
|
||||
dnsQueriesToday: number;
|
||||
}
|
||||
31
packages/integrations/src/pi-hole/pi-hole-integration.ts
Normal file
31
packages/integrations/src/pi-hole/pi-hole-integration.ts
Normal file
@@ -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<DnsHoleSummary> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
9
packages/integrations/src/pi-hole/pi-hole-types.ts
Normal file
9
packages/integrations/src/pi-hole/pi-hole-types.ts
Normal file
@@ -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(),
|
||||
});
|
||||
1
packages/integrations/src/types.ts
Normal file
1
packages/integrations/src/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
|
||||
8
packages/integrations/tsconfig.json
Normal file
8
packages/integrations/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -58,23 +58,70 @@ const cacheClient = createRedisConnection();
|
||||
* @param name name of the channel
|
||||
* @returns cache channel object
|
||||
*/
|
||||
export const createCacheChannel = <TData>(name: string) => {
|
||||
export const createCacheChannel = <TData>(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<TData>(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<TData>) => {
|
||||
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() }));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<MultiSelect
|
||||
label={t("label")}
|
||||
data={options.options as unknown as SelectOption[]}
|
||||
data={options.options.map((option) =>
|
||||
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}`)}
|
||||
|
||||
@@ -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 extends SelectOption> = 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 (
|
||||
<Select
|
||||
label={t("label")}
|
||||
data={options.options as unknown as SelectOption[]}
|
||||
description={options.withDescription ? t("description") : undefined}
|
||||
label={tWidget("label")}
|
||||
data={options.options.map((option) =>
|
||||
typeof option === "string"
|
||||
? option
|
||||
: {
|
||||
value: option.value,
|
||||
label: translateIfNecessary(t, option.label)!,
|
||||
},
|
||||
)}
|
||||
description={options.withDescription ? tWidget("description") : undefined}
|
||||
searchable={options.searchable}
|
||||
{...form.getInputProps(`options.${property}`)}
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { LoaderComponent } from "next/dynamic";
|
||||
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
|
||||
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import type { stringOrTranslation } from "@homarr/translation";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { WidgetImports } from ".";
|
||||
import type { inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
|
||||
import type { IntegrationSelectOption } from "./widget-integration-select";
|
||||
|
||||
type ServerDataLoader<TKind extends WidgetKind> = () => Promise<{
|
||||
default: (props: WidgetProps<TKind>) => Promise<Record<string, unknown>>;
|
||||
@@ -64,11 +65,20 @@ export interface WidgetDefinition {
|
||||
icon: TablerIcon;
|
||||
supportedIntegrations?: IntegrationKind[];
|
||||
options: WidgetOptionsRecord;
|
||||
errors?: Partial<
|
||||
Record<
|
||||
DefaultErrorData["code"],
|
||||
{
|
||||
icon: TablerIcon;
|
||||
message: stringOrTranslation;
|
||||
}
|
||||
>
|
||||
>;
|
||||
}
|
||||
|
||||
export interface WidgetProps<TKind extends WidgetKind> {
|
||||
options: inferOptionsFromDefinition<WidgetOptionsRecordOf<TKind>>;
|
||||
integrations: inferIntegrationsFromDefinition<WidgetImports[TKind]["definition"]>;
|
||||
integrationIds: string[];
|
||||
}
|
||||
|
||||
type inferServerDataForKind<TKind extends WidgetKind> = WidgetImports[TKind] extends {
|
||||
@@ -87,19 +97,4 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
|
||||
height: number;
|
||||
};
|
||||
|
||||
type inferIntegrationsFromDefinition<TDefinition extends WidgetDefinition> = TDefinition extends {
|
||||
supportedIntegrations: infer TSupportedIntegrations;
|
||||
} // check if definition has supportedIntegrations
|
||||
? TSupportedIntegrations extends IntegrationKind[] // check if supportedIntegrations is an array of IntegrationKind
|
||||
? IntegrationSelectOptionFor<TSupportedIntegrations[number]>[] // if so, return an array of IntegrationSelectOptionFor
|
||||
: IntegrationSelectOption[] // otherwise, return an array of IntegrationSelectOption without specifying the kind
|
||||
: IntegrationSelectOption[];
|
||||
|
||||
interface IntegrationSelectOptionFor<TIntegration extends IntegrationKind> {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
kind: TIntegration[number];
|
||||
}
|
||||
|
||||
export type WidgetOptionsRecordOf<TKind extends WidgetKind> = WidgetImports[TKind]["definition"]["options"];
|
||||
|
||||
146
packages/widgets/src/dns-hole/summary/component.tsx
Normal file
146
packages/widgets/src/dns-hole/summary/component.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import type { BoxProps } from "@mantine/core";
|
||||
import { Box, Card, Center, Flex, Text } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatNumber } from "@homarr/common";
|
||||
import type { stringOrTranslation, TranslationFunction } from "@homarr/translation";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps, WidgetProps } from "../../definition";
|
||||
import { NoIntegrationSelectedError } from "../../errors";
|
||||
|
||||
export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<"dnsHoleSummary">) {
|
||||
const integrationId = integrationIds.at(0);
|
||||
|
||||
if (!integrationId) {
|
||||
throw new NoIntegrationSelectedError();
|
||||
}
|
||||
|
||||
const [data] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
|
||||
{
|
||||
integrationId,
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Box h="100%" {...boxPropsByLayout(options.layout)}>
|
||||
{stats.map((item, index) => (
|
||||
<StatCard key={index} item={item} usePiHoleColors={options.usePiHoleColors} data={data} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{
|
||||
icon: IconBarrierBlock,
|
||||
value: ({ adsBlockedToday }) => formatNumber(adsBlockedToday, 2),
|
||||
label: (t) => t("widget.dnsHoleSummary.data.adsBlockedToday"),
|
||||
color: "rgba(240, 82, 60, 0.4)", // RED
|
||||
},
|
||||
{
|
||||
icon: IconPercentage,
|
||||
value: ({ adsBlockedTodayPercentage }, t) =>
|
||||
t("common.rtl", {
|
||||
value: formatNumber(adsBlockedTodayPercentage, 2),
|
||||
symbol: "%",
|
||||
}),
|
||||
label: (t) => t("widget.dnsHoleSummary.data.adsBlockedTodayPercentage"),
|
||||
color: "rgba(255, 165, 20, 0.4)", // YELLOW
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
value: ({ dnsQueriesToday }) => formatNumber(dnsQueriesToday, 2),
|
||||
label: (t) => t("widget.dnsHoleSummary.data.dnsQueriesToday"),
|
||||
color: "rgba(0, 175, 218, 0.4)", // BLUE
|
||||
},
|
||||
{
|
||||
icon: IconWorldWww,
|
||||
value: ({ domainsBeingBlocked }) => formatNumber(domainsBeingBlocked, 2),
|
||||
label: (t) => t("widget.dnsHoleSummary.data.domainsBeingBlocked"),
|
||||
color: "rgba(0, 176, 96, 0.4)", // GREEN
|
||||
},
|
||||
] satisfies StatItem[];
|
||||
|
||||
interface StatItem {
|
||||
icon: TablerIcon;
|
||||
value: (x: RouterOutputs["widget"]["dnsHole"]["summary"], t: TranslationFunction) => string;
|
||||
label: stringOrTranslation;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
item: StatItem;
|
||||
data: RouterOutputs["widget"]["dnsHole"]["summary"];
|
||||
usePiHoleColors: boolean;
|
||||
}
|
||||
const StatCard = ({ item, data, usePiHoleColors }: StatCardProps) => {
|
||||
const { ref, height, width } = useElementSize();
|
||||
const isLong = width > height + 20;
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
m={6}
|
||||
p={3}
|
||||
bg={usePiHoleColors ? item.color : "rgba(96, 96, 96, 0.1)"}
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
withBorder
|
||||
>
|
||||
<Center h="100%" w="100%">
|
||||
<Flex h="100%" w="100%" align="center" justify="space-evenly" direction={isLong ? "row" : "column"}>
|
||||
<item.icon size={30} style={{ margin: "0 10" }} />
|
||||
<Flex
|
||||
justify="center"
|
||||
direction="column"
|
||||
style={{
|
||||
flex: isLong ? 1 : undefined,
|
||||
}}
|
||||
>
|
||||
<Text ta="center" lh={1.2} size="md" fw="bold">
|
||||
{item.value(data, t)}
|
||||
</Text>
|
||||
{item.label && (
|
||||
<Text ta="center" lh={1.2} size="0.75rem">
|
||||
{translateIfNecessary(t, item.label)}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Center>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const boxPropsByLayout = (layout: WidgetProps<"dnsHoleSummary">["options"]["layout"]): BoxProps => {
|
||||
if (layout === "grid") {
|
||||
return {
|
||||
display: "grid",
|
||||
style: {
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gridTemplateRows: "1fr 1fr",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
display: "flex",
|
||||
style: {
|
||||
flexDirection: layout,
|
||||
},
|
||||
};
|
||||
};
|
||||
29
packages/widgets/src/dns-hole/summary/index.ts
Normal file
29
packages/widgets/src/dns-hole/summary/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { IconAd, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../../definition";
|
||||
import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("dnsHoleSummary", {
|
||||
icon: IconAd,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
usePiHoleColors: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
layout: factory.select({
|
||||
options: (["grid", "row", "column"] as const).map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.dnsHoleSummary.option.layout.option.${value}.label`),
|
||||
})),
|
||||
defaultValue: "grid",
|
||||
}),
|
||||
})),
|
||||
supportedIntegrations: ["piHole"],
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
message: (t) => t("widget.dnsHoleSummary.error.internalServerError"),
|
||||
},
|
||||
},
|
||||
})
|
||||
.withServerData(() => import("./serverData"))
|
||||
.withDynamicImport(() => import("./component"));
|
||||
24
packages/widgets/src/dns-hole/summary/serverData.ts
Normal file
24
packages/widgets/src/dns-hole/summary/serverData.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
"use server";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
import type { WidgetProps } from "../../definition";
|
||||
|
||||
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"dnsHoleSummary">) {
|
||||
const integrationId = integrationIds.at(0);
|
||||
if (!integrationId) return { initialData: undefined };
|
||||
|
||||
try {
|
||||
const data = await api.widget.dnsHole.summary({
|
||||
integrationId,
|
||||
});
|
||||
|
||||
return {
|
||||
initialData: data,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
initialData: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
36
packages/widgets/src/errors/base-component.tsx
Normal file
36
packages/widgets/src/errors/base-component.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import Link from "next/link";
|
||||
import { Anchor, Button, Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { stringOrTranslation } from "@homarr/translation";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
interface BaseWidgetErrorProps {
|
||||
icon: TablerIcon;
|
||||
message: stringOrTranslation;
|
||||
showLogsLink?: boolean;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
export const BaseWidgetError = (props: BaseWidgetErrorProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Stack h="100%" align="center" justify="center" gap="md">
|
||||
<props.icon size={40} />
|
||||
<Stack gap={0}>
|
||||
<Text ta="center">{translateIfNecessary(t, props.message)}</Text>
|
||||
{props.showLogsLink && (
|
||||
<Anchor component={Link} href="/manage/tools/logs" target="_blank" ta="center" size="sm">
|
||||
{t("widget.common.error.action.logs")}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Button onClick={props.onRetry} size="sm" variant="light">
|
||||
{t("common.action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
11
packages/widgets/src/errors/base.ts
Normal file
11
packages/widgets/src/errors/base.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { TablerIcon } from "@tabler/icons-react";
|
||||
|
||||
import type { stringOrTranslation } from "@homarr/translation";
|
||||
|
||||
export abstract class ErrorBoundaryError extends Error {
|
||||
public abstract getErrorBoundaryData(): {
|
||||
icon: TablerIcon;
|
||||
message: stringOrTranslation;
|
||||
showLogsLink: boolean;
|
||||
};
|
||||
}
|
||||
42
packages/widgets/src/errors/component.tsx
Normal file
42
packages/widgets/src/errors/component.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useMemo } from "react";
|
||||
import { IconExclamationCircle } from "@tabler/icons-react";
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import { widgetImports } from "..";
|
||||
import { ErrorBoundaryError } from "./base";
|
||||
import { BaseWidgetError } from "./base-component";
|
||||
|
||||
interface WidgetErrorProps {
|
||||
kind: WidgetKind;
|
||||
error: unknown;
|
||||
resetErrorBoundary: () => void;
|
||||
}
|
||||
|
||||
export const WidgetError = ({ error, resetErrorBoundary, kind }: WidgetErrorProps) => {
|
||||
const currentDefinition = useMemo(() => widgetImports[kind].definition, [kind]);
|
||||
|
||||
if (error instanceof ErrorBoundaryError) {
|
||||
return <BaseWidgetError {...error.getErrorBoundaryData()} onRetry={resetErrorBoundary} />;
|
||||
}
|
||||
|
||||
if (error instanceof TRPCClientError && "code" in error.data) {
|
||||
const errorData = error.data as DefaultErrorData;
|
||||
|
||||
if (!("errors" in currentDefinition && errorData.code in currentDefinition.errors)) return null;
|
||||
|
||||
const errorDefinition = currentDefinition.errors[errorData.code as keyof typeof currentDefinition.errors];
|
||||
|
||||
return <BaseWidgetError {...errorDefinition} onRetry={resetErrorBoundary} showLogsLink />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseWidgetError
|
||||
icon={IconExclamationCircle}
|
||||
message={(error as { toString: () => string }).toString()}
|
||||
onRetry={resetErrorBoundary}
|
||||
/>
|
||||
);
|
||||
};
|
||||
2
packages/widgets/src/errors/index.ts
Normal file
2
packages/widgets/src/errors/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./no-integration-selected";
|
||||
export * from "./base";
|
||||
19
packages/widgets/src/errors/no-integration-selected.tsx
Normal file
19
packages/widgets/src/errors/no-integration-selected.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { IconPlugX } from "@tabler/icons-react";
|
||||
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
|
||||
import { ErrorBoundaryError } from "./base";
|
||||
|
||||
export class NoIntegrationSelectedError extends ErrorBoundaryError {
|
||||
constructor() {
|
||||
super("No integration selected");
|
||||
}
|
||||
|
||||
public getErrorBoundaryData() {
|
||||
return {
|
||||
icon: IconPlugX,
|
||||
message: (t: TranslationFunction) => t("widget.common.error.noIntegration"),
|
||||
showLogsLink: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type { WidgetKind } from "@homarr/definitions";
|
||||
import * as app from "./app";
|
||||
import * as clock from "./clock";
|
||||
import type { WidgetComponentProps } from "./definition";
|
||||
import * as dnsHoleSummary from "./dns-hole/summary";
|
||||
import * as iframe from "./iframe";
|
||||
import type { WidgetImportRecord } from "./import";
|
||||
import * as notebook from "./notebook";
|
||||
@@ -27,6 +28,7 @@ export const widgetImports = {
|
||||
notebook,
|
||||
iframe,
|
||||
video,
|
||||
dnsHoleSummary,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
export type WidgetImports = typeof widgetImports;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Button, Group, Stack } from "@mantine/core";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { BoardItemIntegration } from "@homarr/validation";
|
||||
|
||||
import { widgetImports } from "..";
|
||||
import { getInputForType } from "../_inputs";
|
||||
@@ -19,8 +18,8 @@ import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal";
|
||||
|
||||
export interface WidgetEditModalState {
|
||||
options: Record<string, unknown>;
|
||||
integrationIds: string[];
|
||||
advancedOptions: BoardItemAdvancedOptions;
|
||||
integrations: BoardItemIntegration[];
|
||||
}
|
||||
|
||||
interface ModalProps<TSort extends WidgetKind> {
|
||||
@@ -57,7 +56,7 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
|
||||
<WidgetIntegrationSelect
|
||||
label={t("item.edit.field.integrations.label")}
|
||||
data={innerProps.integrationData}
|
||||
{...form.getInputProps("integrations")}
|
||||
{...form.getInputProps("integrationIds")}
|
||||
/>
|
||||
)}
|
||||
{Object.entries(definition.options).map(([key, value]: [string, OptionsBuilderResult[string]]) => {
|
||||
|
||||
@@ -55,13 +55,14 @@ export const WidgetIntegrationSelect = ({
|
||||
const handleValueRemove = (valueToRemove: string) =>
|
||||
onChange(multiSelectValues.filter((value) => value !== valueToRemove));
|
||||
|
||||
const values = multiSelectValues.map((item) => (
|
||||
<IntegrationPill
|
||||
key={item}
|
||||
option={data.find((integration) => integration.id === item)!}
|
||||
onRemove={() => handleValueRemove(item)}
|
||||
/>
|
||||
));
|
||||
const values = multiSelectValues.map((item) => {
|
||||
const option = data.find((integration) => integration.id === item);
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <IntegrationPill key={item} option={option} onRemove={() => handleValueRemove(item)} />;
|
||||
});
|
||||
|
||||
const options = data.map((item) => {
|
||||
return (
|
||||
|
||||
1101
pnpm-lock.yaml
generated
1101
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"packageRules": [
|
||||
{
|
||||
"matchUpdateTypes": [
|
||||
"minor",
|
||||
"patch",
|
||||
"pin",
|
||||
"digest"
|
||||
],
|
||||
"automerge": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -16,8 +16,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "^14.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^7.9.0",
|
||||
"@typescript-eslint/parser": "^7.9.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.11.0",
|
||||
"@typescript-eslint/parser": "^7.11.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "^1.13.3",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
|
||||
@@ -18,6 +18,7 @@ export default defineConfig({
|
||||
reporter: ["html", "json-summary", "json"],
|
||||
all: true,
|
||||
exclude: ["apps/nextjs/.next/"],
|
||||
reportOnFailure: true
|
||||
},
|
||||
|
||||
exclude: [...configDefaults.exclude, "apps/nextjs/.next"],
|
||||
|
||||
Reference in New Issue
Block a user