From 44cddd5f3cb565db9ece31295ae8874335a8ca7d Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 28 Dec 2024 15:47:01 +0100 Subject: [PATCH 1/2] feat(widget): add minecraft server status widget --- packages/api/src/router/widgets/index.ts | 2 + packages/api/src/router/widgets/minecraft.ts | 36 ++++++++++++ packages/cron-jobs/src/index.ts | 2 + .../src/jobs/minecraft-server-status.ts | 25 ++++++++ packages/definitions/src/widget.ts | 1 + packages/redis/src/index.ts | 1 + packages/redis/src/lib/channel.ts | 10 ++++ .../src/lib/cached-widget-request-handler.ts | 36 ++++++++++++ .../src/minecraft-server-status.ts | 35 ++++++++++++ packages/translation/src/lang/en.json | 22 +++++++ packages/widgets/src/index.tsx | 2 + .../src/minecraft/server-status/component.tsx | 57 +++++++++++++++++++ .../src/minecraft/server-status/index.ts | 15 +++++ 13 files changed, 244 insertions(+) create mode 100644 packages/api/src/router/widgets/minecraft.ts create mode 100644 packages/cron-jobs/src/jobs/minecraft-server-status.ts create mode 100644 packages/request-handler/src/lib/cached-widget-request-handler.ts create mode 100644 packages/request-handler/src/minecraft-server-status.ts create mode 100644 packages/widgets/src/minecraft/server-status/component.tsx create mode 100644 packages/widgets/src/minecraft/server-status/index.ts diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 5960587eb..ce39bf9f7 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -8,6 +8,7 @@ import { indexerManagerRouter } from "./indexer-manager"; import { mediaRequestsRouter } from "./media-requests"; import { mediaServerRouter } from "./media-server"; import { mediaTranscodingRouter } from "./media-transcoding"; +import { minecraftRouter } from "./minecraft"; import { notebookRouter } from "./notebook"; import { rssFeedRouter } from "./rssFeed"; import { smartHomeRouter } from "./smart-home"; @@ -27,4 +28,5 @@ export const widgetRouter = createTRPCRouter({ indexerManager: indexerManagerRouter, healthMonitoring: healthMonitoringRouter, mediaTranscoding: mediaTranscodingRouter, + minecraft: minecraftRouter, }); diff --git a/packages/api/src/router/widgets/minecraft.ts b/packages/api/src/router/widgets/minecraft.ts new file mode 100644 index 000000000..15a7f1c7a --- /dev/null +++ b/packages/api/src/router/widgets/minecraft.ts @@ -0,0 +1,36 @@ +import { observable } from "@trpc/server/observable"; +import { z } from "zod"; + +import type { MinecraftServerStatus } from "@homarr/request-handler/minecraft-server-status"; +import { minecraftServerStatusRequestHandler } from "@homarr/request-handler/minecraft-server-status"; + +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +const serverStatusInputSchema = z.object({ + domain: z.string().nonempty(), + isBedrockServer: z.boolean(), +}); +export const minecraftRouter = createTRPCRouter({ + getServerStatus: publicProcedure.input(serverStatusInputSchema).query(async ({ input }) => { + const innerHandler = minecraftServerStatusRequestHandler.handler({ + isBedrockServer: input.isBedrockServer, + domain: input.domain, + }); + return innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true }); + }), + subscribeServerStatus: publicProcedure.input(serverStatusInputSchema).subscription(({ input }) => { + return observable((emit) => { + const innerHandler = minecraftServerStatusRequestHandler.handler({ + isBedrockServer: input.isBedrockServer, + domain: input.domain, + }); + const unsubscribe = innerHandler.subscribe((data) => { + emit.next(data); + }); + + return () => { + unsubscribe(); + }; + }); + }), +}); diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts index 4bb6e0bf5..50dc00702 100644 --- a/packages/cron-jobs/src/index.ts +++ b/packages/cron-jobs/src/index.ts @@ -9,6 +9,7 @@ import { mediaOrganizerJob } from "./jobs/integrations/media-organizer"; import { mediaRequestListJob, mediaRequestStatsJob } from "./jobs/integrations/media-requests"; import { mediaServerJob } from "./jobs/integrations/media-server"; import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding"; +import { minecraftServerStatusJob } from "./jobs/minecraft-server-status"; import { pingJob } from "./jobs/ping"; import type { RssFeed } from "./jobs/rss-feeds"; import { rssFeedsJob } from "./jobs/rss-feeds"; @@ -33,6 +34,7 @@ export const jobGroup = createCronJobGroup({ sessionCleanup: sessionCleanupJob, updateChecker: updateCheckerJob, mediaTranscoding: mediaTranscodingJob, + minecraftServerStatus: minecraftServerStatusJob, }); export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number]; diff --git a/packages/cron-jobs/src/jobs/minecraft-server-status.ts b/packages/cron-jobs/src/jobs/minecraft-server-status.ts new file mode 100644 index 000000000..e6c084702 --- /dev/null +++ b/packages/cron-jobs/src/jobs/minecraft-server-status.ts @@ -0,0 +1,25 @@ +import SuperJSON from "superjson"; + +import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions"; +import { db, eq } from "@homarr/db"; +import { items } from "@homarr/db/schema"; +import { minecraftServerStatusRequestHandler } from "@homarr/request-handler/minecraft-server-status"; + +import type { WidgetComponentProps } from "../../../widgets/src"; +import { createCronJob } from "../lib"; + +export const minecraftServerStatusJob = createCronJob("minecraftServerStatus", EVERY_5_MINUTES).withCallback( + async () => { + const dbItems = await db.query.items.findMany({ + where: eq(items.kind, "minecraftServerStatus"), + }); + + await Promise.allSettled( + dbItems.map(async (item) => { + const options = SuperJSON.parse["options"]>(item.options); + const innerHandler = minecraftServerStatusRequestHandler.handler(options); + await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true }); + }), + ); + }, +); diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 633a1004a..a829b9ae8 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -15,6 +15,7 @@ export const widgetKinds = [ "mediaRequests-requestList", "mediaRequests-requestStats", "mediaTranscoding", + "minecraftServerStatus", "rssFeed", "bookmarks", "indexerManager", diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index 0f4a99f38..b752177ac 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -5,6 +5,7 @@ export { createItemAndIntegrationChannel, createItemChannel, createIntegrationOptionsChannel, + createWidgetOptionsChannel, createChannelWithLatestAndEvents, handshakeAsync, createSubPubChannel, diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts index 68664a2d7..42eb2ef13 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -183,6 +183,16 @@ export const createIntegrationOptionsChannel = ( return createChannelWithLatestAndEvents(channelName); }; +export const createWidgetOptionsChannel = ( + widgetKind: WidgetKind, + queryKey: string, + options: Record, +) => { + const optionsKey = hashObjectBase64(options); + const channelName = `widget:${widgetKind}:${queryKey}:options:${optionsKey}`; + return createChannelWithLatestAndEvents(channelName); +}; + export const createItemChannel = (itemId: string) => { return createChannelWithLatestAndEvents(`item:${itemId}`); }; diff --git a/packages/request-handler/src/lib/cached-widget-request-handler.ts b/packages/request-handler/src/lib/cached-widget-request-handler.ts new file mode 100644 index 000000000..4016bc5b4 --- /dev/null +++ b/packages/request-handler/src/lib/cached-widget-request-handler.ts @@ -0,0 +1,36 @@ +import type { Duration } from "dayjs/plugin/duration"; + +import type { WidgetKind } from "@homarr/definitions"; +import { createWidgetOptionsChannel } from "@homarr/redis"; + +import { createCachedRequestHandler } from "./cached-request-handler"; + +interface Options> { + // Unique key for this request handler + queryKey: string; + requestAsync: (input: TInput) => Promise; + cacheDuration: Duration; + widgetKind: TKind; +} + +export const createCachedWidgetRequestHandler = < + TData, + TKind extends WidgetKind, + TInput extends Record, +>( + requestHandlerOptions: Options, +) => { + return { + handler: (widgetOptions: TInput) => + createCachedRequestHandler({ + queryKey: requestHandlerOptions.queryKey, + requestAsync: async (input: TInput) => { + return await requestHandlerOptions.requestAsync(input); + }, + cacheDuration: requestHandlerOptions.cacheDuration, + createRedisChannel(input, options) { + return createWidgetOptionsChannel(requestHandlerOptions.widgetKind, options.queryKey, input); + }, + }).handler(widgetOptions), + }; +}; diff --git a/packages/request-handler/src/minecraft-server-status.ts b/packages/request-handler/src/minecraft-server-status.ts new file mode 100644 index 000000000..d2b98fcc3 --- /dev/null +++ b/packages/request-handler/src/minecraft-server-status.ts @@ -0,0 +1,35 @@ +import dayjs from "dayjs"; +import { z } from "zod"; + +import { fetchWithTimeout } from "@homarr/common"; + +import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler"; + +export const minecraftServerStatusRequestHandler = createCachedWidgetRequestHandler({ + queryKey: "minecraftServerStatusApiResult", + widgetKind: "minecraftServerStatus", + async requestAsync(input: { domain: string; isBedrockServer: boolean }) { + const path = `/3/${input.isBedrockServer ? "bedrock/" : ""}${input.domain}`; + + const response = await fetchWithTimeout(`https://api.mcsrvstat.us${path}`); + return responseSchema.parse(await response.json()); + }, + cacheDuration: dayjs.duration(5, "minutes"), +}); + +const responseSchema = z + .object({ + online: z.literal(false), + }) + .or( + z.object({ + online: z.literal(true), + players: z.object({ + online: z.number(), + max: z.number(), + }), + icon: z.string().optional(), + }), + ); + +export type MinecraftServerStatus = z.infer; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 734919e96..6fb5a12b5 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1151,6 +1151,25 @@ } } }, + "minecraftServerStatus": { + "name": "Minecraft Server Status", + "description": "Displays the status of a Minecraft server", + "option": { + "title": { + "label": "Title" + }, + "domain": { + "label": "Server address" + }, + "isBedrockServer": { + "label": "Bedrock server" + } + }, + "status": { + "online": "Online", + "offline": "Offline" + } + }, "notebook": { "name": "Notebook", "description": "A simple notebook widget that supports markdown", @@ -2319,6 +2338,9 @@ "error": "Error" }, "job": { + "minecraftServerStatus": { + "label": "Minecraft server status" + }, "iconsUpdater": { "label": "Icons Updater" }, diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 7fc8f97ff..b041c11ba 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -22,6 +22,7 @@ import * as mediaRequestsList from "./media-requests/list"; import * as mediaRequestsStats from "./media-requests/stats"; import * as mediaServer from "./media-server"; import * as mediaTranscoding from "./media-transcoding"; +import * as minecraftServerStatus from "./minecraft/server-status"; import * as notebook from "./notebook"; import type { WidgetOptionDefinition } from "./options"; import * as rssFeed from "./rssFeed"; @@ -54,6 +55,7 @@ export const widgetImports = { indexerManager, healthMonitoring, mediaTranscoding, + minecraftServerStatus, } satisfies WidgetImportRecord; export type WidgetImports = typeof widgetImports; diff --git a/packages/widgets/src/minecraft/server-status/component.tsx b/packages/widgets/src/minecraft/server-status/component.tsx new file mode 100644 index 000000000..fe92c8454 --- /dev/null +++ b/packages/widgets/src/minecraft/server-status/component.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { Box, Flex, Group, Text, Tooltip } from "@mantine/core"; +import { IconUsersGroup } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../../definition"; + +export default function MinecraftServerStatusWidget({ options }: WidgetComponentProps<"minecraftServerStatus">) { + const [{ data }] = clientApi.widget.minecraft.getServerStatus.useSuspenseQuery(options); + const utils = clientApi.useUtils(); + clientApi.widget.minecraft.subscribeServerStatus.useSubscription(options, { + onData(data) { + utils.widget.minecraft.getServerStatus.setData(options, { + data, + timestamp: new Date(), + }); + }, + }); + const tStatus = useScopedI18n("widget.minecraftServerStatus.status"); + + const title = options.title.trim().length > 0 ? options.title : options.domain; + + return ( + + + + + + + {title} + + + {data.online && ( + <> + + + + + {data.players.online}/{data.players.max} + + + + )} + + ); +} diff --git a/packages/widgets/src/minecraft/server-status/index.ts b/packages/widgets/src/minecraft/server-status/index.ts new file mode 100644 index 000000000..df4c0e2bc --- /dev/null +++ b/packages/widgets/src/minecraft/server-status/index.ts @@ -0,0 +1,15 @@ +import { IconBrandMinecraft } from "@tabler/icons-react"; + +import { z } from "@homarr/validation"; + +import { createWidgetDefinition } from "../../definition"; +import { optionsBuilder } from "../../options"; + +export const { componentLoader, definition } = createWidgetDefinition("minecraftServerStatus", { + icon: IconBrandMinecraft, + options: optionsBuilder.from((factory) => ({ + title: factory.text({ defaultValue: "" }), + domain: factory.text({ defaultValue: "hypixel.net", validate: z.string().nonempty() }), + isBedrockServer: factory.switch({ defaultValue: false }), + })), +}).withDynamicImport(() => import("./component")); From 551a2a5fdda6494230ada6a234d25911a8f331bb Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 28 Dec 2024 16:03:08 +0100 Subject: [PATCH 2/2] fix: deepsource issues --- packages/api/src/router/widgets/minecraft.ts | 2 +- packages/widgets/src/minecraft/server-status/component.tsx | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/api/src/router/widgets/minecraft.ts b/packages/api/src/router/widgets/minecraft.ts index 15a7f1c7a..ab1d4c7fb 100644 --- a/packages/api/src/router/widgets/minecraft.ts +++ b/packages/api/src/router/widgets/minecraft.ts @@ -16,7 +16,7 @@ export const minecraftRouter = createTRPCRouter({ isBedrockServer: input.isBedrockServer, domain: input.domain, }); - return innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true }); + return await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true }); }), subscribeServerStatus: publicProcedure.input(serverStatusInputSchema).subscription(({ input }) => { return observable((emit) => { diff --git a/packages/widgets/src/minecraft/server-status/component.tsx b/packages/widgets/src/minecraft/server-status/component.tsx index fe92c8454..bb0be1367 100644 --- a/packages/widgets/src/minecraft/server-status/component.tsx +++ b/packages/widgets/src/minecraft/server-status/component.tsx @@ -43,7 +43,11 @@ export default function MinecraftServerStatusWidget({ options }: WidgetComponent {data.online && ( <> - + {`minecraft