"use client"; import "../widgets-common.css"; import { useCallback, useMemo, useState } from "react"; import type { MantineStyleProp } from "@mantine/core"; import { ActionIcon, Avatar, AvatarGroup, Button, Center, Chip, Divider, Group, Modal, Paper, Popover, Progress, Space, Stack, Text, Title, Tooltip, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { IconAlertTriangle, IconCirclesRelation, IconFilter, 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, useIntegrationConnected } from "@homarr/common"; import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions"; import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; interface QuickFilter { integrationKinds: string[]; statuses: ExtendedDownloadClientItem["state"][]; } //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, }; export default function DownloadClientsWidget({ isEditMode, integrationIds, options, setOptions, }: WidgetComponentProps<"downloads">) { const integrationsWithInteractions = useIntegrationsWithInteractAccess().flatMap(({ id }) => integrationIds.includes(id) ? [id] : [], ); const [currentItems] = clientApi.widget.downloads.getJobsAndStatuses.useSuspenseQuery( { integrationIds, limitPerIntegration: options.limitPerIntegration, }, { refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, }, ); const utils = clientApi.useUtils(); //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); //User quick settings for filters const [quickFilters, setQuickFilters] = useState({ integrationKinds: [], statuses: [] }); const availableStatuses = useMemo(() => { //Redefine list of available statuses from current items const statuses = Array.from(new Set(currentItems.flatMap(({ data }) => data.items.map(({ state }) => state)))); //Reset user filters accordingly to remove unavailable statuses setQuickFilters(({ integrationKinds: names, statuses: prevStatuses }) => { return { integrationKinds: names, statuses: prevStatuses.filter((status) => statuses.includes(status)) }; }); return statuses; }, [currentItems]); //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, limitPerIntegration: options.limitPerIntegration, }, { onData: (data) => { utils.widget.downloads.getJobsAndStatuses.setData( { integrationIds, limitPerIntegration: options.limitPerIntegration }, (prevData) => { return prevData?.map((item) => { if (item.integration.id !== data.integration.id) return item; return { data: data.data, integration: { ...data.integration, updatedAt: new Date(), }, }; }); }, ); }, }, ); //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)) //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)) || (type === "miscellaneous" && ((progress === 1 && options.showCompletedHttp) || progress !== 1)), ) //Filter following user quick setting .filter( ({ state }) => (quickFilters.integrationKinds.length === 0 || quickFilters.integrationKinds.includes(pair.integration.name)) && (quickFilters.statuses.length === 0 || quickFilters.statuses.includes(state)), ) //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 || 1) : 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, integrationsWithInteractions, mutateDeleteItem, mutatePauseItem, mutateResumeItem, options.activeTorrentThreshold, options.categoryFilter, options.filterIsWhitelist, options.showCompletedTorrent, options.showCompletedUsenet, options.showCompletedHttp, quickFilters, ], ); //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); const isTorrent = getIntegrationKindsByCategory("torrent").some((kind) => kind === integration.kind); /** Derived from current items */ const { totalUp, totalDown } = data.items .filter( ({ category }) => !options.applyFilterToRatio || !data.status.types.includes("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?.types.length ?? Infinity) - (statusB?.types.length ?? Infinity), ), [ currentItems, integrationIds, integrationsWithInteractions, options.applyFilterToRatio, options.categoryFilter, options.filterIsWhitelist, ], ); //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"); if (data.some(({ type }) => type === "miscellaneous")) integrationTypes.push("miscellaneous"); //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; //Default styling behavior for stopping interaction when editing. (Applied everywhere except the table header) const editStyle: MantineStyleProp = { pointerEvents: isEditMode ? "none" : undefined, }; //Base element in common with all columns const columnsDefBase = useCallback( ({ key, showHeader, }: { key: keyof ExtendedDownloadClientItem; showHeader: boolean; }): MRT_ColumnDef => { return { id: key, accessorKey: key, header: key, size: columnsRatios[key], Header: () => showHeader ? ( {t(`items.${key}.columnTitle`)} ) : null, }; }, [t], ); //Make columns and cell elements, Memoized to data with deps on data and EditMode const columns = useMemo[]>( () => [ { ...columnsDefBase({ key: "actions", showHeader: false }), 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 }), sortUndefined: "last", Cell: ({ cell }) => { const added = cell.getValue(); return {added !== undefined ? dayjs(added).fromNow() : "unknown"}; }, }, { ...columnsDefBase({ key: "category", showHeader: false }), sortUndefined: "last", Cell: ({ cell }) => { const category = cell.getValue(); return ( category !== undefined && ( ) ); }, }, { ...columnsDefBase({ key: "downSpeed", showHeader: true }), sortUndefined: "last", Cell: ({ cell }) => { const downSpeed = cell.getValue(); return downSpeed ? {humanFileSize(downSpeed, "/s")} : null; }, }, { ...columnsDefBase({ key: "id", showHeader: false }), enableSorting: false, Cell: ({ cell }) => { const id = cell.getValue(); return ( ); }, }, { ...columnsDefBase({ key: "index", showHeader: true }), Cell: ({ cell }) => { const index = cell.getValue(); return {index}; }, }, { ...columnsDefBase({ key: "integration", showHeader: false }), 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 }), 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 }), 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 }), Cell: ({ cell }) => { const received = cell.getValue(); return {humanFileSize(received)}; }, }, { ...columnsDefBase({ key: "sent", showHeader: true }), sortUndefined: "last", Cell: ({ cell }) => { const sent = cell.getValue(); return sent && {humanFileSize(sent)}; }, }, { ...columnsDefBase({ key: "size", showHeader: true }), 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 }), 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 }), sortUndefined: "last", Cell: ({ cell }) => { const upSpeed = cell.getValue(); return upSpeed && {humanFileSize(upSpeed, "/s")}; }, }, ], [columnsDefBase, t, tCommon], ); //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", }, mantineTableBodyProps: { style: editStyle }, mantineTableHeadCellProps: { p: 4, }, mantineTableBodyCellProps: ({ cell, row }) => ({ onClick: () => { setClickedIndex(row.index); if (cell.column.id !== "actions") open(); }, p: 4, }), 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, }, }); //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 (options.columns.length === 0) return (
{t("errors.noColumns")}
); //The actual widget return ( {integrationTypes.includes("torrent") && ( {`${t("globalRatio")}:`} {(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]); 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})`} {item.type !== "miscellaneous" && ( )} {item.type !== "miscellaneous" && ( )} {item.type !== "miscellaneous" && } )}
); }; const NormalizedLine = ({ itemKey, values, }: { itemKey: Exclude; values?: number | string | string[]; }) => { const t = useScopedI18n("widget.downloads.items"); if (typeof values !== "number" && (values === undefined || values.length === 0)) return null; return ( {`${t(`${itemKey}.detailsTitle`)}:`} {Array.isArray(values) ? ( {values.map((value) => ( {value} ))} ) : ( {values} )} ); }; interface ClientsControlProps { clients: ExtendedClientStatus[]; filters: QuickFilter; setFilters: (filters: QuickFilter) => void; availableStatuses: QuickFilter["statuses"]; } const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: 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 ( {t("items.integration.columnTitle")} setFilters({ ...filters, integrationKinds: names })} > {clients.map(({ integration }) => ( {integration.name} ))} {t("items.state.columnTitle")} setFilters({ ...filters, statuses: statuses as typeof filters.statuses })} > {availableStatuses.map((status) => ( {t(`states.${status}`)} ))} {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 ? : } ) : ( )} ))} ); }; interface ClientAvatarProps { client: ExtendedClientStatus; } const ClientAvatar = ({ client }: ClientAvatarProps) => { const isConnected = useIntegrationConnected(client.integration.updatedAt, { timeout: 30000 }); return ( ); };