-
-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(widget): add minecraft server status widget (#1801)
- Loading branch information
1 parent
b8a155d
commit 0ebf4bc
Showing
13 changed files
with
248 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}; | ||
}); | ||
}), | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }); | ||
}), | ||
); | ||
}, | ||
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
36 changes: 36 additions & 0 deletions
36
packages/request-handler/src/lib/cached-widget-request-handler.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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), | ||
}; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
61 changes: 61 additions & 0 deletions
61
packages/widgets/src/minecraft/server-status/component.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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")); |