From 1eb47311fa81184bd67830d0669d9f87d9922e70 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Mon, 7 Jul 2025 17:04:45 +0200 Subject: [PATCH] feat(ping): ignore certificate error and show request duration (#3546) --- apps/tasks/package.json | 1 - packages/api/package.json | 1 - .../api/src/router/test/widgets/app.spec.ts | 53 ------------------- packages/api/src/router/widgets/app.ts | 44 ++++++++------- packages/cron-jobs/package.json | 1 - packages/cron-jobs/src/jobs/ping.ts | 18 ++----- packages/ping/eslint.config.js | 4 -- packages/ping/index.ts | 1 - packages/ping/package.json | 36 ------------- packages/ping/src/index.ts | 34 ------------ packages/ping/tsconfig.json | 9 ---- packages/redis/src/index.ts | 4 -- packages/request-handler/package.json | 3 +- packages/request-handler/src/ping.ts | 50 +++++++++++++++++ packages/widgets/src/app/component.tsx | 9 +--- .../widgets/src/app/ping/ping-indicator.tsx | 26 ++++----- pnpm-lock.yaml | 40 ++------------ 17 files changed, 94 insertions(+), 240 deletions(-) delete mode 100644 packages/api/src/router/test/widgets/app.spec.ts delete mode 100644 packages/ping/eslint.config.js delete mode 100644 packages/ping/index.ts delete mode 100644 packages/ping/package.json delete mode 100644 packages/ping/src/index.ts delete mode 100644 packages/ping/tsconfig.json create mode 100644 packages/request-handler/src/ping.ts diff --git a/apps/tasks/package.json b/apps/tasks/package.json index 56733ff3b..2c193f0c5 100644 --- a/apps/tasks/package.json +++ b/apps/tasks/package.json @@ -30,7 +30,6 @@ "@homarr/icons": "workspace:^0.1.0", "@homarr/integrations": "workspace:^0.1.0", "@homarr/log": "workspace:^", - "@homarr/ping": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", diff --git a/packages/api/package.json b/packages/api/package.json index 6442cb2bf..bd34e92f0 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -35,7 +35,6 @@ "@homarr/log": "workspace:^", "@homarr/old-import": "workspace:^0.1.0", "@homarr/old-schema": "workspace:^0.1.0", - "@homarr/ping": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0", "@homarr/request-handler": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0", diff --git a/packages/api/src/router/test/widgets/app.spec.ts b/packages/api/src/router/test/widgets/app.spec.ts deleted file mode 100644 index 3aacd2f59..000000000 --- a/packages/api/src/router/test/widgets/app.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; - -import type { Session } from "@homarr/auth"; -import { createDb } from "@homarr/db/test"; -import * as ping from "@homarr/ping"; - -import { appRouter } from "../../widgets/app"; - -// Mock the auth module to return an empty session -vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); -vi.mock("@homarr/ping", () => ({ sendPingRequestAsync: async () => await Promise.resolve(null) })); - -describe("ping should call sendPingRequestAsync with url and return result", () => { - test("ping with error response should return error and url", async () => { - // Arrange - const spy = vi.spyOn(ping, "sendPingRequestAsync"); - const url = "http://localhost"; - const db = createDb(); - const caller = appRouter.createCaller({ - db, - deviceType: undefined, - session: null, - }); - spy.mockImplementation(() => Promise.resolve({ error: "error" })); - - // Act - const result = await caller.ping({ url }); - - // Assert - expect(result.url).toBe(url); - expect("error" in result).toBe(true); - }); - - test("ping with success response should return statusCode and url", async () => { - // Arrange - const spy = vi.spyOn(ping, "sendPingRequestAsync"); - const url = "http://localhost"; - const db = createDb(); - const caller = appRouter.createCaller({ - db, - deviceType: undefined, - session: null, - }); - spy.mockImplementation(() => Promise.resolve({ statusCode: 200 })); - - // Act - const result = await caller.ping({ url }); - - // Assert - expect(result.url).toBe(url); - expect("statusCode" in result).toBe(true); - }); -}); diff --git a/packages/api/src/router/widgets/app.ts b/packages/api/src/router/widgets/app.ts index b3718e9de..a337b2e54 100644 --- a/packages/api/src/router/widgets/app.ts +++ b/packages/api/src/router/widgets/app.ts @@ -1,20 +1,12 @@ import { observable } from "@trpc/server/observable"; import { z } from "zod"; -import { sendPingRequestAsync } from "@homarr/ping"; -import { pingChannel, pingUrlChannel } from "@homarr/redis"; +import { pingUrlChannel } from "@homarr/redis"; +import { pingRequestHandler } from "@homarr/request-handler/ping"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const appRouter = createTRPCRouter({ - ping: publicProcedure.input(z.object({ url: z.string() })).query(async ({ input }) => { - const pingResult = await sendPingRequestAsync(input.url); - - return { - url: input.url, - ...pingResult, - }; - }), updatedPing: publicProcedure .input( z.object({ @@ -23,21 +15,27 @@ export const appRouter = createTRPCRouter({ ) .subscription(async ({ input }) => { await pingUrlChannel.addAsync(input.url); + const innerHandler = pingRequestHandler.handler({ url: input.url }); - const pingResult = await sendPingRequestAsync(input.url); + return observable<{ url: string; statusCode: number; durationMs: number } | { url: string; error: string }>( + (emit) => { + // Run ping request in background + void innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }).then(({ data }) => { + emit.next({ url: input.url, ...data }); + }); - return observable<{ url: string; statusCode: number } | { url: string; error: string }>((emit) => { - emit.next({ url: input.url, ...pingResult }); - const unsubscribe = pingChannel.subscribe((message) => { - // Only emit if same url - if (message.url !== input.url) return; - emit.next(message); - }); + const unsubscribe = innerHandler.subscribe((pingResponse) => { + emit.next({ + url: input.url, + ...pingResponse, + }); + }); - return () => { - unsubscribe(); - void pingUrlChannel.removeAsync(input.url); - }; - }); + return () => { + unsubscribe(); + void pingUrlChannel.removeAsync(input.url); + }; + }, + ); }), }); diff --git a/packages/cron-jobs/package.json b/packages/cron-jobs/package.json index ffe9adae0..fe9009802 100644 --- a/packages/cron-jobs/package.json +++ b/packages/cron-jobs/package.json @@ -32,7 +32,6 @@ "@homarr/icons": "workspace:^0.1.0", "@homarr/integrations": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0", - "@homarr/ping": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0", "@homarr/request-handler": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0", diff --git a/packages/cron-jobs/src/jobs/ping.ts b/packages/cron-jobs/src/jobs/ping.ts index 318db80bd..52a85b2c5 100644 --- a/packages/cron-jobs/src/jobs/ping.ts +++ b/packages/cron-jobs/src/jobs/ping.ts @@ -2,8 +2,8 @@ import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; import { db } from "@homarr/db"; import { getServerSettingByKeyAsync } from "@homarr/db/queries"; import { logger } from "@homarr/log"; -import { sendPingRequestAsync } from "@homarr/ping"; -import { pingChannel, pingUrlChannel } from "@homarr/redis"; +import { pingUrlChannel } from "@homarr/redis"; +import { pingRequestHandler } from "@homarr/request-handler/ping"; import { createCronJob } from "../lib"; @@ -28,16 +28,6 @@ export const pingJob = createCronJob("ping", EVERY_MINUTE, { }); const pingAsync = async (url: string) => { - const pingResult = await sendPingRequestAsync(url); - - if ("statusCode" in pingResult) { - logger.debug(`executed ping for url ${url} with status code ${pingResult.statusCode}`); - } else { - logger.error(`Executing ping for url ${url} failed with error: ${pingResult.error}`); - } - - await pingChannel.publishAsync({ - url, - ...pingResult, - }); + const handler = pingRequestHandler.handler({ url }); + await handler.getCachedOrUpdatedDataAsync({ forceUpdate: true }); }; diff --git a/packages/ping/eslint.config.js b/packages/ping/eslint.config.js deleted file mode 100644 index f7a5a7d36..000000000 --- a/packages/ping/eslint.config.js +++ /dev/null @@ -1,4 +0,0 @@ -import baseConfig from "@homarr/eslint-config/base"; - -/** @type {import('typescript-eslint').Config} */ -export default [...baseConfig]; diff --git a/packages/ping/index.ts b/packages/ping/index.ts deleted file mode 100644 index 3bd16e178..000000000 --- a/packages/ping/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./src"; diff --git a/packages/ping/package.json b/packages/ping/package.json deleted file mode 100644 index 2799bf3c6..000000000 --- a/packages/ping/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "@homarr/ping", - "version": "0.1.0", - "private": true, - "license": "Apache-2.0", - "type": "module", - "exports": { - ".": "./index.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/certificates": "workspace:^0.1.0", - "@homarr/common": "workspace:^0.1.0", - "@homarr/log": "workspace:^0.1.0" - }, - "devDependencies": { - "@homarr/eslint-config": "workspace:^0.2.0", - "@homarr/prettier-config": "workspace:^0.1.0", - "@homarr/tsconfig": "workspace:^0.1.0", - "eslint": "^9.30.1", - "typescript": "^5.8.3" - } -} diff --git a/packages/ping/src/index.ts b/packages/ping/src/index.ts deleted file mode 100644 index b8f024c60..000000000 --- a/packages/ping/src/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { fetch } from "undici"; - -import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; -import { extractErrorMessage } from "@homarr/common"; -import { logger } from "@homarr/log"; - -export const sendPingRequestAsync = async (url: string) => { - try { - return await fetchWithTimeoutAndCertificates(url).then((response) => ({ statusCode: response.status })); - } catch (error) { - logger.error(new Error(`Failed to send ping request to "${url}"`, { cause: error })); - return { - error: extractErrorMessage(error), - }; - } -}; - -/** - * Same as fetch, but with a timeout of 10 seconds. - * Also respects certificates. - * https://stackoverflow.com/questions/46946380/fetch-api-request-timeout - * @param param0 fetch arguments - * @returns fetch response - */ -export const fetchWithTimeoutAndCertificates = (...[url, requestInit]: Parameters) => { - const controller = new AbortController(); - - // 10 seconds timeout: - const timeoutId = setTimeout(() => controller.abort(), 10000); - - return fetchWithTrustedCertificatesAsync(url, { signal: controller.signal, ...requestInit }).finally(() => { - clearTimeout(timeoutId); - }); -}; diff --git a/packages/ping/tsconfig.json b/packages/ping/tsconfig.json deleted file mode 100644 index 612bef8df..000000000 --- a/packages/ping/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "@homarr/tsconfig/base.json", - "compilerOptions": { - "types": ["node"], - "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" - }, - "include": ["*.ts", "src"], - "exclude": ["node_modules"] -} diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index c61393e51..e510a3d8c 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -12,10 +12,6 @@ export { createGetSetChannel, } from "./lib/channel"; -export const exampleChannel = createSubPubChannel<{ message: string }>("example"); -export const pingChannel = createSubPubChannel<{ url: string; statusCode: number } | { url: string; error: string }>( - "ping", -); export const pingUrlChannel = createListChannel("ping-url"); export const homeAssistantEntityState = createSubPubChannel<{ diff --git a/packages/request-handler/package.json b/packages/request-handler/package.json index 8d1d09693..b129f17da 100644 --- a/packages/request-handler/package.json +++ b/packages/request-handler/package.json @@ -31,7 +31,8 @@ "@homarr/redis": "workspace:^0.1.0", "dayjs": "^1.11.13", "octokit": "^5.0.3", - "superjson": "2.2.2" + "superjson": "2.2.2", + "undici": "7.11.0" }, "devDependencies": { "@homarr/eslint-config": "workspace:^0.2.0", diff --git a/packages/request-handler/src/ping.ts b/packages/request-handler/src/ping.ts new file mode 100644 index 000000000..a53930b2c --- /dev/null +++ b/packages/request-handler/src/ping.ts @@ -0,0 +1,50 @@ +import dayjs from "dayjs"; +import duration from "dayjs/plugin/duration"; +import { fetch } from "undici"; + +import { extractErrorMessage } from "@homarr/common"; +import { LoggingAgent } from "@homarr/common/server"; +import { logger } from "@homarr/log"; + +import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler"; + +dayjs.extend(duration); + +type PingResponse = + | { + statusCode: number; + durationMs: number; + } + | { + error: string; + }; +export const pingRequestHandler = createCachedWidgetRequestHandler({ + queryKey: "pingResult", + widgetKind: "app", + async requestAsync(input) { + return await sendPingRequestAsync(input.url); + }, + cacheDuration: dayjs.duration(1, "minute"), +}); + +const sendPingRequestAsync = async (url: string) => { + try { + const start = performance.now(); + return await fetch(url, { + dispatcher: new LoggingAgent({ + connect: { + rejectUnauthorized: false, + }, + }), + }).then((response) => { + const end = performance.now(); + logger.debug(`Ping request succeeded url="${url}" status="${response.status}" duration="${end - start}ms"`); + return { statusCode: response.status, durationMs: end - start }; + }); + } catch (error) { + logger.error(new Error(`Failed to send ping request to url="${url}"`, { cause: error })); + return { + error: extractErrorMessage(error), + }; + } +}; diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index a89532063..e6f3d70ee 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -1,25 +1,20 @@ "use client"; import type { PropsWithChildren } from "react"; -import { Suspense } from "react"; import { Flex, Text, Tooltip, UnstyledButton } from "@mantine/core"; -import { IconLoader } from "@tabler/icons-react"; import combineClasses from "clsx"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useSettings } from "@homarr/settings"; import { useRegisterSpotlightContextResults } from "@homarr/spotlight"; -import { useI18n } from "@homarr/translation/client"; import { MaskedOrNormalImage } from "@homarr/ui"; import type { WidgetComponentProps } from "../definition"; import classes from "./app.module.css"; -import { PingDot } from "./ping/ping-dot"; import { PingIndicator } from "./ping/ping-indicator"; export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) { - const t = useI18n(); const settings = useSettings(); const board = useRequiredBoard(); const [app] = clientApi.app.byId.useSuspenseQuery( @@ -97,9 +92,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget {options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? ( - }> - - + ) : null} ); diff --git a/packages/widgets/src/app/ping/ping-indicator.tsx b/packages/widgets/src/app/ping/ping-indicator.tsx index b96841376..a00ec2640 100644 --- a/packages/widgets/src/app/ping/ping-indicator.tsx +++ b/packages/widgets/src/app/ping/ping-indicator.tsx @@ -1,8 +1,9 @@ import { useState } from "react"; -import { IconCheck, IconX } from "@tabler/icons-react"; +import { IconCheck, IconLoader, IconX } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; +import { useI18n } from "@homarr/translation/client"; import { PingDot } from "./ping-dot"; @@ -11,17 +12,8 @@ interface PingIndicatorProps { } export const PingIndicator = ({ href }: PingIndicatorProps) => { - const [ping] = clientApi.widget.app.ping.useSuspenseQuery( - { - url: href, - }, - { - refetchOnMount: false, - refetchOnWindowFocus: false, - }, - ); - - const [pingResult, setPingResult] = useState(ping); + const t = useI18n(); + const [pingResult, setPingResult] = useState(null); clientApi.widget.app.updatedPing.useSubscription( { url: href }, @@ -32,13 +24,21 @@ export const PingIndicator = ({ href }: PingIndicatorProps) => { }, ); + if (!pingResult) { + return ; + } + const isError = "error" in pingResult || pingResult.statusCode >= 500; return ( ); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59ba5dcf1..0723a77c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -374,9 +374,6 @@ importers: '@homarr/log': specifier: workspace:^ version: link:../../packages/log - '@homarr/ping': - specifier: workspace:^0.1.0 - version: link:../../packages/ping '@homarr/redis': specifier: workspace:^0.1.0 version: link:../../packages/redis @@ -575,9 +572,6 @@ importers: '@homarr/old-schema': specifier: workspace:^0.1.0 version: link:../old-schema - '@homarr/ping': - specifier: workspace:^0.1.0 - version: link:../ping '@homarr/redis': specifier: workspace:^0.1.0 version: link:../redis @@ -982,9 +976,6 @@ importers: '@homarr/log': specifier: workspace:^0.1.0 version: link:../log - '@homarr/ping': - specifier: workspace:^0.1.0 - version: link:../ping '@homarr/redis': specifier: workspace:^0.1.0 version: link:../redis @@ -1689,34 +1680,6 @@ importers: specifier: ^5.8.3 version: 5.8.3 - packages/ping: - dependencies: - '@homarr/certificates': - specifier: workspace:^0.1.0 - version: link:../certificates - '@homarr/common': - specifier: workspace:^0.1.0 - version: link:../common - '@homarr/log': - specifier: workspace:^0.1.0 - version: link:../log - devDependencies: - '@homarr/eslint-config': - specifier: workspace:^0.2.0 - version: link:../../tooling/eslint - '@homarr/prettier-config': - specifier: workspace:^0.1.0 - version: link:../../tooling/prettier - '@homarr/tsconfig': - specifier: workspace:^0.1.0 - version: link:../../tooling/typescript - eslint: - specifier: ^9.30.1 - version: 9.30.1 - typescript: - specifier: ^5.8.3 - version: 5.8.3 - packages/redis: dependencies: '@homarr/common': @@ -1786,6 +1749,9 @@ importers: superjson: specifier: 2.2.2 version: 2.2.2 + undici: + specifier: 7.11.0 + version: 7.11.0 devDependencies: '@homarr/eslint-config': specifier: workspace:^0.2.0