feat: downloads widget (#844)
Usenet and Torrent downloads in 1 widget. sabNZBd, NzbGet, Deluge, qBitTorrent, and transmission support. Columns can be reordered in Edit mode. Sorting enabled. Time uses Dayjs with auto translation. Can pause/resume single items, clients, or all. Can delete items (With option to delete assossiated files). Clients list and details. Include all filtering and processing for ratio from oldmarr torrent widget. Invalidation of old data (older than 30 seconds) to show an integration is not responding anymore. Misc (So many miscs): Fixed validation error with multiText. Fixed translation application for multiSelect to behave the same as select. Added background to gitignore (I needed to add a background to visually test opacity, probably will in the future too) Added setOptions to frontend components so potential updates made from the Dashboard can be saved. Extracted background and border color to use in widgets. humanFileSize function based on the si format (powers of 1024, not 1000). Improved integrationCreatorByKind by @Meierschlumpf. Changed integrationCreatorByKind to integrationCreator so it functions directly from the integration. Added integrationCreatorFromSecrets to directly work with secrets from db. Added getIntegrationKindsByCategory to get a list of integrations sharing categories. Added IntegrationKindByCategory type to get the types possible for a category (Great to cast on integration.kind that isn't already properly limited/typed but for which we know the limitation) Added a common AtLeastOneOf type. Applied to TKind and IntegrationSecretKind[] where it was already being used and Added to the getIntegrationKindsByCategory's output to be more freely used. Added the Modify type, instead of omiting to then add again just to change a parameters type, use the modify instead. Applied code wide already. Hook to get list of integration depending on permission level of user. (By @Meierschlumpf)
This commit is contained in:
@@ -3,18 +3,20 @@
|
||||
import { MultiSelect } from "@mantine/core";
|
||||
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
export const WidgetMultiSelectInput = ({ property, kind, options }: CommonWidgetInputProps<"multiSelect">) => {
|
||||
const t = useWidgetInputTranslation(kind, property);
|
||||
const t = useI18n();
|
||||
const tWidget = useWidgetInputTranslation(kind, property);
|
||||
const form = useFormContext();
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
label={t("label")}
|
||||
label={tWidget("label")}
|
||||
data={options.options.map((option) =>
|
||||
typeof option === "string"
|
||||
? option
|
||||
@@ -23,7 +25,7 @@ export const WidgetMultiSelectInput = ({ property, kind, options }: CommonWidget
|
||||
label: translateIfNecessary(t, option.label) ?? option.value,
|
||||
},
|
||||
)}
|
||||
description={options.withDescription ? t("description") : undefined}
|
||||
description={options.withDescription ? tWidget("description") : undefined}
|
||||
searchable={options.searchable}
|
||||
{...form.getInputProps(`options.${property}`)}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IconCalendar } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
@@ -17,7 +18,7 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
|
||||
defaultValue: 2,
|
||||
}),
|
||||
})),
|
||||
supportedIntegrations: ["sonarr", "radarr", "lidarr", "readarr"],
|
||||
supportedIntegrations: getIntegrationKindsByCategory("calendar"),
|
||||
})
|
||||
.withServerData(() => import("./serverData"))
|
||||
.withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -93,6 +93,11 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
|
||||
} & {
|
||||
boardId: string | undefined; // undefined when in preview mode
|
||||
isEditMode: boolean;
|
||||
setOptions: ({
|
||||
newOptions,
|
||||
}: {
|
||||
newOptions: Partial<inferOptionsFromDefinition<WidgetOptionsRecordOf<TKind>>>;
|
||||
}) => void;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { IconDeviceGamepad, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetDefinition } from "../../definition";
|
||||
import { optionsBuilder } from "../../options";
|
||||
|
||||
@@ -10,7 +12,7 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
|
||||
defaultValue: true,
|
||||
}),
|
||||
})),
|
||||
supportedIntegrations: ["piHole", "adGuardHome"],
|
||||
supportedIntegrations: getIntegrationKindsByCategory("dnsHole"),
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { IconAd, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetDefinition } from "../../definition";
|
||||
import { optionsBuilder } from "../../options";
|
||||
|
||||
@@ -17,7 +19,7 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
|
||||
defaultValue: "grid",
|
||||
}),
|
||||
})),
|
||||
supportedIntegrations: ["piHole", "adGuardHome"],
|
||||
supportedIntegrations: getIntegrationKindsByCategory("dnsHole"),
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
|
||||
922
packages/widgets/src/downloads/component.tsx
Normal file
922
packages/widgets/src/downloads/component.tsx
Normal file
@@ -0,0 +1,922 @@
|
||||
"use client";
|
||||
|
||||
import "../widgets-common.css";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { MantineStyleProp } from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
AvatarGroup,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Progress,
|
||||
Space,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure, useListState, useTimeout } from "@mantine/hooks";
|
||||
import type { IconProps } from "@tabler/icons-react";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCirclesRelation,
|
||||
IconInfinity,
|
||||
IconInfoCircle,
|
||||
IconPlayerPause,
|
||||
IconPlayerPlay,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table";
|
||||
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 { Integration } from "@homarr/db/schema/sqlite";
|
||||
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type {
|
||||
DownloadClientJobsAndStatus,
|
||||
ExtendedClientStatus,
|
||||
ExtendedDownloadClientItem,
|
||||
} from "@homarr/integrations";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { NoIntegrationSelectedError } from "../errors";
|
||||
|
||||
//Ratio table for relative width between columns
|
||||
const columnsRatios: Record<keyof ExtendedDownloadClientItem, number> = {
|
||||
actions: 2,
|
||||
added: 4,
|
||||
category: 1,
|
||||
downSpeed: 3,
|
||||
id: 1,
|
||||
index: 1,
|
||||
integration: 1,
|
||||
name: 8,
|
||||
progress: 4,
|
||||
ratio: 2,
|
||||
received: 3,
|
||||
sent: 3,
|
||||
size: 3,
|
||||
state: 3,
|
||||
time: 4,
|
||||
type: 2,
|
||||
upSpeed: 3,
|
||||
};
|
||||
|
||||
const actionIconIconStyle: IconProps["style"] = {
|
||||
height: "var(--ai-icon-size)",
|
||||
width: "var(--ai-icon-size)",
|
||||
};
|
||||
|
||||
const standardIconStyle: IconProps["style"] = {
|
||||
height: "var(--icon-size)",
|
||||
width: "var(--icon-size)",
|
||||
};
|
||||
|
||||
const invalidateTime = 30000;
|
||||
|
||||
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: Integration;
|
||||
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 },
|
||||
) ?? [],
|
||||
);
|
||||
|
||||
//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 }),
|
||||
);
|
||||
},
|
||||
invalidateTime,
|
||||
{ autoInvoke: true },
|
||||
);
|
||||
|
||||
//Translations
|
||||
const t = useScopedI18n("widget.downloads");
|
||||
const tCommon = useScopedI18n("common");
|
||||
|
||||
//Item modal state and selection
|
||||
const [clickedIndex, setClickedIndex] = useState<number>(0);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
//Get API mutation functions
|
||||
const { mutate: mutateResumeItem } = clientApi.widget.downloads.resumeItem.useMutation();
|
||||
const { mutate: mutatePauseItem } = clientApi.widget.downloads.pauseItem.useMutation();
|
||||
const { mutate: mutateDeleteItem } = clientApi.widget.downloads.deleteItem.useMutation();
|
||||
|
||||
//Subscribe to dynamic data changes
|
||||
clientApi.widget.downloads.subscribeToJobsAndStatuses.useSubscription(
|
||||
{
|
||||
integrationIds,
|
||||
},
|
||||
{
|
||||
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);
|
||||
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 }),
|
||||
);
|
||||
//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);
|
||||
}
|
||||
//Reset no update timer
|
||||
invalidationTimer.clear();
|
||||
invalidationTimer.start();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
//Flatten Data array for which each element has it's integration, data (base + calculated) and actions. Memoized on data subscription
|
||||
const data = useMemo<ExtendedDownloadClientItem[]>(
|
||||
() =>
|
||||
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: Integration; timestamp: Date; data: DownloadClientJobsAndStatus } =>
|
||||
pair.data != null,
|
||||
)
|
||||
//Construct normalized items list
|
||||
.flatMap((pair) =>
|
||||
//Apply user white/black list
|
||||
pair.data.items
|
||||
.filter(
|
||||
({ category }) =>
|
||||
options.filterIsWhitelist ===
|
||||
options.categoryFilter.some((filter) =>
|
||||
(Array.isArray(category) ? category : [category]).includes(filter),
|
||||
),
|
||||
)
|
||||
//Filter completed items following widget option
|
||||
.filter(
|
||||
({ type, progress, upSpeed }) =>
|
||||
(type === "torrent" &&
|
||||
((progress === 1 &&
|
||||
options.showCompletedTorrent &&
|
||||
(upSpeed ?? 0) >= Number(options.activeTorrentThreshold) * 1024) ||
|
||||
progress !== 1)) ||
|
||||
(type === "usenet" && ((progress === 1 && options.showCompletedUsenet) || progress !== 1)),
|
||||
)
|
||||
//Add extrapolated data and actions if user is allowed interaction
|
||||
.map((item): ExtendedDownloadClientItem => {
|
||||
const received = Math.floor(item.size * item.progress);
|
||||
const integrationIds = [pair.integration.id];
|
||||
return {
|
||||
integration: pair.integration,
|
||||
...item,
|
||||
category: item.category !== undefined && item.category.length > 0 ? item.category : undefined,
|
||||
received,
|
||||
ratio: item.sent !== undefined ? item.sent / received : undefined,
|
||||
//Only add if permission to use mutations
|
||||
actions: integrationsWithInteractions.includes(pair.integration.id)
|
||||
? {
|
||||
resume: () => mutateResumeItem({ integrationIds, item }),
|
||||
pause: () => mutatePauseItem({ integrationIds, item }),
|
||||
delete: ({ fromDisk }) => mutateDeleteItem({ integrationIds, item, fromDisk }),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
)
|
||||
//flatMap already sorts by integration by nature, add sorting by integration type (usenet | torrent)
|
||||
.sort(({ type: typeA }, { type: typeB }) => typeA.length - typeB.length),
|
||||
[currentItems, integrationIds, options],
|
||||
);
|
||||
|
||||
//Flatten Clients Array for which each elements has the integration and general client infos.
|
||||
const clients = useMemo<ExtendedClientStatus[]>(
|
||||
() =>
|
||||
currentItems
|
||||
.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
|
||||
.filter(
|
||||
({ category }) =>
|
||||
!options.applyFilterToRatio ||
|
||||
data.status.type !== "torrent" ||
|
||||
options.filterIsWhitelist ===
|
||||
options.categoryFilter.some((filter) =>
|
||||
(Array.isArray(category) ? category : [category]).includes(filter),
|
||||
),
|
||||
)
|
||||
.reduce(
|
||||
({ totalUp, totalDown }, { sent, size, progress }) => ({
|
||||
totalUp: isTorrent ? (totalUp ?? 0) + (sent ?? 0) : undefined,
|
||||
totalDown: totalDown + size * progress,
|
||||
}),
|
||||
{ totalDown: 0, totalUp: isTorrent ? 0 : undefined },
|
||||
);
|
||||
return {
|
||||
integration,
|
||||
interact,
|
||||
status: {
|
||||
totalUp,
|
||||
totalDown,
|
||||
ratio: totalUp === undefined ? undefined : totalUp / totalDown,
|
||||
...data.status,
|
||||
},
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
({ status: statusA }, { status: statusB }) =>
|
||||
(statusA?.type.length ?? Infinity) - (statusB?.type.length ?? Infinity),
|
||||
),
|
||||
[currentItems, integrationIds, options],
|
||||
);
|
||||
|
||||
//Check existing types between torrents and usenet
|
||||
const integrationTypes: ExtendedDownloadClientItem["type"][] = [];
|
||||
if (data.some(({ type }) => type === "torrent")) integrationTypes.push("torrent");
|
||||
if (data.some(({ type }) => type === "usenet")) integrationTypes.push("usenet");
|
||||
|
||||
//Set the visibility of columns depending on widget settings and available data/integrations.
|
||||
const columnVisibility: MRT_VisibilityState = {
|
||||
id: options.columns.includes("id"),
|
||||
actions: options.columns.includes("actions") && integrationsWithInteractions.length > 0,
|
||||
added: options.columns.includes("added"),
|
||||
category: options.columns.includes("category"),
|
||||
downSpeed: options.columns.includes("downSpeed"),
|
||||
index: options.columns.includes("index"),
|
||||
integration: options.columns.includes("integration") && clients.length > 1,
|
||||
name: options.columns.includes("name"),
|
||||
progress: options.columns.includes("progress"),
|
||||
ratio: options.columns.includes("ratio") && integrationTypes.includes("torrent"),
|
||||
received: options.columns.includes("received"),
|
||||
sent: options.columns.includes("sent") && integrationTypes.includes("torrent"),
|
||||
size: options.columns.includes("size"),
|
||||
state: options.columns.includes("state"),
|
||||
time: options.columns.includes("time"),
|
||||
type: options.columns.includes("type") && integrationTypes.length > 1,
|
||||
upSpeed: options.columns.includes("upSpeed") && integrationTypes.includes("torrent"),
|
||||
} satisfies Record<keyof ExtendedDownloadClientItem, boolean>;
|
||||
|
||||
//Set a relative width using ratio table
|
||||
const totalWidth = options.columns.reduce(
|
||||
(count: number, column) => (columnVisibility[column] ? count + columnsRatios[column] : count),
|
||||
0,
|
||||
);
|
||||
|
||||
//Default styling behavior for stopping interaction when editing. (Applied everywhere except the table header)
|
||||
const editStyle: MantineStyleProp = {
|
||||
pointerEvents: isEditMode ? "none" : undefined,
|
||||
};
|
||||
|
||||
//General style sizing as vars that should apply or be applied to all elements
|
||||
const baseStyle: MantineStyleProp = {
|
||||
"--total-width": totalWidth,
|
||||
"--ratio-width": "calc(100cqw / var(--total-width))",
|
||||
"--space-size": "calc(var(--ratio-width) * 0.1)", //Standard gap and spacing value
|
||||
"--text-fz": "calc(var(--ratio-width) * 0.45)", //General Font Size
|
||||
"--button-fz": "var(--text-fz)",
|
||||
"--icon-size": "calc(var(--ratio-width) * 2 / 3)", //Normal icon size
|
||||
"--ai-icon-size": "calc(var(--ratio-width) * 0.5)", //Icon inside action icons size
|
||||
"--button-size": "calc(var(--ratio-width) * 0.75)", //Action Icon, button and avatar size
|
||||
"--image-size": "var(--button-size)",
|
||||
"--mrt-base-background-color": "transparent",
|
||||
};
|
||||
|
||||
//Base element in common with all columns
|
||||
const columnsDefBase = ({
|
||||
key,
|
||||
showHeader,
|
||||
align,
|
||||
}: {
|
||||
key: keyof ExtendedDownloadClientItem;
|
||||
showHeader: boolean;
|
||||
align?: "center" | "left" | "right" | "justify" | "char";
|
||||
}): MRT_ColumnDef<ExtendedDownloadClientItem> => {
|
||||
const style: MantineStyleProp = {
|
||||
minWidth: 0,
|
||||
width: "var(--column-width)",
|
||||
height: "var(--ratio-width)",
|
||||
padding: "var(--space-size)",
|
||||
transition: "unset",
|
||||
"--key-width": columnsRatios[key],
|
||||
"--column-width": "calc((var(--key-width)/var(--total-width) * 100cqw))",
|
||||
};
|
||||
return {
|
||||
id: key,
|
||||
accessorKey: key,
|
||||
header: key,
|
||||
size: columnsRatios[key],
|
||||
mantineTableBodyCellProps: { style, align },
|
||||
mantineTableHeadCellProps: {
|
||||
style,
|
||||
align: isEditMode ? "center" : align,
|
||||
},
|
||||
Header: () => (showHeader && !isEditMode ? <Text fw={700}>{t(`items.${key}.columnTitle`)}</Text> : ""),
|
||||
};
|
||||
};
|
||||
|
||||
//Make columns and cell elements, Memoized to data with deps on data and EditMode
|
||||
const columns = useMemo<MRT_ColumnDef<ExtendedDownloadClientItem>[]>(
|
||||
() => [
|
||||
{
|
||||
...columnsDefBase({ key: "actions", showHeader: false, align: "center" }),
|
||||
enableSorting: false,
|
||||
Cell: ({ cell, row }) => {
|
||||
const actions = cell.getValue<ExtendedDownloadClientItem["actions"]>();
|
||||
const pausedAction = row.original.state === "paused" ? "resume" : "pause";
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
return actions ? (
|
||||
<Group wrap="nowrap" gap="var(--space-size)">
|
||||
<Tooltip label={t(`actions.item.${pausedAction}`)}>
|
||||
<ActionIcon variant="light" radius={999} onClick={actions[pausedAction]} size="var(--button-size)">
|
||||
{pausedAction === "resume" ? (
|
||||
<IconPlayerPlay style={actionIconIconStyle} />
|
||||
) : (
|
||||
<IconPlayerPause style={actionIconIconStyle} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("actions.item.delete.title")}>
|
||||
<ActionIcon color="red" radius={999} onClick={open} size="var(--button-size)">
|
||||
<IconTrash style={actionIconIconStyle} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Modal opened={opened} onClose={close} title={t("actions.item.delete.modalTitle")} size="auto" centered>
|
||||
<Group>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
close();
|
||||
actions.delete({ fromDisk: false });
|
||||
}}
|
||||
>
|
||||
{t("actions.item.delete.entry")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
close();
|
||||
actions.delete({ fromDisk: true });
|
||||
}}
|
||||
leftSection={<IconAlertTriangle />}
|
||||
>
|
||||
{t("actions.item.delete.entryAndFiles")}
|
||||
</Button>
|
||||
<Button color="green" onClick={close}>
|
||||
{tCommon("action.cancel")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</Group>
|
||||
) : (
|
||||
<ActionIcon radius={999} disabled size="var(--button-size)">
|
||||
<IconX style={actionIconIconStyle} />
|
||||
</ActionIcon>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "added", showHeader: true, align: "center" }),
|
||||
sortUndefined: "last",
|
||||
Cell: ({ cell }) => {
|
||||
const added = cell.getValue<ExtendedDownloadClientItem["added"]>();
|
||||
return <Text>{added !== undefined ? dayjs(added).fromNow() : "unknown"}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "category", showHeader: false, align: "center" }),
|
||||
sortUndefined: "last",
|
||||
Cell: ({ cell }) => {
|
||||
const category = cell.getValue<ExtendedDownloadClientItem["category"]>();
|
||||
return (
|
||||
category !== undefined && (
|
||||
<Tooltip label={category}>
|
||||
<IconInfoCircle style={standardIconStyle} />
|
||||
</Tooltip>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "downSpeed", showHeader: true, align: "right" }),
|
||||
sortUndefined: "last",
|
||||
Cell: ({ cell }) => {
|
||||
const downSpeed = cell.getValue<ExtendedDownloadClientItem["downSpeed"]>();
|
||||
return downSpeed && <Text>{humanFileSize(downSpeed, "/s")}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "id", showHeader: false, align: "center" }),
|
||||
enableSorting: false,
|
||||
Cell: ({ cell }) => {
|
||||
const id = cell.getValue<ExtendedDownloadClientItem["id"]>();
|
||||
return (
|
||||
<Tooltip label={id}>
|
||||
<IconCirclesRelation style={standardIconStyle} />
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "index", showHeader: true, align: "center" }),
|
||||
Cell: ({ cell }) => {
|
||||
const index = cell.getValue<ExtendedDownloadClientItem["index"]>();
|
||||
return <Text>{index}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "integration", showHeader: false, align: "center" }),
|
||||
Cell: ({ cell }) => {
|
||||
const integration = cell.getValue<ExtendedDownloadClientItem["integration"]>();
|
||||
return (
|
||||
<Tooltip label={integration.name}>
|
||||
<Avatar size="var(--image-size)" radius={0} src={getIconUrl(integration.kind)} />
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "name", showHeader: true }),
|
||||
Cell: ({ cell }) => {
|
||||
const name = cell.getValue<ExtendedDownloadClientItem["name"]>();
|
||||
return (
|
||||
<Text lineClamp={1} style={{ wordBreak: "break-all" }}>
|
||||
{name}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "progress", showHeader: true, align: "center" }),
|
||||
Cell: ({ cell, row }) => {
|
||||
const progress = cell.getValue<ExtendedDownloadClientItem["progress"]>();
|
||||
return (
|
||||
<Stack w="100%" align="center" gap="var(--space-size)">
|
||||
<Text lh="var(--text-fz)">
|
||||
{new Intl.NumberFormat("en", { style: "percent", notation: "compact", unitDisplay: "narrow" }).format(
|
||||
progress,
|
||||
)}
|
||||
</Text>
|
||||
<Progress
|
||||
h="calc(var(--ratio-width)*0.25)"
|
||||
w="100%"
|
||||
value={progress * 100}
|
||||
color={row.original.state === "paused" ? "yellow" : progress === 1 ? "green" : "blue"}
|
||||
radius={999}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "ratio", showHeader: true, align: "center" }),
|
||||
sortUndefined: "last",
|
||||
Cell: ({ cell }) => {
|
||||
const ratio = cell.getValue<ExtendedDownloadClientItem["ratio"]>();
|
||||
return ratio !== undefined && <Text>{ratio.toFixed(ratio >= 100 ? 0 : ratio >= 10 ? 1 : 2)}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "received", showHeader: true, align: "right" }),
|
||||
Cell: ({ cell }) => {
|
||||
const received = cell.getValue<ExtendedDownloadClientItem["received"]>();
|
||||
return <Text>{humanFileSize(received)}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "sent", showHeader: true, align: "right" }),
|
||||
sortUndefined: "last",
|
||||
Cell: ({ cell }) => {
|
||||
const sent = cell.getValue<ExtendedDownloadClientItem["sent"]>();
|
||||
return sent && <Text>{humanFileSize(sent)}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "size", showHeader: true, align: "right" }),
|
||||
Cell: ({ cell }) => {
|
||||
const size = cell.getValue<ExtendedDownloadClientItem["size"]>();
|
||||
return <Text>{humanFileSize(size)}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "state", showHeader: true }),
|
||||
enableSorting: false,
|
||||
Cell: ({ cell }) => {
|
||||
const state = cell.getValue<ExtendedDownloadClientItem["state"]>();
|
||||
return <Text>{t(`states.${state}`)}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "time", showHeader: true, align: "center" }),
|
||||
Cell: ({ cell }) => {
|
||||
const time = cell.getValue<ExtendedDownloadClientItem["time"]>();
|
||||
return time === 0 ? <IconInfinity style={standardIconStyle} /> : <Text>{dayjs().add(time).fromNow()}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "type", showHeader: true }),
|
||||
Cell: ({ cell }) => {
|
||||
const type = cell.getValue<ExtendedDownloadClientItem["type"]>();
|
||||
return <Text>{type}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "upSpeed", showHeader: true, align: "right" }),
|
||||
sortUndefined: "last",
|
||||
Cell: ({ cell }) => {
|
||||
const upSpeed = cell.getValue<ExtendedDownloadClientItem["upSpeed"]>();
|
||||
return upSpeed && <Text>{humanFileSize(upSpeed, "/s")}</Text>;
|
||||
},
|
||||
},
|
||||
],
|
||||
[clickedIndex, isEditMode, data, integrationIds, options],
|
||||
);
|
||||
|
||||
//Table build and config
|
||||
const table = useMantineReactTable({
|
||||
columns,
|
||||
data,
|
||||
enablePagination: false,
|
||||
enableTopToolbar: false,
|
||||
enableBottomToolbar: false,
|
||||
enableColumnActions: false,
|
||||
enableSorting: options.enableRowSorting && !isEditMode,
|
||||
enableMultiSort: true,
|
||||
enableStickyHeader: false,
|
||||
enableColumnOrdering: isEditMode,
|
||||
enableRowVirtualization: true,
|
||||
rowVirtualizerOptions: { overscan: 5 },
|
||||
mantinePaperProps: { flex: 1, withBorder: false, shadow: undefined },
|
||||
mantineTableContainerProps: { style: { height: "100%" } },
|
||||
mantineTableProps: {
|
||||
className: "downloads-widget-table",
|
||||
style: {
|
||||
"--sortButtonSize": "var(--button-size)",
|
||||
"--dragButtonSize": "var(--button-size)",
|
||||
},
|
||||
},
|
||||
mantineTableBodyProps: { style: editStyle },
|
||||
mantineTableBodyCellProps: ({ cell, row }) => ({
|
||||
onClick: () => {
|
||||
setClickedIndex(row.index);
|
||||
if (cell.column.id !== "actions") open();
|
||||
},
|
||||
}),
|
||||
onColumnOrderChange: (order) => {
|
||||
//Order has a tendency to add the disabled column at the end of the the real ordered array
|
||||
const columnOrder = (order as typeof options.columns).filter((column) => options.columns.includes(column));
|
||||
setOptions({ newOptions: { columns: columnOrder } });
|
||||
},
|
||||
initialState: {
|
||||
sorting: [{ id: options.defaultSort, desc: options.descendingDefaultSort }],
|
||||
columnVisibility: {
|
||||
actions: false,
|
||||
added: false,
|
||||
category: false,
|
||||
downSpeed: false,
|
||||
id: false,
|
||||
index: false,
|
||||
integration: false,
|
||||
name: false,
|
||||
progress: false,
|
||||
ratio: false,
|
||||
received: false,
|
||||
sent: false,
|
||||
size: false,
|
||||
state: false,
|
||||
time: false,
|
||||
type: false,
|
||||
upSpeed: false,
|
||||
} satisfies Record<keyof ExtendedDownloadClientItem, boolean>,
|
||||
columnOrder: options.columns,
|
||||
},
|
||||
state: {
|
||||
columnVisibility,
|
||||
columnOrder: options.columns,
|
||||
},
|
||||
});
|
||||
|
||||
const isLangRtl = tCommon("rtl", { value: "0", symbol: "1" }).startsWith("1");
|
||||
|
||||
//Used for Global Torrent Ratio
|
||||
const globalTraffic = clients
|
||||
.filter(({ integration: { kind } }) =>
|
||||
getIntegrationKindsByCategory("torrent").some((integrationKind) => integrationKind === kind),
|
||||
)
|
||||
.reduce(
|
||||
({ up, down }, { status }) => ({
|
||||
up: up + (status?.totalUp ?? 0),
|
||||
down: down + (status?.totalDown ?? 0),
|
||||
}),
|
||||
{ up: 0, down: 0 },
|
||||
);
|
||||
|
||||
if (integrationIds.length === 0) {
|
||||
throw new NoIntegrationSelectedError();
|
||||
}
|
||||
|
||||
if (options.columns.length === 0)
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Text fz="7.5cqw">{t("errors.noColumns")}</Text>
|
||||
</Center>
|
||||
);
|
||||
|
||||
//The actual widget
|
||||
return (
|
||||
<Stack gap={0} h="100%" display="flex" style={baseStyle}>
|
||||
<MantineReactTable table={table} />
|
||||
<Group
|
||||
h="var(--ratio-width)"
|
||||
px="var(--space-size)"
|
||||
justify={integrationTypes.includes("torrent") ? "space-between" : "end"}
|
||||
style={{
|
||||
flexDirection: isLangRtl ? "row-reverse" : "row",
|
||||
borderTop: "0.0625rem solid var(--border-color)",
|
||||
}}
|
||||
>
|
||||
{integrationTypes.includes("torrent") && (
|
||||
<Group pt="var(--space-size)" style={{ flexDirection: isLangRtl ? "row-reverse" : "row" }}>
|
||||
<Text>{tCommon("rtl", { value: t("globalRatio"), symbol: tCommon("symbols.colon") })}</Text>
|
||||
<Text>{(globalTraffic.up / globalTraffic.down).toFixed(2)}</Text>
|
||||
</Group>
|
||||
)}
|
||||
<ClientsControl clients={clients} style={editStyle} />
|
||||
</Group>
|
||||
<ItemInfoModal items={data} currentIndex={clickedIndex} opened={opened} onClose={close} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemInfoModalProps {
|
||||
items: ExtendedDownloadClientItem[];
|
||||
currentIndex: number;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalProps) => {
|
||||
const item = useMemo<ExtendedDownloadClientItem | undefined>(
|
||||
() => items[currentIndex],
|
||||
[items, currentIndex, opened],
|
||||
);
|
||||
const t = useScopedI18n("widget.downloads.states");
|
||||
//The use case for "No item found" should be impossible, hence no translation
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} centered title={item?.id ?? "ERROR"} size="auto">
|
||||
{item === undefined ? (
|
||||
<Center>{"No item found"}</Center>
|
||||
) : (
|
||||
<Stack align="center">
|
||||
<Title>{item.name}</Title>
|
||||
<Group>
|
||||
<Avatar src={getIconUrl(item.integration.kind)} />
|
||||
<Text>{`${item.integration.name} (${item.integration.kind})`}</Text>
|
||||
</Group>
|
||||
<NormalizedLine itemKey="index" values={item.index} />
|
||||
<NormalizedLine itemKey="type" values={item.type} />
|
||||
<NormalizedLine itemKey="state" values={t(item.state)} />
|
||||
<NormalizedLine
|
||||
itemKey="upSpeed"
|
||||
values={item.upSpeed === undefined ? undefined : humanFileSize(item.upSpeed, "/s")}
|
||||
/>
|
||||
<NormalizedLine
|
||||
itemKey="downSpeed"
|
||||
values={item.downSpeed === undefined ? undefined : humanFileSize(item.downSpeed, "/s")}
|
||||
/>
|
||||
<NormalizedLine itemKey="sent" values={item.sent === undefined ? undefined : humanFileSize(item.sent)} />
|
||||
<NormalizedLine itemKey="received" values={humanFileSize(item.received)} />
|
||||
<NormalizedLine itemKey="size" values={humanFileSize(item.size)} />
|
||||
<NormalizedLine
|
||||
itemKey="progress"
|
||||
values={new Intl.NumberFormat("en", {
|
||||
style: "percent",
|
||||
notation: "compact",
|
||||
unitDisplay: "narrow",
|
||||
}).format(item.progress)}
|
||||
/>
|
||||
<NormalizedLine itemKey="ratio" values={item.ratio} />
|
||||
<NormalizedLine itemKey="added" values={item.added === undefined ? "unknown" : dayjs(item.added).format()} />
|
||||
<NormalizedLine itemKey="time" values={item.time !== 0 ? dayjs().add(item.time).format() : "∞"} />
|
||||
<NormalizedLine itemKey="category" values={item.category} />
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const NormalizedLine = ({
|
||||
itemKey,
|
||||
values,
|
||||
}: {
|
||||
itemKey: Exclude<keyof ExtendedDownloadClientItem, "integration" | "actions" | "name" | "id">;
|
||||
values?: number | string | string[];
|
||||
}) => {
|
||||
const t = useScopedI18n("widget.downloads.items");
|
||||
const tCommon = useScopedI18n("common");
|
||||
const translatedKey = t(`${itemKey}.detailsTitle`);
|
||||
const isLangRtl = tCommon("rtl", { value: "0", symbol: "1" }).startsWith("1"); //Maybe make a common "isLangRtl" somewhere
|
||||
const keyString = tCommon("rtl", { value: translatedKey, symbol: tCommon("symbols.colon") });
|
||||
if (typeof values !== "number" && (values === undefined || values.length === 0)) return null;
|
||||
return (
|
||||
<Group
|
||||
w="100%"
|
||||
display="flex"
|
||||
style={{ flexDirection: isLangRtl ? "row-reverse" : "row" }}
|
||||
align="top"
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Text>{keyString}</Text>
|
||||
{Array.isArray(values) ? (
|
||||
<Stack>
|
||||
{values.map((value) => (
|
||||
<Text key={value}>{value}</Text>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text>{values}</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface ClientsControlProps {
|
||||
clients: ExtendedClientStatus[];
|
||||
style?: MantineStyleProp;
|
||||
}
|
||||
|
||||
const ClientsControl = ({ clients, style }: ClientsControlProps) => {
|
||||
const integrationsStatuses = clients.reduce(
|
||||
(acc, { status, integration: { id }, interact }) =>
|
||||
status && interact ? (acc[status.paused ? "paused" : "active"].push(id), acc) : acc,
|
||||
{ paused: [] as string[], active: [] as string[] },
|
||||
);
|
||||
const someInteract = clients.some(({ interact }) => interact);
|
||||
const totalSpeed = humanFileSize(
|
||||
clients.reduce((count, { status }) => count + (status?.rates.down ?? 0), 0),
|
||||
"/s",
|
||||
);
|
||||
const { mutate: mutateResumeQueue } = clientApi.widget.downloads.resume.useMutation();
|
||||
const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const t = useScopedI18n("widget.downloads");
|
||||
return (
|
||||
<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)"}
|
||||
/>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
{someInteract && (
|
||||
<Tooltip label={t("actions.clients.resume")}>
|
||||
<ActionIcon
|
||||
size="var(--button-size)"
|
||||
radius={999}
|
||||
disabled={integrationsStatuses.paused.length === 0}
|
||||
variant="light"
|
||||
onClick={() => mutateResumeQueue({ integrationIds: integrationsStatuses.paused })}
|
||||
>
|
||||
<IconPlayerPlay style={actionIconIconStyle} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
radius={999}
|
||||
h="var(--button-size)"
|
||||
px="calc(var(--space-size)*2)"
|
||||
fw="500"
|
||||
onClick={open}
|
||||
>
|
||||
{totalSpeed}
|
||||
</Button>
|
||||
{someInteract && (
|
||||
<Tooltip label={t("actions.clients.pause")}>
|
||||
<ActionIcon
|
||||
size="var(--button-size)"
|
||||
radius={999}
|
||||
disabled={integrationsStatuses.active.length === 0}
|
||||
variant="light"
|
||||
onClick={() => mutatePauseQueue({ integrationIds: integrationsStatuses.active })}
|
||||
>
|
||||
<IconPlayerPause style={actionIconIconStyle} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Modal opened={opened} onClose={close} title={t("actions.clients.modalTitle")} centered size="auto">
|
||||
<Stack gap="10px">
|
||||
{clients.map((client) => (
|
||||
<Stack key={client.integration.id} gap="10px">
|
||||
<Divider />
|
||||
<Group wrap="nowrap" w="100%">
|
||||
<Paper withBorder radius={999}>
|
||||
<Group gap={5} pl={10} pr={15} fz={16} w={275} justify="space-between" wrap="nowrap">
|
||||
<Avatar radius={0} src={getIconUrl(client.integration.kind)} />
|
||||
{client.status ? (
|
||||
<Tooltip disabled={client.status.ratio === undefined} label={client.status.ratio?.toFixed(2)}>
|
||||
<Stack gap={0} pt={5} h={60} justify="center" flex={1}>
|
||||
{client.status.rates.up !== undefined ? (
|
||||
<Group display="flex" justify="center" c="green" w="100%" gap={5}>
|
||||
<Text flex={1} ta="right">
|
||||
{`↑ ${humanFileSize(client.status.rates.up, "/s")}`}
|
||||
</Text>
|
||||
<Text>{"-"}</Text>
|
||||
<Text flex={1} ta="left">
|
||||
{humanFileSize(client.status.totalUp ?? 0)}
|
||||
</Text>
|
||||
</Group>
|
||||
) : undefined}
|
||||
<Group display="flex" justify="center" c="blue" w="100%" gap={5}>
|
||||
<Text flex={1} ta="right">
|
||||
{`↓ ${humanFileSize(client.status.rates.down, "/s")}`}
|
||||
</Text>
|
||||
<Text>{"-"}</Text>
|
||||
<Text flex={1} ta="left">
|
||||
{humanFileSize(Math.floor(client.status.totalDown ?? 0))}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text c="red" ta="center">
|
||||
{t("errors.noCommunications")}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
<Text lineClamp={1} fz={22}>
|
||||
{client.integration.name}
|
||||
</Text>
|
||||
<Space flex={1} />
|
||||
{client.status && client.interact ? (
|
||||
<Tooltip label={t(`actions.client.${client.status.paused ? "resume" : "pause"}`)}>
|
||||
<ActionIcon
|
||||
radius={999}
|
||||
variant="light"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
(client.status?.paused ? mutateResumeQueue : mutatePauseQueue)({
|
||||
integrationIds: [client.integration.id],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{client.status.paused ? <IconPlayerPlay /> : <IconPlayerPause />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<ActionIcon radius={999} variant="light" size="lg" disabled>
|
||||
<IconX />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
110
packages/widgets/src/downloads/index.ts
Normal file
110
packages/widgets/src/downloads/index.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { IconDownload } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { ExtendedDownloadClientItem } from "@homarr/integrations";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
const columnsList = [
|
||||
"id",
|
||||
"actions",
|
||||
"added",
|
||||
"category",
|
||||
"downSpeed",
|
||||
"index",
|
||||
"integration",
|
||||
"name",
|
||||
"progress",
|
||||
"ratio",
|
||||
"received",
|
||||
"sent",
|
||||
"size",
|
||||
"state",
|
||||
"time",
|
||||
"type",
|
||||
"upSpeed",
|
||||
] as const satisfies (keyof ExtendedDownloadClientItem)[];
|
||||
const sortingExclusion = ["actions", "id", "state"] as const satisfies readonly (typeof columnsList)[number][];
|
||||
const columnsSort = columnsList.filter((column) =>
|
||||
sortingExclusion.some((exclusion) => exclusion !== column),
|
||||
) as Exclude<typeof columnsList, (typeof sortingExclusion)[number]>;
|
||||
|
||||
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("downloads", {
|
||||
icon: IconDownload,
|
||||
options: optionsBuilder.from(
|
||||
(factory) => ({
|
||||
columns: factory.multiSelect({
|
||||
defaultValue: ["integration", "name", "progress", "time", "actions"],
|
||||
options: columnsList.map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.downloads.items.${value}.columnTitle`),
|
||||
})),
|
||||
searchable: true,
|
||||
}),
|
||||
enableRowSorting: factory.switch({
|
||||
defaultValue: false,
|
||||
}),
|
||||
defaultSort: factory.select({
|
||||
defaultValue: "type",
|
||||
options: columnsSort.map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.downloads.items.${value}.columnTitle`),
|
||||
})),
|
||||
}),
|
||||
descendingDefaultSort: factory.switch({
|
||||
defaultValue: false,
|
||||
}),
|
||||
showCompletedUsenet: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
showCompletedTorrent: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
activeTorrentThreshold: factory.number({
|
||||
//in KiB/s
|
||||
validate: z.number().min(0),
|
||||
defaultValue: 0,
|
||||
step: 1,
|
||||
}),
|
||||
categoryFilter: factory.multiText({
|
||||
defaultValue: [] as string[],
|
||||
validate: z.string(),
|
||||
}),
|
||||
filterIsWhitelist: factory.switch({
|
||||
defaultValue: false,
|
||||
}),
|
||||
applyFilterToRatio: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
defaultSort: {
|
||||
shouldHide: (options) => !options.enableRowSorting,
|
||||
},
|
||||
descendingDefaultSort: {
|
||||
shouldHide: (options) => !options.enableRowSorting,
|
||||
},
|
||||
showCompletedUsenet: {
|
||||
shouldHide: (_, integrationKinds) =>
|
||||
!getIntegrationKindsByCategory("usenet").some((kinds) => integrationKinds.includes(kinds)),
|
||||
},
|
||||
showCompletedTorrent: {
|
||||
shouldHide: (_, integrationKinds) =>
|
||||
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
|
||||
},
|
||||
activeTorrentThreshold: {
|
||||
shouldHide: (_, integrationKinds) =>
|
||||
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
|
||||
},
|
||||
applyFilterToRatio: {
|
||||
shouldHide: (_, integrationKinds) =>
|
||||
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
|
||||
},
|
||||
},
|
||||
),
|
||||
supportedIntegrations: getIntegrationKindsByCategory("downloadClient"),
|
||||
})
|
||||
.withServerData(() => import("./serverData"))
|
||||
.withDynamicImport(() => import("./component"));
|
||||
21
packages/widgets/src/downloads/serverData.ts
Normal file
21
packages/widgets/src/downloads/serverData.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
"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,
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import * as clock from "./clock";
|
||||
import type { WidgetComponentProps } from "./definition";
|
||||
import * as dnsHoleControls from "./dns-hole/controls";
|
||||
import * as dnsHoleSummary from "./dns-hole/summary";
|
||||
import * as downloads from "./downloads";
|
||||
import * as iframe from "./iframe";
|
||||
import type { WidgetImportRecord } from "./import";
|
||||
import * as indexerManager from "./indexer-manager";
|
||||
@@ -45,6 +46,7 @@ export const widgetImports = {
|
||||
"smartHome-executeAutomation": smartHomeExecuteAutomation,
|
||||
mediaServer,
|
||||
calendar,
|
||||
downloads,
|
||||
"mediaRequests-requestList": mediaRequestsList,
|
||||
"mediaRequests-requestStats": mediaRequestsStats,
|
||||
rssFeed,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { IconReportSearch, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
@@ -10,7 +12,7 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
|
||||
defaultValue: true,
|
||||
}),
|
||||
})),
|
||||
supportedIntegrations: ["prowlarr"],
|
||||
supportedIntegrations: getIntegrationKindsByCategory("indexerManager"),
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { IconZoomQuestion } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetDefinition } from "../../definition";
|
||||
import { optionsBuilder } from "../../options";
|
||||
|
||||
@@ -10,7 +12,7 @@ export const { componentLoader, definition, serverDataLoader } = createWidgetDef
|
||||
defaultValue: true,
|
||||
}),
|
||||
})),
|
||||
supportedIntegrations: ["overseerr", "jellyseerr"],
|
||||
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
|
||||
})
|
||||
.withServerData(() => import("./serverData"))
|
||||
.withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { IconChartBar } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetDefinition } from "../../definition";
|
||||
|
||||
export const { componentLoader, definition, serverDataLoader } = createWidgetDefinition("mediaRequests-requestStats", {
|
||||
icon: IconChartBar,
|
||||
options: {},
|
||||
supportedIntegrations: ["overseerr", "jellyseerr"],
|
||||
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
|
||||
})
|
||||
.withServerData(() => import("./serverData"))
|
||||
.withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -47,9 +47,9 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
|
||||
z.object({
|
||||
options: z.object(
|
||||
objectEntries(widgetImports[innerProps.kind].definition.options).reduce(
|
||||
(acc, [key, value]: [string, { validate?: z.ZodType<unknown> }]) => {
|
||||
(acc, [key, value]: [string, { type: string; validate?: z.ZodType<unknown> }]) => {
|
||||
if (value.validate) {
|
||||
acc[key] = value.validate;
|
||||
acc[key] = value.type === "multiText" ? z.array(value.validate).optional() : value.validate;
|
||||
}
|
||||
|
||||
return acc;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { IconBinaryTree } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetDefinition } from "../../definition";
|
||||
import { optionsBuilder } from "../../options";
|
||||
|
||||
@@ -15,5 +17,5 @@ export const { definition, componentLoader } = createWidgetDefinition("smartHome
|
||||
entityUnit: factory.text(),
|
||||
clickable: factory.switch(),
|
||||
})),
|
||||
supportedIntegrations: ["homeAssistant"],
|
||||
supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"),
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { IconBinaryTree } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetDefinition } from "../../definition";
|
||||
import { optionsBuilder } from "../../options";
|
||||
|
||||
@@ -9,5 +11,5 @@ export const { definition, componentLoader } = createWidgetDefinition("smartHome
|
||||
displayName: factory.text(),
|
||||
automationId: factory.text(),
|
||||
})),
|
||||
supportedIntegrations: ["homeAssistant"],
|
||||
supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"),
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
36
packages/widgets/src/widgets-common.css
Normal file
36
packages/widgets/src/widgets-common.css
Normal file
@@ -0,0 +1,36 @@
|
||||
.downloads-widget-table {
|
||||
/*Set Header static and overflow body instead*/
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
.mantine-Table-tbody {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
scrollbar-width: 0;
|
||||
}
|
||||
/*Hide scrollbar until I can apply an overlay scrollbar instead*/
|
||||
.mantine-Table-tbody::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
||||
/*Properly size header*/
|
||||
.mrt-table-head-cell-labels {
|
||||
min-height: var(--ratioWidth);
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
/*Properly size controls*/
|
||||
.mrt-grab-handle-button {
|
||||
margin: unset;
|
||||
width: var(--dragButtonSize);
|
||||
min-width: var(--dragButtonSize);
|
||||
height: var(--dragButtonSize);
|
||||
min-height: var(--dragButtonSize);
|
||||
}
|
||||
.mrt-table-head-sort-button {
|
||||
margin: unset;
|
||||
width: var(--sortButtonSize);
|
||||
min-width: var(--sortButtonSize);
|
||||
height: var(--sortButtonSize);
|
||||
min-height: var(--sortButtonSize);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user