Skip to content

Commit

Permalink
feat(widget): add minecraft server status widget (#1801)
Browse files Browse the repository at this point in the history
  • Loading branch information
Meierschlumpf authored Dec 31, 2024
1 parent b8a155d commit 0ebf4bc
Show file tree
Hide file tree
Showing 13 changed files with 248 additions and 0 deletions.
2 changes: 2 additions & 0 deletions packages/api/src/router/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -27,4 +28,5 @@ export const widgetRouter = createTRPCRouter({
indexerManager: indexerManagerRouter,
healthMonitoring: healthMonitoringRouter,
mediaTranscoding: mediaTranscodingRouter,
minecraft: minecraftRouter,
});
36 changes: 36 additions & 0 deletions packages/api/src/router/widgets/minecraft.ts
Original file line number Diff line number Diff line change
@@ -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 await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
}),
subscribeServerStatus: publicProcedure.input(serverStatusInputSchema).subscription(({ input }) => {
return observable<MinecraftServerStatus>((emit) => {
const innerHandler = minecraftServerStatusRequestHandler.handler({
isBedrockServer: input.isBedrockServer,
domain: input.domain,
});
const unsubscribe = innerHandler.subscribe((data) => {
emit.next(data);
});

return () => {
unsubscribe();
};
});
}),
});
2 changes: 2 additions & 0 deletions packages/cron-jobs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -33,6 +34,7 @@ export const jobGroup = createCronJobGroup({
sessionCleanup: sessionCleanupJob,
updateChecker: updateCheckerJob,
mediaTranscoding: mediaTranscodingJob,
minecraftServerStatus: minecraftServerStatusJob,
});

export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
Expand Down
25 changes: 25 additions & 0 deletions packages/cron-jobs/src/jobs/minecraft-server-status.ts
Original file line number Diff line number Diff line change
@@ -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<WidgetComponentProps<"minecraftServerStatus">["options"]>(item.options);
const innerHandler = minecraftServerStatusRequestHandler.handler(options);
await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
}),
);
},
);
1 change: 1 addition & 0 deletions packages/definitions/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const widgetKinds = [
"mediaRequests-requestList",
"mediaRequests-requestStats",
"mediaTranscoding",
"minecraftServerStatus",
"rssFeed",
"bookmarks",
"indexerManager",
Expand Down
1 change: 1 addition & 0 deletions packages/redis/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
createItemAndIntegrationChannel,
createItemChannel,
createIntegrationOptionsChannel,
createWidgetOptionsChannel,
createChannelWithLatestAndEvents,
handshakeAsync,
createSubPubChannel,
Expand Down
10 changes: 10 additions & 0 deletions packages/redis/src/lib/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,16 @@ export const createIntegrationOptionsChannel = <TData>(
return createChannelWithLatestAndEvents<TData>(channelName);
};

export const createWidgetOptionsChannel = <TData>(
widgetKind: WidgetKind,
queryKey: string,
options: Record<string, unknown>,
) => {
const optionsKey = hashObjectBase64(options);
const channelName = `widget:${widgetKind}:${queryKey}:options:${optionsKey}`;
return createChannelWithLatestAndEvents<TData>(channelName);
};

export const createItemChannel = <TData>(itemId: string) => {
return createChannelWithLatestAndEvents<TData>(`item:${itemId}`);
};
Expand Down
36 changes: 36 additions & 0 deletions packages/request-handler/src/lib/cached-widget-request-handler.ts
Original file line number Diff line number Diff line change
@@ -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<TData, TKind extends WidgetKind, TInput extends Record<string, unknown>> {
// Unique key for this request handler
queryKey: string;
requestAsync: (input: TInput) => Promise<TData>;
cacheDuration: Duration;
widgetKind: TKind;
}

export const createCachedWidgetRequestHandler = <
TData,
TKind extends WidgetKind,
TInput extends Record<string, unknown>,
>(
requestHandlerOptions: Options<TData, TKind, TInput>,
) => {
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<TData>(requestHandlerOptions.widgetKind, options.queryKey, input);
},
}).handler(widgetOptions),
};
};
35 changes: 35 additions & 0 deletions packages/request-handler/src/minecraft-server-status.ts
Original file line number Diff line number Diff line change
@@ -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<typeof responseSchema>;
22 changes: 22 additions & 0 deletions packages/translation/src/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -2324,6 +2343,9 @@
"error": "Error"
},
"job": {
"minecraftServerStatus": {
"label": "Minecraft server status"
},
"iconsUpdater": {
"label": "Icons Updater"
},
Expand Down
2 changes: 2 additions & 0 deletions packages/widgets/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -54,6 +55,7 @@ export const widgetImports = {
indexerManager,
healthMonitoring,
mediaTranscoding,
minecraftServerStatus,
} satisfies WidgetImportRecord;

export type WidgetImports = typeof widgetImports;
Expand Down
61 changes: 61 additions & 0 deletions packages/widgets/src/minecraft/server-status/component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"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 (
<Flex
className="minecraftServerStatus-wrapper"
h="100%"
w="100%"
direction="column"
p="7.5cqmin"
justify="center"
align="center"
>
<Group gap="5cqmin" wrap="nowrap" align="center">
<Tooltip label={data.online ? tStatus("online") : tStatus("offline")}>
<Box w="8cqmin" h="8cqmin" bg={data.online ? "teal" : "red"} style={{ borderRadius: "100%" }}></Box>
</Tooltip>
<Text size="10cqmin" fw="bold">
{title}
</Text>
</Group>
{data.online && (
<>
<img
style={{ flex: 1, transform: "scale(0.8)", objectFit: "contain" }}
alt={`minecraft icon ${options.domain}`}
src={data.icon}
/>
<Group gap="2cqmin" c="gray.6" align="center">
<IconUsersGroup style={{ width: "10cqmin", height: "10cqmin" }} />
<Text size="10cqmin">
{data.players.online}/{data.players.max}
</Text>
</Group>
</>
)}
</Flex>
);
}
15 changes: 15 additions & 0 deletions packages/widgets/src/minecraft/server-status/index.ts
Original file line number Diff line number Diff line change
@@ -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"));

0 comments on commit 0ebf4bc

Please sign in to comment.