refactor: replace serverdata with suspense query (#1265)

* refactor: replace serverdata with suspense query

* fix: deepsource issues
This commit is contained in:
Meier Lukas
2024-10-11 23:47:07 +02:00
committed by GitHub
parent 511c9a4dbb
commit 0f8d9edb3e
41 changed files with 288 additions and 646 deletions

View File

@@ -28,7 +28,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />,
getInitialBoardAsync: getInitialBoard,
isBoardContentPage: true,
}),
// eslint-disable-next-line no-restricted-syntax
page: async () => {

View File

@@ -8,5 +8,4 @@ export default createBoardLayout<{ locale: string; name: string }>({
async getInitialBoardAsync({ name }) {
return await api.board.getBoardByName({ name });
},
isBoardContentPage: false,
});

View File

@@ -4,7 +4,6 @@ import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server";
import { logger } from "@homarr/log";
import { GlobalItemServerDataRunner } from "@homarr/widgets";
import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
@@ -18,13 +17,11 @@ import { BoardMantineProvider } from "./(content)/_theme";
interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>;
isBoardContentPage: boolean;
}
export const createBoardLayout = <TParams extends Params>({
headerActions,
getInitialBoardAsync: getInitialBoard,
isBoardContentPage,
}: CreateBoardLayoutProps<TParams>) => {
const Layout = async ({
params,
@@ -42,21 +39,19 @@ export const createBoardLayout = <TParams extends Params>({
});
return (
<GlobalItemServerDataRunner board={initialBoard} shouldRun={isBoardContentPage}>
<BoardProvider initialBoard={initialBoard}>
<BoardMantineProvider>
<CustomCss />
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardMantineProvider>
</BoardProvider>
</GlobalItemServerDataRunner>
<BoardProvider initialBoard={initialBoard}>
<BoardMantineProvider>
<CustomCss />
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardMantineProvider>
</BoardProvider>
);
};

View File

@@ -4,7 +4,7 @@ import { QueryErrorResetBoundary } from "@tanstack/react-query";
import combineClasses from "clsx";
import { ErrorBoundary } from "react-error-boundary";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, useServerDataFor } from "@homarr/widgets";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors";
import type { Item } from "~/app/[locale]/boards/_types";
@@ -53,7 +53,6 @@ interface InnerContentProps {
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const serverData = useServerDataFor(item.id);
const Comp = loadWidgetDynamic(item.kind);
const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options);
const newItem = { ...item, options };
@@ -61,8 +60,6 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
updateItemOptions({ itemId: item.id, newOptions });
if (!serverData?.isReady) return null;
return (
<QueryErrorResetBoundary>
{({ reset }) => (
@@ -79,8 +76,6 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
<Comp
options={options as never}
integrationIds={item.integrationIds}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
serverData={serverData?.data as never}
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}

View File

@@ -9,11 +9,12 @@ export const calendarRouter = createTRPCRouter({
findAllEvents: publicProcedure
.unstable_concat(createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.query(async ({ ctx }) => {
return await Promise.all(
const result = await Promise.all(
ctx.integrations.flatMap(async (integration) => {
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id);
return await cache.getAsync();
}),
);
return result.filter((item) => item !== null).flatMap((item) => item.data);
}),
});

View File

@@ -3,7 +3,7 @@ import { IconApps, IconDeviceDesktopX } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("app", {
export const { definition, componentLoader } = createWidgetDefinition("app", {
icon: IconApps,
options: optionsBuilder.from((factory) => ({
appId: factory.app(),

View File

@@ -11,7 +11,20 @@ import type { WidgetComponentProps } from "../definition";
import { CalendarDay } from "./calender-day";
import classes from "./component.module.css";
export default function CalendarWidget({ isEditMode, serverData }: WidgetComponentProps<"calendar">) {
export default function CalendarWidget({ isEditMode, integrationIds, itemId }: WidgetComponentProps<"calendar">) {
const [events] = clientApi.widget.calendar.findAllEvents.useSuspenseQuery(
{
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemId!,
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const [month, setMonth] = useState(new Date());
const params = useParams();
const locale = params.locale as string;
@@ -68,7 +81,7 @@ export default function CalendarWidget({ isEditMode, serverData }: WidgetCompone
},
}}
renderDay={(date) => {
const eventsForDate = (serverData?.initialData ?? []).filter((event) => dayjs(event.date).isSame(date, "day"));
const eventsForDate = events.filter((event) => dayjs(event.date).isSame(date, "day"));
return <CalendarDay date={date} events={eventsForDate} disabled={isEditMode} />;
}}
/>

View File

@@ -6,7 +6,7 @@ import { z } from "@homarr/validation";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("calendar", {
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
icon: IconCalendar,
options: optionsBuilder.from((factory) => ({
filterPastMonths: factory.number({
@@ -19,6 +19,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
}),
})),
supportedIntegrations: getIntegrationKindsByCategory("calendar"),
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));
}).withDynamicImport(() => import("./component"));

View File

@@ -1,35 +0,0 @@
"use server";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerDataAsync({ integrationIds, itemId }: WidgetProps<"calendar">) {
if (!itemId) {
return {
initialData: [],
};
}
try {
const data = await api.widget.calendar.findAllEvents({
integrationIds,
itemId,
});
return {
initialData: data
.filter(
(
item,
): item is Exclude<Exclude<RouterOutputs["widget"]["calendar"]["findAllEvents"][number], null>, undefined> =>
item !== null,
)
.flatMap((item) => item.data),
};
} catch {
return {
initialData: [],
};
}
}

View File

@@ -8,57 +8,22 @@ import type { TablerIcon } from "@homarr/ui";
import type { WidgetImports } from ".";
import type { inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
type ServerDataLoader<TKind extends WidgetKind> = () => Promise<{
default: (props: WidgetProps<TKind>) => Promise<Record<string, unknown>>;
}>;
const createWithDynamicImport =
<
TKind extends WidgetKind,
TDefinition extends WidgetDefinition,
TServerDataLoader extends ServerDataLoader<TKind> | undefined,
>(
kind: TKind,
definition: TDefinition,
serverDataLoader: TServerDataLoader,
) =>
(
componentLoader: () => LoaderComponent<
WidgetComponentProps<TKind> &
(TServerDataLoader extends ServerDataLoader<TKind>
? {
serverData: Awaited<ReturnType<Awaited<ReturnType<TServerDataLoader>>["default"]>>;
}
: never)
>,
) => ({
definition: {
...definition,
kind,
},
kind,
serverDataLoader,
componentLoader,
});
const createWithServerData =
<TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) =>
<TServerDataLoader extends ServerDataLoader<TKind>>(serverDataLoader: TServerDataLoader) => ({
(componentLoader: () => LoaderComponent<WidgetComponentProps<TKind>>) => ({
definition: {
...definition,
kind,
},
kind,
serverDataLoader,
withDynamicImport: createWithDynamicImport(kind, definition, serverDataLoader),
componentLoader,
});
export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition extends WidgetDefinition>(
kind: TKind,
definition: TDefinition,
) => ({
withServerData: createWithServerData(kind, definition),
withDynamicImport: createWithDynamicImport(kind, definition, undefined),
withDynamicImport: createWithDynamicImport(kind, definition),
});
export interface WidgetDefinition {
@@ -83,15 +48,7 @@ export interface WidgetProps<TKind extends WidgetKind> {
itemId: string | undefined; // undefined when in preview mode
}
type inferServerDataForKind<TKind extends WidgetKind> = WidgetImports[TKind] extends {
serverDataLoader: ServerDataLoader<TKind>;
}
? Awaited<ReturnType<Awaited<ReturnType<WidgetImports[TKind]["serverDataLoader"]>>["default"]>>
: undefined;
export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & {
serverData?: inferServerDataForKind<TKind>;
} & {
boardId: string | undefined; // undefined when in preview mode
isEditMode: boolean;
setOptions: ({

View File

@@ -39,16 +39,25 @@ export default function DnsHoleControlsWidget({
options,
integrationIds,
isEditMode,
serverData,
}: WidgetComponentProps<typeof widgetKind>) {
// DnsHole integrations with interaction permissions
const integrationsWithInteractions = useIntegrationsWithInteractAccess()
.map(({ id }) => id)
.filter((id) => integrationIds.includes(id));
// Initial summaries, null summary means disconnected, undefined status means processing
const [summaries, setSummaries] = useState(serverData?.initialData ?? []);
const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
{
widgetKind: "dnsHoleControls",
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const utils = clientApi.useUtils();
// Subscribe to summary updates
clientApi.widget.dnsHole.subscribeToSummary.useSubscription(
{
@@ -57,8 +66,20 @@ export default function DnsHoleControlsWidget({
},
{
onData: (data) => {
setSummaries((prevSummaries) =>
prevSummaries.map((summary) => (summary.integration.id === data.integration.id ? data : summary)),
utils.widget.dnsHole.summary.setData(
{
widgetKind: "dnsHoleControls",
integrationIds,
},
(prevData) => {
if (!prevData) return undefined;
const newData = prevData.map((summary) =>
summary.integration.id === data.integration.id ? { ...summary, summary: data.summary } : summary,
);
return newData;
},
);
},
},
@@ -67,39 +88,77 @@ export default function DnsHoleControlsWidget({
// Mutations for dnsHole state, set to undefined on click, and change again on settle
const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({
onSettled: (_, error, { integrationId }) => {
setSummaries((prevSummaries) =>
prevSummaries.map((data) => ({
...data,
summary:
data.integration.id === integrationId && data.summary
? { ...data.summary, status: error ? "disabled" : "enabled" }
: data.summary,
})),
utils.widget.dnsHole.summary.setData(
{
widgetKind: "dnsHoleControls",
integrationIds,
},
(prevData) => {
if (!prevData) return [];
return prevData.map((item) =>
item.integration.id === integrationId && item.summary
? {
...item,
summary: {
...item.summary,
status: error ? "disabled" : "enabled",
},
}
: item,
);
},
);
},
});
const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({
onSettled: (_, error, { integrationId }) => {
setSummaries((prevSummaries) =>
prevSummaries.map((data) => ({
...data,
summary:
data.integration.id === integrationId && data.summary
? { ...data.summary, status: error ? "enabled" : "disabled" }
: data.summary,
})),
utils.widget.dnsHole.summary.setData(
{
widgetKind: "dnsHoleControls",
integrationIds,
},
(prevData) => {
if (!prevData) return [];
return prevData.map((item) =>
item.integration.id === integrationId && item.summary
? {
...item,
summary: {
...item.summary,
status: error ? "enabled" : "disabled",
},
}
: item,
);
},
);
},
});
const toggleDns = (integrationId: string) => {
const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId);
if (!integrationStatus?.summary?.status) return;
setSummaries((prevSummaries) =>
prevSummaries.map((data) => ({
...data,
summary:
data.integration.id === integrationId && data.summary ? { ...data.summary, status: undefined } : data.summary,
})),
utils.widget.dnsHole.summary.setData(
{
widgetKind: "dnsHoleControls",
integrationIds,
},
(prevData) => {
if (!prevData) return [];
return prevData.map((item) =>
item.integration.id === integrationId && item.summary
? {
...item,
summary: {
...item.summary,
status: undefined,
},
}
: item,
);
},
);
if (integrationStatus.summary.status === "enabled") {
disableDns({ integrationId, duration: 0 });

View File

@@ -7,7 +7,7 @@ import { optionsBuilder } from "../../options";
export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition(widgetKind, {
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconDeviceGamepad,
options: optionsBuilder.from((factory) => ({
showToggleAllButtons: factory.switch({
@@ -21,6 +21,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
message: (t) => t("widget.dnsHoleControls.error.internalServerError"),
},
},
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));
}).withDynamicImport(() => import("./component"));

View File

@@ -1,29 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import { widgetKind } from ".";
import type { WidgetProps } from "../../definition";
export default async function getServerDataAsync({ integrationIds }: WidgetProps<typeof widgetKind>) {
if (integrationIds.length === 0) {
return {
initialData: [],
};
}
try {
const currentDns = await api.widget.dnsHole.summary({
widgetKind,
integrationIds,
});
return {
initialData: currentDns,
};
} catch {
return {
initialData: [],
};
}
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useMemo } from "react";
import type { BoxProps } from "@mantine/core";
import { Avatar, AvatarGroup, Box, Card, Flex, Stack, Text, Tooltip } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
@@ -20,12 +20,20 @@ import { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition";
import { NoIntegrationSelectedError } from "../../errors";
export default function DnsHoleSummaryWidget({
options,
integrationIds,
serverData,
}: WidgetComponentProps<typeof widgetKind>) {
const [summaries, setSummaries] = useState(serverData?.initialData ?? []);
export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<typeof widgetKind>) {
const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
{
widgetKind,
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const utils = clientApi.useUtils();
const t = useI18n();
@@ -36,8 +44,21 @@ export default function DnsHoleSummaryWidget({
},
{
onData: (data) => {
setSummaries((prevSummaries) =>
prevSummaries.map((summary) => (summary.integration.id === data.integration.id ? data : summary)),
utils.widget.dnsHole.summary.setData(
{
widgetKind,
integrationIds,
},
(prevData) => {
if (!prevData) {
return undefined;
}
const newData = prevData.map((item) =>
item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item,
);
return newData;
},
);
},
},
@@ -46,17 +67,10 @@ export default function DnsHoleSummaryWidget({
const data = useMemo(
() =>
summaries
.filter(
(
pair,
): pair is {
integration: typeof pair.integration;
timestamp: typeof pair.timestamp;
summary: DnsHoleSummary;
} => pair.summary !== null && Math.abs(dayjs(pair.timestamp).diff()) < 30000,
)
.flatMap(({ summary }) => summary),
[summaries, serverData],
.filter((pair) => Math.abs(dayjs(pair.timestamp).diff()) < 30000)
.flatMap(({ summary }) => summary)
.filter((summary) => summary !== null),
[summaries],
);
if (integrationIds.length === 0) {

View File

@@ -7,7 +7,7 @@ import { optionsBuilder } from "../../options";
export const widgetKind = "dnsHoleSummary";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition(widgetKind, {
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconAd,
options: optionsBuilder.from((factory) => ({
usePiHoleColors: factory.switch({
@@ -28,6 +28,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
message: (t) => t("widget.dnsHoleSummary.error.internalServerError"),
},
},
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));
}).withDynamicImport(() => import("./component"));

View File

@@ -1,29 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import { widgetKind } from ".";
import type { WidgetProps } from "../../definition";
export default async function getServerDataAsync({ integrationIds }: WidgetProps<typeof widgetKind>) {
if (integrationIds.length === 0) {
return {
initialData: [],
};
}
try {
const currentDns = await api.widget.dnsHole.summary({
widgetKind,
integrationIds,
});
return {
initialData: currentDns,
};
} catch {
return {
initialData: [],
};
}
}

View File

@@ -21,7 +21,7 @@ import {
Title,
Tooltip,
} from "@mantine/core";
import { useDisclosure, useListState, useTimeout } from "@mantine/hooks";
import { useDisclosure, useTimeout } from "@mantine/hooks";
import type { IconProps } from "@tabler/icons-react";
import {
IconAlertTriangle,
@@ -40,9 +40,6 @@ import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { humanFileSize } from "@homarr/common";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema/sqlite";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
import type {
DownloadClientJobsAndStatus,
@@ -91,30 +88,35 @@ export default function DownloadClientsWidget({
isEditMode,
integrationIds,
options,
serverData,
setOptions,
}: WidgetComponentProps<"downloads">) {
const integrationsWithInteractions = useIntegrationsWithInteractAccess().flatMap(({ id }) =>
integrationIds.includes(id) ? [id] : [],
);
const [currentItems, currentItemsHandlers] = useListState<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>;
timestamp: Date;
data: DownloadClientJobsAndStatus | null;
}>(
//Automatically invalidate data older than 30 seconds
serverData?.initialData?.map((item) =>
dayjs().diff(item.timestamp) < invalidateTime ? item : { ...item, timestamp: new Date(0), data: null },
) ?? [],
const [currentItems] = clientApi.widget.downloads.getJobsAndStatuses.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
select(data) {
return data.map((item) =>
dayjs().diff(item.timestamp) < invalidateTime ? item : { ...item, timestamp: new Date(0), data: null },
);
},
},
);
const utils = clientApi.useUtils();
//Invalidate all data after no update for 30 seconds using timer
const invalidationTimer = useTimeout(
() => {
currentItemsHandlers.applyWhere(
() => true,
(item) => ({ ...item, timestamp: new Date(0), data: null }),
utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) =>
prevData?.map((item) => ({ ...item, timestamp: new Date(0), data: null })),
);
},
invalidateTime,
@@ -146,20 +148,24 @@ export default function DownloadClientsWidget({
//Don't update already invalid data (new Date (0))
.filter(({ timestamp }) => dayjs().diff(timestamp) > invalidateTime && timestamp > new Date(0))
.map(({ integration }) => integration.id);
currentItemsHandlers.applyWhere(
({ integration }) => invalidIndexes.includes(integration.id),
//Set date to now so it won't update that integration for at least 30 seconds
(item) => ({ ...item, timestamp: new Date(0), data: null }),
utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) =>
prevData?.map((item) =>
invalidIndexes.includes(item.integration.id) ? item : { ...item, timestamp: new Date(0), data: null },
),
);
//Find id to update
const updateIndex = currentItems.findIndex((pair) => pair.integration.id === data.integration.id);
if (updateIndex >= 0) {
//Update found index
currentItemsHandlers.setItem(updateIndex, data);
} else if (integrationIds.includes(data.integration.id)) {
//Append index not found (new integration)
currentItemsHandlers.append(data);
}
utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) => {
const updateIndex = currentItems.findIndex((pair) => pair.integration.id === data.integration.id);
if (updateIndex >= 0) {
//Update found index
return prevData?.map((pair, index) => (index === updateIndex ? data : pair));
} else if (integrationIds.includes(data.integration.id)) {
//Append index not found (new integration)
return [...(prevData ?? []), data];
}
return undefined;
});
//Reset no update timer
invalidationTimer.clear();
invalidationTimer.start();

View File

@@ -31,7 +31,7 @@ const columnsSort = columnsList.filter((column) =>
sortingExclusion.some((exclusion) => exclusion !== column),
) as Exclude<typeof columnsList, (typeof sortingExclusion)[number]>;
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("downloads", {
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
icon: IconDownload,
options: optionsBuilder.from(
(factory) => ({
@@ -105,6 +105,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
},
),
supportedIntegrations: getIntegrationKindsByCategory("downloadClient"),
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));
}).withDynamicImport(() => import("./component"));

View File

@@ -1,21 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"downloads">) {
if (integrationIds.length === 0) {
return {
initialData: undefined,
};
}
const jobsAndStatuses = await api.widget.downloads.getJobsAndStatuses({
integrationIds,
});
return {
initialData: jobsAndStatuses,
};
}

View File

@@ -17,7 +17,7 @@ import {
Text,
Tooltip,
} from "@mantine/core";
import { useDisclosure, useElementSize, useListState } from "@mantine/hooks";
import { useDisclosure, useElementSize } from "@mantine/hooks";
import {
IconBrain,
IconClock,
@@ -30,19 +30,27 @@ import {
IconVersions,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { NoIntegrationSelectedError } from "../errors";
export default function HealthMonitoringWidget({
options,
integrationIds,
serverData,
}: WidgetComponentProps<"healthMonitoring">) {
export default function HealthMonitoringWidget({ options, integrationIds }: WidgetComponentProps<"healthMonitoring">) {
const t = useI18n();
const [healthData] = useListState(serverData?.initialData ?? []);
const [healthData] = clientApi.widget.healthMonitoring.getHealthStatus.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
select: (data) => data.filter((health) => health !== null),
},
);
const [opened, { open, close }] = useDisclosure(false);
if (integrationIds.length === 0) {

View File

@@ -3,7 +3,7 @@ import { IconHeartRateMonitor, IconServerOff } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("healthMonitoring", {
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor,
options: optionsBuilder.from((factory) => ({
fahrenheit: factory.switch({
@@ -26,6 +26,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
message: (t) => t("widget.healthMonitoring.error.internalServerError"),
},
},
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));
}).withDynamicImport(() => import("./component"));

View File

@@ -1,27 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"healthMonitoring">) {
if (integrationIds.length === 0) {
return {
initialData: [],
};
}
try {
const currentHealthInfo = await api.widget.healthMonitoring.getHealthStatus({
integrationIds,
});
return {
initialData: currentHealthInfo.filter((health) => health !== null),
};
} catch {
return {
initialData: [],
};
}
}

View File

@@ -30,8 +30,6 @@ export { reduceWidgetOptionsWithDefaultValues } from "./options";
export type { WidgetDefinition } from "./definition";
export { WidgetEditModal } from "./modals/widget-edit-modal";
export { useServerDataFor } from "./server/provider";
export { GlobalItemServerDataRunner } from "./server/runner";
export type { WidgetComponentProps };
export const widgetImports = {

View File

@@ -1,25 +1,26 @@
"use client";
import { useState } from "react";
import { Anchor, Button, Card, Container, Flex, Group, ScrollArea, Text } from "@mantine/core";
import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { Indexer } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { NoIntegrationSelectedError } from "../errors";
export default function IndexerManagerWidget({
options,
integrationIds,
serverData,
}: WidgetComponentProps<"indexerManager">) {
export default function IndexerManagerWidget({ options, integrationIds }: WidgetComponentProps<"indexerManager">) {
const t = useI18n();
const [indexersData, setIndexersData] = useState<{ integrationId: string; indexers: Indexer[] }[]>(
serverData?.initialData ?? [],
const [indexersData] = clientApi.widget.indexerManager.getIndexersStatus.useSuspenseQuery(
{ integrationIds },
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const utils = clientApi.useUtils();
const { mutate: testAll, isPending } = clientApi.widget.indexerManager.testAllIndexers.useMutation();
@@ -27,11 +28,11 @@ export default function IndexerManagerWidget({
{ integrationIds },
{
onData(newData) {
setIndexersData((prevData) => {
return prevData.map((item) =>
utils.widget.indexerManager.getIndexersStatus.setData({ integrationIds }, (previousData) =>
previousData?.map((item) =>
item.integrationId === newData.integrationId ? { ...item, indexers: newData.indexers } : item,
);
});
),
);
},
},
);

View File

@@ -5,7 +5,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("indexerManager", {
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
icon: IconReportSearch,
options: optionsBuilder.from((factory) => ({
openIndexerSiteInNewTab: factory.switch({
@@ -19,6 +19,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
message: (t) => t("widget.indexerManager.error.internalServerError"),
},
},
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));
}).withDynamicImport(() => import("./component"));

View File

@@ -1,27 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"indexerManager">) {
if (integrationIds.length === 0) {
return {
initialData: [],
};
}
try {
const currentIndexers = await api.widget.indexerManager.getIndexersStatus({
integrationIds,
});
return {
initialData: currentIndexers,
};
} catch {
return {
initialData: [],
};
}
}

View File

@@ -15,30 +15,26 @@ export default function MediaServerWidget({
integrationIds,
isEditMode,
options,
serverData,
itemId,
}: WidgetComponentProps<"mediaRequests-requestList">) {
const t = useScopedI18n("widget.mediaRequests-requestList");
const isQueryEnabled = Boolean(itemId);
const { data: mediaRequests, isError: _isError } = clientApi.widget.mediaRequests.getLatestRequests.useQuery(
const [mediaRequests] = clientApi.widget.mediaRequests.getLatestRequests.useSuspenseQuery(
{
integrationIds,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemId!,
},
{
initialData: !serverData ? undefined : serverData.initialData,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
enabled: integrationIds.length > 0 && isQueryEnabled,
},
);
const sortedMediaRequests = useMemo(
() =>
mediaRequests
?.filter((group) => group != null)
.filter((group) => group != null)
.flatMap((group) => group.data)
.flatMap(({ medias, integration }) => medias.map((media) => ({ ...media, integrationId: integration.id })))
.sort(({ status: statusA }, { status: statusB }) => {
@@ -49,7 +45,7 @@ export default function MediaServerWidget({
return 1;
}
return 0;
}) ?? [],
}),
[mediaRequests, integrationIds],
);

View File

@@ -5,7 +5,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
export const { componentLoader, definition, serverDataLoader } = createWidgetDefinition("mediaRequests-requestList", {
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
icon: IconZoomQuestion,
options: optionsBuilder.from((factory) => ({
linksTargetNewTab: factory.switch({
@@ -13,6 +13,4 @@ export const { componentLoader, definition, serverDataLoader } = createWidgetDef
}),
})),
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));
}).withDynamicImport(() => import("./component"));

View File

@@ -1,22 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../../definition";
export default async function getServerDataAsync({ integrationIds, itemId }: WidgetProps<"mediaRequests-requestList">) {
if (integrationIds.length === 0 || !itemId) {
return {
initialData: undefined,
};
}
const requests = await api.widget.mediaRequests.getLatestRequests({
integrationIds,
itemId,
});
return {
initialData: requests.filter((group) => group != null),
};
}

View File

@@ -27,30 +27,26 @@ import classes from "./component.module.css";
export default function MediaServerWidget({
integrationIds,
isEditMode,
serverData,
itemId,
}: WidgetComponentProps<"mediaRequests-requestStats">) {
const t = useScopedI18n("widget.mediaRequests-requestStats");
const isQueryEnabled = Boolean(itemId);
const { data: requestStats, isError: _isError } = clientApi.widget.mediaRequests.getStats.useQuery(
const [requestStats] = clientApi.widget.mediaRequests.getStats.useSuspenseQuery(
{
integrationIds,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemId!,
},
{
initialData: !serverData ? undefined : serverData.initialData,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
enabled: integrationIds.length > 0 && isQueryEnabled,
},
);
const { width, height, ref } = useElementSize();
const baseData = useMemo(
() => requestStats?.filter((group) => group != null).flatMap((group) => group.data) ?? [],
() => requestStats.filter((group) => group != null).flatMap((group) => group.data),
[requestStats],
);

View File

@@ -4,10 +4,8 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../../definition";
export const { componentLoader, definition, serverDataLoader } = createWidgetDefinition("mediaRequests-requestStats", {
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", {
icon: IconChartBar,
options: {},
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));
}).withDynamicImport(() => import("./component"));

View File

@@ -1,25 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../../definition";
export default async function getServerDataAsync({
integrationIds,
itemId,
}: WidgetProps<"mediaRequests-requestStats">) {
if (integrationIds.length === 0 || !itemId) {
return {
initialData: undefined,
};
}
const stats = await api.widget.mediaRequests.getStats({
integrationIds,
itemId,
});
return {
initialData: stats.filter((group) => group != null),
};
}

View File

@@ -2,7 +2,6 @@
import { useMemo } from "react";
import { Avatar, Box, Group, Text } from "@mantine/core";
import { useListState } from "@mantine/hooks";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
@@ -12,14 +11,19 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import type { WidgetComponentProps } from "../definition";
export default function MediaServerWidget({
serverData,
integrationIds,
isEditMode,
}: WidgetComponentProps<"mediaServer">) {
const [currentStreams, currentStreamsHandlers] = useListState<{ integrationId: string; sessions: StreamSession[] }>(
serverData?.initialData ?? [],
export default function MediaServerWidget({ integrationIds, isEditMode }: WidgetComponentProps<"mediaServer">) {
const [currentStreams] = clientApi.widget.mediaServer.getCurrentStreams.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const utils = clientApi.useUtils();
const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(
() => [
{
@@ -62,15 +66,17 @@ export default function MediaServerWidget({
{
enabled: !isEditMode,
onData(data) {
currentStreamsHandlers.applyWhere(
(pair) => pair.integrationId === data.integrationId,
(pair) => {
return {
...pair,
sessions: data.data,
};
},
);
utils.widget.mediaServer.getCurrentStreams.setData({ integrationIds }, (previousData) => {
return previousData?.map((pair) => {
if (pair.integrationId === data.integrationId) {
return {
...pair,
sessions: data.data,
};
}
return pair;
});
});
},
},
);

View File

@@ -2,10 +2,8 @@ import { IconVideo } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
export const { componentLoader, definition, serverDataLoader } = createWidgetDefinition("mediaServer", {
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
icon: IconVideo,
options: {},
supportedIntegrations: ["jellyfin"],
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));
}).withDynamicImport(() => import("./component"));

View File

@@ -1,21 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"mediaServer">) {
if (integrationIds.length === 0) {
return {
initialData: [],
};
}
const currentStreams = await api.widget.mediaServer.getCurrentStreams({
integrationIds,
});
return {
initialData: currentStreams,
};
}

View File

@@ -3,15 +3,29 @@ import { Card, Flex, Group, Image, ScrollArea, Stack, Text } from "@mantine/core
import { IconClock } from "@tabler/icons-react";
import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.scss";
export default function RssFeed({ serverData, options }: WidgetComponentProps<"rssFeed">) {
if (serverData?.initialData === undefined) {
return null;
}
export default function RssFeed({ options, itemId }: WidgetComponentProps<"rssFeed">) {
const [rssFeeds] = clientApi.widget.rssFeed.getFeeds.useSuspenseQuery(
{
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemId!,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
select(data) {
return data?.data ?? [];
},
},
);
const entries = serverData.initialData
const entries = rssFeeds
.filter((feedGroup) => feedGroup.feed.entries !== undefined)
.flatMap((feedGroup) => feedGroup.feed.entries)
.filter((entry) => entry !== undefined)

View File

@@ -11,7 +11,7 @@ import { optionsBuilder } from "../options";
* - https://datatracker.ietf.org/doc/html/rfc5023
* - https://www.jsonfeed.org/version/1.1/
*/
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("rssFeed", {
export const { definition, componentLoader } = createWidgetDefinition("rssFeed", {
icon: IconRss,
options: optionsBuilder.from((factory) => ({
feedUrls: factory.multiText({
@@ -27,6 +27,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
validate: z.number().min(1).max(9999),
}),
})),
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));
}).withDynamicImport(() => import("./component"));

View File

@@ -1,21 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerDataAsync({ itemId }: WidgetProps<"rssFeed">) {
if (!itemId) {
return {
initialData: undefined,
lastUpdatedAt: null,
};
}
const data = await api.widget.rssFeed.getFeeds({
itemId,
});
return {
initialData: data?.data,
lastUpdatedAt: data?.timestamp,
};
}

View File

@@ -1,13 +0,0 @@
"use client";
import { useServerDataInitializer } from "./provider";
interface Props {
id: string;
serverData: Record<string, unknown> | undefined;
}
export const ClientServerDataInitalizer = ({ id, serverData }: Props) => {
useServerDataInitializer(id, serverData);
return null;
};

View File

@@ -1,74 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext, useEffect, useState } from "react";
type Data = Record<
string,
{
data: Record<string, unknown> | undefined;
isReady: boolean;
}
>;
interface GlobalItemServerDataContext {
setItemServerData: (id: string, data: Record<string, unknown> | undefined) => void;
data: Data;
initalItemIds: string[];
}
const GlobalItemServerDataContext = createContext<GlobalItemServerDataContext | null>(null);
interface Props {
initalItemIds: string[];
}
export const GlobalItemServerDataProvider = ({ children, initalItemIds }: PropsWithChildren<Props>) => {
const [data, setData] = useState<Data>({});
const setItemServerData = (id: string, itemData: Record<string, unknown> | undefined) => {
setData((prev) => ({
...prev,
[id]: {
data: itemData,
isReady: true,
},
}));
};
return (
<GlobalItemServerDataContext.Provider value={{ setItemServerData, data, initalItemIds }}>
{children}
</GlobalItemServerDataContext.Provider>
);
};
export const useServerDataFor = (id: string) => {
const context = useContext(GlobalItemServerDataContext);
if (!context) {
throw new Error("GlobalItemServerDataProvider is required");
}
// When the item is not in the initial list, it means the data can not come from the server
if (!context.initalItemIds.includes(id)) {
return {
data: undefined,
isReady: true,
};
}
return context.data[id];
};
export const useServerDataInitializer = (id: string, serverData: Record<string, unknown> | undefined) => {
const context = useContext(GlobalItemServerDataContext);
if (!context) {
throw new Error("GlobalItemServerDataProvider is required");
}
useEffect(() => {
context.setItemServerData(id, serverData);
}, []);
};

View File

@@ -1,51 +0,0 @@
import type { PropsWithChildren } from "react";
import { Suspense } from "react";
import type { RouterOutputs } from "@homarr/api";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "..";
import { ClientServerDataInitalizer } from "./client";
import { GlobalItemServerDataProvider } from "./provider";
type Board = RouterOutputs["board"]["getHomeBoard"];
type Props = PropsWithChildren<{
shouldRun: boolean;
board: Board;
}>;
export const GlobalItemServerDataRunner = ({ board, shouldRun, children }: Props) => {
if (!shouldRun) return children;
const allItems = board.sections.flatMap((section) => section.items);
return (
<GlobalItemServerDataProvider initalItemIds={allItems.map(({ id }) => id)}>
{allItems.map((item) => (
<Suspense key={item.id}>
<ItemDataLoader item={item} />
</Suspense>
))}
{children}
</GlobalItemServerDataProvider>
);
};
interface ItemDataLoaderProps {
item: Board["sections"][number]["items"][number];
}
const ItemDataLoader = async ({ item }: ItemDataLoaderProps) => {
const widgetImport = widgetImports[item.kind];
if (!("serverDataLoader" in widgetImport) || !widgetImport.serverDataLoader) {
return <ClientServerDataInitalizer id={item.id} serverData={undefined} />;
}
const loader = await widgetImport.serverDataLoader();
const optionsWithDefault = reduceWidgetOptionsWithDefaultValues(item.kind, item.options);
const data = await loader.default({
...item,
options: optionsWithDefault as never,
itemId: item.id,
});
return <ClientServerDataInitalizer id={item.id} serverData={data} />;
};