mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 16:30:57 +01:00
feat(ping): ignore certificate error and show request duration (#3546)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import baseConfig from "@homarr/eslint-config/base";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [...baseConfig];
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./src";
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"],
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -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<{
|
||||
|
||||
@@ -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",
|
||||
|
||||
50
packages/request-handler/src/ping.ts
Normal file
50
packages/request-handler/src/ping.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
40
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user