Files
homarr/packages/widgets/src/indexer-manager/component.tsx
T

225 lines
8.7 KiB
TypeScript

"use client";
import { useCallback, useMemo } from "react";
import {
ActionIcon,
Anchor,
Badge,
Card,
Center,
Flex,
Group,
Loader,
ScrollArea,
Stack,
Text,
Tooltip,
VisuallyHidden,
} from "@mantine/core";
import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react";
import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { useWidgetRuntimeActions } from "../runtime-hooks";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import actionTargetClasses from "../common/action-target.module.css";
import classes from "./component.module.css";
import { getIndexerDisplayStatus } from "./status";
const statusPresentation = {
healthy: { color: "green" },
unhealthy: { color: "red" },
disabled: { color: "gray" },
} as const;
export default function IndexerManagerWidget({
options,
integrationIds,
width,
height,
isEditMode,
widgetRuntimeRef,
displayMode,
}: WidgetComponentProps<"indexerManager">) {
const t = useI18n("widget.indexerManager");
const tCommon = useI18n("common");
const indexersQuery = clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds });
const isInitialPending = isInitialWidgetQueryPending(indexersQuery);
const indexersData = getUsableWidgetQueryData(indexersQuery) ?? [];
const utils = clientApi.useUtils();
const {
mutate: testAll,
isPending,
error: testAllError,
} = clientApi.widget.indexerManager.testAllIndexers.useMutation({
onSettled: () => void utils.widget.indexerManager.getIndexersStatus.invalidate(),
});
const interactIntegrationIds = new Set(useIntegrationsWithInteractAccess().map(({ id }) => id));
const canInteract = integrationIds.length > 0 && integrationIds.every((id) => interactIntegrationIds.has(id));
const board = useRequiredBoard();
const isAdvanced = displayMode === "advanced";
const hasSmallWidth = !isAdvanced && width < 256;
const isDense = !isAdvanced && (width < 280 || height < 180);
const showHealthCounts = isAdvanced || (width >= 200 && height >= 100);
const allIndexers = indexersData.flatMap((entry) => entry.indexers);
const hasSourceError = indexersData.some((entry) => Boolean(entry.error));
const unavailableCount = allIndexers.filter(
(indexer) => indexer.status === false || indexer.enabled === false,
).length;
const testAllIndexers = useCallback(() => {
if (!canInteract || isPending) return;
testAll({ integrationIds });
}, [canInteract, integrationIds, isPending, testAll]);
const runtimeActions = useMemo(() => ({ testAllIndexers }), [testAllIndexers]);
useWidgetRuntimeActions(widgetRuntimeRef, runtimeActions);
return (
<Flex
className={`indexer-manager-container ${classes.root}`}
h="100%"
direction="column"
gap={isDense ? 4 : "xs"}
p={isDense ? "xs" : "sm"}
>
<Group className="indexer-manager-title" align="center" gap="xs" wrap="nowrap" w="100%">
{testAllError && <VisuallyHidden role="alert">{tCommon("error")}</VisuallyHidden>}
<Tooltip label={t("title")} disabled={!hasSmallWidth}>
<IconReportSearch
className="indexer-manager-title-icon"
size={hasSmallWidth ? 16 : 20}
style={{ minWidth: hasSmallWidth ? 16 : 20 }}
/>
</Tooltip>
{!hasSmallWidth && (
<Text size={isDense ? "xs" : "sm"} fw={600} truncate="end">
{t("title")}
</Text>
)}
{showHealthCounts && (
<Group gap={4} wrap="nowrap">
<Badge size="xs" color="green" variant="light">
{allIndexers.length - unavailableCount}
</Badge>
{unavailableCount > 0 && (
<Badge size="xs" color="red" variant="light">
{unavailableCount}
</Badge>
)}
</Group>
)}
<Tooltip label={testAllError ? tCommon("error") : t("testAll")}>
<ActionIcon
className={combineClasses("indexer-manager-test-action-icon", classes.testAction, actionTargetClasses.root)}
size="sm"
radius={board.itemRadius}
variant="light"
color={testAllError ? "red" : undefined}
loading={isPending}
disabled={isEditMode || !canInteract}
loaderProps={{ type: "dots" }}
onClick={() => {
testAllIndexers();
}}
aria-label={t("testAll")}
>
<IconTestPipe size="var(--mantine-font-size-sm)" />
</ActionIcon>
</Tooltip>
</Group>
<Card
className={combineClasses("indexer-manager-list-container", classes.card)}
w="100%"
p={isDense ? 4 : "xs"}
radius={board.itemRadius}
flex={1}
>
{isInitialPending ? (
<Center h="100%">
<Loader size="sm" />
</Center>
) : allIndexers.length === 0 && !hasSourceError ? (
<WidgetEmptyState />
) : (
<ScrollArea className="indexer-manager-list-scroll-area" h="100%" scrollbars="y">
{indexersData.map(({ integrationId, integrationName, indexers, error }) => (
<Stack gap={4} className={`indexer-manager-${integrationId}-list-container`} p={0} key={integrationId}>
{(isAdvanced || indexersData.length > 1 || error) && (
<Group justify="space-between" wrap="nowrap" py={4}>
<Text size="xs" fw={600} truncate="end">
{integrationName}
</Text>
{error && (
<Badge size="xs" color="red" variant="light">
{tCommon("error")}
</Badge>
)}
</Group>
)}
{indexers.map((indexer) => {
const href = getSafeApplicationUrl(indexer.url);
const displayStatus = getIndexerDisplayStatus(indexer);
const presentation = statusPresentation[displayStatus];
return (
<Group
className={`indexer-manager-line indexer-manager-${indexer.name} ${classes.indexerRow}`}
key={indexer.id}
justify="space-between"
gap="xs"
wrap="nowrap"
>
<Anchor
className={combineClasses("indexer-manager-line-anchor", classes.indexerLink)}
component={href ? "a" : "span"}
href={href}
target={href ? (options.openIndexerSiteInNewTab ? "_blank" : "_self") : undefined}
rel={href && options.openIndexerSiteInNewTab ? SAFE_NEW_TAB_REL : undefined}
title={href}
>
<Text
className="indexer-manager-line-anchor-text"
c="dimmed"
size={hasSmallWidth ? "xs" : "sm"}
truncate="end"
>
{indexer.name}
</Text>
</Anchor>
{isAdvanced ? (
<Badge size="xs" color={presentation.color} variant="light">
{t(`status.${displayStatus}` as never)}
</Badge>
) : displayStatus === "healthy" ? (
<IconCircleCheck
className="indexer-manager-line-status-icon indexer-manager-line-icon-enabled"
color="var(--mantine-color-green-6)"
size={hasSmallWidth ? 12 : 16}
/>
) : (
<IconCircleX
className="indexer-manager-line-status-icon indexer-manager-line-icon-disabled"
color="var(--mantine-color-red-6)"
size={hasSmallWidth ? 12 : 16}
/>
)}
</Group>
);
})}
</Stack>
))}
</ScrollArea>
)}
</Card>
</Flex>
);
}