Compare commits

..
Author SHA1 Message Date
Thomas 07cb5a7561 Update run.sh 2026-05-06 23:34:39 +02:00
Thomas Camlong 77c9548111 perf: cap node heap size 2026-05-04 14:46:56 +02:00
Thomas Camlong 5ab1ddb9d9 perf: lazy load pages 2026-05-04 14:46:53 +02:00
Releases Homarr 80d18bb10d chore(release): 1.60.1-beta.1 [skip ci] 2026-05-01 20:44:34 +00:00
homarr-releases[bot] 2bb0f61da7 chore(release): release v1.60.1-beta 2026-05-01 20:43:32 +00:00
Meier Lukas 24386656fd Merge branch 'next' of https://github.com/homarr-labs/homarr into next 2026-05-01 22:42:07 +02:00
Meier Lukas 453360fe61 ci: disable package-manager cache 2026-05-01 22:42:02 +02:00
Releases Homarr beac1ed780 chore(release): 1.60.1-beta.1 [skip ci] 2026-05-01 20:41:02 +00:00
homarr-releases[bot] aa1b3810f1 chore(release): release v1.60.1-beta 2026-05-01 20:40:05 +00:00
Meier Lukas ce3c1b6cc1 chore: retrigger release v1.60.1-beta.1 2026-05-01 22:38:50 +02:00
Releases Homarr 3f6b830f9b chore(release): 1.60.1-beta.1 [skip ci] 2026-05-01 20:36:08 +00:00
homarr-releases[bot] f0cb79799a chore(release): release v1.60.1-beta 2026-05-01 20:35:08 +00:00
Meier Lukas 7561462594 Merge branch 'main' into next 2026-05-01 22:30:33 +02:00
Meier LukasandCopilot bfa8566284 ci: add beta release workflow (#5601)
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 22:18:45 +02:00
Meier LukasandCopilot a1aa5505a3 perf(memory): combine 3 node processes into one (#5600)
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 16:35:11 +02:00
Meier LukasandCopilot e77b9bb3d2 ci: support releases on next branch (#5599)
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 18:28:32 +02:00
77 changed files with 1463 additions and 3643 deletions

No files matched your search

-4
View File
@@ -12,10 +12,6 @@ AUTH_SECRET="supersecret"
# or starting the project without any (which will show a randomly generated one).
SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
# Cron job API key is generated every time the container starts as it is required for communication between nextjs-api and tasks-api
# export CRON_JOB_API_KEY=$(openssl rand -base64 32)
# CRON_JOB_API_KEY="your-generated-api-key"
LOG_LEVEL='info'
# This is how you can use the sqlite driver:
-2
View File
@@ -33,8 +33,6 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.60.1-beta.1
- 1.60.0
- 1.59.3
- 1.59.2
- 1.59.1
+10 -8
View File
@@ -5,6 +5,7 @@ on:
branches:
- main
- beta
- next
- dev
workflow_dispatch:
inputs:
@@ -34,7 +35,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
env:
SKIP_RELEASE: ${{ github.event_name == 'workflow_dispatch' || github.ref_name == 'dev' }}
SKIP_RELEASE: ${{ github.event_name == 'workflow_dispatch' || github.ref_name == 'dev' || github.ref_name == 'next' }}
outputs:
version: ${{ steps.read-semver.outputs.version || steps.version-fallback.outputs.version }}
git_ref: ${{ steps.read-git-ref.outputs.ref || github.ref }}
@@ -54,7 +55,7 @@ jobs:
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # required to commit package.json & changelog changes, merge them to dev and publish the release
permission-contents: write # required to commit package.json & changelog changes, merge them to dev / next and publish the release
- uses: actions/checkout@v6
if: env.SKIP_RELEASE == 'false'
@@ -66,7 +67,7 @@ jobs:
if: env.SKIP_RELEASE == 'false'
with:
node-version: 24.15.0
cache: "pnpm"
package-manager-cache: false
- run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false'
- name: Install dependencies
@@ -95,20 +96,21 @@ jobs:
id: read-git-ref
run: |
echo "ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Update dev branch
- name: Update dev / next branch
if: env.SKIP_RELEASE == 'false'
continue-on-error: true # Prevent pipeline from failing when merge fails
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
UPSTREAM_BRANCH: ${{ github.ref_name == 'main' && 'dev' || 'next' }}
run: |
git config user.name "Releases Homarr"
git config user.email "175486441+homarr-releases[bot]@users.noreply.github.com"
git remote set-url origin https://x-access-token:${{ steps.obtainToken.outputs.token }}@github.com/${{ github.repository }}.git
git fetch origin dev
git checkout dev
git pull origin dev
git fetch origin ${{ env.UPSTREAM_BRANCH }}
git checkout ${{ env.UPSTREAM_BRANCH }}
git pull origin ${{ env.UPSTREAM_BRANCH }}
git rebase ${{ github.ref_name }}
git push origin dev
git push origin ${{ env.UPSTREAM_BRANCH }}
build-amd64:
name: Build docker image for amd64
needs: release
+1 -1
View File
@@ -71,4 +71,4 @@ en.d.json.ts
# personal agent config
AGENTS.md
.opencode
.opencode
+18
View File
@@ -1,3 +1,21 @@
## [1.60.1-beta.1](https://github.com/homarr-labs/homarr/compare/v1.60.0...v1.60.1-beta.1) (2026-05-01)
### Performance Improvements
* **memory:** combine 3 node processes into one ([#5600](https://github.com/homarr-labs/homarr/issues/5600)) ([a1aa550](https://github.com/homarr-labs/homarr/commit/a1aa5505a365688b456090a882770e561479833e))
## [1.60.1-beta.1](https://github.com/homarr-labs/homarr/compare/v1.60.0...v1.60.1-beta.1) (2026-05-01)
### Performance Improvements
* **memory:** combine 3 node processes into one ([#5600](https://github.com/homarr-labs/homarr/issues/5600)) ([a1aa550](https://github.com/homarr-labs/homarr/commit/a1aa5505a365688b456090a882770e561479833e))
## [1.60.1-beta.1](https://github.com/homarr-labs/homarr/compare/v1.60.0...v1.60.1-beta.1) (2026-05-01)
### Performance Improvements
* **memory:** combine 3 node processes into one ([#5600](https://github.com/homarr-labs/homarr/issues/5600)) ([a1aa550](https://github.com/homarr-labs/homarr/commit/a1aa5505a365688b456090a882770e561479833e))
## [1.60.0](https://github.com/homarr-labs/homarr/compare/v1.59.3...v1.60.0) (2026-05-01)
### Features
-2
View File
@@ -43,8 +43,6 @@ RUN mkdir -p /var/cache/nginx && \
COPY --from=builder /app/apps/nextjs/next.config.ts .
COPY --from=builder /app/apps/nextjs/package.json .
COPY --from=builder /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
COPY --from=builder /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
COPY --from=builder /app/packages/db/migrations ./db/migrations
+2
View File
@@ -32,6 +32,8 @@ const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true,
preloadEntriesOnStart: false,
webpackMemoryOptimizations: true,
},
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
images: {
+2
View File
@@ -44,9 +44,11 @@
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/settings": "workspace:^0.1.0",
"@homarr/spotlight": "workspace:^0.1.0",
"@homarr/tasks": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/websocket": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "catalog:",
"@mantine/core": "catalog:",
@@ -8,12 +8,13 @@ import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { parseCookies, setClientCookie } from "@homarr/common";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemeCookieKey } from "@homarr/definitions";
export const CustomMantineProvider = ({
children,
defaultColorScheme,
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => {
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
const manager = useColorSchemeManager();
return (
<DirectionProvider>
@@ -34,7 +35,7 @@ export const CustomMantineProvider = ({
export function useColorSchemeManager(): MantineColorSchemeManager {
const { data: session } = useSession();
const updateCookieValue = (value: MantineColorScheme) => {
const updateCookieValue = (value: Exclude<MantineColorScheme, "auto">) => {
setClientCookie(colorSchemeCookieKey, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
};
@@ -59,6 +60,7 @@ export function useColorSchemeManager(): MantineColorSchemeManager {
},
set: (value) => {
if (value === "auto") return;
try {
if (session) {
mutateColorScheme({ colorScheme: value });
+1 -2
View File
@@ -4,7 +4,6 @@ import { Inter } from "next/font/google";
import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css";
import "~/styles/color-scheme.scss";
import "~/styles/scroll-area.scss";
import { notFound } from "next/navigation";
@@ -130,7 +129,7 @@ export default async function Layout(props: {
dir={direction}
data-mantine-color-scheme={colorScheme}
style={{
backgroundColor: colorScheme === "dark" ? "#242424" : colorScheme === "auto" ? undefined : "#fff",
backgroundColor: colorScheme === "dark" ? "#242424" : "#fff",
}}
suppressHydrationWarning
>
@@ -1,7 +1,7 @@
"use client";
import { Group, Text } from "@mantine/core";
import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
import { IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
@@ -34,7 +34,6 @@ export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: Serve
};
const appearanceIcons = {
auto: IconDeviceDesktop,
light: IconSun,
dark: IconMoon,
};
@@ -1,94 +0,0 @@
"use client";
import React from "react";
import { Combobox, Group, InputBase, Text, useCombobox } from "@mantine/core";
import { IconCheck, IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
const schemeIcons: Record<ColorScheme, React.ElementType> = {
auto: IconDeviceDesktop,
light: IconSun,
dark: IconMoon,
};
interface ColorSchemeComboboxProps {
value: ColorScheme;
onChange: (value: ColorScheme) => void;
width?: string;
}
export const ColorSchemeCombobox = ({ value, onChange, width }: ColorSchemeComboboxProps) => {
const tOptions = useScopedI18n("common.colorScheme.options");
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
});
const handleOnOptionSubmit = React.useCallback(
(val: string) => {
if (!val) return;
onChange(val as ColorScheme);
combobox.closeDropdown();
},
[onChange, combobox],
);
const handleOnClick = React.useCallback(() => {
combobox.toggleDropdown();
}, [combobox]);
return (
<Combobox store={combobox} onOptionSubmit={handleOnOptionSubmit}>
<Combobox.Target>
<InputBase
component="button"
type="button"
pointer
rightSection={<Combobox.Chevron />}
rightSectionPointerEvents="none"
onClick={handleOnClick}
variant="filled"
w={width}
>
<OptionItem scheme={value} currentScheme={value} tOptions={tOptions} />
</InputBase>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{colorSchemes.map((scheme) => (
<Combobox.Option value={scheme} key={scheme}>
<OptionItem scheme={scheme} currentScheme={value} tOptions={tOptions} showCheck />
</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
};
const OptionItem = ({
scheme,
currentScheme,
tOptions,
showCheck,
}: {
scheme: ColorScheme;
currentScheme: ColorScheme;
tOptions: (key: ColorScheme) => string;
showCheck?: boolean;
}) => {
const Icon = schemeIcons[scheme];
return (
<Group wrap="nowrap" justify="space-between">
<Group wrap="nowrap">
<Icon size={16} stroke={1.5} />
<Text>{tOptions(scheme)}</Text>
</Group>
{showCheck && scheme === currentScheme && <IconCheck color="currentColor" size={16} />}
</Group>
);
};
@@ -1,17 +1,49 @@
"use client";
import { useMantineColorScheme } from "@mantine/core";
import { Group, Text, useMantineColorScheme } from "@mantine/core";
import { IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions";
import { ColorSchemeCombobox } from "./color-scheme-combobox";
import { colorSchemes } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithCustomItems } from "@homarr/ui";
interface CurrentColorSchemeComboboxProps {
w?: string;
}
export const CurrentColorSchemeCombobox = ({ w }: CurrentColorSchemeComboboxProps) => {
const tOptions = useScopedI18n("common.colorScheme.options");
const { colorScheme, setColorScheme } = useMantineColorScheme();
return <ColorSchemeCombobox value={colorScheme as ColorScheme} onChange={setColorScheme} width={w} />;
return (
<SelectWithCustomItems
value={colorScheme}
onChange={(value) => setColorScheme((value as ColorScheme | null) ?? "light")}
data={colorSchemes.map((scheme) => ({
value: scheme,
label: tOptions(scheme),
}))}
SelectOption={ColorSchemeCustomOption}
w={w}
/>
);
};
const appearanceIcons = {
light: IconSun,
dark: IconMoon,
};
const ColorSchemeCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
const Icon = appearanceIcons[value];
return (
<Group>
<Icon size={16} stroke={1.5} />
<Text fz="sm" fw={500}>
{label}
</Text>
</Group>
);
};
@@ -5,7 +5,16 @@ import { useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { useHotkeys, useTimeout } from "@mantine/hooks";
import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
import {
IconCheck,
IconHome,
IconLogin,
IconLogout,
IconMoon,
IconSettings,
IconSun,
IconTool,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client";
@@ -15,7 +24,6 @@ import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
import { AvailableUpdatesMenuItem } from "./layout/header/update";
@@ -26,9 +34,13 @@ interface UserAvatarMenuProps {
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const { toggleColorScheme } = useMantineColorScheme();
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
const ColorSchemeIcon = colorScheme === "dark" ? IconSun : IconMoon;
const colorSchemeText = colorScheme === "dark" ? t("switchToLightMode") : t("switchToDarkMode");
const session = useSession();
const router = useRouter();
@@ -55,14 +67,14 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
<Menu width={300} withArrow withinPortal keepMounted>
<Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
<Menu.Item onClick={toggleColorScheme} leftSection={<ColorSchemeIcon size="1rem" />}>
{colorSchemeText}
</Menu.Item>
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
{t("homeBoard")}
</Menu.Item>
<Menu.Divider />
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentColorSchemeCombobox />
</Menu.Item>
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentLanguageCombobox />
</Menu.Item>
+6
View File
@@ -0,0 +1,6 @@
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("@homarr/tasks");
await import("@homarr/websocket");
}
}
-12
View File
@@ -1,12 +0,0 @@
// Prevent flash of white background when color scheme is "auto" and OS prefers dark.
// CSS media queries are evaluated synchronously during the browser's initial paint,
// before any JavaScript executes, so this eliminates the white→dark flicker.
html[data-mantine-color-scheme="auto"] {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
html[data-mantine-color-scheme="auto"] {
background-color: #242424;
}
}
+1 -3
View File
@@ -5,12 +5,11 @@
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./src/index.ts"
".": "./src/main.ts"
},
"main": "./src/main.ts",
"types": "./src/main.ts",
"scripts": {
"build": "esbuild src/main.ts --bundle --platform=node --loader:.scss=text --external:*.node --external:@opentelemetry/api --external:deasync --external:bcrypt --outfile=tasks.cjs",
"clean": "rm -rf .turbo node_modules",
"dev": "pnpm with-env tsx ./src/main.ts",
"format": "prettier --check . --ignore-path ../../.gitignore",
@@ -24,7 +23,6 @@
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^",
"@homarr/cron-job-api": "workspace:^0.1.0",
"@homarr/cron-jobs": "workspace:^0.1.0",
"@homarr/cron-jobs-core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
-40
View File
@@ -1,52 +1,12 @@
// This import has to be the first import in the file so that the agent is overridden before any other modules are imported.
import "./overrides";
import type { FastifyTRPCPluginOptions } from "@trpc/server/adapters/fastify";
import { fastifyTRPCPlugin } from "@trpc/server/adapters/fastify";
import fastify from "fastify";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { JobRouter } from "@homarr/cron-job-api";
import { jobRouter } from "@homarr/cron-job-api";
import { CRON_JOB_API_KEY_HEADER, CRON_JOB_API_PATH, CRON_JOB_API_PORT } from "@homarr/cron-job-api/constants";
import { jobGroup } from "@homarr/cron-jobs";
import { db } from "@homarr/db";
import { JobManager } from "./job-manager";
import { onStartAsync } from "./on-start";
const logger = createLogger({ module: "tasksMain" });
const server = fastify({
maxParamLength: 5000,
});
server.register(fastifyTRPCPlugin, {
prefix: CRON_JOB_API_PATH,
trpcOptions: {
router: jobRouter,
createContext: ({ req }) => ({
manager: new JobManager(db, jobGroup),
apiKey: req.headers[CRON_JOB_API_KEY_HEADER] as string | undefined,
}),
onError({ path, error }) {
logger.error(new ErrorWithMetadata("Error in tasks tRPC handler", { path }, { cause: error }));
},
} satisfies FastifyTRPCPluginOptions<JobRouter>["trpcOptions"],
});
void (async () => {
await onStartAsync();
await jobGroup.initializeAsync();
await jobGroup.startAllAsync();
try {
await server.listen({ port: CRON_JOB_API_PORT });
logger.info("Tasks web server started successfully", { port: CRON_JOB_API_PORT });
} catch (err) {
logger.error(
new ErrorWithMetadata("Failed to start tasks web server", { port: CRON_JOB_API_PORT }, { cause: err }),
);
process.exit(1);
}
})();
+3 -1
View File
@@ -4,10 +4,12 @@
"private": true,
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./src/main.ts"
},
"main": "./src/main.ts",
"types": "./src/main.ts",
"scripts": {
"build": "esbuild src/main.ts --bundle --platform=node --outfile=wssServer.cjs --external:bcrypt --external:@opentelemetry/api --external:deasync --external:cpu-features --loader:.html=text --loader:.scss=text --loader:.node=text",
"clean": "rm -rf .turbo node_modules",
"dev": "pnpm with-env tsx ./src/main.ts",
"format": "prettier --check . --ignore-path ../../.gitignore",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.60.0",
"version": "1.60.1-beta.1",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -15,7 +15,7 @@
"db:migration:sqlite:run": "pnpm -F db migration:sqlite:run",
"db:push": "pnpm -F db push:sqlite",
"db:studio": "pnpm -F db studio",
"dev": "turbo dev --parallel",
"dev": "pnpm -F nextjs dev",
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
"format": "turbo format --continue -- --cache --cache-location node_modules/.cache/.prettiercache",
"format:fix": "turbo format --continue -- --write --cache --cache-location node_modules/.cache/.prettiercache",
@@ -84,9 +84,9 @@
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.5",
"esbuild@<=0.24.2": ">=0.28.0",
"form-data@>=4.0.0 <4.0.4": ">=4.0.5",
"hono@<4.6.5": ">=4.12.16",
"hono@<4.6.5": ">=4.12.15",
"linkifyjs@<4.3.2": ">=4.3.2",
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
"nanoid@>=4.0.0 <5.0.9": ">=5.1.9",
"prismjs@<1.30.0": ">=1.30.0",
"proxmox-api>undici": "7.24.6",
"react-is": "^19.2.5",
-1
View File
@@ -25,7 +25,6 @@
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/cron-job-api": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0",
"@homarr/cron-jobs": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
+19 -16
View File
@@ -2,33 +2,36 @@ import { observable } from "@trpc/server/observable";
import z from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { jobGroupKeys, jobNameSchema } from "@homarr/cron-job-api";
import { cronJobApi } from "@homarr/cron-job-api/client";
import type { TaskStatus } from "@homarr/cron-job-status";
import { createCronJobStatusChannel } from "@homarr/cron-job-status";
import { jobGroup } from "@homarr/cron-jobs";
import { JobManager } from "@homarr/cron-jobs/job-manager";
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
const jobGroupKeys = jobGroup.getKeys();
const jobNameSchema = z.enum(jobGroup.getKeys());
const logger = createLogger({ module: "cronJobsRouter" });
export const cronJobsRouter = createTRPCRouter({
triggerJob: permissionRequiredProcedure
.requiresPermission("admin")
.input(jobNameSchema)
.mutation(async ({ input }) => {
await cronJobApi.trigger.mutate(input);
.mutation(async ({ input, ctx }) => {
await new JobManager(ctx.db, jobGroup).triggerAsync(input);
}),
startJob: permissionRequiredProcedure
.requiresPermission("admin")
.input(jobNameSchema)
.mutation(async ({ input }) => {
await cronJobApi.start.mutate(input);
.mutation(async ({ input, ctx }) => {
await new JobManager(ctx.db, jobGroup).startAsync(input);
}),
stopJob: permissionRequiredProcedure
.requiresPermission("admin")
.input(jobNameSchema)
.mutation(async ({ input }) => {
await cronJobApi.stop.mutate(input);
.mutation(({ input, ctx }) => {
new JobManager(ctx.db, jobGroup).stop(input);
}),
updateJobInterval: permissionRequiredProcedure
.requiresPermission("admin")
@@ -38,23 +41,23 @@ export const cronJobsRouter = createTRPCRouter({
cron: z.string(),
}),
)
.mutation(async ({ input }) => {
await cronJobApi.updateInterval.mutate(input);
.mutation(async ({ input, ctx }) => {
await new JobManager(ctx.db, jobGroup).updateIntervalAsync(input.name, input.cron);
}),
disableJob: permissionRequiredProcedure
.requiresPermission("admin")
.input(jobNameSchema)
.mutation(async ({ input }) => {
await cronJobApi.disable.mutate(input);
.mutation(async ({ input, ctx }) => {
await new JobManager(ctx.db, jobGroup).disableAsync(input);
}),
enableJob: permissionRequiredProcedure
.requiresPermission("admin")
.input(jobNameSchema)
.mutation(async ({ input }) => {
await cronJobApi.enable.mutate(input);
.mutation(async ({ input, ctx }) => {
await new JobManager(ctx.db, jobGroup).enableAsync(input);
}),
getJobs: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
return await cronJobApi.getAll.query();
getJobs: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
return await new JobManager(ctx.db, jobGroup).getAllAsync();
}),
subscribeToStatusUpdates: permissionRequiredProcedure.requiresPermission("admin").subscription(() => {
return observable<TaskStatus>((emit) => {
+1 -1
View File
@@ -110,7 +110,7 @@ export const userRouter = createTRPCRouter({
const queryResult = (await trx.delete(schema.invites).where(inviteWhere)) as
| QueryResult
| MySqlRawQueryResult;
let count: number;
let count = 0;
if (Array.isArray(queryResult)) {
count = queryResult[0].affectedRows;
} else {
-1
View File
@@ -30,5 +30,4 @@ export const widgetRouter = createTRPCRouter({
timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)),
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
});
-205
View File
@@ -1,205 +0,0 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createIntegrationAsync } from "@homarr/integrations";
import {
umamiActiveVisitorsRequestHandler,
umamiEventNamesRequestHandler,
umamiMultiEventRequestHandler,
umamiRequestHandler,
umamiTopPagesRequestHandler,
umamiTopReferrersRequestHandler,
} from "@homarr/request-handler/umami";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "umami-router" });
export const umamiRouter = createTRPCRouter({
getWebsites: publicProcedure.concat(createOneIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => {
try {
const instance = await createIntegrationAsync(ctx.integration);
return await instance.getWebsitesAsync();
} catch (error) {
logger.warn(new Error("Failed to load websites", { cause: error }));
return [];
}
}),
getVisitorStats: publicProcedure
.input(
z.object({
websiteId: z.string(),
timeFrame: z.string(),
eventName: z.string().optional(),
}),
)
.concat(createManyIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = umamiRequestHandler.handler(integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventName: input.eventName,
});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
visitorStats: data,
updatedAt: timestamp,
};
}),
);
return results;
}),
getEventNames: publicProcedure
.input(z.object({ websiteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
try {
const innerHandler = umamiEventNamesRequestHandler.handler(ctx.integration, { websiteId: input.websiteId });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
} catch (error) {
logger.warn(
new ErrorWithMetadata("Failed to load event names", { websiteId: input.websiteId }, { cause: error }),
);
return [];
}
}),
getTopPages: publicProcedure
.input(
z.object({
websiteId: z.string(),
timeFrame: z.string(),
limit: z.number().int().min(1).max(500),
}),
)
.concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
try {
const innerHandler = umamiTopPagesRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
limit: input.limit,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
} catch (error) {
logger.warn(
new ErrorWithMetadata(
"Failed to load top pages",
{ websiteId: input.websiteId, timeFrame: input.timeFrame, limit: input.limit },
{ cause: error },
),
);
return [];
}
}),
getTopReferrers: publicProcedure
.input(
z.object({
websiteId: z.string(),
timeFrame: z.string(),
limit: z.number().int().min(1).max(500),
}),
)
.concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
try {
const innerHandler = umamiTopReferrersRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
limit: input.limit,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
} catch (error) {
logger.warn(
new ErrorWithMetadata(
"Failed to load top referrers",
{ websiteId: input.websiteId, timeFrame: input.timeFrame, limit: input.limit },
{ cause: error },
),
);
return [];
}
}),
getMultiEventTimeSeries: publicProcedure
.input(
z.object({
websiteId: z.string(),
timeFrame: z.string(),
eventNames: z.array(z.string()),
}),
)
.concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
try {
const sortedNames = [...input.eventNames].sort();
const innerHandler = umamiMultiEventRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventNames: sortedNames,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
} catch (error) {
logger.warn(
new ErrorWithMetadata(
"Failed to load multi-event time series",
{ websiteId: input.websiteId, timeFrame: input.timeFrame, eventNames: JSON.stringify(input.eventNames) },
{ cause: error },
),
);
return [];
}
}),
getActiveVisitors: publicProcedure
.input(z.object({ websiteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
try {
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
} catch (error) {
logger.warn(
new ErrorWithMetadata("Failed to load active visitors", { websiteId: input.websiteId }, { cause: error }),
);
return 0;
}
}),
subscribeActiveVisitors: publicProcedure
.input(z.object({ websiteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "umami"))
.subscription(({ ctx, input }) => {
return observable<number>((emit) => {
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
});
const unsubscribe = innerHandler.subscribe((count) => {
emit.next(count);
});
return () => {
unsubscribe();
};
});
}),
});
+1 -1
View File
@@ -36,7 +36,7 @@ export const createSessionAsync = async (
...user,
email: user.email ?? "",
permissions: await getCurrentUserPermissionsAsync(db, user.id),
colorScheme: "auto",
colorScheme: "dark",
},
} as Session;
};
-9
View File
@@ -1,9 +0,0 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [],
},
...baseConfig,
];
-46
View File
@@ -1,46 +0,0 @@
{
"name": "@homarr/cron-job-api",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./env": "./src/env.ts",
"./constants": "./src/constants.ts",
"./client": "./src/client.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/cron-jobs": "workspace:^0.1.0",
"@tanstack/react-query": "catalog:",
"@trpc/client": "catalog:",
"@trpc/server": "catalog:",
"@trpc/tanstack-react-query": "catalog:",
"react": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/react": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:"
}
}
-20
View File
@@ -1,20 +0,0 @@
import { createTRPCClient, httpLink } from "@trpc/client";
import type { JobRouter } from ".";
import { CRON_JOB_API_KEY_HEADER, CRON_JOB_API_PATH, CRON_JOB_API_PORT } from "./constants";
import { env } from "./env";
export const cronJobApi = createTRPCClient<JobRouter>({
links: [
httpLink({
url: `${getBaseUrl()}${CRON_JOB_API_PATH}`,
headers: {
[CRON_JOB_API_KEY_HEADER]: env.CRON_JOB_API_KEY,
},
}),
],
});
function getBaseUrl() {
return `http://localhost:${CRON_JOB_API_PORT}`;
}
-3
View File
@@ -1,3 +0,0 @@
export const CRON_JOB_API_PORT = 3002;
export const CRON_JOB_API_PATH = "/trpc";
export const CRON_JOB_API_KEY_HEADER = "cron-job-api-key";
-11
View File
@@ -1,11 +0,0 @@
import { z } from "zod/v4";
import { env as commonEnv } from "@homarr/common/env";
import { createEnv } from "@homarr/core/infrastructure/env";
export const env = createEnv({
server: {
CRON_JOB_API_KEY: commonEnv.NODE_ENV === "development" ? z.string().default("test") : z.string(),
},
experimental__runtimeEnv: process.env,
});
-85
View File
@@ -1,85 +0,0 @@
import { initTRPC, TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import type { JobGroupKeys } from "@homarr/cron-jobs";
import { jobGroup } from "@homarr/cron-jobs";
import { env } from "./env";
export const jobGroupKeys = jobGroup.getKeys();
export const jobNameSchema = z.enum(jobGroup.getKeys());
export interface IJobManager {
startAsync(name: JobGroupKeys): Promise<void>;
triggerAsync(name: JobGroupKeys): Promise<void>;
stop(name: JobGroupKeys): void;
updateIntervalAsync(name: JobGroupKeys, cron: string): Promise<void>;
disableAsync(name: JobGroupKeys): Promise<void>;
enableAsync(name: JobGroupKeys): Promise<void>;
getAllAsync(): Promise<
{
name: JobGroupKeys;
cron: string;
preventManualExecution: boolean;
preventCustomInterval: boolean;
isEnabled: boolean;
}[]
>;
}
const t = initTRPC
.context<{
manager: IJobManager;
apiKey?: string;
}>()
.create();
const createTrpcRouter = t.router;
const apiKeyProcedure = t.procedure.use(({ ctx, next }) => {
if (ctx.apiKey !== env.CRON_JOB_API_KEY) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Missing or invalid API key",
});
}
return next({
ctx: {
...ctx,
apiKey: undefined, // Clear the API key after checking
},
});
});
export const jobRouter = createTrpcRouter({
start: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
await ctx.manager.startAsync(input);
}),
trigger: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
await ctx.manager.triggerAsync(input);
}),
stop: apiKeyProcedure.input(jobNameSchema).mutation(({ input, ctx }) => {
ctx.manager.stop(input);
}),
updateInterval: apiKeyProcedure
.input(
z.object({
name: jobNameSchema,
cron: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
await ctx.manager.updateIntervalAsync(input.name, input.cron);
}),
disable: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
await ctx.manager.disableAsync(input);
}),
enable: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
await ctx.manager.enableAsync(input);
}),
getAll: apiKeyProcedure.query(({ ctx }) => {
return ctx.manager.getAllAsync();
}),
});
export type JobRouter = typeof jobRouter;
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}
+4 -2
View File
@@ -5,7 +5,8 @@
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts"
".": "./index.ts",
"./job-manager": "./src/job-manager.ts"
},
"typesVersions": {
"*": {
@@ -38,7 +39,8 @@
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"semver-parser": "catalog:"
"semver-parser": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
+39 -30
View File
@@ -20,7 +20,6 @@ import { networkControllerJob } from "./jobs/integrations/network-controller";
import { refreshNotificationsJob } from "./jobs/integrations/notifications";
import { timetableJob } from "./jobs/integrations/timetable";
import { tracearrJob } from "./jobs/integrations/tracearr";
import { umamiActiveVisitorsJob } from "./jobs/integrations/umami";
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
import { pingJob } from "./jobs/ping";
import { rssFeedsJob } from "./jobs/rss-feeds";
@@ -28,34 +27,44 @@ import { updateCheckerJob } from "./jobs/update-checker";
import { weatherJob } from "./jobs/weather";
import { createCronJobGroup } from "./lib";
export const jobGroup = createCronJobGroup({
analytics: analyticsJob,
iconsUpdater: iconsUpdaterJob,
ping: pingJob,
smartHomeEntityState: smartHomeEntityStateJob,
mediaServer: mediaServerJob,
mediaOrganizer: mediaOrganizerJob,
downloads: downloadsJob,
dnsHole: dnsHoleJob,
mediaRequestStats: mediaRequestStatsJob,
mediaRequestList: mediaRequestListJob,
rssFeeds: rssFeedsJob,
indexerManager: indexerManagerJob,
healthMonitoring: healthMonitoringJob,
updateChecker: updateCheckerJob,
mediaTranscoding: mediaTranscodingJob,
minecraftServerStatus: minecraftServerStatusJob,
dockerContainers: dockerContainersJob,
networkController: networkControllerJob,
firewallCpu: firewallCpuJob,
firewallMemory: firewallMemoryJob,
firewallVersion: firewallVersionJob,
firewallInterfaces: firewallInterfacesJob,
refreshNotifications: refreshNotificationsJob,
weather: weatherJob,
timetable: timetableJob,
tracearr: tracearrJob,
umamiActiveVisitors: umamiActiveVisitorsJob,
});
const getJobGroup = () => {
return createCronJobGroup({
analytics: analyticsJob,
iconsUpdater: iconsUpdaterJob,
ping: pingJob,
smartHomeEntityState: smartHomeEntityStateJob,
mediaServer: mediaServerJob,
mediaOrganizer: mediaOrganizerJob,
downloads: downloadsJob,
dnsHole: dnsHoleJob,
mediaRequestStats: mediaRequestStatsJob,
mediaRequestList: mediaRequestListJob,
rssFeeds: rssFeedsJob,
indexerManager: indexerManagerJob,
healthMonitoring: healthMonitoringJob,
updateChecker: updateCheckerJob,
mediaTranscoding: mediaTranscodingJob,
minecraftServerStatus: minecraftServerStatusJob,
dockerContainers: dockerContainersJob,
networkController: networkControllerJob,
firewallCpu: firewallCpuJob,
firewallMemory: firewallMemoryJob,
firewallVersion: firewallVersionJob,
firewallInterfaces: firewallInterfacesJob,
refreshNotifications: refreshNotificationsJob,
weather: weatherJob,
timetable: timetableJob,
tracearr: tracearrJob,
});
};
// Add global type augmentation for homarr
declare global {
var cronJobs: ReturnType<typeof getJobGroup> | undefined;
}
global.cronJobs ??= getJobGroup();
export const jobGroup = global.cronJobs;
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
@@ -1,7 +1,6 @@
import { Cron } from "croner";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IJobManager } from "@homarr/cron-job-api";
import type { jobGroup as cronJobGroup, JobGroupKeys } from "@homarr/cron-jobs";
import type { Database, InferInsertModel } from "@homarr/db";
import { eq } from "@homarr/db";
@@ -9,7 +8,7 @@ import { cronJobConfigurations } from "@homarr/db/schema";
const logger = createLogger({ module: "jobManager" });
export class JobManager implements IJobManager {
export class JobManager {
constructor(
private db: Database,
private jobGroup: typeof cronJobGroup,
@@ -1,14 +0,0 @@
import { EVERY_30_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { umamiActiveVisitorsRequestHandler } from "@homarr/request-handler/umami";
import { createCronJob } from "../../lib";
export const umamiActiveVisitorsJob = createCronJob("umamiActiveVisitors", EVERY_30_SECONDS).withCallback(
createRequestIntegrationJobHandler(umamiActiveVisitorsRequestHandler.handler, {
widgetKinds: ["umami"],
getInput: {
umami: (options) => (options.websiteId ? { websiteId: options.websiteId } : []),
},
}),
);
+1 -3
View File
@@ -4,7 +4,6 @@ import {
everyoneGroup,
getIntegrationDefaultUrl,
getIntegrationName,
integrationDefs,
integrationKinds,
} from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
@@ -144,9 +143,8 @@ const seedDefaultIntegrationsAsync = async (db: Database) => {
const defaultIntegrations = integrationKinds.reduce<Integration[]>((acc, kind) => {
const name = getIntegrationName(kind);
const defaultUrl = getIntegrationDefaultUrl(kind);
const hasNoAuthOption = integrationDefs[kind].secretKinds.some((kinds) => kinds.length === 0);
if (defaultUrl !== undefined && hasNoAuthOption) {
if (defaultUrl !== undefined) {
acc.push({
id: "new",
name: `${name} Default`,
-10
View File
@@ -370,15 +370,6 @@ export const integrationDefs = {
category: ["speedtest"],
documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"),
},
umami: {
name: "Umami",
secretKinds: [["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/umami.svg",
category: ["analytics"],
defaultUrl: "https://api.umami.is/v1",
// @ts-expect-error - docs page will be created when integration is merged
documentationUrl: createDocumentationLink("/docs/integrations/umami"),
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
name: "Mock",
@@ -470,7 +461,6 @@ export const integrationCategories = [
"notes",
"mediaMonitoring",
"speedtest",
"analytics",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];
+1 -1
View File
@@ -1,2 +1,2 @@
export const colorSchemes = ["auto", "light", "dark"] as const;
export const colorSchemes = ["light", "dark"] as const;
export type ColorScheme = (typeof colorSchemes)[number];
-1
View File
@@ -37,6 +37,5 @@ export const widgetKinds = [
"immich-albumCarousel",
"tracearr",
"speedtestTracker",
"umami",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -77,7 +77,7 @@ export class AnchorIntegration extends Integration {
});
if (!response.ok) {
let errorBody: string | null;
let errorBody: string | null = null;
try {
errorBody = await response.text();
} catch {
@@ -46,7 +46,6 @@ import { SeerrIntegration } from "../seerr/seerr-integration";
import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
import { TracearrIntegration } from "../tracearr/tracearr-integration";
import { TrueNasIntegration } from "../truenas/truenas-integration";
import { UmamiIntegration } from "../umami/umami-integration";
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
import { UnraidIntegration } from "../unraid/unraid-integration";
import type { Integration, IntegrationInput } from "./integration";
@@ -122,7 +121,6 @@ export const integrationCreators = {
searchCh: SearchChIntegration,
immich: ImmichIntegration,
speedtestTracker: SpeedtestTrackerIntegration,
umami: UmamiIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
-2
View File
@@ -33,7 +33,6 @@ export { CoolifyIntegration } from "./coolify/coolify-integration";
export { ImmichIntegration } from "./immich/immich-integration";
export { TracearrIntegration } from "./tracearr/tracearr-integration";
export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-integration";
export { UmamiIntegration } from "./umami/umami-integration";
// Types
export type { IntegrationInput } from "./base/integration";
@@ -69,7 +68,6 @@ export type {
} from "./anchor/anchor-types";
export type { TracearrDashboardData } from "./tracearr/tracearr-types";
export type { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-types";
export type { UmamiVisitorStats } from "./umami/umami-types";
// Schemas
export { anchorNotesListInputSchema } from "./anchor/anchor-types";
-1
View File
@@ -16,4 +16,3 @@ export * from "./anchor/anchor-types";
export * from "./interfaces/timetable/timetable-types";
export * from "./tracearr/tracearr-types";
export * from "./speedtest-tracker/speedtest-tracker-types";
export * from "./umami/umami-types";
@@ -1,450 +0,0 @@
import dayjs from "dayjs";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type {
UmamiEventSeries,
UmamiMetricItem,
UmamiPageviewDataPoint,
UmamiPageviews,
UmamiStats,
UmamiVisitorStats,
UmamiWebsite,
} from "./umami-types";
import {
umamiActiveVisitorsSchema,
umamiAuthResponseSchema,
umamiMetricItemSchema,
umamiPageviewDataPointSchema,
umamiPageviewsSchema,
umamiStatsSchema,
umamiWebsiteSchema,
} from "./umami-types";
// Umami event type ID for custom events (type 1 = page view, type 2 = custom event)
const UMAMI_CUSTOM_EVENT_TYPE = 2;
const logger = createLogger({ module: "umami-integration" });
export class UmamiIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const authHeaders = await this.getAuthHeadersAsync();
const url = this.url("/websites");
const response = await input.fetchAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return { success: true };
}
public async getWebsitesAsync(): Promise<UmamiWebsite[]> {
const authHeaders = await this.getAuthHeadersAsync();
const url = this.url("/websites");
const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders });
if (!response.ok) throw new ResponseError(response);
const json = await response.json();
// Response envelope differs by hosting:
// - Umami self-hosted returns the array directly: [website, ...]
// - Umami Cloud wraps it: { data: [website, ...] }
// Same divergence applies to the /events list responses below.
return Array.isArray(json)
? umamiWebsiteSchema.array().parse(json)
: umamiWebsiteSchema.array().parse((json as { data: unknown }).data);
}
// Server-side timeFrame resolution is intentional. Keeping `timeFrame` (a
// stable string like "7d") in the cache key means all users viewing the same
// dashboard share one Redis cache entry per (integration, website, timeFrame)
// for the 5-minute TTL. Client-supplied startAt/endAt would include a
// per-second `now` and fragment the cache one-per-user-per-second.
//
// Window bounds mirror Umami UI preset semantics, verified against the
// Umami Cloud dashboard for the same site and timeFrame: each "Last N"
// preset includes the in-progress period as one of N + 1 buckets, snapped
// to bucket boundaries.
// - "24h": current hour + 24 prior hours (25-bucket hour grid)
// - "7d": current day + 7 prior days ( 8-bucket day grid)
// - "30d": current day + 30 prior days (31-bucket day grid)
// - "today" / "month" / "lastMonth": calendar-aligned windows.
//
// Caveat: dayjs() uses the server's local timezone. Callers needing user-local
// boundaries (e.g. "today") may see off-by-one behaviour when the server
// runs in UTC but the user is in a different timezone.
private computeTimeRange(timeFrame: string): { startAt: number; endAt: number; unit: string } {
const startOfToday = dayjs().startOf("day");
const endOfToday = dayjs().endOf("day");
switch (timeFrame) {
case "today":
return { startAt: startOfToday.valueOf(), endAt: endOfToday.valueOf(), unit: "hour" };
case "7d":
return {
startAt: startOfToday.subtract(7, "day").valueOf(),
endAt: endOfToday.valueOf(),
unit: "day",
};
case "30d":
return {
startAt: startOfToday.subtract(30, "day").valueOf(),
endAt: endOfToday.valueOf(),
unit: "day",
};
case "month":
return {
startAt: dayjs().startOf("month").valueOf(),
endAt: dayjs().endOf("month").valueOf(),
unit: "day",
};
case "lastMonth": {
const lastMonth = dayjs().subtract(1, "month");
return {
startAt: lastMonth.startOf("month").valueOf(),
endAt: lastMonth.endOf("month").valueOf(),
unit: "day",
};
}
case "24h":
default:
return {
startAt: dayjs().subtract(24, "hour").startOf("hour").valueOf(),
endAt: dayjs().endOf("hour").valueOf(),
unit: "hour",
};
}
}
public async getVisitorStatsAsync(
websiteId: string,
timeFrame = "24h",
eventName?: string,
): Promise<UmamiVisitorStats> {
const authHeaders = await this.getAuthHeadersAsync();
const website = await this.getWebsiteByIdAsync(websiteId, authHeaders);
const { startAt, endAt, unit } = this.computeTimeRange(timeFrame);
const [stats, pageviews, eventMetrics, eventTimeSeries] = await Promise.all([
this.getWebsiteStatsAsync(website.id, startAt, endAt, authHeaders),
this.getWebsitePageviewsAsync(website.id, startAt, endAt, authHeaders, unit),
eventName ? this.getWebsiteEventMetricsAsync(website.id, startAt, endAt, authHeaders) : Promise.resolve(null),
eventName
? this.getWebsiteEventTimeSeriesAsync(website.id, startAt, endAt, unit, eventName, authHeaders)
: Promise.resolve(null),
]);
const eventCount = eventName && eventMetrics ? (eventMetrics.find(({ x }) => x === eventName)?.y ?? 0) : undefined;
const eventByTimestamp = new Map<number, number>(
(eventTimeSeries ?? []).map(({ x, y }) => [this.parseUmamiDate(x), y]),
);
const dataPoints = pageviews.sessions.map((point) => ({
timestamp: point.x,
visitors: point.y,
...(eventTimeSeries ? { events: eventByTimestamp.get(this.parseUmamiDate(point.x)) ?? 0 } : {}),
}));
return {
domain: website.domain,
websiteName: website.name,
websiteId: website.id,
totalVisitors: stats.visitors,
totalPageviews: stats.pageviews,
totalVisits: stats.visits,
bounceRate: stats.visits > 0 ? Math.round((stats.bounces / stats.visits) * 100) : 0,
avgDuration: stats.visits > 0 ? Math.round(stats.totaltime / stats.visits) : 0,
dataPoints,
timeFrame,
eventCount,
};
}
public async getEventNamesAsync(websiteId: string): Promise<string[]> {
const authHeaders = await this.getAuthHeadersAsync();
const now = Date.now();
const startAt = dayjs().subtract(30, "day").valueOf();
const url = this.url(`/websites/${websiteId}/events`, {
startAt: startAt.toString(),
endAt: now.toString(),
pageSize: "100000",
});
const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders });
if (!response.ok) {
logger.warn("Failed to load event names", { status: response.status, websiteId });
return [];
}
const json = await response.json();
const rawArray: unknown[] = Array.isArray(json)
? json
: json && typeof json === "object" && "data" in json && Array.isArray((json as { data: unknown }).data)
? (json as { data: unknown[] }).data
: [];
const names = new Set<string>();
for (const item of rawArray) {
const record = item as Record<string, unknown>;
if (typeof record.eventName === "string" && record.eventName) {
names.add(record.eventName);
}
}
return [...names].sort();
}
public async getActiveVisitorsAsync(websiteId: string): Promise<number> {
const authHeaders = await this.getAuthHeadersAsync();
const url = this.url(`/websites/${websiteId}/active`);
const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders });
if (!response.ok) {
logger.warn("Failed to load active visitors", { status: response.status, websiteId });
return 0;
}
return umamiActiveVisitorsSchema.parse(await response.json()).visitors;
}
/**
* Umami self-hosted returns "YYYY-MM-DD HH:MM:SS"; Umami Cloud returns ISO 8601.
* Normalise both to epoch ms so timestamps from /pageviews and /events join.
*/
private parseUmamiDate(dateString: string): number {
return new Date(dateString.includes("T") ? dateString : `${dateString.replace(" ", "T")}Z`).getTime();
}
private async getWebsiteByIdAsync(websiteId: string, authHeaders: Record<string, string>): Promise<UmamiWebsite> {
const url = this.url(`/websites/${websiteId}`);
const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders });
if (!response.ok) throw new ResponseError(response);
return umamiWebsiteSchema.parse(await response.json());
}
public async getTopPagesAsync(websiteId: string, timeFrame: string, limit: number): Promise<UmamiMetricItem[]> {
const authHeaders = await this.getAuthHeadersAsync();
const { startAt, endAt } = this.computeTimeRange(timeFrame);
return this.getWebsiteMetricsAsync(websiteId, startAt, endAt, "path", authHeaders, limit);
}
public async getTopReferrersAsync(websiteId: string, timeFrame: string, limit: number): Promise<UmamiMetricItem[]> {
const authHeaders = await this.getAuthHeadersAsync();
const { startAt, endAt } = this.computeTimeRange(timeFrame);
return this.getWebsiteMetricsAsync(websiteId, startAt, endAt, "referrer", authHeaders, limit);
}
public async getMultiEventTimeSeriesAsync(
websiteId: string,
timeFrame: string,
eventNames: string[],
): Promise<UmamiEventSeries[]> {
const authHeaders = await this.getAuthHeadersAsync();
const { startAt, endAt, unit } = this.computeTimeRange(timeFrame);
const results = await Promise.all(
eventNames.map(async (eventName) => {
const dataPoints = await this.getWebsiteEventTimeSeriesAsync(
websiteId,
startAt,
endAt,
unit,
eventName,
authHeaders,
);
return { eventName, dataPoints: dataPoints ?? [] };
}),
);
return results;
}
private async getWebsiteEventMetricsAsync(
websiteId: string,
startAt: number,
endAt: number,
authHeaders: Record<string, string>,
): Promise<UmamiMetricItem[]> {
return await this.getWebsiteMetricsAsync(websiteId, startAt, endAt, "event", authHeaders);
}
private async getWebsiteMetricsAsync(
websiteId: string,
startAt: number,
endAt: number,
type: "event" | "path" | "referrer",
authHeaders: Record<string, string>,
limit?: number,
): Promise<UmamiMetricItem[]> {
const params: Record<string, string> = {
startAt: startAt.toString(),
endAt: endAt.toString(),
type,
};
if (limit !== undefined) params.limit = limit.toString();
const url = this.url(`/websites/${websiteId}/metrics`, params);
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
logger.warn("Failed to load website metrics", { status: response.status, websiteId, type });
return [];
}
const json = await response.json();
if (Array.isArray(json)) return umamiMetricItemSchema.array().parse(json);
logger.warn("Unexpected metrics response shape", { websiteId, type });
return [];
}
private async getWebsiteEventTimeSeriesAsync(
websiteId: string,
startAt: number,
endAt: number,
unit: string,
eventName: string,
authHeaders: Record<string, string>,
): Promise<UmamiPageviewDataPoint[] | undefined> {
const url = this.url(`/websites/${websiteId}/events`, {
startAt: startAt.toString(),
endAt: endAt.toString(),
unit,
timezone: "UTC",
eventName,
pageSize: "100000",
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
logger.warn("Failed to load event time series", { status: response.status, websiteId, eventName });
return undefined;
}
const json = await response.json();
const rawArray: unknown[] = Array.isArray(json)
? json
: json && typeof json === "object" && "data" in json && Array.isArray((json as { data: unknown }).data)
? (json as { data: unknown[] }).data
: [];
if (rawArray.length === 0) return undefined;
const firstItem = rawArray[0] as Record<string, unknown>;
// Two response shapes coexist:
// - Umami self-hosted returns aggregated buckets: { x: timestamp, y: count }
// - Umami Cloud returns raw event records: { createdAt, eventName, eventType }
// We aggregate the raw Cloud records ourselves into the same bucket grid so
// the result joins cleanly with /pageviews timestamps.
// Aggregated { x, y } format — parse directly (self-hosted instances)
if ("x" in firstItem && "y" in firstItem) {
const points = umamiPageviewDataPointSchema.array().parse(rawArray);
return points.length === 0 ? undefined : points;
}
// Raw event records — aggregate by time bucket (Umami Cloud)
if ("createdAt" in firstItem) {
const pad = (number: number) => String(number).padStart(2, "0");
// Format must match Umami's pageview x-axis timestamps: "YYYY-MM-DD HH:MM:SS"
const truncate = (isoDate: string): string => {
const date = new Date(isoDate);
const dateString = `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
if (unit === "hour") {
return `${dateString} ${pad(date.getUTCHours())}:00:00`;
}
if (unit === "month") {
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-01 00:00:00`;
}
return `${dateString} 00:00:00`;
};
const buckets = new Map<string, number>();
for (const item of rawArray) {
const event = item as { eventType?: number; eventName?: string; createdAt: string };
if (event.eventType !== UMAMI_CUSTOM_EVENT_TYPE || event.eventName !== eventName) continue;
const bucket = truncate(event.createdAt);
buckets.set(bucket, (buckets.get(bucket) ?? 0) + 1);
}
if (buckets.size === 0) return undefined;
return (
Array.from(buckets.entries())
.sort(([itemA], [itemB]) => itemA.localeCompare(itemB))
// eslint-disable-next-line id-length
.map(([x, y]) => ({ x, y }))
);
}
return undefined;
}
private async getWebsiteStatsAsync(
websiteId: string,
startAt: number,
endAt: number,
authHeaders: Record<string, string>,
): Promise<UmamiStats> {
const url = this.url(`/websites/${websiteId}/stats`, {
startAt: startAt.toString(),
endAt: endAt.toString(),
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
throw new ResponseError(response);
}
return umamiStatsSchema.parse(await response.json());
}
private async getWebsitePageviewsAsync(
websiteId: string,
startAt: number,
endAt: number,
authHeaders: Record<string, string>,
unit = "hour",
): Promise<UmamiPageviews> {
const url = this.url(`/websites/${websiteId}/pageviews`, {
startAt: startAt.toString(),
endAt: endAt.toString(),
unit,
timezone: "UTC",
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
throw new ResponseError(response);
}
return umamiPageviewsSchema.parse(await response.json());
}
private async getAuthHeadersAsync(): Promise<Record<string, string>> {
if (this.hasSecretValue("apiKey")) {
return { "x-umami-api-key": this.getSecretValue("apiKey") };
}
const token = await this.getJwtTokenAsync();
return { Authorization: `Bearer ${token}` };
}
private async getJwtTokenAsync(): Promise<string> {
const url = this.url("/auth/login");
const response = await fetchWithTrustedCertificatesAsync(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
}),
});
if (!response.ok) throw new ResponseError(response);
return umamiAuthResponseSchema.parse(await response.json()).token;
}
}
@@ -1,84 +0,0 @@
import { z } from "zod/v4";
export const umamiWebsiteSchema = z.object({
id: z.string(),
name: z.string(),
domain: z.string(),
});
export type UmamiWebsite = z.infer<typeof umamiWebsiteSchema>;
export const umamiAuthResponseSchema = z.object({
token: z.string(),
});
export type UmamiAuthResponse = z.infer<typeof umamiAuthResponseSchema>;
export const umamiStatsSchema = z.object({
pageviews: z.number(),
visitors: z.number(),
visits: z.number(),
bounces: z.number(),
totaltime: z.number(),
});
export type UmamiStats = z.infer<typeof umamiStatsSchema>;
export const umamiPageviewDataPointSchema = z.object({
x: z.string(),
y: z.number(),
});
export type UmamiPageviewDataPoint = z.infer<typeof umamiPageviewDataPointSchema>;
export const umamiPageviewsSchema = z.object({
pageviews: z.array(umamiPageviewDataPointSchema),
sessions: z.array(umamiPageviewDataPointSchema),
});
export type UmamiPageviews = z.infer<typeof umamiPageviewsSchema>;
export const umamiMetricItemSchema = z.object({
// x can be null for direct traffic (no referrer) in the referrers endpoint
x: z
.string()
.nullable()
.transform((value) => value ?? ""),
y: z.number(),
});
export type UmamiMetricItem = z.infer<typeof umamiMetricItemSchema>;
export const umamiEventRecordSchema = z.object({
id: z.string(),
eventName: z.string(),
createdAt: z.string(),
});
export type UmamiEventRecord = z.infer<typeof umamiEventRecordSchema>;
export const umamiActiveVisitorsSchema = z.object({
visitors: z.number(),
});
export type UmamiActiveVisitors = z.infer<typeof umamiActiveVisitorsSchema>;
// Internal aggregated type returned by the integration — not a direct API response
export interface UmamiEventSeries {
eventName: string;
dataPoints: UmamiPageviewDataPoint[];
}
export interface UmamiVisitorStats {
domain: string;
websiteId: string;
websiteName: string;
totalVisitors: number;
totalPageviews: number;
totalVisits: number;
bounceRate: number;
avgDuration: number;
dataPoints: { timestamp: string; visitors: number; events?: number }[];
timeFrame: string;
eventCount?: number;
}
+1 -1
View File
@@ -24,7 +24,7 @@ export const mapAndDecryptUsers = (importUsers: OldmarrImportUser[], encryptionT
oldId: id,
id: createId(),
name: user.name.toLowerCase(),
colorScheme: settings?.colorScheme === "environment" ? "auto" : settings?.colorScheme,
colorScheme: settings?.colorScheme === "environment" ? undefined : settings?.colorScheme,
firstDayOfWeek: settings?.firstDayOfWeek === "sunday" ? 0 : settings?.firstDayOfWeek === "monday" ? 1 : 6,
provider: "credentials",
pingIconsEnabled: settings?.replacePingWithIcons,
@@ -1,138 +0,0 @@
import type { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods";
import { Octokit } from "octokit";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getAvailableUpdatesAsync } from "../update-checker";
const mockEnv = vi.hoisted(() => ({
NO_EXTERNAL_CONNECTION: false,
}));
vi.mock("@homarr/common/env", () => ({
env: mockEnv,
}));
vi.mock("octokit", () => {
const mockOctokit = {
rest: {
repos: {
listReleases: vi.fn(),
},
},
};
return {
Octokit: class {
rest = mockOctokit.rest;
},
};
});
describe("getAvailableUpdatesAsync", () => {
beforeEach(() => {
mockEnv.NO_EXTERNAL_CONNECTION = false;
});
test("should return available non pre-release updates", async () => {
// Arrange
const currentVersion = "v1.2.3";
const releases = fakeReleasesWithPrereleases();
const listReleasesSpy = vi.spyOn(new Octokit().rest.repos, "listReleases");
listReleasesSpy.mockReturnValue(Promise.resolve({ data: releases, status: 200, headers: {}, url: "" }));
// Act
const result = await getAvailableUpdatesAsync(currentVersion);
// Assert
expect(result.map((update) => update.tagName)).toEqual(["v1.3.0", "v1.2.4"]);
expect(listReleasesSpy).toHaveBeenCalledWith({
owner: "homarr-labs",
repo: "homarr",
});
});
test("should return all available updates", async () => {
// Arrange
const currentVersion = "v1.2.4-beta.2";
const releases = fakeReleasesWithPrereleases();
const listReleasesSpy = vi.spyOn(new Octokit().rest.repos, "listReleases");
listReleasesSpy.mockReturnValue(Promise.resolve({ data: releases, status: 200, headers: {}, url: "" }));
// Act
const result = await getAvailableUpdatesAsync(currentVersion);
// Assert
expect(result.map((update) => update.tagName)).toEqual([
"v1.3.1-beta.1",
"v1.3.0",
"v1.3.0-beta.1",
"v1.2.4",
"v1.2.4-beta.3",
]);
expect(listReleasesSpy).toHaveBeenCalledWith({
owner: "homarr-labs",
repo: "homarr",
query: undefined,
});
});
test("should skip releases with invalid semver tags", async () => {
// Arrange
const currentVersion = "v1.2.3";
const releases = fakeReleases(["v1.2.4", false], ["invalid-tag", false], ["v1.3.0", false]);
const listReleasesSpy = vi.spyOn(new Octokit().rest.repos, "listReleases");
listReleasesSpy.mockReturnValue(Promise.resolve({ data: releases, status: 200, headers: {}, url: "" }));
// Act
const result = await getAvailableUpdatesAsync(currentVersion);
// Assert
expect(result.map((update) => update.tagName)).toEqual(["v1.3.0", "v1.2.4"]);
});
test("should throw error for invalid current version", async () => {
// Arrange
const currentVersion = "invalid-version";
// Act
const act = () => getAvailableUpdatesAsync(currentVersion);
// Assert
await expect(act()).rejects.toThrow("non semantic current version");
});
test("should return empty array when NO_EXTERNAL_CONNECTION=true", async () => {
// Arrange
mockEnv.NO_EXTERNAL_CONNECTION = true;
const currentVersion = "v1.0.0";
// Act
const result = await getAvailableUpdatesAsync(currentVersion);
// Assert
expect(result).toEqual([]);
});
});
const fakeReleases = (...inputs: [string, boolean][]) => inputs.map(fakeRelease);
const fakeRelease = ([tagName, isPrerelease]: [string, boolean]) =>
({
tag_name: tagName,
html_url: `https://github.com/homarr-labs/homarr/releases/tag/${tagName}`,
name: tagName,
body_html: undefined,
prerelease: isPrerelease,
}) as RestEndpointMethodTypes["repos"]["listReleases"]["response"]["data"][number];
const fakeReleasesWithPrereleases = () =>
fakeReleases(
["v1.1.0", false],
["v1.2.2", false],
["v1.2.3", false],
["v1.2.4-beta.1", true],
["v1.2.4-beta.2", true],
["v1.2.4-beta.3", true],
["v1.2.4", false],
["v1.3.0-beta.1", true],
["v1.3.0", false],
["v1.3.1-beta.1", true],
);
-84
View File
@@ -1,84 +0,0 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { UmamiEventSeries, UmamiMetricItem, UmamiVisitorStats } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const umamiRequestHandler = createCachedIntegrationRequestHandler<
UmamiVisitorStats,
"umami",
{ websiteId: string; timeFrame: string; eventName?: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getVisitorStatsAsync(input.websiteId, input.timeFrame, input.eventName);
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "umamiVisitorStats",
});
export const umamiEventNamesRequestHandler = createCachedIntegrationRequestHandler<
string[],
"umami",
{ websiteId: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getEventNamesAsync(input.websiteId);
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "umamiEventNames",
});
export const umamiTopPagesRequestHandler = createCachedIntegrationRequestHandler<
UmamiMetricItem[],
"umami",
{ websiteId: string; timeFrame: string; limit: number }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getTopPagesAsync(input.websiteId, input.timeFrame, input.limit);
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "umamiTopPages",
});
export const umamiTopReferrersRequestHandler = createCachedIntegrationRequestHandler<
UmamiMetricItem[],
"umami",
{ websiteId: string; timeFrame: string; limit: number }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getTopReferrersAsync(input.websiteId, input.timeFrame, input.limit);
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "umamiTopReferrers",
});
export const umamiMultiEventRequestHandler = createCachedIntegrationRequestHandler<
UmamiEventSeries[],
"umami",
{ websiteId: string; timeFrame: string; eventNames: string[] }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getMultiEventTimeSeriesAsync(input.websiteId, input.timeFrame, input.eventNames);
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "umamiMultiEvent",
});
export const umamiActiveVisitorsRequestHandler = createCachedIntegrationRequestHandler<
number,
"umami",
{ websiteId: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getActiveVisitorsAsync(input.websiteId);
},
cacheDuration: dayjs.duration(30, "seconds"),
queryKey: "umamiActiveVisitors",
});
+48 -92
View File
@@ -1,11 +1,10 @@
import dayjs from "dayjs";
import { Octokit } from "octokit";
import { compareSemVer, isValidSemVer, parseSemVer } from "semver-parser";
import { compareSemVer, isValidSemVer } from "semver-parser";
import { env } from "@homarr/common/env";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createChannelWithLatestAndEvents } from "@homarr/redis";
import { createCachedRequestHandler } from "@homarr/request-handler/lib/cached-request-handler";
@@ -17,101 +16,58 @@ export const updateCheckerRequestHandler = createCachedRequestHandler({
queryKey: "homarr-update-checker",
cacheDuration: dayjs.duration(1, "hour"),
async requestAsync(_) {
if (env.NO_EXTERNAL_CONNECTION)
return {
availableUpdates: [],
};
const octokit = new Octokit({
request: {
fetch: fetchWithTrustedCertificatesAsync,
},
});
const releases = await octokit.rest.repos.listReleases({
owner: "homarr-labs",
repo: "homarr",
});
const currentVersion = (packageJson as { version: string }).version;
const availableReleases = [];
for (const release of releases.data) {
if (!isValidSemVer(release.tag_name)) {
logger.warn("Unable to parse semantic tag. Update check might not work.", { tagName: release.tag_name });
continue;
}
availableReleases.push(release);
}
const availableNewerReleases = availableReleases
.filter((release) => compareSemVer(release.tag_name, currentVersion) > 0)
.sort((releaseA, releaseB) => compareSemVer(releaseB.tag_name, releaseA.tag_name));
if (availableNewerReleases.length > 0) {
logger.info(
"Update checker found a new available version",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
{ version: availableReleases[0]!.tag_name, currentVersion },
);
} else {
logger.debug("Update checker did not find any available updates", { currentVersion });
}
return {
availableUpdates: await getAvailableUpdatesAsync(packageJson.version),
availableUpdates: availableNewerReleases.map((release) => ({
name: release.name,
contentHtml: release.body_html,
url: release.html_url,
tagName: release.tag_name,
})),
};
},
createRedisChannel() {
return createChannelWithLatestAndEvents<{
availableUpdates: Update[];
availableUpdates: { name: string | null; contentHtml?: string; url: string; tagName: string }[];
}>("homarr:update");
},
});
interface Update {
name: string | null;
contentHtml?: string;
url: string;
tagName: string;
isPrerelease: boolean;
}
const isPrereleaseTag = (tagName: string) => {
try {
const parsed = parseSemVer(tagName);
return Boolean(parsed.pre?.length);
} catch {
return false;
}
};
export const getAvailableUpdatesAsync = async (currentVersion: string) => {
if (env.NO_EXTERNAL_CONNECTION) return [];
if (!isValidSemVer(currentVersion)) {
throw new ErrorWithMetadata("Unable to check for updates due to non semantic current version", {
currentVersion,
});
}
const octokit = new Octokit({
request: {
fetch: fetchWithTrustedCertificatesAsync,
},
});
const isCurrentPrerelease = isPrereleaseTag(currentVersion);
const releases = await octokit.rest.repos.listReleases({
owner: "homarr-labs",
repo: "homarr",
});
const { skippedTags, semanticReleases } = releases.data
.map((release) => ({
name: release.name,
contentHtml: release.body_html,
url: release.html_url,
tagName: release.tag_name,
isPrerelease: release.prerelease,
}))
.reduce(
(prev, curr) => {
if (!isValidSemVer(curr.tagName)) {
prev.skippedTags.push(curr.tagName);
return prev;
}
prev.semanticReleases.push(curr);
return prev;
},
{ semanticReleases: [] as Update[], skippedTags: [] as string[] },
);
if (skippedTags.length > 0) {
logger.warn(
"Some releases were skipped during the update check because their tag name is not a valid semantic version",
{
skippedTags: skippedTags.join(","),
},
);
}
const availableUpdates = semanticReleases
.filter((release) => isCurrentPrerelease || !release.isPrerelease)
.filter((release) => compareSemVer(release.tagName, currentVersion) > 0)
.sort((releaseA, releaseB) => compareSemVer(releaseB.tagName, releaseA.tagName));
if (availableUpdates.length === 0) {
logger.debug("No available updates found", { currentVersion });
return [];
}
logger.info("Found available updates", {
version: availableUpdates[0]?.tagName,
count: availableUpdates.length,
currentVersion,
});
return availableUpdates;
};
+1 -1
View File
@@ -36,7 +36,7 @@ export const defaultServerSettings = {
enableGravatar: true,
},
appearance: {
defaultColorScheme: "auto" as ColorScheme,
defaultColorScheme: "light" as ColorScheme,
},
culture: {
defaultLocale: "en" as SupportedLanguage,
@@ -1,8 +1,7 @@
import { Group, Text, useComputedColorScheme, useMantineColorScheme } from "@mantine/core";
import { Group, Text, useMantineColorScheme } from "@mantine/core";
import {
IconBox,
IconCategoryPlus,
IconDeviceDesktop,
IconFileImport,
IconLanguage,
IconMailForward,
@@ -53,17 +52,13 @@ export const globalCommandGroup = createGroup<Command>({
useOptions() {
const tOption = useScopedI18n("search.mode.command.group.globalCommand.option");
const { colorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme("light");
const { data: session } = useSession();
const commands: (Command & { hidden?: boolean })[] = [
{
commandKey: "colorScheme",
icon: colorScheme === "auto" ? IconDeviceDesktop : computedColorScheme === "dark" ? IconSun : IconMoon,
name:
colorScheme === "auto"
? tOption("colorScheme.auto")
: tOption(`colorScheme.${computedColorScheme === "dark" ? "light" : "dark"}`),
icon: colorScheme === "dark" ? IconSun : IconMoon,
name: tOption(`colorScheme.${colorScheme === "dark" ? "light" : "dark"}`),
useInteraction: () => {
const { toggleColorScheme } = useMantineColorScheme();
-104
View File
@@ -1080,7 +1080,6 @@
},
"colorScheme": {
"options": {
"auto": "Auto",
"light": "Light",
"dark": "Dark"
}
@@ -2097,104 +2096,6 @@
"internalServerError": "Failed to fetch Tracearr data"
}
},
"umami": {
"name": "Umami Analytics",
"description": "Display visitor stats from your Umami analytics instance",
"visitors": "visitors",
"active": "active",
"pageviews": "pageviews",
"visits": "visits",
"bounceRate": "bounce",
"avgDuration": "avg",
"option": {
"websiteId": {
"label": "Website",
"description": "Select a website tracked by your Umami instance",
"placeholder": "Select a website",
"loading": "Loading websites...",
"noWebsites": "No websites found",
"configureIntegrationFirst": "Connect a Umami integration first"
},
"timeFrame": {
"label": "Time frame",
"description": "The time period to display visitor data for",
"option": {
"today": "Today",
"24h": "Last 24 hours",
"7d": "Last 7 days",
"30d": "Last 30 days",
"month": "This month",
"lastMonth": "Last month"
}
},
"eventName": {
"label": "Event",
"description": "Show total occurrences of a specific event",
"placeholder": "Select an event",
"loading": "Loading events...",
"noEvents": "No events found",
"none": "No event",
"configureWebsiteFirst": "Select a website first to load events"
},
"chartType": {
"label": "Chart type",
"description": "Display style for the chart",
"option": {
"bar": "Bar chart",
"sparkline": "Sparkline"
}
},
"chartStyle": {
"label": "Chart style",
"description": "How to display the event series in the chart",
"option": {
"grouped": "Side by side",
"overlay": "Overlay (subset)"
}
},
"viewMode": {
"label": "View",
"description": "What to display in the widget body",
"option": {
"chart": "Chart",
"events": "Events chart",
"topPages": "Top pages",
"topReferrers": "Top referrers"
}
},
"eventNames": {
"label": "Events",
"description": "Select one or more events to chart",
"placeholder": "Select events...",
"loading": "Loading events...",
"noEvents": "No events found",
"configureWebsiteFirst": "Select a website first to load events"
},
"topCount": {
"label": "Items to show",
"description": "Number of top items to display (max 500)"
}
},
"event": {
"occurrences": "events",
"label": "events"
},
"topPages": {
"direct": "Direct"
},
"topReferrers": {
"direct": "Direct"
},
"events": {
"selectPrompt": "Select events to chart"
},
"error": {
"noIntegration": "No Umami integration configured",
"noWebsite": "No website configured",
"noData": "No data available",
"internalServerError": "Failed to fetch Umami data"
}
},
"common": {
"location": {
"query": "City / Postal code",
@@ -3667,7 +3568,6 @@
"defaultColorScheme": {
"label": "Default color scheme",
"options": {
"auto": "Auto",
"light": "Light",
"dark": "Dark"
}
@@ -3768,9 +3668,6 @@
},
"tracearr": {
"label": "Tracearr"
},
"umamiActiveVisitors": {
"label": "Umami Active Visitors"
}
},
"interval": {
@@ -4483,7 +4380,6 @@
"title": "Global commands",
"option": {
"colorScheme": {
"auto": "Toggle appearance mode",
"light": "Switch to light mode",
"dark": "Switch to dark mode"
},
+2 -2
View File
@@ -3791,8 +3791,8 @@
"subtitle": "{count} contribuendo alle traduzioni in molte lingue"
},
"sponsors": {
"title": "Sponsor",
"subtitle": "{count} sostengono il progetto finanziariamente"
"title": "",
"subtitle": ""
},
"libraries": {
"title": "Librerie",
+88 -88
View File
@@ -1526,13 +1526,13 @@
}
},
"timetable": {
"name": "Timeplan",
"description": "Viser avgangstid for en stasjon",
"title": "Avganger {station}",
"noStation": "Ingen stasjon valgt",
"name": "",
"description": "",
"title": "",
"noStation": "",
"option": {
"station": {
"label": "Stasjon"
"label": ""
}
}
},
@@ -1607,32 +1607,32 @@
}
},
"anchorNote": {
"name": "Ankermerknad",
"description": "Viser et valgt notat fra anker",
"name": "",
"description": "",
"option": {
"noteId": {
"label": "Notat"
"label": ""
},
"showTitle": {
"label": "Vis tittel"
"label": ""
},
"showUpdatedAt": {
"label": "Vis sist oppdatert"
"label": ""
}
},
"empty": "Velg et notat som skal vises",
"emptyContent": "Dette notatet har ikke innhold",
"notFound": "Notat ikke funnet",
"noNotes": "Fant ingen notater",
"integrationRequired": "Velg en ankerintegrasjon først",
"updatedAt": "Oppdatert {date}",
"untitled": "Uten navn",
"edit": "Rediger",
"save": "Lagre",
"cancel": "Avbryt",
"readOnlyViewer": "Du har seertilgang til dette notatet. Redigering er deaktivert.",
"saveForbidden": "Du har ikke tillatelse til å redigere dette notatet.",
"saveFailed": "Kunne ikke lagre dette notatet. Prøv på nytt."
"empty": "",
"emptyContent": "",
"notFound": "",
"noNotes": "",
"integrationRequired": "",
"updatedAt": "",
"untitled": "",
"edit": "",
"save": "",
"cancel": "",
"readOnlyViewer": "",
"saveForbidden": "",
"saveFailed": ""
},
"iframe": {
"name": "iFrame",
@@ -1666,7 +1666,7 @@
"label": "Tillat geolokalisering"
},
"allowModals": {
"label": "Tillat modaler"
"label": ""
}
},
"error": {
@@ -1901,7 +1901,7 @@
"label": "Vis minneinfo"
},
"gpu": {
"label": "Vis CPU-info"
"label": ""
},
"showUptime": {
"label": "Vis oppetid"
@@ -2042,58 +2042,58 @@
}
},
"tracearr": {
"name": "Tracearr",
"description": "Forbedret medieserverovervåking med detaljert strøm- og transkodingsdata",
"name": "",
"description": "",
"option": {
"showStreams": {
"label": "Vis aktive strømmer"
"label": ""
},
"showStats": {
"label": "Vis statistikksammendrag"
"label": ""
},
"showRecentActivity": {
"label": "Vis nylig aktivitet"
"label": ""
},
"showViolations": {
"label": "Vis brudd"
"label": ""
}
},
"stats": {
"activeStreams": "Aktive strømmninger",
"totalUsers": "Totalt antall brukere",
"transcodes": "Transkoder",
"bandwidth": "Båndbredde"
"activeStreams": "",
"totalUsers": "",
"transcodes": "",
"bandwidth": ""
},
"streams": {
"empty": "Ingen aktive strømminger",
"user": "Bruker",
"media": "Media",
"state": "Tilstand",
"quality": "Kvalitet",
"device": "Enhet",
"progress": "Fremgang",
"empty": "",
"user": "",
"media": "",
"state": "",
"quality": "",
"device": "",
"progress": "",
"videoDecision": {
"directPlay": "Direkte avspilling",
"transcode": "Transkode",
"directStream": "Direktestrøm"
"directPlay": "",
"transcode": "",
"directStream": ""
}
},
"recentActivity": {
"title": "Nylig aktivitet",
"empty": "Ingen nylig aktivitet",
"watched": "Sett",
"partial": "Delvis"
"title": "",
"empty": "",
"watched": "",
"partial": ""
},
"violations": {
"title": "Brudd",
"empty": "Ingen nylige brudd",
"rule": "Regel",
"user": "Bruker"
"title": "",
"empty": "",
"rule": "",
"user": ""
},
"noSectionsEnabled": "Ingen seksjoner aktivert",
"noSectionsEnabled": "",
"error": {
"noIntegration": "Ingen Tracearr-integrasjon konfigurert",
"internalServerError": "Kunne ikke hente Tracearr data"
"noIntegration": "",
"internalServerError": ""
}
},
"common": {
@@ -2546,7 +2546,7 @@
"label": "Skjul beskrivelse"
},
"showPosterImage": {
"label": "Vis plakatbilde"
"label": ""
}
}
},
@@ -2761,42 +2761,42 @@
"option": {}
},
"speedtestTracker": {
"name": "Speedtest tracker",
"description": "Viser hastighetstesten i fra din Speedtest Tracker-instans.",
"name": "",
"description": "",
"option": {
"showLatestResult": {
"label": "Vis siste resultat"
"label": ""
},
"showStats": {
"label": "Vis gjennomsnitt"
"label": ""
},
"showRecentResults": {
"label": "Vis nylige resultater"
"label": ""
},
"showPingGraph": {
"label": "Vis ping-graf"
"label": ""
}
},
"latestResult": "Siste resultat",
"averages": "Gjennomsnitt",
"tests": "{count} tester",
"recentResults": "Nylige resultater",
"recentResultsSpeed": "Hastighet",
"recentResultsPing": "Ping",
"download": "Nedlasting",
"upload": "Opplastning",
"ping": "Ping",
"status": "Status",
"avgDownload": "Snitt nedlasting",
"avgUpload": "Snitt opplasting",
"avgPing": "Snitt ping",
"healthy": "Frisk",
"unhealthy": "Usunn",
"ok": "OK",
"fail": "Feil",
"noSectionsEnabled": "Aktiver minst ett avsnitt i widgetalternativer.",
"latestResult": "",
"averages": "",
"tests": "",
"recentResults": "",
"recentResultsSpeed": "",
"recentResultsPing": "",
"download": "",
"upload": "",
"ping": "",
"status": "",
"avgDownload": "",
"avgUpload": "",
"avgPing": "",
"healthy": "",
"unhealthy": "",
"ok": "",
"fail": "",
"noSectionsEnabled": "",
"error": {
"internalServerError": "Kunne ikke hente hastighetsteste data"
"internalServerError": ""
}
},
"systemResources": {
@@ -2812,7 +2812,7 @@
"option": {
"cpu": "CPU",
"memory": "Minne",
"gpu": "GPU",
"gpu": "",
"network": "Nettverk"
}
},
@@ -2829,7 +2829,7 @@
"card": {
"cpu": "CPU",
"memory": "Minne",
"gpu": "GPU",
"gpu": "",
"network": "NETT",
"up": "OPP",
"down": "NED"
@@ -3595,7 +3595,7 @@
"label": "Minecraft serverstatus"
},
"timetable": {
"label": "Timeplan"
"label": ""
},
"iconsUpdater": {
"label": "Ikon oppdatering"
@@ -3667,7 +3667,7 @@
"label": "Vær"
},
"tracearr": {
"label": "Tracearr"
"label": ""
}
},
"interval": {
@@ -3791,8 +3791,8 @@
"subtitle": "{count} bidrar med oversettelser på mange språk"
},
"sponsors": {
"title": "Sponsorer",
"subtitle": "{count} støtter prosjektet økonomisk"
"title": "",
"subtitle": ""
},
"libraries": {
"title": "Biblioteker",
-6
View File
@@ -12,9 +12,6 @@ import { WidgetSliderInput } from "./widget-slider-input";
import { WidgetSortedItemListInput } from "./widget-sortable-item-list-input";
import { WidgetSwitchInput } from "./widget-switch-input";
import { WidgetTextInput } from "./widget-text-input";
import { WidgetUmamiEventInput } from "./widget-umami-event-input";
import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input";
import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input";
const mapping = {
anchorNote: WidgetAnchorNoteInput,
@@ -30,9 +27,6 @@ const mapping = {
sortableItemList: WidgetSortedItemListInput,
multiReleasesRepositories: WidgetMultiReleasesRepositoriesInput,
dynamicSelect: WidgetDynamicSelectInput,
umamiEventName: WidgetUmamiEventInput,
umamiEventNames: WidgetUmamiEventNamesInput,
umamiWebsite: WidgetUmamiWebsiteInput,
} satisfies Record<WidgetOptionType, unknown>;
export const getInputForType = <TType extends WidgetOptionType>(type: TType) => {
@@ -1,46 +0,0 @@
"use client";
import { Select, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetUmamiEventInput = ({ property, kind }: CommonWidgetInputProps<"umamiEventName">) => {
const t = useScopedI18n("widget.umami.option.eventName");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const integrationIds = form.values.integrationIds;
const websiteId = form.values.options.websiteId as string | undefined;
const { data: eventNames, isPending } = clientApi.widget.umami.getEventNames.useQuery(
{ integrationId: integrationIds[0] ?? "", websiteId: websiteId ?? "" },
{ enabled: Boolean(integrationIds[0]) && Boolean(websiteId) },
);
if (!websiteId || integrationIds.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("configureWebsiteFirst")}
</Text>
);
}
const data = [{ value: "", label: t("none") }, ...(eventNames ?? [])];
return (
<Select
label={tInput("label")}
description={tInput("description")}
placeholder={isPending ? t("loading") : undefined}
searchable
nothingFoundMessage={t("noEvents")}
data={data}
{...form.getInputProps(`options.${property}`)}
/>
);
};
@@ -1,44 +0,0 @@
"use client";
import { MultiSelect, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetUmamiEventNamesInput = ({ property, kind }: CommonWidgetInputProps<"umamiEventNames">) => {
const t = useScopedI18n("widget.umami.option.eventNames");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const integrationIds = form.values.integrationIds;
const websiteId = form.values.options.websiteId as string | undefined;
const { data: eventNames, isPending } = clientApi.widget.umami.getEventNames.useQuery(
{ integrationId: integrationIds[0] ?? "", websiteId: websiteId ?? "" },
{ enabled: Boolean(integrationIds[0]) && Boolean(websiteId) },
);
if (!websiteId || integrationIds.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("configureWebsiteFirst")}
</Text>
);
}
return (
<MultiSelect
label={tInput("label")}
description={tInput("description")}
placeholder={isPending ? t("loading") : t("placeholder")}
searchable
nothingFoundMessage={t("noEvents")}
data={eventNames ?? []}
{...form.getInputProps(`options.${property}`)}
/>
);
};
@@ -1,44 +0,0 @@
"use client";
import { Select, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetUmamiWebsiteInput = ({ property, kind }: CommonWidgetInputProps<"umamiWebsite">) => {
const t = useScopedI18n("widget.umami.option.websiteId");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const integrationIds = form.values.integrationIds;
const { data: websites, isPending } = clientApi.widget.umami.getWebsites.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ enabled: Boolean(integrationIds[0]) },
);
if (integrationIds.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("configureIntegrationFirst")}
</Text>
);
}
return (
<Select
label={tInput("label")}
description={tInput("description")}
placeholder={isPending ? t("loading") : t("placeholder")}
clearable
searchable
nothingFoundMessage={t("noWebsites")}
data={(websites ?? []).map((site) => ({ value: site.id, label: site.domain }))}
{...form.getInputProps(`options.${property}`)}
/>
);
};
-2
View File
@@ -46,7 +46,6 @@ import * as systemDisks from "./system-disks";
import * as systemResources from "./system-resources";
import * as timetable from "./timetable";
import * as tracearr from "./tracearr";
import * as umami from "./umami";
import * as video from "./video";
import * as weather from "./weather";
@@ -92,7 +91,6 @@ export const widgetImports = {
"immich-albumCarousel": immichAlbumCarousel,
tracearr,
speedtestTracker,
umami,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;
-15
View File
@@ -160,21 +160,6 @@ const optionsFactory = {
defaultValue: "",
withDescription: false,
}),
umamiEventName: () => ({
type: "umamiEventName" as const,
defaultValue: "",
withDescription: true,
}),
umamiEventNames: () => ({
type: "umamiEventNames" as const,
defaultValue: [] as string[],
withDescription: true,
}),
umamiWebsite: () => ({
type: "umamiWebsite" as const,
defaultValue: "",
withDescription: true,
}),
sortableItemList: <const TItem, const TOptionValue extends UniqueIdentifier>(
input: SortableItemListInput<TItem, TOptionValue>,
) => ({
@@ -76,7 +76,6 @@ export default function SmartHomeEntityStateWidget({
return (
<UnstyledButton
mod={{ "entity-state": entityState, "entity-id": options.entityId }}
onClick={handleClick}
w="100%"
h="100%"
@@ -2,7 +2,7 @@
import type { ReactNode } from "react";
import type { AreaChartSeries } from "@mantine/charts";
import { AreaChart, LineChart } from "@mantine/charts";
import { Card, Center, Group, Loader, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
import { Card, Center, Group, Loader, Stack, Text, useMantineColorScheme, useMantineTheme } from "@mantine/core";
import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
import type { TooltipProps, YAxisProps } from "recharts";
@@ -36,13 +36,14 @@ export const CommonChart = ({
}) => {
const { ref: elementSizeRef, height } = useElementSize();
const theme = useMantineTheme();
const colorScheme = useComputedColorScheme("light");
const scheme = useMantineColorScheme();
const board = useRequiredBoard();
const { hovered, ref: hoverRef } = useHover();
const ref = useMergedRef(elementSizeRef, hoverRef);
const opacity = board.opacity / 100;
const backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
const backgroundColor =
scheme.colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
const ChartComponent = chartType === "line" ? LineChart : AreaChart;
const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
-39
View File
@@ -1,39 +0,0 @@
"use client";
import { Stack, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { UmamiContent } from "./umami-content";
export default function UmamiWidget({ options, integrationIds }: WidgetComponentProps<"umami">) {
if (!options.websiteId || options.websiteId.trim() === "") {
return <NoWebsiteConfigured />;
}
return (
<UmamiContent
integrationIds={integrationIds}
websiteId={options.websiteId}
timeFrame={options.timeFrame}
eventName={options.eventName || undefined}
eventNames={options.eventNames}
chartStyle={options.chartStyle}
chartType={options.chartType}
viewMode={options.viewMode}
topCount={options.topCount}
/>
);
}
function NoWebsiteConfigured() {
const t = useScopedI18n("widget.umami");
return (
<Stack align="center" justify="center" h="100%">
<Text c="dimmed" size="sm">
{t("error.noWebsite")}
</Text>
</Stack>
);
}
-88
View File
@@ -1,88 +0,0 @@
import { IconChartBar, IconServerOff } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const timeFrameValues = ["today", "24h", "7d", "30d", "month", "lastMonth"] as const;
export const { definition, componentLoader } = createWidgetDefinition("umami", {
icon: IconChartBar,
supportedIntegrations: ["umami"],
createOptions() {
return optionsBuilder.from(
(factory) => ({
websiteId: factory.umamiWebsite(),
timeFrame: factory.select({
options: timeFrameValues.map((value) => ({
value,
label: (t) => t(`widget.umami.option.timeFrame.option.${value}`),
})),
defaultValue: "24h",
withDescription: true,
}),
viewMode: factory.select({
options: [
{ value: "chart", label: (t) => t("widget.umami.option.viewMode.option.chart") },
{ value: "events", label: (t) => t("widget.umami.option.viewMode.option.events") },
{ value: "topPages", label: (t) => t("widget.umami.option.viewMode.option.topPages") },
{ value: "topReferrers", label: (t) => t("widget.umami.option.viewMode.option.topReferrers") },
] as const,
defaultValue: "chart",
withDescription: true,
}),
chartType: factory.select({
options: [
{ value: "bar", label: (t) => t("widget.umami.option.chartType.option.bar") },
{ value: "sparkline", label: (t) => t("widget.umami.option.chartType.option.sparkline") },
] as const,
defaultValue: "bar",
withDescription: true,
}),
chartStyle: factory.select({
options: [
{ value: "grouped", label: (t) => t("widget.umami.option.chartStyle.option.grouped") },
{ value: "overlay", label: (t) => t("widget.umami.option.chartStyle.option.overlay") },
] as const,
defaultValue: "grouped",
withDescription: true,
}),
eventName: factory.umamiEventName(),
eventNames: factory.umamiEventNames(),
topCount: factory.number({
validate: z.number().int().min(1).max(500),
defaultValue: 5,
step: 1,
withDescription: true,
}),
}),
{
viewMode: {
shouldHide: (options) => !options.websiteId,
},
chartType: {
shouldHide: (options) =>
!options.websiteId || (options.viewMode !== "chart" && options.viewMode !== "events"),
},
chartStyle: {
shouldHide: (options) => options.viewMode !== "chart" || !options.eventName || options.chartType !== "bar",
},
eventName: {
shouldHide: (options) => !options.websiteId || options.viewMode !== "chart",
},
eventNames: {
shouldHide: (options) => !options.websiteId || options.viewMode !== "events",
},
topCount: {
shouldHide: (options) => !options.websiteId || options.viewMode === "chart" || options.viewMode === "events",
},
},
);
},
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.umami.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
@@ -1,220 +0,0 @@
"use client";
import { BarChart, LineChart } from "@mantine/charts";
import { Box, Group, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { formatDuration } from "@homarr/common";
import type { UmamiVisitorStats } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { UmamiEventsContent } from "./umami-events-content";
import { UmamiTopPagesContent, UmamiTopReferrersContent } from "./umami-top-list";
import { formatTimeFrameLabel, formatXLabel } from "./umami-utils";
import type { TimeFrame } from "./umami-utils";
interface UmamiContentProps {
integrationIds: string[];
websiteId: string;
timeFrame: TimeFrame;
eventName?: string;
eventNames: string[];
chartStyle: string;
chartType: string;
viewMode: string;
topCount: number;
}
export function UmamiContent({
integrationIds,
websiteId,
timeFrame,
eventName,
eventNames,
chartStyle,
chartType,
viewMode,
topCount,
}: UmamiContentProps) {
const t = useScopedI18n("widget.umami");
const { colorScheme } = useMantineColorScheme();
const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057";
const [results] = clientApi.widget.umami.getVisitorStats.useSuspenseQuery({
integrationIds,
websiteId,
timeFrame,
eventName,
});
const activeVisitorsInput = { integrationId: integrationIds[0] ?? "", websiteId };
const [activeVisitors] = clientApi.widget.umami.getActiveVisitors.useSuspenseQuery(activeVisitorsInput);
const utils = clientApi.useUtils();
clientApi.widget.umami.subscribeActiveVisitors.useSubscription(activeVisitorsInput, {
onData(count) {
utils.widget.umami.getActiveVisitors.setData(activeVisitorsInput, () => count);
},
});
const { data: multiEventSeries } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].sort() },
{ enabled: viewMode === "events" && eventNames.length > 0 },
);
const multiEventTotal = multiEventSeries
? multiEventSeries.flatMap(({ dataPoints }) => dataPoints).reduce((sum, { y }) => sum + y, 0)
: undefined;
const firstResult = results[0];
if (!firstResult) {
return (
<Stack align="center" justify="center" h="100%">
<Text c="dimmed" size="sm">
{t("error.noData")}
</Text>
</Stack>
);
}
const { visitorStats } = firstResult;
const chartData = visitorStats.dataPoints.map((point: UmamiVisitorStats["dataPoints"][number]) => ({
label: formatXLabel(point.timestamp, timeFrame),
visitors: point.visitors,
...(point.events !== undefined ? { events: point.events } : {}),
}));
const hasEventSeries = visitorStats.dataPoints.some(({ events }) => events !== undefined);
const series = [
{ name: "visitors", color: "blue.5" },
...(hasEventSeries ? [{ name: "events", color: "orange.5" }] : []),
];
const isOverlay = chartStyle === "overlay" && hasEventSeries;
const overlayBarSize = 16;
return (
<Stack gap={4} p="xs" h="100%">
<Group justify="space-between" align="baseline" wrap="nowrap">
<Text size="xs" c="dimmed" truncate="end" style={{ maxWidth: "55%" }}>
{visitorStats.domain} ({formatTimeFrameLabel(timeFrame, t)})
</Text>
{(eventName && viewMode === "chart" && visitorStats.eventCount !== undefined) ||
(viewMode === "events" && multiEventTotal !== undefined) ? (
<Group gap={6} align="baseline" wrap="nowrap">
<Group gap={4} align="baseline">
<Text size="lg" fw={700} lh={1}>
{viewMode === "events"
? (multiEventTotal ?? 0).toLocaleString()
: (visitorStats.eventCount ?? 0).toLocaleString()}
</Text>
<Text size="xs" c="dimmed">
{t("event.label")}
</Text>
</Group>
<Text size="xs" c="dimmed">
/
</Text>
<Group gap={4} align="baseline">
<Text size="lg" fw={700} lh={1}>
{visitorStats.totalVisitors.toLocaleString()}
</Text>
<Text size="xs" c="dimmed">
{t("visitors")}
</Text>
</Group>
</Group>
) : (
<Group gap={4} align="baseline">
<Text size="xl" fw={700} lh={1}>
{visitorStats.totalVisitors.toLocaleString()}
</Text>
<Text size="xs" c="dimmed">
{t("visitors")}
</Text>
</Group>
)}
</Group>
<Group wrap="wrap" style={{ columnGap: 12, rowGap: 4 }}>
<Text size="xs" c="green">
● {activeVisitors.toLocaleString()} {t("active")}
</Text>
<Text size="xs" c="dimmed">
{visitorStats.totalPageviews.toLocaleString()} {t("pageviews")}
</Text>
<Text size="xs" c="dimmed">
{visitorStats.totalVisits.toLocaleString()} {t("visits")}
</Text>
<Text size="xs" c="dimmed">
{visitorStats.bounceRate}% {t("bounceRate")}
</Text>
<Text size="xs" c="dimmed">
{formatDuration(visitorStats.avgDuration * 1000)} {t("avgDuration")}
</Text>
</Group>
<Box mt={4} style={{ flex: 1, minHeight: 0 }}>
{viewMode === "events" ? (
<UmamiEventsContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
eventNames={eventNames}
chartType={chartType}
/>
) : viewMode === "topPages" ? (
<UmamiTopPagesContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
) : viewMode === "topReferrers" ? (
<UmamiTopReferrersContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
) : chartType === "sparkline" ? (
<LineChart
h="100%"
data={chartData}
dataKey="label"
series={series}
withDots={false}
curveType="monotone"
tickLine="none"
gridAxis="none"
withLegend={false}
withTooltip
withXAxis
withYAxis={false}
xAxisProps={{
tick: { fontSize: 9, fill: tickColor },
interval: "preserveStartEnd",
}}
/>
) : (
<BarChart
h="100%"
data={chartData}
dataKey="label"
series={series}
tickLine="none"
gridAxis="none"
withLegend={false}
withTooltip
withXAxis
withYAxis={false}
barProps={isOverlay ? { barSize: overlayBarSize, radius: 2 } : { radius: 2 }}
barChartProps={isOverlay ? { barGap: -overlayBarSize } : undefined}
xAxisProps={{
tick: { fontSize: 9, fill: tickColor },
interval: "preserveStartEnd",
}}
/>
)}
</Box>
</Stack>
);
}
@@ -1,138 +0,0 @@
"use client";
import { BarChart, LineChart } from "@mantine/charts";
import { Box, Group, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { UmamiEventSeries } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { EVENT_COLORS, formatXLabel } from "./umami-utils";
interface UmamiEventsContentProps {
integrationIds: string[];
websiteId: string;
timeFrame: string;
eventNames: string[];
chartType: string;
}
export function UmamiEventsContent({
integrationIds,
websiteId,
timeFrame,
eventNames,
chartType,
}: UmamiEventsContentProps) {
const t = useScopedI18n("widget.umami");
const { colorScheme } = useMantineColorScheme();
const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057";
const [series] = clientApi.widget.umami.getMultiEventTimeSeries.useSuspenseQuery({
integrationId: integrationIds[0] ?? "",
websiteId,
timeFrame,
eventNames: [...eventNames].sort(),
});
if (eventNames.length === 0) {
return (
<Stack align="center" justify="center" h="100%">
<Text c="dimmed" size="sm">
{t("events.selectPrompt")}
</Text>
</Stack>
);
}
// Collect all unique timestamps across all series
const allTimestamps = Array.from(
new Set(series.flatMap(({ dataPoints }) => dataPoints.map(({ x: xPoint }) => xPoint))),
).sort();
// Build per-event lookup by timestamp string
const byEvent = new Map(
series.map((serie: UmamiEventSeries) => [
serie.eventName,
new Map(serie.dataPoints.map((point) => [point.x, point.y])),
]),
);
const chartData = allTimestamps.map((timestamp) => {
const row: Record<string, string | number> = { label: formatXLabel(timestamp, timeFrame) };
for (const serie of series) {
row[serie.eventName] = byEvent.get(serie.eventName)?.get(timestamp) ?? 0;
}
return row;
});
const chartSeries = series.map((serie: UmamiEventSeries, index: number) => ({
name: serie.eventName,
color: EVENT_COLORS[index % EVENT_COLORS.length] ?? "blue.5",
}));
return (
<Stack gap={4} h="100%">
{chartSeries.length > 1 && (
<Group gap={12} justify="center" wrap="wrap" style={{ flexShrink: 0, rowGap: 2 }}>
{chartSeries.map((serie) => (
<Group key={serie.name} gap={4} align="center" wrap="nowrap">
<Box
w={8}
h={8}
style={{
borderRadius: "50%",
backgroundColor: `var(--mantine-color-${serie.color.replace(".", "-")})`,
flexShrink: 0,
}}
/>
<Text size="xs" c="dimmed" truncate>
{serie.name}
</Text>
</Group>
))}
</Group>
)}
<Box style={{ flex: 1, minHeight: 0 }}>
{chartType === "sparkline" ? (
<LineChart
h="100%"
data={chartData}
dataKey="label"
series={chartSeries}
withDots={false}
curveType="monotone"
tickLine="none"
gridAxis="none"
withLegend={false}
withTooltip
withXAxis
withYAxis={false}
xAxisProps={{
tick: { fontSize: 9, fill: tickColor },
interval: "preserveStartEnd",
}}
/>
) : (
<BarChart
h="100%"
data={chartData}
dataKey="label"
series={chartSeries}
tickLine="none"
gridAxis="none"
withLegend={false}
withTooltip
withXAxis
withYAxis={false}
barProps={{ radius: 2 }}
xAxisProps={{
tick: { fontSize: 9, fill: tickColor },
interval: "preserveStartEnd",
}}
/>
)}
</Box>
</Stack>
);
}
@@ -1,77 +0,0 @@
"use client";
import { Group, ScrollArea, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { UmamiMetricItem } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
interface UmamiTopListProps {
integrationIds: string[];
websiteId: string;
timeFrame: string;
limit: number;
}
export function UmamiTopPagesContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) {
const t = useScopedI18n("widget.umami");
const [data] = clientApi.widget.umami.getTopPages.useSuspenseQuery({
integrationId: integrationIds[0] ?? "",
websiteId,
timeFrame,
limit,
});
return <UmamiTopList items={data} heading={t("option.viewMode.option.topPages")} emptyLabel={t("topPages.direct")} />;
}
export function UmamiTopReferrersContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) {
const t = useScopedI18n("widget.umami");
const [data] = clientApi.widget.umami.getTopReferrers.useSuspenseQuery({
integrationId: integrationIds[0] ?? "",
websiteId,
timeFrame,
limit,
});
return (
<UmamiTopList
items={data}
heading={t("option.viewMode.option.topReferrers")}
emptyLabel={t("topReferrers.direct")}
/>
);
}
function UmamiTopList({
items,
heading,
emptyLabel,
}: {
items: UmamiMetricItem[];
heading: string;
emptyLabel: string;
}) {
return (
<Stack gap={2} h="100%">
<Text size="xs" c="dimmed" fw={500}>
{heading}
</Text>
<ScrollArea style={{ flex: 1 }} scrollbars="y">
<Stack gap={2}>
{items.map((item, i) => (
<Group key={item.x} gap="xs" wrap="nowrap" px={2}>
<Text size="xs" c="dimmed" w={18} ta="right" flex="0 0 auto">
{i + 1}.
</Text>
<Text size="xs" truncate="end" style={{ flex: 1 }}>
{item.x || emptyLabel}
</Text>
<Text size="xs" fw={600} flex="0 0 auto">
{item.y.toLocaleString()}
</Text>
</Group>
))}
</Stack>
</ScrollArea>
</Stack>
);
}
-33
View File
@@ -1,33 +0,0 @@
import type { useScopedI18n } from "@homarr/translation/client";
import { timeFrameValues } from "./index";
export const EVENT_COLORS = ["blue.5", "orange.5", "green.5", "red.5", "violet.5", "teal.5", "yellow.5", "pink.5"];
export function formatXLabel(timestamp: string, timeFrame: string): string {
const date = new Date(timestamp);
switch (timeFrame) {
case "today":
case "24h":
return `${date.getHours().toString().padStart(2, "0")}:00`;
case "7d":
case "30d":
case "month":
case "lastMonth":
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
default:
return timestamp;
}
}
export type TimeFrame = (typeof timeFrameValues)[number];
export function formatTimeFrameLabel(
timeFrame: TimeFrame,
t: ReturnType<typeof useScopedI18n<"widget.umami">>,
): string {
if (timeFrameValues.includes(timeFrame)) {
return t(`option.timeFrame.option.${timeFrame}`);
}
return timeFrame;
}
+1050 -1113
View File
File diff suppressed because it is too large. Load diff
+42 -43
View File
@@ -2,6 +2,11 @@ packages:
- apps/*
- packages/*
- tooling/*
minimumReleaseAge: 4320
minimumReleaseAgeExclude:
- next
- "@next/*"
catalogMode: strict
catalog:
"@auth/core": ^0.41.2
"@auth/drizzle-adapter": ^1.11.2
@@ -13,7 +18,6 @@ catalog:
"@dnd-kit/sortable": ^10.0.0
"@dnd-kit/utilities": ^3.2.2
"@drizzle-team/brocli": ^0.12.0
'@eslint/js': 10.0.1
"@extractus/feed-extractor": 7.1.7
"@gitbeaker/rest": ^43.8.0
"@homarr/gridstack": ^1.12.0
@@ -45,34 +49,34 @@ catalog:
"@semantic-release/release-notes-generator": ^14.1.0
"@t3-oss/env-nextjs": ^0.13.11
"@tabler/icons-react": ^3.41.1
"@tanstack/react-query": ^5.100.8
"@tanstack/react-query-devtools": ^5.100.8
"@tanstack/react-query-next-experimental": ^5.100.8
"@tanstack/react-query": ^5.100.5
"@tanstack/react-query-devtools": ^5.100.5
"@tanstack/react-query-next-experimental": ^5.100.5
"@testcontainers/mysql": ^11.14.0
"@testcontainers/postgresql": ^11.14.0
"@testcontainers/redis": ^11.14.0
"@tiptap/extension-color": 3.22.5
"@tiptap/extension-highlight": 3.22.5
"@tiptap/extension-image": 3.22.5
"@tiptap/extension-link": ^3.22.5
"@tiptap/extension-placeholder": ^3.22.5
"@tiptap/extension-table": 3.22.5
"@tiptap/extension-table-cell": 3.22.5
"@tiptap/extension-table-header": 3.22.5
"@tiptap/extension-table-row": 3.22.5
"@tiptap/extension-task-item": 3.22.5
"@tiptap/extension-task-list": 3.22.5
"@tiptap/extension-text-align": 3.22.5
"@tiptap/extension-text-style": 3.22.5
"@tiptap/extension-underline": 3.22.5
"@tiptap/react": ^3.22.5
"@tiptap/starter-kit": ^3.22.5
"@trpc/client": ^11.17.0
"@trpc/next": ^11.17.0
"@trpc/react-query": ^11.17.0
"@trpc/server": ^11.17.0
"@trpc/tanstack-react-query": ^11.17.0
"@turbo/gen": ^2.9.7
"@tiptap/extension-color": 3.22.4
"@tiptap/extension-highlight": 3.22.4
"@tiptap/extension-image": 3.22.4
"@tiptap/extension-link": ^3.22.4
"@tiptap/extension-placeholder": ^3.22.4
"@tiptap/extension-table": 3.22.4
"@tiptap/extension-table-cell": 3.22.4
"@tiptap/extension-table-header": 3.22.4
"@tiptap/extension-table-row": 3.22.4
"@tiptap/extension-task-item": 3.22.4
"@tiptap/extension-task-list": 3.22.4
"@tiptap/extension-text-align": 3.22.4
"@tiptap/extension-text-style": 3.22.4
"@tiptap/extension-underline": 3.22.4
"@tiptap/react": ^3.22.4
"@tiptap/starter-kit": ^3.22.4
"@trpc/client": ^11.16.0
"@trpc/next": ^11.16.0
"@trpc/react-query": ^11.16.0
"@trpc/server": ^11.16.0
"@trpc/tanstack-react-query": ^11.16.0
"@turbo/gen": ^2.9.6
"@types/adm-zip": 0.5.8
"@types/bcrypt": 6.0.0
"@types/better-sqlite3": 7.6.13
@@ -121,9 +125,9 @@ catalog:
drizzle-orm: ^0.45.2
drizzle-zod: ^0.8.3
esbuild: ^0.28.0
eslint: ^10.3.0
eslint: ^9.39.4
eslint-config-prettier: ^10.1.8
eslint-config-turbo: ^2.9.7
eslint-config-turbo: ^2.9.6
eslint-plugin-import: ^2.32.0
eslint-plugin-jsx-a11y: ^6.10.2
eslint-plugin-react: ^7.37.5
@@ -134,19 +138,19 @@ catalog:
glob: ^13.0.6
ical.js: ^2.2.1
ioredis: 5.10.1
isomorphic-dompurify: ^3.12.0
isomorphic-dompurify: ^3.10.0
jotai: ^2.19.1
jsdom: ^29.1.1
jsdom: ^29.0.2
json5: ^2.2.3
ldapts: 8.1.7
lodash.clonedeep: ^4.5.0
mantine-form-zod-resolver: ^1.3.0
mantine-react-table: 2.0.0-beta.9
maria2: ^0.4.1
mysql2: 3.22.3
mysql2: 3.22.2
next: 16.2.4
next-auth: 5.0.0-beta.31
next-intl: 4.11.0
next-intl: 4.9.1
node-loader: ^2.1.0
octokit: ^5.0.5
pg: ^8.20.0
@@ -167,13 +171,13 @@ catalog:
semver-parser: ^4.1.8
superjson: 2.2.6
svgson: ^5.3.1
swagger-ui-react: ^5.32.5
swagger-ui-react: ^5.32.4
testcontainers: ^11.14.0
timezones-ical-library: ^2.2.0
timezones-ical-library: ^2.1.3
trpc-to-openapi: ^3.2.0
tsdav: ^2.2.0
tsdav: ^2.1.8
tsx: 4.20.4
turbo: ^2.9.7
turbo: ^2.9.6
typescript: ^5.9.3
typescript-eslint: ^8.58.2
undici: 7.24.6
@@ -184,12 +188,7 @@ catalog:
winston: 3.19.0
ws: ^8.20.0
xml2js: ^0.6.2
yaml: 2.8.4
zod: ^4.4.2
yaml: 2.8.3
zod: ^4.3.6
zod-form-data: ^3.0.1
zod-validation-error: ^5.0.0
catalogMode: strict
minimumReleaseAge: 4320
minimumReleaseAgeExclude:
- next
- "@next/*"
+1 -11
View File
@@ -16,8 +16,6 @@ fi
# Auth secret is generated every time the container starts as it is required, but not used because we don't need JWTs or Mail hashing
export AUTH_SECRET=$(openssl rand -base64 32)
# Cron job API key is generated every time the container starts as it is required for communication between nextjs-api and tasks-api
export CRON_JOB_API_KEY=$(openssl rand -base64 32)
# Support secret file for encryption key (is not empty and file exists)
if [ -n "$SECRET_ENCRYPTION_KEY_FILE" ] && [ -f "$SECRET_ENCRYPTION_KEY_FILE" ]; then
@@ -43,21 +41,13 @@ else
REDIS_PID=$!
fi
node apps/tasks/tasks.cjs &
TASKS_PID=$!
node apps/websocket/wssServer.cjs &
WSS_PID=$!
node apps/nextjs/server.js &
NEXTJS_PID=$!
# Function to handle SIGTERM and shut down services
terminate() {
echo "Received SIGTERM. Shutting down..."
kill -TERM $NGINX_PID $TASKS_PID $WSS_PID $NEXTJS_PID 2>/dev/null
kill -TERM $NGINX_PID $NEXTJS_PID 2>/dev/null
wait
# kill redis-server last because of logging of other services and only if $REDIS_PID is set
if [ -n "$REDIS_PID" ]; then
+3 -2
View File
@@ -1,9 +1,10 @@
/// <reference types="./types.d.ts" />
import eslint from "@eslint/js";
import importPlugin from "eslint-plugin-import";
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
export default defineConfig(
export default tseslint.config(
{
// Globally ignored files
ignores: ["**/*.config.js", "**/*.cjs", "**/lang/*.d.json.ts"],
-1
View File
@@ -27,7 +27,6 @@
"typescript-eslint": "catalog:"
},
"devDependencies": {
"@eslint/js": "catalog:",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "catalog:",
-2
View File
@@ -1,5 +1,3 @@
/// <reference types="./types.d.ts" />
import reactPlugin from "eslint-plugin-react";
import hooksPlugin from "eslint-plugin-react-hooks";
+45
View File
@@ -1,3 +1,38 @@
/**
* Since the ecosystem hasn't fully migrated to ESLint's new FlatConfig system yet,
* we "need" to type some of the plugins manually :(
*/
declare module "@eslint/js" {
// Why the hell doesn't eslint themselves export their types?
import type { Linter } from "eslint";
export const configs: {
readonly recommended: { readonly rules: Readonly<Linter.RulesRecord> };
readonly all: { readonly rules: Readonly<Linter.RulesRecord> };
};
}
declare module "eslint-plugin-import" {
import type { Linter, Rule } from "eslint";
export const configs: {
recommended: { rules: Linter.RulesRecord };
};
export const rules: Record<string, Rule.RuleModule>;
}
declare module "eslint-plugin-react" {
import type { Linter, Rule } from "eslint";
export const configs: {
recommended: { rules: Linter.RulesRecord };
all: { rules: Linter.RulesRecord };
"jsx-runtime": { rules: Linter.RulesRecord };
};
export const rules: Record<string, Rule.RuleModule>;
}
declare module "eslint-plugin-react-hooks" {
import type { Linter, Rule } from "eslint";
@@ -11,3 +46,13 @@ declare module "eslint-plugin-react-hooks" {
};
export const rules: Record<string, Rule.RuleModule>;
}
declare module "@next/eslint-plugin-next" {
import type { Linter, Rule } from "eslint";
export const configs: {
recommended: { rules: Linter.RulesRecord };
"core-web-vitals": { rules: Linter.RulesRecord };
};
export const rules: Record<string, Rule.RuleModule>;
}