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:
23
packages/widgets/src/bookmarks/add-button.tsx
Normal file
23
packages/widgets/src/bookmarks/add-button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)"}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconBrowserOff } from "@tabler/icons-react";
|
||||
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
1
packages/widgets/src/modals/index.ts
Normal file
1
packages/widgets/src/modals/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./widget-edit-modal";
|
||||
@@ -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>,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user