mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 16:30:57 +01:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
25
apps/tasks/src/jobs/ping.ts
Normal file
25
apps/tasks/src/jobs/ping.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
},
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
51
packages/api/src/router/test/widgets/app.spec.ts
Normal file
51
packages/api/src/router/test/widgets/app.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
42
packages/api/src/router/widgets/app.ts
Normal file
42
packages/api/src/router/widgets/app.ts
Normal 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);
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
11
packages/common/src/error.ts
Normal file
11
packages/common/src/error.ts
Normal 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";
|
||||
};
|
||||
@@ -5,3 +5,4 @@ export * from "./array";
|
||||
export * from "./stopwatch";
|
||||
export * from "./hooks";
|
||||
export * from "./number";
|
||||
export * from "./error";
|
||||
|
||||
41
packages/common/src/test/error.spec.ts
Normal file
41
packages/common/src/test/error.spec.ts
Normal 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
1
packages/ping/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
40
packages/ping/package.json
Normal file
40
packages/ping/package.json
Normal 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"
|
||||
}
|
||||
13
packages/ping/src/index.ts
Normal file
13
packages/ping/src/index.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
};
|
||||
9
packages/ping/tsconfig.json
Normal file
9
packages/ping/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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() }));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -640,6 +640,9 @@ export default {
|
||||
showDescriptionTooltip: {
|
||||
label: "Show description tooltip",
|
||||
},
|
||||
pingEnabled: {
|
||||
label: "Enable simple ping",
|
||||
},
|
||||
},
|
||||
error: {
|
||||
notFound: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
129
packages/widgets/src/app/test/serverData.spec.ts
Normal file
129
packages/widgets/src/app/test/serverData.spec.ts
Normal 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
31
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user