feat: add simple app ping (#580)

* feat: add simple ping

* refactor: make ping run on server and show errors

* fix: format issues

* fix: missing translation for enabled ping option for app

* refactor: remove ping queue as no longer needed

* chore: address pull request feedback

* test: add some unit tests

* fix: format issues

* fix: deepsource issues
This commit is contained in:
Meier Lukas
2024-06-08 17:33:16 +02:00
committed by GitHub
parent 3dca787fa7
commit d7ecdf5567
25 changed files with 542 additions and 22 deletions

View File

@@ -24,6 +24,7 @@
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/icons": "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

@@ -1,13 +1,15 @@
import { iconsUpdaterJob } from "~/jobs/icons-updater";
import { analyticsJob } from "./jobs/analytics";
import { pingJob } from "./jobs/ping";
import { queuesJob } from "./jobs/queue";
import { createJobGroup } from "./lib/cron-job/group";
export const jobs = createJobGroup({
// Add your jobs here:
analytics: analyticsJob,
iconsUpdater: iconsUpdaterJob,
ping: pingJob,
// This job is used to process queues.
queues: queuesJob,
iconsUpdater: iconsUpdaterJob,
analytics: analyticsJob,
});

View File

@@ -0,0 +1,25 @@
import { logger } from "@homarr/log";
import { sendPingRequestAsync } from "@homarr/ping";
import { pingChannel, pingUrlChannel } from "@homarr/redis";
import { EVERY_MINUTE } from "~/lib/cron-job/constants";
import { createCronJob } from "~/lib/cron-job/creator";
export const pingJob = createCronJob(EVERY_MINUTE).withCallback(async () => {
const urls = await pingUrlChannel.getAllAsync();
for (const url of new Set(urls)) {
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,
});
}
});

View File

