feat(ping): ignore certificate error and show request duration (#3546)

This commit is contained in:
Meier Lukas
2025-07-07 17:04:45 +02:00
committed by GitHub
parent 1fe3450555
commit 1eb47311fa
17 changed files with 94 additions and 240 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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);
});
});

View File

@@ -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);
};
},
);
}),
});

View File

@@ -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",

View File

@@ -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 });
};

View File

@@ -1,4 +0,0 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [...baseConfig];

View File

@@ -1 +0,0 @@
export * from "./src";

View File

@@ -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"
}
}

View File

@@ -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<typeof fetch>) => {
const controller = new AbortController();
// 10 seconds timeout:
const timeoutId = setTimeout(() => controller.abort(), 10000);
return fetchWithTrustedCertificatesAsync(url, { signal: controller.signal, ...requestInit }).finally(() => {
clearTimeout(timeoutId);
});
};

View File

@@ -1,9 +0,0 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"types": ["node"],
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View File

@@ -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<string>("ping-url");
export const homeAssistantEntityState = createSubPubChannel<{

View File

@@ -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",

View File

@@ -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<PingResponse, "app", { url: string }>({
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),
};
}
};

View File

@@ -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
</Flex>
</Tooltip.Floating>
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
<Suspense fallback={<PingDot icon={IconLoader} color="blue" tooltip={`${t("common.action.loading")}`} />}>
<PingIndicator href={app.pingUrl ?? app.href} />
</Suspense>
<PingIndicator href={app.pingUrl ?? app.href} />
) : null}
</AppLink>
);

View File

@@ -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<RouterOutputs["widget"]["app"]["ping"]>(ping);
const t = useI18n();
const [pingResult, setPingResult] = useState<RouterOutputs["widget"]["app"]["updatedPing"] | null>(null);
clientApi.widget.app.updatedPing.useSubscription(
{ url: href },
@@ -32,13 +24,21 @@ export const PingIndicator = ({ href }: PingIndicatorProps) => {
},
);
if (!pingResult) {
return <PingDot icon={IconLoader} color="blue" tooltip={`${t("common.action.loading")}`} />;
}
const isError = "error" in pingResult || pingResult.statusCode >= 500;
return (
<PingDot
icon={isError ? IconX : IconCheck}
color={isError ? "red" : "green"}
tooltip={"statusCode" in pingResult ? pingResult.statusCode.toString() : pingResult.error}
tooltip={
"statusCode" in pingResult
? `${pingResult.statusCode} - ${pingResult.durationMs.toFixed(0)}ms`
: pingResult.error
}
/>
);
};

40
pnpm-lock.yaml generated
View File

@@ -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