"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 = { 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(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( () => 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( () => 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; //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 => { 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 ? {t(`items.${key}.columnTitle`)} : ""), }; }; //Make columns and cell elements, Memoized to data with deps on data and EditMode const columns = useMemo[]>( () => [ { ...columnsDefBase({ key: "actions", showHeader: false, align: "center" }), enableSorting: false, Cell: ({ cell, row }) => { const actions = cell.getValue(); const pausedAction = row.original.state === "paused" ? "resume" : "pause"; const [opened, { open, close }] = useDisclosure(false); return actions ? ( {pausedAction === "resume" ? ( ) : ( )} ) : ( ); }, }, { ...columnsDefBase({ key: "added", showHeader: true, align: "center" }), sortUndefined: "last", Cell: ({ cell }) => { const added = cell.getValue(); return {added !== undefined ? dayjs(added).fromNow() : "unknown"}; }, }, { ...columnsDefBase({ key: "category", showHeader: false, align: "center" }), sortUndefined: "last", Cell: ({ cell }) => { const category = cell.getValue(); return ( category !== undefined && ( ) ); }, }, { ...columnsDefBase({ key: "downSpeed", showHeader: true, align: "right" }), sortUndefined: "last", Cell: ({ cell }) => { const downSpeed = cell.getValue(); return downSpeed && {humanFileSize(downSpeed, "/s")}; }, }, { ...columnsDefBase({ key: "id", showHeader: false, align: "center" }), enableSorting: false, Cell: ({ cell }) => { const id = cell.getValue(); return ( ); }, }, { ...columnsDefBase({ key: "index", showHeader: true, align: "center" }), Cell: ({ cell }) => { const index = cell.getValue(); return {index}; }, }, { ...columnsDefBase({ key: "integration", showHeader: false, align: "center" }), Cell: ({ cell }) => { const integration = cell.getValue(); return ( ); }, }, { ...columnsDefBase({ key: "name", showHeader: true }), Cell: ({ cell }) => { const name = cell.getValue(); return ( {name} ); }, }, { ...columnsDefBase({ key: "progress", showHeader: true, align: "center" }), Cell: ({ cell, row }) => { const progress = cell.getValue(); return ( {new Intl.NumberFormat("en", { style: "percent", notation: "compact", unitDisplay: "narrow" }).format( progress, )} ); }, }, { ...columnsDefBase({ key: "ratio", showHeader: true, align: "center" }), sortUndefined: "last", Cell: ({ cell }) => { const ratio = cell.getValue(); return ratio !== undefined && {ratio.toFixed(ratio >= 100 ? 0 : ratio >= 10 ? 1 : 2)}; }, }, { ...columnsDefBase({ key: "received", showHeader: true, align: "right" }), Cell: ({ cell }) => { const received = cell.getValue(); return {humanFileSize(received)}; }, }, { ...columnsDefBase({ key: "sent", showHeader: true, align: "right" }), sortUndefined: "last", Cell: ({ cell }) => { const sent = cell.getValue(); return sent && {humanFileSize(sent)}; }, }, { ...columnsDefBase({ key: "size", showHeader: true, align: "right" }), Cell: ({ cell }) => { const size = cell.getValue(); return {humanFileSize(size)}; }, }, { ...columnsDefBase({ key: "state", showHeader: true }), enableSorting: false, Cell: ({ cell }) => { const state = cell.getValue(); return {t(`states.${state}`)}; }, }, { ...columnsDefBase({ key: "time", showHeader: true, align: "center" }), Cell: ({ cell }) => { const time = cell.getValue(); return time === 0 ? : {dayjs().add(time).fromNow()}; }, }, { ...columnsDefBase({ key: "type", showHeader: true }), Cell: ({ cell }) => { const type = cell.getValue(); return {type}; }, }, { ...columnsDefBase({ key: "upSpeed", showHeader: true, align: "right" }), sortUndefined: "last", Cell: ({ cell }) => { const upSpeed = cell.getValue(); return upSpeed && {humanFileSize(upSpeed, "/s")}; }, }, ], [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, 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 (
{t("errors.noColumns")}
); //The actual widget return ( {integrationTypes.includes("torrent") && ( {tCommon("rtl", { value: t("globalRatio"), symbol: tCommon("symbols.colon") })} {(globalTraffic.up / globalTraffic.down).toFixed(2)} )} ); } interface ItemInfoModalProps { items: ExtendedDownloadClientItem[]; currentIndex: number; opened: boolean; onClose: () => void; } const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalProps) => { const item = useMemo( () => 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 ( {item === undefined ? (
{"No item found"}
) : ( {item.name} {`${item.integration.name} (${item.integration.kind})`} )}
); }; const NormalizedLine = ({ itemKey, values, }: { itemKey: Exclude; 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 ( {keyString} {Array.isArray(values) ? ( {values.map((value) => ( {value} ))} ) : ( {values} )} ); }; 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 ( {clients.map((client) => ( ))} {someInteract && ( mutateResumeQueue({ integrationIds: integrationsStatuses.paused })} > )} {someInteract && ( mutatePauseQueue({ integrationIds: integrationsStatuses.active })} > )} {clients.map((client) => ( {client.status ? ( {client.status.rates.up !== undefined ? ( {`↑ ${humanFileSize(client.status.rates.up, "/s")}`} {"-"} {humanFileSize(client.status.totalUp ?? 0)} ) : undefined} {`↓ ${humanFileSize(client.status.rates.down, "/s")}`} {"-"} {humanFileSize(Math.floor(client.status.totalDown ?? 0))} ) : ( {t("errors.noCommunications")} )} {client.integration.name} {client.status && client.interact ? ( { (client.status?.paused ? mutateResumeQueue : mutatePauseQueue)({ integrationIds: [client.integration.id], }); }} > {client.status.paused ? : } ) : ( )} ))} ); };