mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 16:30:57 +01:00
feat(widget): add minecraft server status widget (#1801)
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
36
packages/api/src/router/widgets/minecraft.ts
Normal file
36
packages/api/src/router/widgets/minecraft.ts
Normal file
@@ -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();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
25
packages/cron-jobs/src/jobs/minecraft-server-status.ts
Normal file
25
packages/cron-jobs/src/jobs/minecraft-server-status.ts
Normal file
@@ -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 });
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -15,6 +15,7 @@ export const widgetKinds = [
|
||||
"mediaRequests-requestList",
|
||||
"mediaRequests-requestStats",
|
||||
"mediaTranscoding",
|
||||
"minecraftServerStatus",
|
||||
"rssFeed",
|
||||
"bookmarks",
|
||||
"indexerManager",
|
||||
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
createItemAndIntegrationChannel,
|
||||
createItemChannel,
|
||||
createIntegrationOptionsChannel,
|
||||
createWidgetOptionsChannel,
|
||||
createChannelWithLatestAndEvents,
|
||||
handshakeAsync,
|
||||
createSubPubChannel,
|
||||
|
||||
@@ -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}`);
|
||||
};
|
||||
|
||||
@@ -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
packages/request-handler/src/minecraft-server-status.ts
Normal file
35
packages/request-handler/src/minecraft-server-status.ts
Normal file
@@ -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>;
|
||||
@@ -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",
|
||||
@@ -2324,6 +2343,9 @@
|
||||
"error": "Error"
|
||||
},
|
||||
"job": {
|
||||
"minecraftServerStatus": {
|
||||
"label": "Minecraft server status"
|
||||
},
|
||||
"iconsUpdater": {
|
||||
"label": "Icons Updater"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
61
packages/widgets/src/minecraft/server-status/component.tsx
Normal file
61
packages/widgets/src/minecraft/server-status/component.tsx
Normal file
@@ -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
packages/widgets/src/minecraft/server-status/index.ts
Normal file
15
packages/widgets/src/minecraft/server-status/index.ts
Normal file
@@ -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"));
|
||||
Reference in New Issue
Block a user