Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(widget): add minecraft server status widget #1801

Merged
merged 2 commits into from
Dec 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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 @@ -2319,6 +2338,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"));
Loading