@@ -1,6 +1,7 @@
import cron from "node-cron";
import type { MaybePromise } from "@homarr/common/types";
import { logger } from "@homarr/log";
interface CreateCronJobOptions {
runOnStart?: boolean;
@@ -9,11 +10,22 @@ interface CreateCronJobOptions {
export const createCronJob = (cronExpression: string, options: CreateCronJobOptions = { runOnStart: false }) => {
return {
withCallback: (callback: () => MaybePromise<void>) => {
const catchingCallbackAsync = async () => {
try {
await callback();
} catch (error) {
logger.error(
`apps/tasks/src/lib/cron-job/creator.ts: The callback of a cron job failed, expression ${cronExpression}, with error:`,
error,
);
}
};
if (options.runOnStart) {
void callback();
void catchingCallbackAsync();
}
const task = cron.schedule(cronExpression, () => void callback(), {
const task = cron.schedule(cronExpression, () => void catchingCallbackAsync(), {
scheduled: false,
});
return {

View File

@@ -41,15 +41,14 @@ export const createQueueClient = <TQueues extends Queues>(queues: TQueues) => {
};
return acc;
},
{} as Record<
keyof TQueues,
(
data: z.infer<TQueues[keyof TQueues]["_input"]>,
{} as {
[key in keyof TQueues]: (
data: z.infer<TQueues[key]["_input"]>,
props: {
executionDate?: Date;
} | void,
) => Promise<void>
>,
) => Promise<void>;
},
),
};
};

View File

@@ -1,3 +1,4 @@
import { logger } from "@homarr/log";
import { queueChannel } from "@homarr/redis";
import { queueRegistry } from "~/queues";
@@ -14,7 +15,18 @@ export const queueWorkerAsync = async () => {
for (const execution of executions) {
const queue = queueRegistry.get(execution.name);
if (!queue) continue;
await queue.callback(execution.data);
try {
await queue.callback(execution.data);
} catch (err) {
logger.error(
`apps/tasks/src/lib/queue/worker.ts: Error occured when executing queue ${execution.name} with data`,
execution.data,
"and error:",
err,
);
}
await queueChannel.markAsDoneAsync(execution._id);
}
};

View File

@@ -25,6 +25,7 @@
"@homarr/definitions": "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/tasks": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",

View File

@@ -0,0 +1,51 @@
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,
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,
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

@@ -0,0 +1,42 @@
import { observable } from "@trpc/server/observable";
import { sendPingRequestAsync } from "@homarr/ping";
import { pingChannel, pingUrlChannel } from "@homarr/redis";
import { z } from "@homarr/validation";
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({
url: z.string(),
}),
)
.subscription(async ({ input }) => {
await pingUrlChannel.addAsync(input.url);
const pingResult = await sendPingRequestAsync(input.url);
return observable<{ url: string; statusCode: number } | { url: string; error: string }>((emit) => {
emit.next({ url: input.url, ...pingResult });
pingChannel.subscribe((message) => {
// Only emit if same url
if (message.url !== input.url) return;
emit.next(message);
});
return () => {
void pingUrlChannel.removeAsync(input.url);
};
});
}),
});

View File

@@ -1,4 +1,5 @@
import { createTRPCRouter } from "../../trpc";
import { appRouter } from "./app";
import { dnsHoleRouter } from "./dns-hole";
import { notebookRouter } from "./notebook";
import { weatherRouter } from "./weather";
@@ -6,5 +7,6 @@ import { weatherRouter } from "./weather";
export const widgetRouter = createTRPCRouter({
notebook: notebookRouter,
weather: weatherRouter,
app: appRouter,
dnsHole: dnsHoleRouter,
});

View File

@@ -0,0 +1,11 @@
export const extractErrorMessage = (error: unknown) => {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
return "Unknown error";
};

View File

@@ -5,3 +5,4 @@ export * from "./array";
export * from "./stopwatch";
export * from "./hooks";
export * from "./number";
export * from "./error";

View File

@@ -0,0 +1,41 @@
import { describe, expect, test } from "vitest";
import { extractErrorMessage } from "../error";
describe("error to resolve to correct message", () => {
test("error class to resolve to error message", () => {
// Arrange
const error = new Error("Message");
// Act
const message = extractErrorMessage(error);
// Assert
expect(typeof message).toBe("string");
expect(message).toBe("Message");
});
test("error string to resolve to error message", () => {
// Arrange
const error = "Message";
// Act
const message = extractErrorMessage(error);
// Assert
expect(typeof message).toBe("string");
expect(message).toBe("Message");
});
test("error whatever to resolve to unknown error message", () => {
// Arrange
const error = 5;
// Act
const message = extractErrorMessage(error);
// Assert
expect(typeof message).toBe("string");
expect(message).toBe("Unknown error");
});
});

1
packages/ping/index.ts Normal file
View File

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

View File

@@ -0,0 +1,40 @@
{
"name": "@homarr/ping",
"private": true,
"version": "0.1.0",
"exports": {
".": "./index.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"license": "MIT",
"type": "module",
"scripts": {
"clean": "rm -rf .turbo node_modules",
"lint": "eslint .",
"format": "prettier --check . --ignore-path ../../.gitignore",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@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": "^8.57.0",
"typescript": "^5.4.5"
},
"eslintConfig": {
"extends": [
"@homarr/eslint-config/base"
]
},
"prettier": "@homarr/prettier-config"
}

View File

@@ -0,0 +1,13 @@
import { extractErrorMessage } from "@homarr/common";
import { logger } from "@homarr/log";
export const sendPingRequestAsync = async (url: string) => {
try {
return await fetch(url).then((response) => ({ statusCode: response.status }));
} catch (error) {
logger.error("packages/ping/src/index.ts:", error);
return {
error: extractErrorMessage(error),
};
}
};

View File

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

View File

@@ -1,8 +1,12 @@
import { createQueueChannel, createSubPubChannel } from "./lib/channel";
import { createListChannel, createQueueChannel, createSubPubChannel } from "./lib/channel";
export { createCacheChannel } 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 queueChannel = createQueueChannel<{
name: string;
executionDate: Date;

View File

@@ -51,7 +51,40 @@ export const createSubPubChannel = <TData>(name: string) => {
};
};
const cacheClient = createRedisConnection();
const getSetClient = createRedisConnection();
/**
* Creates a new redis channel for a list
* @param name name of channel
* @returns list channel object
*/
export const createListChannel = <TItem>(name: string) => {
const listChannelName = `list:${name}`;
return {
/**
* Get all items in list
* @returns an array of all items
*/
getAllAsync: async () => {
const items = await getSetClient.lrange(listChannelName, 0, -1);
return items.map((item) => superjson.parse<TItem>(item));
},
/**
* Remove an item from the channels list by item
* @param item item to remove
*/
removeAsync: async (item: TItem) => {
await getSetClient.lrem(listChannelName, 0, superjson.stringify(item));
},
/**
* Add an item to the channels list
* @param item item to add
*/
addAsync: async (item: TItem) => {
await getSetClient.lpush(listChannelName, superjson.stringify(item));
},
};
};
/**
* Creates a new cache channel.
@@ -68,7 +101,7 @@ export const createCacheChannel = <TData>(name: string, cacheDurationMs: number
* @returns data or null if not found or expired
*/
getAsync: async () => {
const data = await cacheClient.get(cacheChannelName);
const data = await getSetClient.get(cacheChannelName);
if (!data) return null;
const parsedData = superjson.parse<{ data: TData; timestamp: Date }>(data);
@@ -84,13 +117,13 @@ export const createCacheChannel = <TData>(name: string, cacheDurationMs: number
* @returns data or new data if not present or expired
*/
consumeAsync: async (callback: () => Promise<TData>) => {
const data = await cacheClient.get(cacheChannelName);
const data = await getSetClient.get(cacheChannelName);
const getNewDataAsync = async () => {
logger.debug(`Cache miss for channel '${cacheChannelName}'`);
const newData = await callback();
const result = { data: newData, timestamp: new Date() };
await cacheClient.set(cacheChannelName, superjson.stringify(result));
await getSetClient.set(cacheChannelName, superjson.stringify(result));
logger.debug(`Cache updated for channel '${cacheChannelName}'`);
return result;
};
@@ -115,14 +148,14 @@ export const createCacheChannel = <TData>(name: string, cacheDurationMs: number
* Invalidate the cache channels data.
*/
invalidateAsync: async () => {
await cacheClient.del(cacheChannelName);
await getSetClient.del(cacheChannelName);
},
/**
* Set the data in the cache channel.
* @param data data to be stored in the cache channel
*/
setAsync: async (data: TData) => {
await cacheClient.set(cacheChannelName, superjson.stringify({ data, timestamp: new Date() }));
await getSetClient.set(cacheChannelName, superjson.stringify({ data, timestamp: new Date() }));
},
};
};

View File

@@ -640,6 +640,9 @@ export default {
showDescriptionTooltip: {
label: "Show description tooltip",
},
pingEnabled: {
label: "Enable simple ping",
},
},
error: {
notFound: {

View File

@@ -1,9 +1,11 @@
"use client";
import type { PropsWithChildren } from "react";
import { Center, Flex, Loader, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { useState } from "react";
import { Box, Center, Flex, Loader, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconDeviceDesktopX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useRegisterSpotlightActions } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
@@ -33,6 +35,19 @@ export default function AppWidget({ options, serverData, isEditMode, width, heig
},
);
const [pingResult, setPingResult] = useState<RouterOutputs["widget"]["app"]["ping"] | null>(null);
const shouldRunPing = Boolean(app?.href) && options.pingEnabled;
clientApi.widget.app.updatedPing.useSubscription(
{ url: app?.href ?? "" },
{
enabled: shouldRunPing,
onData(data) {
setPingResult(data);
},
},
);
useRegisterSpotlightActions(
`app-${options.appId}`,
app?.href
@@ -77,7 +92,7 @@ export default function AppWidget({ options, serverData, isEditMode, width, heig
return (
<AppLink href={app?.href ?? ""} openInNewTab={options.openInNewTab} enabled={Boolean(app?.href) && !isEditMode}>
<Flex align="center" justify="center" h="100%">
<Flex align="center" justify="center" h="100%" pos="relative">
<Tooltip.Floating
label={app?.description}
position="right-start"
@@ -103,6 +118,8 @@ export default function AppWidget({ options, serverData, isEditMode, width, heig
<img src={app?.iconUrl} alt={app?.name} className={classes.appIcon} />
</Flex>
</Tooltip.Floating>
{shouldRunPing && <PingIndicator pingResult={pingResult} />}
</Flex>
</AppLink>
);
@@ -122,3 +139,31 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren<Ap
) : (
children
);
interface PingIndicatorProps {
pingResult: RouterOutputs["widget"]["app"]["ping"] | null;
}
const PingIndicator = ({ pingResult }: PingIndicatorProps) => {
return (
<Box bottom={4} right={4} pos="absolute">
<Tooltip
label={pingResult && "statusCode" in pingResult ? pingResult.statusCode : pingResult?.error}
disabled={!pingResult}
>
<Box
style={{
borderRadius: "100%",
backgroundColor: !pingResult
? "orange"
: "error" in pingResult || pingResult.statusCode >= 500
? "red"
: "green",
}}
w={16}
h={16}
></Box>
</Tooltip>
</Box>
);
};

View File

@@ -9,6 +9,7 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
appId: factory.app(),
openInNewTab: factory.switch({ defaultValue: true }),
showDescriptionTooltip: factory.switch({ defaultValue: false }),
pingEnabled: factory.switch({ defaultValue: false }),
})),
})
.withServerData(() => import("./serverData"))

View File

@@ -1,14 +1,25 @@
"use server";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerDataAsync({ options }: WidgetProps<"app">) {
if (!options.appId) {
return { app: null, pingResult: null };
}
try {
const app = await api.app.byId({ id: options.appId });
return { app };
let pingResult: RouterOutputs["widget"]["app"]["ping"] | null = null;
if (app.href && options.pingEnabled) {
pingResult = await api.widget.app.ping({ url: app.href });
}
return { app, pingResult };
} catch (error) {
return { app: null };
return { app: null, pingResult: null };
}
}

View File

@@ -0,0 +1,129 @@
import { TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { objectKeys } from "@homarr/common";
import type { WidgetProps } from "../../definition";
import getServerDataAsync from "../serverData";
const mockApp = (override: Partial<RouterOutputs["app"]["byId"]>) =>
({
id: "1",
name: "Mock app",
iconUrl: "https://some.com/icon.png",
description: null,
href: "https://google.ch",
...override,
}) satisfies RouterOutputs["app"]["byId"];
vi.mock("@homarr/api/server", () => ({
api: {
app: {
byId: () => null,
},
widget: {
app: {
ping: () => null,
},
},
},
}));
describe("getServerDataAsync should load app and ping result", () => {
test("when appId is empty it should return null for app and pingResult", async () => {
// Arrange
const options = {
appId: "",
pingEnabled: true,
};
// Act
const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">);
// Assert
expect(result.app).toBeNull();
expect(result.pingResult).toBeNull();
});
test("when app exists and ping is disabled it should return existing app and pingResult null", async () => {
// Arrange
const spy = vi.spyOn(api.app, "byId");
const options = {
appId: "1",
pingEnabled: false,
};
const mockedApp = mockApp({});
spy.mockImplementation(() => Promise.resolve(mockedApp));
// Act
const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">);
// Assert
expect(result.pingResult).toBeNull();
objectKeys(mockedApp).forEach((key) => expect(result.app?.[key]).toBe(mockedApp[key]));
});
test("when app exists without href and ping enabled it should return existing app and pingResult null", async () => {
// Arrange
const spy = vi.spyOn(api.app, "byId");
const options = {
appId: "1",
pingEnabled: true,
};
const mockedApp = mockApp({ href: null });
spy.mockImplementation(() => Promise.resolve(mockedApp));
// Act
const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">);
// Assert
expect(result.pingResult).toBeNull();
objectKeys(mockedApp).forEach((key) => expect(result.app?.[key]).toBe(mockedApp[key]));
});
test("when app does not exist it should return for both null", async () => {
// Arrange
const spy = vi.spyOn(api.app, "byId");
const options = {
appId: "1",
pingEnabled: true,
};
spy.mockImplementation(() =>
Promise.reject(
new TRPCError({
code: "NOT_FOUND",
}),
),
);
// Act
const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">);
// Assert
expect(result.pingResult).toBeNull();
expect(result.app).toBeNull();
});
test("when app found and ping enabled it should return existing app and pingResult", async () => {
// Arrange
const spyById = vi.spyOn(api.app, "byId");
const spyPing = vi.spyOn(api.widget.app, "ping");
const options = {
appId: "1",
pingEnabled: true,
};
const mockedApp = mockApp({});
const pingResult = { statusCode: 200, url: "http://localhost" };
spyById.mockImplementation(() => Promise.resolve(mockedApp));
spyPing.mockImplementation(() => Promise.resolve(pingResult));
// Act
const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">);
// Assert
expect(result.pingResult).toBe(pingResult);
expect(result.app).toBe(mockedApp);
});
});

31
pnpm-lock.yaml generated
View File

@@ -268,6 +268,9 @@ 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
@@ -427,6 +430,9 @@ importers:
'@homarr/log':
specifier: workspace:^
version: link:../log
'@homarr/ping':
specifier: workspace:^0.1.0
version: link:../ping
'@homarr/redis':
specifier: workspace:^0.1.0
version: link:../redis
@@ -783,6 +789,31 @@ importers:
specifier: ^5.4.5
version: 5.4.5
packages/ping:
dependencies:
'@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: ^8.57.0
version: 8.57.0
typescript:
specifier: ^5.4.5
version: 5.4.5
packages/redis:
dependencies:
'@homarr/common':