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/definitions": "workspace:^0.1.0",
|
||||||
"@homarr/icons": "workspace:^0.1.0",
|
"@homarr/icons": "workspace:^0.1.0",
|
||||||
"@homarr/log": "workspace:^",
|
"@homarr/log": "workspace:^",
|
||||||
|
"@homarr/ping": "workspace:^0.1.0",
|
||||||
"@homarr/redis": "workspace:^0.1.0",
|
"@homarr/redis": "workspace:^0.1.0",
|
||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
"@homarr/server-settings": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { iconsUpdaterJob } from "~/jobs/icons-updater";
|
import { iconsUpdaterJob } from "~/jobs/icons-updater";
|
||||||
import { analyticsJob } from "./jobs/analytics";
|
import { analyticsJob } from "./jobs/analytics";
|
||||||
|
import { pingJob } from "./jobs/ping";
|
||||||
import { queuesJob } from "./jobs/queue";
|
import { queuesJob } from "./jobs/queue";
|
||||||
import { createJobGroup } from "./lib/cron-job/group";
|
import { createJobGroup } from "./lib/cron-job/group";
|
||||||
|
|
||||||
export const jobs = createJobGroup({
|
export const jobs = createJobGroup({
|
||||||
// Add your jobs here:
|
// Add your jobs here:
|
||||||
|
analytics: analyticsJob,
|
||||||
|
iconsUpdater: iconsUpdaterJob,
|
||||||
|
ping: pingJob,
|
||||||
|
|
||||||
// This job is used to process queues.
|
// This job is used to process queues.
|
||||||
queues: queuesJob,
|
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 cron from "node-cron";
|
||||||
|
|
||||||
import type { MaybePromise } from "@homarr/common/types";
|
import type { MaybePromise } from "@homarr/common/types";
|
||||||
|
import { logger } from "@homarr/log";
|
||||||
|
|
||||||
interface CreateCronJobOptions {
|
interface CreateCronJobOptions {
|
||||||
runOnStart?: boolean;
|
runOnStart?: boolean;
|
||||||
@@ -9,11 +10,22 @@ interface CreateCronJobOptions {
|
|||||||
export const createCronJob = (cronExpression: string, options: CreateCronJobOptions = { runOnStart: false }) => {
|
export const createCronJob = (cronExpression: string, options: CreateCronJobOptions = { runOnStart: false }) => {
|
||||||
return {
|
return {
|
||||||
withCallback: (callback: () => MaybePromise<void>) => {
|
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) {
|
if (options.runOnStart) {
|
||||||
void callback();
|
void catchingCallbackAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
const task = cron.schedule(cronExpression, () => void callback(), {
|
const task = cron.schedule(cronExpression, () => void catchingCallbackAsync(), {
|
||||||
scheduled: false,
|
scheduled: false,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -41,15 +41,14 @@ export const createQueueClient = <TQueues extends Queues>(queues: TQueues) => {
|
|||||||
};
|
};
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<
|
{} as {
|
||||||
keyof TQueues,
|
[key in keyof TQueues]: (
|
||||||
(
|
data: z.infer<TQueues[key]["_input"]>,
|
||||||
data: z.infer<TQueues[keyof TQueues]["_input"]>,
|
|
||||||
props: {
|
props: {
|
||||||
executionDate?: Date;
|
executionDate?: Date;
|
||||||
} | void,
|
} | void,
|
||||||
) => Promise<void>
|
) => Promise<void>;
|
||||||
>,
|
},
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { logger } from "@homarr/log";
|
||||||
import { queueChannel } from "@homarr/redis";
|
import { queueChannel } from "@homarr/redis";
|
||||||
|
|
||||||
import { queueRegistry } from "~/queues";
|
import { queueRegistry } from "~/queues";
|
||||||
@@ -14,7 +15,18 @@ export const queueWorkerAsync = async () => {
|
|||||||
for (const execution of executions) {
|
for (const execution of executions) {
|
||||||
const queue = queueRegistry.get(execution.name);
|
const queue = queueRegistry.get(execution.name);
|
||||||
if (!queue) continue;
|
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);
|
await queueChannel.markAsDoneAsync(execution._id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"@homarr/integrations": "workspace:^0.1.0",
|
"@homarr/integrations": "workspace:^0.1.0",
|
||||||
"@homarr/log": "workspace:^",
|
"@homarr/log": "workspace:^",
|
||||||
|
"@homarr/ping": "workspace:^0.1.0",
|
||||||
"@homarr/redis": "workspace:^0.1.0",
|
"@homarr/redis": "workspace:^0.1.0",
|
||||||
"@homarr/tasks": "workspace:^0.1.0",
|
"@homarr/tasks": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "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 { createTRPCRouter } from "../../trpc";
|
||||||
|
import { appRouter } from "./app";
|
||||||
import { dnsHoleRouter } from "./dns-hole";
|
import { dnsHoleRouter } from "./dns-hole";
|
||||||
import { notebookRouter } from "./notebook";
|
import { notebookRouter } from "./notebook";
|
||||||
import { weatherRouter } from "./weather";
|
import { weatherRouter } from "./weather";
|
||||||
@@ -6,5 +7,6 @@ import { weatherRouter } from "./weather";
|
|||||||
export const widgetRouter = createTRPCRouter({
|
export const widgetRouter = createTRPCRouter({
|
||||||
notebook: notebookRouter,
|
notebook: notebookRouter,
|
||||||
weather: weatherRouter,
|
weather: weatherRouter,
|
||||||
|
app: appRouter,
|
||||||
dnsHole: dnsHoleRouter,
|
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 "./stopwatch";
|
||||||
export * from "./hooks";
|
export * from "./hooks";
|
||||||
export * from "./number";
|
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 { createCacheChannel } from "./lib/channel";
|
||||||
|
|
||||||
export const exampleChannel = createSubPubChannel<{ message: string }>("example");
|
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<{
|
export const queueChannel = createQueueChannel<{
|
||||||
name: string;
|
name: string;
|
||||||
executionDate: Date;
|
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.
|
* 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
|
* @returns data or null if not found or expired
|
||||||
*/
|
*/
|
||||||
getAsync: async () => {
|
getAsync: async () => {
|
||||||
const data = await cacheClient.get(cacheChannelName);
|
const data = await getSetClient.get(cacheChannelName);
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
const parsedData = superjson.parse<{ data: TData; timestamp: Date }>(data);
|
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
|
* @returns data or new data if not present or expired
|
||||||
*/
|
*/
|
||||||
consumeAsync: async (callback: () => Promise<TData>) => {
|
consumeAsync: async (callback: () => Promise<TData>) => {
|
||||||
const data = await cacheClient.get(cacheChannelName);
|
const data = await getSetClient.get(cacheChannelName);
|
||||||
|
|
||||||
const getNewDataAsync = async () => {
|
const getNewDataAsync = async () => {
|
||||||
logger.debug(`Cache miss for channel '${cacheChannelName}'`);
|
logger.debug(`Cache miss for channel '${cacheChannelName}'`);
|
||||||
const newData = await callback();
|
const newData = await callback();
|
||||||
const result = { data: newData, timestamp: new Date() };
|
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}'`);
|
logger.debug(`Cache updated for channel '${cacheChannelName}'`);
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
@@ -115,14 +148,14 @@ export const createCacheChannel = <TData>(name: string, cacheDurationMs: number
|
|||||||
* Invalidate the cache channels data.
|
* Invalidate the cache channels data.
|
||||||
*/
|
*/
|
||||||
invalidateAsync: async () => {
|
invalidateAsync: async () => {
|
||||||
await cacheClient.del(cacheChannelName);
|
await getSetClient.del(cacheChannelName);
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Set the data in the cache channel.
|
* Set the data in the cache channel.
|
||||||
* @param data data to be stored in the cache channel
|
* @param data data to be stored in the cache channel
|
||||||
*/
|
*/
|
||||||
setAsync: async (data: TData) => {
|
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: {
|
showDescriptionTooltip: {
|
||||||
label: "Show description tooltip",
|
label: "Show description tooltip",
|
||||||
},
|
},
|
||||||
|
pingEnabled: {
|
||||||
|
label: "Enable simple ping",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
notFound: {
|
notFound: {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
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 { IconDeviceDesktopX } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useRegisterSpotlightActions } from "@homarr/spotlight";
|
import { useRegisterSpotlightActions } from "@homarr/spotlight";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
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(
|
useRegisterSpotlightActions(
|
||||||
`app-${options.appId}`,
|
`app-${options.appId}`,
|
||||||
app?.href
|
app?.href
|
||||||
@@ -77,7 +92,7 @@ export default function AppWidget({ options, serverData, isEditMode, width, heig
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLink href={app?.href ?? ""} openInNewTab={options.openInNewTab} enabled={Boolean(app?.href) && !isEditMode}>
|
<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
|
<Tooltip.Floating
|
||||||
label={app?.description}
|
label={app?.description}
|
||||||
position="right-start"
|
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} />
|
<img src={app?.iconUrl} alt={app?.name} className={classes.appIcon} />
|
||||||
</Flex>
|
</Flex>
|
||||||
</Tooltip.Floating>
|
</Tooltip.Floating>
|
||||||
|
|
||||||
|
{shouldRunPing && <PingIndicator pingResult={pingResult} />}
|
||||||
</Flex>
|
</Flex>
|
||||||
</AppLink>
|
</AppLink>
|
||||||
);
|
);
|
||||||
@@ -122,3 +139,31 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren<Ap
|
|||||||
) : (
|
) : (
|
||||||
children
|
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(),
|
appId: factory.app(),
|
||||||
openInNewTab: factory.switch({ defaultValue: true }),
|
openInNewTab: factory.switch({ defaultValue: true }),
|
||||||
showDescriptionTooltip: factory.switch({ defaultValue: false }),
|
showDescriptionTooltip: factory.switch({ defaultValue: false }),
|
||||||
|
pingEnabled: factory.switch({ defaultValue: false }),
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
.withServerData(() => import("./serverData"))
|
.withServerData(() => import("./serverData"))
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
|
|
||||||
import type { WidgetProps } from "../definition";
|
import type { WidgetProps } from "../definition";
|
||||||
|
|
||||||
export default async function getServerDataAsync({ options }: WidgetProps<"app">) {
|
export default async function getServerDataAsync({ options }: WidgetProps<"app">) {
|
||||||
|
if (!options.appId) {
|
||||||
|
return { app: null, pingResult: null };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const app = await api.app.byId({ id: options.appId });
|
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) {
|
} 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':
|
'@homarr/log':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../packages/log
|
version: link:../../packages/log
|
||||||
|
'@homarr/ping':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../../packages/ping
|
||||||
'@homarr/redis':
|
'@homarr/redis':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../../packages/redis
|
version: link:../../packages/redis
|
||||||
@@ -427,6 +430,9 @@ importers:
|
|||||||
'@homarr/log':
|
'@homarr/log':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../log
|
version: link:../log
|
||||||
|
'@homarr/ping':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../ping
|
||||||
'@homarr/redis':
|
'@homarr/redis':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../redis
|
version: link:../redis
|
||||||
@@ -783,6 +789,31 @@ importers:
|
|||||||
specifier: ^5.4.5
|
specifier: ^5.4.5
|
||||||
version: 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:
|
packages/redis:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@homarr/common':
|
'@homarr/common':
|
||||||
|
|||||||
Reference in New Issue
Block a user