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 { mediaRequestsRouter } from "./media-requests";
|
||||||
import { mediaServerRouter } from "./media-server";
|
import { mediaServerRouter } from "./media-server";
|
||||||
import { mediaTranscodingRouter } from "./media-transcoding";
|
import { mediaTranscodingRouter } from "./media-transcoding";
|
||||||
|
import { minecraftRouter } from "./minecraft";
|
||||||
import { notebookRouter } from "./notebook";
|
import { notebookRouter } from "./notebook";
|
||||||
import { rssFeedRouter } from "./rssFeed";
|
import { rssFeedRouter } from "./rssFeed";
|
||||||
import { smartHomeRouter } from "./smart-home";
|
import { smartHomeRouter } from "./smart-home";
|
||||||
@@ -27,4 +28,5 @@ export const widgetRouter = createTRPCRouter({
|
|||||||
indexerManager: indexerManagerRouter,
|
indexerManager: indexerManagerRouter,
|
||||||
healthMonitoring: healthMonitoringRouter,
|
healthMonitoring: healthMonitoringRouter,
|
||||||
mediaTranscoding: mediaTranscodingRouter,
|
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 { mediaRequestListJob, mediaRequestStatsJob } from "./jobs/integrations/media-requests";
|
||||||
import { mediaServerJob } from "./jobs/integrations/media-server";
|
import { mediaServerJob } from "./jobs/integrations/media-server";
|
||||||
import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding";
|
import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding";
|
||||||
|
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
|
||||||
import { pingJob } from "./jobs/ping";
|
import { pingJob } from "./jobs/ping";
|
||||||
import type { RssFeed } from "./jobs/rss-feeds";
|
import type { RssFeed } from "./jobs/rss-feeds";
|
||||||
import { rssFeedsJob } from "./jobs/rss-feeds";
|
import { rssFeedsJob } from "./jobs/rss-feeds";
|
||||||
@@ -33,6 +34,7 @@ export const jobGroup = createCronJobGroup({
|
|||||||
sessionCleanup: sessionCleanupJob,
|
sessionCleanup: sessionCleanupJob,
|
||||||
updateChecker: updateCheckerJob,
|
updateChecker: updateCheckerJob,
|
||||||
mediaTranscoding: mediaTranscodingJob,
|
mediaTranscoding: mediaTranscodingJob,
|
||||||
|
minecraftServerStatus: minecraftServerStatusJob,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
|
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-requestList",
|
||||||
"mediaRequests-requestStats",
|
"mediaRequests-requestStats",
|
||||||
"mediaTranscoding",
|
"mediaTranscoding",
|
||||||
|
"minecraftServerStatus",
|
||||||
"rssFeed",
|
"rssFeed",
|
||||||
"bookmarks",
|
"bookmarks",
|
||||||
"indexerManager",
|
"indexerManager",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export {
|
|||||||
createItemAndIntegrationChannel,
|
createItemAndIntegrationChannel,
|
||||||
createItemChannel,
|
createItemChannel,
|
||||||
createIntegrationOptionsChannel,
|
createIntegrationOptionsChannel,
|
||||||
|
createWidgetOptionsChannel,
|
||||||
createChannelWithLatestAndEvents,
|
createChannelWithLatestAndEvents,
|
||||||
handshakeAsync,
|
handshakeAsync,
|
||||||
createSubPubChannel,
|
createSubPubChannel,
|
||||||
|
|||||||
@@ -183,6 +183,16 @@ export const createIntegrationOptionsChannel = <TData>(
|
|||||||
return createChannelWithLatestAndEvents<TData>(channelName);
|
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) => {
|
export const createItemChannel = <TData>(itemId: string) => {
|
||||||
return createChannelWithLatestAndEvents<TData>(`item:${itemId}`);
|
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": {
|
"notebook": {
|
||||||
"name": "Notebook",
|
"name": "Notebook",
|
||||||
"description": "A simple notebook widget that supports markdown",
|
"description": "A simple notebook widget that supports markdown",
|
||||||
@@ -2324,6 +2343,9 @@
|
|||||||
"error": "Error"
|
"error": "Error"
|
||||||
},
|
},
|
||||||
"job": {
|
"job": {
|
||||||
|
"minecraftServerStatus": {
|
||||||
|
"label": "Minecraft server status"
|
||||||
|
},
|
||||||
"iconsUpdater": {
|
"iconsUpdater": {
|
||||||
"label": "Icons Updater"
|
"label": "Icons Updater"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import * as mediaRequestsList from "./media-requests/list";
|
|||||||
import * as mediaRequestsStats from "./media-requests/stats";
|
import * as mediaRequestsStats from "./media-requests/stats";
|
||||||
import * as mediaServer from "./media-server";
|
import * as mediaServer from "./media-server";
|
||||||
import * as mediaTranscoding from "./media-transcoding";
|
import * as mediaTranscoding from "./media-transcoding";
|
||||||
|
import * as minecraftServerStatus from "./minecraft/server-status";
|
||||||
import * as notebook from "./notebook";
|
import * as notebook from "./notebook";
|
||||||
import type { WidgetOptionDefinition } from "./options";
|
import type { WidgetOptionDefinition } from "./options";
|
||||||
import * as rssFeed from "./rssFeed";
|
import * as rssFeed from "./rssFeed";
|
||||||
@@ -54,6 +55,7 @@ export const widgetImports = {
|
|||||||
indexerManager,
|
indexerManager,
|
||||||
healthMonitoring,
|
healthMonitoring,
|
||||||
mediaTranscoding,
|
mediaTranscoding,
|
||||||
|
minecraftServerStatus,
|
||||||
} satisfies WidgetImportRecord;
|
} satisfies WidgetImportRecord;
|
||||||
|
|
||||||
export type WidgetImports = typeof widgetImports;
|
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