refactor: add request handlers for centralized cached requests (#1504)

* feat: add object base64 hash method

* chore: add script to add package

* feat: add request-handler package

* wip: add request handlers for all jobs and widget api procedures

* wip: remove errors shown in logs, add missing decryption for secrets in cached-request-job-handler

* wip: highly improve request handler, add request handlers for calendar, media-server, indexer-manager and more, add support for multiple inputs from job handler creator

* refactor: move media-server requests to request-handler, add invalidation logic for dns-hole and media requests

* refactor: remove unused integration item middleware

* feat: add invalidation to switch entity action of smart-home

* fix: lint issues

* chore: use integration-kind-by-category instead of union for request-handlers

* fix: build not working for tasks and websocket

* refactor: add more logs

* refactor: readd timestamp logic for diconnect status

* fix: lint and typecheck issue

* chore: address pull request feedback
This commit is contained in:
Meier Lukas
2024-11-23 17:16:44 +01:00
committed by GitHub
parent cdfb61fb28
commit 32ee9f3dcc
73 changed files with 1114 additions and 665 deletions

View File

@@ -0,0 +1,23 @@
"use client";
import { Button } from "@mantine/core";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import type { SortableItemListInput } from "../options";
import { AppSelectModal } from "./app-select-modal";
export const BookmarkAddButton: SortableItemListInput<
{ name: string; description: string | null; id: string; iconUrl: string; href: string | null },
string
>["AddButton"] = ({ addItem, values }) => {
const { openModal } = useModalAction(AppSelectModal);
const t = useI18n();
return (
<Button onClick={() => openModal({ onSelect: addItem, presentAppIds: values })}>
{t("widget.bookmarks.option.items.add")}
</Button>
);
};

View File

@@ -1,14 +1,12 @@
import { ActionIcon, Avatar, Button, Group, Stack, Text } from "@mantine/core";
import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core";
import { IconClock, IconX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { AppSelectModal } from "./app-select-modal";
import { BookmarkAddButton } from "./add-button";
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
icon: IconClock,
@@ -42,16 +40,7 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
</Group>
);
},
AddButton({ addItem, values }) {
const { openModal } = useModalAction(AppSelectModal);
const t = useI18n();
return (
<Button onClick={() => openModal({ onSelect: addItem, presentAppIds: values })}>
{t("widget.bookmarks.option.items.add")}
</Button>
);
},
AddButton: BookmarkAddButton,
uniqueIdentifier: (item) => item.id,
useData: (initialIds) => {
const { data, error, isLoading } = clientApi.app.byIds.useQuery(initialIds);

View File

@@ -12,17 +12,14 @@ import type { WidgetComponentProps } from "../definition";
import { CalendarDay } from "./calender-day";
import classes from "./component.module.css";
export default function CalendarWidget({
isEditMode,
integrationIds,
itemId,
options,
}: WidgetComponentProps<"calendar">) {
export default function CalendarWidget({ isEditMode, integrationIds, options }: WidgetComponentProps<"calendar">) {
const [month, setMonth] = useState(new Date());
const [events] = clientApi.widget.calendar.findAllEvents.useSuspenseQuery(
{
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemId!,
integrationIds,
month: month.getMonth(),
year: month.getFullYear(),
releaseType: options.releaseType,
},
{
refetchOnMount: false,
@@ -31,7 +28,6 @@ export default function CalendarWidget({
retry: false,
},
);
const [month, setMonth] = useState(new Date());
const params = useParams();
const locale = params.locale as string;
const [firstDayOfWeek] = clientApi.user.getFirstDayOfWeekForUserOrDefault.useSuspenseQuery();

View File

@@ -18,16 +18,16 @@ import {
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconCircleFilled, IconClockPause, IconPlayerPlay, IconPlayerStop } from "@tabler/icons-react";
import dayjs from "dayjs";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useIntegrationConnected } from "@homarr/common";
import { integrationDefs } from "@homarr/definitions";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { widgetKind } from ".";
import type { widgetKind } from ".";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationSelectedError } from "../../errors";
import TimerModal from "./TimerModal";
@@ -47,7 +47,6 @@ export default function DnsHoleControlsWidget({
const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
{
widgetKind: "dnsHoleControls",
integrationIds,
},
{
@@ -61,21 +60,27 @@ export default function DnsHoleControlsWidget({
// Subscribe to summary updates
clientApi.widget.dnsHole.subscribeToSummary.useSubscription(
{
widgetKind,
integrationIds,
},
{
onData: (data) => {
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,
summary.integration.id === data.integration.id
? {
integration: {
...summary.integration,
updatedAt: new Date(),
},
summary: data.summary,
}
: summary,
);
return newData;
@@ -90,14 +95,13 @@ export default function DnsHoleControlsWidget({
onSettled: (_, error, { integrationId }) => {
utils.widget.dnsHole.summary.setData(
{
widgetKind: "dnsHoleControls",
integrationIds,
},
(prevData) => {
if (!prevData) return [];
return prevData.map((item) =>
item.integration.id === integrationId && item.summary
item.integration.id === integrationId
? {
...item,
summary: {
@@ -115,14 +119,13 @@ export default function DnsHoleControlsWidget({
onSettled: (_, error, { integrationId }) => {
utils.widget.dnsHole.summary.setData(
{
widgetKind: "dnsHoleControls",
integrationIds,
},
(prevData) => {
if (!prevData) return [];
return prevData.map((item) =>
item.integration.id === integrationId && item.summary
item.integration.id === integrationId
? {
...item,
summary: {
@@ -138,17 +141,16 @@ export default function DnsHoleControlsWidget({
});
const toggleDns = (integrationId: string) => {
const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId);
if (!integrationStatus?.summary?.status) return;
if (!integrationStatus?.summary.status) return;
utils.widget.dnsHole.summary.setData(
{
widgetKind: "dnsHoleControls",
integrationIds,
},
(prevData) => {
if (!prevData) return [];
return prevData.map((item) =>
item.integration.id === integrationId && item.summary
item.integration.id === integrationId
? {
...item,
summary: {
@@ -170,7 +172,7 @@ export default function DnsHoleControlsWidget({
// make lists of enabled and disabled interactable integrations (with permissions, not disconnected and not processing)
const integrationsSummaries = summaries.reduce(
(acc, { summary, integration: { id } }) =>
integrationsWithInteractions.includes(id) && summary?.status != null ? (acc[summary.status].push(id), acc) : acc,
integrationsWithInteractions.includes(id) && summary.status != null ? (acc[summary.status].push(id), acc) : acc,
{ enabled: [] as string[], disabled: [] as string[] },
);
@@ -310,9 +312,8 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
open,
t,
}) => {
// Independently determine connection status, current state and permissions
const isConnected = data.summary !== null && Math.abs(dayjs(data.timestamp).diff()) < 30000;
const isEnabled = data.summary?.status ? data.summary.status === "enabled" : undefined;
const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 });
const isEnabled = data.summary.status ? data.summary.status === "enabled" : undefined;
const isInteractPermitted = integrationsWithInteractions.includes(data.integration.id);
// Use all factors to infer the state of the action buttons
const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected;
@@ -355,7 +356,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
lts="0.1cqmin"
color="var(--background-color)"
c="var(--mantine-color-text)"
styles={{ section: { marginInlineEnd: "2.5cqmin" } }}
styles={{ section: { marginInlineEnd: "2.5cqmin" }, root: { cursor: "inherit" } }}
leftSection={
isConnected && (
<IconCircleFilled

View File

@@ -5,7 +5,6 @@ import type { BoxProps } from "@mantine/core";
import { Avatar, AvatarGroup, Box, Card, Flex, Stack, Text, Tooltip } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
import { formatNumber } from "@homarr/common";
@@ -16,14 +15,13 @@ import { translateIfNecessary } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { widgetKind } from ".";
import type { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition";
import { NoIntegrationSelectedError } from "../../errors";
export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<typeof widgetKind>) {
const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
{
widgetKind,
integrationIds,
},
{
@@ -39,14 +37,12 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget
clientApi.widget.dnsHole.subscribeToSummary.useSubscription(
{
widgetKind,
integrationIds,
},
{
onData: (data) => {
utils.widget.dnsHole.summary.setData(
{
widgetKind,
integrationIds,
},
(prevData) => {
@@ -64,14 +60,7 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget
},
);
const data = useMemo(
() =>
summaries
.filter((pair) => Math.abs(dayjs(pair.timestamp).diff()) < 30000)
.flatMap(({ summary }) => summary)
.filter((summary) => summary !== null),
[summaries],
);
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
if (integrationIds.length === 0) {
throw new NoIntegrationSelectedError();

View File

@@ -21,7 +21,7 @@ import {
Title,
Tooltip,
} from "@mantine/core";
import { useDisclosure, useTimeout } from "@mantine/hooks";
import { useDisclosure } from "@mantine/hooks";
import type { IconProps } from "@tabler/icons-react";
import {
IconAlertTriangle,
@@ -39,13 +39,9 @@ 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 { humanFileSize, useIntegrationConnected } from "@homarr/common";
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
import type {
DownloadClientJobsAndStatus,
ExtendedClientStatus,
ExtendedDownloadClientItem,
} from "@homarr/integrations";
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
@@ -82,8 +78,6 @@ const standardIconStyle: IconProps["style"] = {
width: "var(--icon-size)",
};
const invalidateTime = 30000;
export default function DownloadClientsWidget({
isEditMode,
integrationIds,
@@ -103,26 +97,10 @@ export default function DownloadClientsWidget({
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(
() => {
utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) =>
prevData?.map((item) => ({ ...item, timestamp: new Date(0), data: null })),
);
},
invalidateTime,
{ autoInvoke: true },
);
//Translations
const t = useScopedI18n("widget.downloads");
const tCommon = useScopedI18n("common");
@@ -143,32 +121,19 @@ export default function DownloadClientsWidget({
},
{
onData: (data) => {
//Use cyclical update to invalidate data older than 30 seconds from unresponsive integrations
const invalidIndexes = currentItems
//Don't update already invalid data (new Date (0))
.filter(({ timestamp }) => dayjs().diff(timestamp) > invalidateTime && timestamp > new Date(0))
.map(({ integration }) => integration.id);
utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) =>
prevData?.map((item) =>
invalidIndexes.includes(item.integration.id) ? item : { ...item, timestamp: new Date(0), data: null },
),
);
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 prevData?.map((item) => {
if (item.integration.id !== data.integration.id) return item;
return undefined;
return {
data: data.data,
integration: {
...data.integration,
updatedAt: new Date(),
},
};
});
});
//Reset no update timer
invalidationTimer.clear();
invalidationTimer.start();
},
},
);
@@ -179,16 +144,6 @@ export default function DownloadClientsWidget({
currentItems
//Insure it is only using selected integrations
.filter(({ integration }) => integrationIds.includes(integration.id))
//Removing any integration with no data associated
.filter(
(
pair,
): pair is {
integration: typeof pair.integration;
timestamp: typeof pair.timestamp;
data: DownloadClientJobsAndStatus;
} => pair.data != null,
)
//Construct normalized items list
.flatMap((pair) =>
//Apply user white/black list
@@ -255,7 +210,6 @@ export default function DownloadClientsWidget({
.filter(({ integration }) => integrationIds.includes(integration.id))
.flatMap(({ integration, data }): ExtendedClientStatus => {
const interact = integrationsWithInteractions.includes(integration.id);
if (!data) return { integration, interact };
const isTorrent = getIntegrationKindsByCategory("torrent").some((kind) => kind === integration.kind);
/** Derived from current items */
const { totalUp, totalDown } = data.items
@@ -821,12 +775,7 @@ const ClientsControl = ({ clients, style }: ClientsControlProps) => {
<Group gap="var(--space-size)" style={style}>
<AvatarGroup spacing="calc(var(--space-size)*2)">
{clients.map((client) => (
<Avatar
key={client.integration.id}
src={getIconUrl(client.integration.kind)}
size="var(--image-size)"
bd={client.status ? 0 : "calc(var(--space-size)*0.5) solid var(--mantine-color-red-filled)"}
/>
<ClientAvatar key={client.integration.id} client={client} />
))}
</AvatarGroup>
{someInteract && (
@@ -939,3 +888,21 @@ const ClientsControl = ({ clients, style }: ClientsControlProps) => {
</Group>
);
};
interface ClientAvatarProps {
client: ExtendedClientStatus;
}
const ClientAvatar = ({ client }: ClientAvatarProps) => {
const isConnected = useIntegrationConnected(client.integration.updatedAt, { timeout: 30000 });
return (
<Avatar
key={client.integration.id}
src={getIconUrl(client.integration.kind)}
style={{ filter: !isConnected ? "grayscale(100%)" : undefined }}
size="var(--image-size)"
bd={client.status ? 0 : "calc(var(--space-size)*0.5) solid var(--mantine-color-red-filled)"}
/>
);
};

View File

@@ -33,7 +33,6 @@ import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import { clientApi } from "@homarr/api/client";
import type { HealthMonitoring } from "@homarr/integrations";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
@@ -53,17 +52,6 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
select: (data) =>
data.filter(
(
health,
): health is {
integrationId: string;
integrationName: string;
healthInfo: HealthMonitoring;
timestamp: Date;
} => health.healthInfo !== null,
),
},
);
const [opened, { open, close }] = useDisclosure(false);
@@ -82,16 +70,7 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg
? { ...item, healthInfo: data.healthInfo, timestamp: data.timestamp }
: item,
);
return newData.filter(
(
health,
): health is {
integrationId: string;
integrationName: string;
healthInfo: HealthMonitoring;
timestamp: Date;
} => health.healthInfo !== null,
);
return newData;
});
},
},
@@ -102,7 +81,7 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg
}
return (
<Stack h="100%" gap="2.5cqmin" className="health-monitoring">
{healthData.map(({ integrationId, integrationName, healthInfo, timestamp }) => {
{healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => {
const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed);
return (
@@ -211,15 +190,17 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg
)}
{options.memory && <MemoryRing available={healthInfo.memAvailable} used={healthInfo.memUsed} />}
</Flex>
<Text
className="health-monitoring-status-update-time"
c="dimmed"
size="3.5cqmin"
ta="center"
mb="2.5cqmin"
>
{t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(timestamp).fromNow() })}
</Text>
{
<Text
className="health-monitoring-status-update-time"
c="dimmed"
size="3.5cqmin"
ta="center"
mb="2.5cqmin"
>
{t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(updatedAt).fromNow() })}
</Text>
}
</Card>
{options.fileSystem &&
disksData.map((disk) => {

View File

@@ -1,3 +1,5 @@
"use client";
import { Box, Stack, Text, Title } from "@mantine/core";
import { IconBrowserOff } from "@tabler/icons-react";

View File

@@ -3,7 +3,8 @@ import type { Loader } from "next/dynamic";
import dynamic from "next/dynamic";
import { Center, Loader as UiLoader } from "@mantine/core";
import type { WidgetKind } from "@homarr/definitions";
import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import * as app from "./app";
import * as bookmarks from "./bookmarks";
@@ -21,16 +22,14 @@ import * as mediaRequestsList from "./media-requests/list";
import * as mediaRequestsStats from "./media-requests/stats";
import * as mediaServer from "./media-server";
import * as notebook from "./notebook";
import type { WidgetOptionDefinition } from "./options";
import * as rssFeed from "./rssFeed";
import * as smartHomeEntityState from "./smart-home/entity-state";
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
import * as video from "./video";
import * as weather from "./weather";
export { reduceWidgetOptionsWithDefaultValues } from "./options";
export type { WidgetDefinition } from "./definition";
export { WidgetEditModal } from "./modals/widget-edit-modal";
export type { WidgetComponentProps };
export const widgetImports = {
@@ -84,3 +83,21 @@ export type inferSupportedIntegrations<TKind extends WidgetKind> = (WidgetImport
}
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
: string[])[number];
export type inferSupportedIntegrationsStrict<TKind extends WidgetKind> = (WidgetImports[TKind]["definition"] extends {
supportedIntegrations: IntegrationKind[];
}
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
: never[])[number];
export const reduceWidgetOptionsWithDefaultValues = (kind: WidgetKind, currentValue: Record<string, unknown> = {}) => {
const definition = widgetImports[kind].definition;
const options = definition.options as Record<string, WidgetOptionDefinition>;
return objectEntries(options).reduce(
(prev, [key, value]) => ({
...prev,
[key]: currentValue[key] ?? value.defaultValue,
}),
{} as Record<string, unknown>,
);
};

View File

@@ -1,4 +1,5 @@
import { useMemo } from "react";
"use client";
import { ActionIcon, Anchor, Avatar, Badge, Card, Group, Image, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
import { IconThumbDown, IconThumbUp } from "@tabler/icons-react";
@@ -15,14 +16,11 @@ export default function MediaServerWidget({
integrationIds,
isEditMode,
options,
itemId,
}: WidgetComponentProps<"mediaRequests-requestList">) {
const t = useScopedI18n("widget.mediaRequests-requestList");
const [mediaRequests] = clientApi.widget.mediaRequests.getLatestRequests.useSuspenseQuery(
{
integrationIds,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemId!,
},
{
refetchOnMount: false,
@@ -30,30 +28,39 @@ export default function MediaServerWidget({
refetchOnReconnect: false,
},
);
const utils = clientApi.useUtils();
clientApi.widget.mediaRequests.subscribeToLatestRequests.useSubscription(
{
integrationIds,
},
{
onData(data) {
utils.widget.mediaRequests.getLatestRequests.setData({ integrationIds }, (prevData) => {
if (!prevData) return [];
const sortedMediaRequests = useMemo(
() =>
mediaRequests
.filter((group) => group != null)
.flatMap((group) => group.data)
.flatMap(({ medias, integration }) => medias.map((media) => ({ ...media, integrationId: integration.id })))
.sort(({ status: statusA }, { status: statusB }) => {
if (statusA === MediaRequestStatus.PendingApproval) {
return -1;
}
if (statusB === MediaRequestStatus.PendingApproval) {
return 1;
}
return 0;
}),
[mediaRequests],
const filteredData = prevData.filter(({ integrationId }) => integrationId !== data.integrationId);
const newData = filteredData.concat(
data.requests.map((request) => ({ ...request, integrationId: data.integrationId })),
);
return newData.sort(({ status: statusA }, { status: statusB }) => {
if (statusA === MediaRequestStatus.PendingApproval) {
return -1;
}
if (statusB === MediaRequestStatus.PendingApproval) {
return 1;
}
return 0;
});
});
},
},
);
const { mutate: mutateRequestAnswer } = clientApi.widget.mediaRequests.answerRequest.useMutation();
if (integrationIds.length === 0) throw new NoIntegrationSelectedError();
if (sortedMediaRequests.length === 0) throw new NoIntegrationDataError();
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
return (
<ScrollArea
@@ -62,7 +69,7 @@ export default function MediaServerWidget({
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack className="mediaRequests-list-list" gap="2cqmin" p="2cqmin">
{sortedMediaRequests.map((mediaRequest) => (
{mediaRequests.map((mediaRequest) => (
<Card
className={`mediaRequests-list-item-wrapper mediaRequests-list-item-${mediaRequest.type} mediaRequests-list-item-${mediaRequest.status}`}
key={mediaRequest.id}

View File

@@ -1,4 +1,5 @@
import { useMemo } from "react";
"use client";
import { ActionIcon, Avatar, Card, Grid, Group, Space, Stack, Text, Tooltip } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import type { Icon } from "@tabler/icons-react";
@@ -27,14 +28,11 @@ import classes from "./component.module.css";
export default function MediaServerWidget({
integrationIds,
isEditMode,
itemId,
}: WidgetComponentProps<"mediaRequests-requestStats">) {
const t = useScopedI18n("widget.mediaRequests-requestStats");
const [requestStats] = clientApi.widget.mediaRequests.getStats.useSuspenseQuery(
{
integrationIds,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemId!,
},
{
refetchOnMount: false,
@@ -45,67 +43,51 @@ export default function MediaServerWidget({
const { width, height, ref } = useElementSize();
const baseData = useMemo(
() => requestStats.filter((group) => group != null).flatMap((group) => group.data),
[requestStats],
);
const stats = useMemo(() => baseData.flatMap(({ stats }) => stats), [baseData]);
const users = useMemo(
() =>
baseData
.flatMap(({ integration, users }) =>
users.flatMap((user) => ({ ...user, appKind: integration.kind, appName: integration.name })),
)
.sort(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
[baseData],
);
if (integrationIds.length === 0) throw new NoIntegrationSelectedError();
if (users.length === 0 || stats.length === 0) throw new NoIntegrationDataError();
if (requestStats.users.length === 0 && requestStats.stats.length === 0) throw new NoIntegrationDataError();
//Add processing and available
const data = [
{
name: "approved",
icon: IconThumbUp,
number: stats.reduce((count, { approved }) => count + approved, 0),
number: requestStats.stats.reduce((count, { approved }) => count + approved, 0),
},
{
name: "pending",
icon: IconHourglass,
number: stats.reduce((count, { pending }) => count + pending, 0),
number: requestStats.stats.reduce((count, { pending }) => count + pending, 0),
},
{
name: "processing",
icon: IconLoaderQuarter,
number: stats.reduce((count, { processing }) => count + processing, 0),
number: requestStats.stats.reduce((count, { processing }) => count + processing, 0),
},
{
name: "declined",
icon: IconThumbDown,
number: stats.reduce((count, { declined }) => count + declined, 0),
number: requestStats.stats.reduce((count, { declined }) => count + declined, 0),
},
{
name: "available",
icon: IconPlayerPlay,
number: stats.reduce((count, { available }) => count + available, 0),
number: requestStats.stats.reduce((count, { available }) => count + available, 0),
},
{
name: "tv",
icon: IconDeviceTv,
number: stats.reduce((count, { tv }) => count + tv, 0),
number: requestStats.stats.reduce((count, { tv }) => count + tv, 0),
},
{
name: "movie",
icon: IconMovie,
number: stats.reduce((count, { movie }) => count + movie, 0),
number: requestStats.stats.reduce((count, { movie }) => count + movie, 0),
},
{
name: "total",
icon: IconReceipt,
number: stats.reduce((count, { total }) => count + total, 0),
number: requestStats.stats.reduce((count, { total }) => count + total, 0),
},
] satisfies { name: keyof RequestStats; icon: Icon; number: number }[];
@@ -156,7 +138,7 @@ export default function MediaServerWidget({
gap="2cqmin"
style={{ overflow: "hidden" }}
>
{users.slice(0, Math.max(Math.floor((height / width) * 5), 1)).map((user) => (
{requestStats.users.slice(0, Math.max(Math.floor((height / width) * 5), 1)).map((user) => (
<Card
className={combineClasses(
"mediaRequests-stats-users-user-wrapper",
@@ -170,12 +152,12 @@ export default function MediaServerWidget({
radius="2.5cqmin"
>
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="2cqmin" display="flex">
<Tooltip label={user.appName}>
<Tooltip label={user.integration.name}>
<Avatar
className="mediaRequests-stats-users-user-avatar"
size="12.5cqmin"
src={user.avatar}
bd={`0.5cqmin solid ${user.appKind === "overseerr" ? "#ECB000" : "#6677CC"}`}
bd={`0.5cqmin solid ${user.integration.kind === "overseerr" ? "#ECB000" : "#6677CC"}`}
/>
</Tooltip>
<Stack className="mediaRequests-stats-users-user-infos" gap="2cqmin">

View File

@@ -0,0 +1 @@
export * from "./widget-edit-modal";

View File

@@ -2,12 +2,10 @@ import type React from "react";
import type { DraggableAttributes, UniqueIdentifier } from "@dnd-kit/core";
import type { ActionIconProps } from "@mantine/core";
import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import type { ZodType } from "@homarr/validation";
import { z } from "@homarr/validation";
import { widgetImports } from ".";
import type { inferSelectOptionValue, SelectOption } from "./_inputs/widget-select-input";
interface CommonInput<TType> {
@@ -25,7 +23,7 @@ interface MultiSelectInput<TOptions extends SelectOption[]>
searchable?: boolean;
}
interface SortableItemListInput<TItem, TOptionValue extends UniqueIdentifier>
export interface SortableItemListInput<TItem, TOptionValue extends UniqueIdentifier>
extends Omit<CommonInput<TOptionValue[]>, "withDescription"> {
AddButton: (props: { addItem: (item: TItem) => void; values: TOptionValue[] }) => React.ReactNode;
ItemComponent: (props: {
@@ -188,15 +186,3 @@ export type OptionsBuilderResult = ReturnType<OptionsBuilder>;
export const optionsBuilder = {
from: createOptions,
};
export const reduceWidgetOptionsWithDefaultValues = (kind: WidgetKind, currentValue: Record<string, unknown> = {}) => {
const definition = widgetImports[kind].definition;
const options = definition.options as Record<string, WidgetOptionDefinition>;
return objectEntries(options).reduce(
(prev, [key, value]) => ({
...prev,
[key]: currentValue[key] ?? value.defaultValue,
}),
{} as Record<string, unknown>,
);
};

View File

@@ -1,3 +1,5 @@
"use client";
import { Card, Flex, Group, Image, ScrollArea, Stack, Text } from "@mantine/core";
import { IconClock } from "@tabler/icons-react";
import dayjs from "dayjs";

View File

@@ -1,41 +1,48 @@
"use client";
import React, { useCallback, useState } from "react";
import { useCallback } from "react";
import { Center, Stack, Text, UnstyledButton } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationSelectedError } from "../../errors";
export default function SmartHomeEntityStateWidget({
options,
integrationIds,
isEditMode,
}: WidgetComponentProps<"smartHome-entityState">) {
const [lastState, setLastState] = useState<{
entityId: string;
state: string;
}>();
const integrationId = integrationIds[0];
if (!integrationId) {
throw new NoIntegrationSelectedError();
}
return <InnerComponent options={options} integrationId={integrationId} isEditMode={isEditMode} />;
}
type InnerComponentProps = Pick<WidgetComponentProps<"smartHome-entityState">, "options" | "isEditMode"> & {
integrationId: string;
};
const InnerComponent = ({ options, integrationId, isEditMode }: InnerComponentProps) => {
const input = {
entityId: options.entityId,
integrationId,
};
const [entityState] = clientApi.widget.smartHome.entityState.useSuspenseQuery(input);
const utils = clientApi.useUtils();
clientApi.widget.smartHome.subscribeEntityState.useSubscription(
{
entityId: options.entityId,
},
{
onData(data) {
setLastState(data);
},
},
);
const { mutate } = clientApi.widget.smartHome.switchEntity.useMutation({
onSettled: () => {
void utils.widget.smartHome.invalidate();
clientApi.widget.smartHome.subscribeEntityState.useSubscription(input, {
onData(data) {
utils.widget.smartHome.entityState.setData(input, data.state);
},
});
const { mutate } = clientApi.widget.smartHome.switchEntity.useMutation();
const attribute = options.entityUnit.length > 0 ? " " + options.entityUnit : "";
const handleClick = useCallback(() => {
@@ -49,9 +56,9 @@ export default function SmartHomeEntityStateWidget({
mutate({
entityId: options.entityId,
integrationId: integrationIds[0] ?? "",
integrationId,
});
}, [integrationIds, isEditMode, mutate, options.clickable, options.entityId]);
}, [integrationId, isEditMode, mutate, options.clickable, options.entityId]);
return (
<UnstyledButton
@@ -66,11 +73,11 @@ export default function SmartHomeEntityStateWidget({
{options.displayName}
</Text>
<Text ta="center">
{lastState?.state}
{entityState}
{attribute}
</Text>
</Stack>
</Center>
</UnstyledButton>
);
}
};

View File

@@ -1,3 +1,5 @@
"use client";
import React from "react";
import { ActionIcon, Center, LoadingOverlay, Overlay, Stack, Text, UnstyledButton } from "@mantine/core";
import { useDisclosure, useTimeout } from "@mantine/hooks";

View File

@@ -1,3 +1,5 @@
"use client";
import { Box, Group, HoverCard, Space, Stack, Text } from "@mantine/core";
import { IconArrowDownRight, IconArrowUpRight, IconMapPin } from "@tabler/icons-react";
import combineClasses from "clsx";