diff --git a/src/hooks/widgets/download-speed/useGetNetworkSpeed.tsx b/src/hooks/widgets/download-speed/useGetNetworkSpeed.tsx new file mode 100644 index 000000000..965607e1c --- /dev/null +++ b/src/hooks/widgets/download-speed/useGetNetworkSpeed.tsx @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { NormalizedDownloadQueueResponse } from '../../../types/api/downloads/queue/NormalizedDownloadQueueResponse'; + +export const useGetDownloadClientsQueue = () => useQuery({ + queryKey: ['network-speed'], + queryFn: async (): Promise => { + const response = await fetch('/api/modules/downloads'); + return response.json(); + }, + refetchInterval: 3000, +}); diff --git a/src/hooks/widgets/torrents/useGetTorrentData.tsx b/src/hooks/widgets/torrents/useGetTorrentData.tsx deleted file mode 100644 index 8027462e3..000000000 --- a/src/hooks/widgets/torrents/useGetTorrentData.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Query, useQuery } from '@tanstack/react-query'; -import axios from 'axios'; -import { NormalizedTorrentListResponse } from '../../../types/api/NormalizedTorrentListResponse'; - -interface TorrentsDataRequestParams { - appId: string; - refreshInterval: number; -} - -export const useGetTorrentData = (params: TorrentsDataRequestParams) => - useQuery({ - queryKey: ['torrentsData', params.appId], - queryFn: fetchData, - refetchOnWindowFocus: true, - refetchInterval(_: any, query: Query) { - if (query.state.fetchFailureCount < 3) { - return params.refreshInterval; - } - return false; - }, - enabled: !!params.appId, - }); - -const fetchData = async (): Promise => { - const response = await axios.post('/api/modules/torrents'); - return response.data as NormalizedTorrentListResponse; -}; diff --git a/src/pages/api/modules/downloads/index.ts b/src/pages/api/modules/downloads/index.ts new file mode 100644 index 000000000..58c584a55 --- /dev/null +++ b/src/pages/api/modules/downloads/index.ts @@ -0,0 +1,191 @@ +import { Deluge } from '@ctrl/deluge'; +import { AllClientData } from '@ctrl/shared-torrent'; +import { getCookie } from 'cookies-next'; +import dayjs from 'dayjs'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { Client } from 'sabnzbd-api'; +import { getConfig } from '../../../../tools/config/getConfig'; +import { + NormalizedDownloadAppStat, + NormalizedDownloadQueueResponse, +} from '../../../../types/api/downloads/queue/NormalizedDownloadQueueResponse'; +import { ConfigAppType, IntegrationField } from '../../../../types/app'; +import { UsenetQueueItem } from '../../../../widgets/useNet/types'; +import { NzbgetClient } from '../usenet/nzbget/nzbget-client'; +import { NzbgetQueueItem, NzbgetStatus } from '../usenet/nzbget/types'; + +const Get = async (request: NextApiRequest, response: NextApiResponse) => { + const configName = getCookie('config-name', { req: request }); + const config = getConfig(configName?.toString() ?? 'default'); + + const clientData: Promise[] = config.apps.map((app) => + GetDataFromClient(app) + ); + + const settledPromises = await Promise.allSettled(clientData); + + const data: NormalizedDownloadAppStat[] = settledPromises + .filter((x) => x.status === 'fulfilled') + .map((promise) => (promise as PromiseFulfilledResult).value) + .filter((x) => x !== undefined); + + const responseBody = { apps: data } as NormalizedDownloadQueueResponse; + + return response.status(200).json(responseBody); +}; + +const GetDataFromClient = async ( + app: ConfigAppType +): Promise => { + const reduceTorrent = (data: AllClientData): NormalizedDownloadAppStat => ({ + type: 'torrent', + appId: app.id, + success: true, + torrents: data.torrents, + totalDownload: data.torrents + .map((torrent) => torrent.downloadSpeed) + .reduce((acc, torrent) => acc + torrent), + totalUpload: data.torrents + .map((torrent) => torrent.uploadSpeed) + .reduce((acc, torrent) => acc + torrent), + }); + + const findField = (app: ConfigAppType, field: IntegrationField) => + app.integration?.properties.find((x) => x.field === field)?.value ?? undefined; + + switch (app.integration?.type) { + case 'deluge': { + return reduceTorrent( + await new Deluge({ + baseUrl: app.url, + password: findField(app, 'password'), + }).getAllData() + ); + } + case 'transmission': { + return reduceTorrent( + await new Deluge({ + baseUrl: app.url, + username: findField(app, 'username'), + password: findField(app, 'password'), + }).getAllData() + ); + } + case 'qBittorrent': { + return reduceTorrent( + await new Deluge({ + baseUrl: app.url, + username: findField(app, 'username'), + password: findField(app, 'password'), + }).getAllData() + ); + } + case 'sabnzbd': { + const { origin } = new URL(app.url); + const client = new Client(origin, findField(app, 'apiKey') ?? ''); + const queue = await client.queue(); + const items: UsenetQueueItem[] = queue.slots.map((slot) => { + const [hours, minutes, seconds] = slot.timeleft.split(':'); + const eta = dayjs.duration({ + hour: parseInt(hours, 10), + minutes: parseInt(minutes, 10), + seconds: parseInt(seconds, 10), + } as any); + + return { + id: slot.nzo_id, + eta: eta.asSeconds(), + name: slot.filename, + progress: parseFloat(slot.percentage), + size: parseFloat(slot.mb) * 1000 * 1000, + state: slot.status.toLowerCase() as any, + }; + }); + const killobitsPerSecond = Number(queue.kbpersec); + const bytesPerSecond = killobitsPerSecond * 1024; // convert killobytes to bytes + return { + type: 'usenet', + appId: app.id, + totalDownload: bytesPerSecond, + nzbs: items, + success: true, + }; + } + case 'nzbGet': { + const url = new URL(app.url); + const options = { + host: url.hostname, + port: url.port, + login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined, + hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined, + }; + + const nzbGet = NzbgetClient(options); + const nzbgetQueue: NzbgetQueueItem[] = await new Promise((resolve, reject) => { + nzbGet.listGroups((err: any, result: NzbgetQueueItem[]) => { + if (!err) { + resolve(result); + } else { + reject(err); + } + }); + }); + if (!nzbgetQueue) { + throw new Error('Error while getting NZBGet queue'); + } + + const nzbgetStatus: NzbgetStatus = await new Promise((resolve, reject) => { + nzbGet.status((err: any, result: NzbgetStatus) => { + if (!err) { + resolve(result); + } else { + reject(err); + } + }); + }); + + if (!nzbgetStatus) { + throw new Error('Error while getting NZBGet status'); + } + + const nzbgetItems: UsenetQueueItem[] = nzbgetQueue.map((item: NzbgetQueueItem) => ({ + id: item.NZBID.toString(), + name: item.NZBName, + progress: (item.DownloadedSizeMB / item.FileSizeMB) * 100, + eta: (item.RemainingSizeMB * 1000000) / nzbgetStatus.DownloadRate, + // Multiple MB to get bytes + size: item.FileSizeMB * 1000 * 1000, + state: getNzbgetState(item.Status), + })); + + return { + type: 'usenet', + appId: app.id, + nzbs: nzbgetItems, + success: true, + totalDownload: 0, + }; + } + default: + return undefined; + } +}; + +export default async (request: NextApiRequest, response: NextApiResponse) => { + if (request.method === 'GET') { + return Get(request, response); + } + + return response.status(405); +}; + +function getNzbgetState(status: string) { + switch (status) { + case 'QUEUED': + return 'queued'; + case 'PAUSED ': + return 'paused'; + default: + return 'downloading'; + } +} diff --git a/src/pages/api/modules/torrents.ts b/src/pages/api/modules/torrents.ts deleted file mode 100644 index 8e40e4851..000000000 --- a/src/pages/api/modules/torrents.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { Deluge } from '@ctrl/deluge'; -import { QBittorrent } from '@ctrl/qbittorrent'; -import { AllClientData } from '@ctrl/shared-torrent'; -import { Transmission } from '@ctrl/transmission'; -import Consola from 'consola'; -import { getCookie } from 'cookies-next'; -import { NextApiRequest, NextApiResponse } from 'next'; -import { getConfig } from '../../../tools/config/getConfig'; -import { NormalizedTorrentListResponse } from '../../../types/api/NormalizedTorrentListResponse'; -import { ConfigAppType, IntegrationType } from '../../../types/app'; - -const supportedTypes: IntegrationType[] = ['deluge', 'qBittorrent', 'transmission']; - -async function Post(req: NextApiRequest, res: NextApiResponse) { - // Get the type of app from the request url - const configName = getCookie('config-name', { req }); - const config = getConfig(configName?.toString() ?? 'default'); - - const clientApps = config.apps.filter( - (app) => - app.integration && app.integration.type && supportedTypes.includes(app.integration.type) - ); - - if (clientApps.length < 1) { - return res.status(500).json({ - allSuccess: false, - missingDownloadClients: true, - labels: [], - torrents: [], - }); - } - - const promiseList: Promise[] = []; - - for (let i = 0; i < clientApps.length; i += 1) { - const app = clientApps[i]; - const getAllData = getAllDataForClient(app); - if (!getAllData) { - continue; - } - const concatenatedPromise = async (): Promise => ({ - clientData: await getAllData, - appId: app.id, - }); - promiseList.push(concatenatedPromise()); - } - - const settledPromises = await Promise.allSettled(promiseList); - const fulfilledPromises = settledPromises.filter( - (settledPromise) => settledPromise.status === 'fulfilled' - ); - - const fulfilledClientData: ConcatenatedClientData[] = fulfilledPromises.map( - (fulfilledPromise) => (fulfilledPromise as PromiseFulfilledResult).value - ); - - const notFulfilledClientData = settledPromises - .filter((x) => x.status === 'rejected') - .map( - (fulfilledPromise) => - (fulfilledPromise as PromiseRejectedResult) - ); - - notFulfilledClientData.forEach((result) => { - Consola.error(`Error while communicating with torrent download client: ${result.reason}`); - }); - - Consola.info( - `Successfully fetched data from ${fulfilledPromises.length} torrent download clients` - ); - - return res.status(200).json({ - labels: fulfilledClientData.flatMap((clientData) => clientData.clientData.labels), - torrents: fulfilledClientData.map((clientData) => ({ - appId: clientData.appId, - torrents: clientData.clientData.torrents, - })), - allSuccess: settledPromises.length === fulfilledPromises.length, - missingDownloadClients: false, - }); -} - -const getAllDataForClient = (app: ConfigAppType) => { - switch (app.integration?.type) { - case 'deluge': { - const password = - app.integration?.properties.find((x) => x.field === 'password')?.value ?? undefined; - return new Deluge({ - baseUrl: app.url, - password, - }).getAllData(); - } - case 'transmission': { - return new Transmission({ - baseUrl: app.url, - username: - app.integration!.properties.find((x) => x.field === 'username')?.value ?? undefined, - password: - app.integration!.properties.find((x) => x.field === 'password')?.value ?? undefined, - }).getAllData(); - } - case 'qBittorrent': { - return new QBittorrent({ - baseUrl: app.url, - username: - app.integration!.properties.find((x) => x.field === 'username')?.value ?? undefined, - password: - app.integration!.properties.find((x) => x.field === 'password')?.value ?? undefined, - }).getAllData(); - } - default: - Consola.error(`unable to find torrent client of type '${app.integration?.type}'`); - return undefined; - } -}; - -type ConcatenatedClientData = { - appId: string; - clientData: AllClientData; -}; - -export default async (req: NextApiRequest, res: NextApiResponse) => { - // Filter out if the reuqest is a POST or a GET - if (req.method === 'POST') { - return Post(req, res); - } - return res.status(405).json({ - statusCode: 405, - message: 'Method not allowed', - }); -}; diff --git a/src/tools/config/migrateConfig.ts b/src/tools/config/migrateConfig.ts index 4ef212c05..4375444cb 100644 --- a/src/tools/config/migrateConfig.ts +++ b/src/tools/config/migrateConfig.ts @@ -9,7 +9,7 @@ import { ICalendarWidget } from '../../widgets/calendar/CalendarTile'; import { IDashDotTile } from '../../widgets/dashDot/DashDotTile'; import { IDateWidget } from '../../widgets/date/DateTile'; import { ITorrent } from '../../widgets/torrent/TorrentTile'; -import { ITorrentNetworkTraffic } from '../../widgets/torrentNetworkTraffic/TorrentNetworkTrafficTile'; +import { ITorrentNetworkTraffic } from '../../widgets/download-speed/TorrentNetworkTrafficTile'; import { IUsenetWidget } from '../../widgets/useNet/UseNetTile'; import { IWeatherWidget } from '../../widgets/weather/WeatherTile'; import { IWidget } from '../../widgets/widgets'; diff --git a/src/types/api/NormalizedTorrentListResponse.ts b/src/types/api/NormalizedTorrentListResponse.ts deleted file mode 100644 index 1c615f159..000000000 --- a/src/types/api/NormalizedTorrentListResponse.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Label, NormalizedTorrent } from '@ctrl/shared-torrent'; - -export type NormalizedTorrentListResponse = { - /** - * Available labels on all torrent clients - */ - labels: Label[]; - - /** - * Feteched and normalized torrents of all download clients - */ - torrents: ConcatenatedTorrentList[]; - - /** - * Indicated wether all requests were a success - */ - allSuccess: boolean; - - /** - * Missing download clients - */ - missingDownloadClients: boolean; -}; - -type ConcatenatedTorrentList = { - appId: string; - torrents: NormalizedTorrent[]; -}; diff --git a/src/types/api/downloads/queue/NormalizedDownloadQueueResponse.ts b/src/types/api/downloads/queue/NormalizedDownloadQueueResponse.ts new file mode 100644 index 000000000..853c756ad --- /dev/null +++ b/src/types/api/downloads/queue/NormalizedDownloadQueueResponse.ts @@ -0,0 +1,23 @@ +import { NormalizedTorrent } from '@ctrl/shared-torrent'; +import { UsenetQueueItem } from '../../../../widgets/useNet/types'; + +export type NormalizedDownloadQueueResponse = { + apps: NormalizedDownloadAppStat[]; +}; + +export type NormalizedDownloadAppStat = { + success: boolean; + appId: string; + totalDownload: number; +} & (TorrentTotalDownload | UsenetTotalDownloas); + +export type TorrentTotalDownload = { + type: 'torrent'; + torrents: NormalizedTorrent[]; + totalUpload: number; +}; + +export type UsenetTotalDownloas = { + type: 'usenet'; + nzbs: UsenetQueueItem[]; +}; diff --git a/src/widgets/download-speed/TorrentNetworkTrafficTile.tsx b/src/widgets/download-speed/TorrentNetworkTrafficTile.tsx new file mode 100644 index 000000000..5aaadb8b4 --- /dev/null +++ b/src/widgets/download-speed/TorrentNetworkTrafficTile.tsx @@ -0,0 +1,294 @@ +import { + Avatar, + Box, + Card, + Group, + Indicator, + Stack, + Text, + Title, + Tooltip, + useMantineTheme, +} from '@mantine/core'; +import { useElementSize, useListState } from '@mantine/hooks'; +import { linearGradientDef } from '@nivo/core'; +import { Datum, ResponsiveLine, Serie } from '@nivo/line'; +import { IconArrowsUpDown, IconDownload, IconUpload } from '@tabler/icons'; +import { useTranslation } from 'next-i18next'; +import { useEffect } from 'react'; +import { useConfigContext } from '../../config/provider'; +import { useGetDownloadClientsQueue } from '../../hooks/widgets/download-speed/useGetNetworkSpeed'; +import { useColorTheme } from '../../tools/color'; +import { humanFileSize } from '../../tools/humanFileSize'; +import { + NormalizedDownloadQueueResponse, + TorrentTotalDownload, +} from '../../types/api/downloads/queue/NormalizedDownloadQueueResponse'; +import { defineWidget } from '../helper'; +import { IWidget } from '../widgets'; + +const definition = defineWidget({ + id: 'dlspeed', + icon: IconArrowsUpDown, + options: {}, + + gridstack: { + minWidth: 2, + minHeight: 2, + maxWidth: 12, + maxHeight: 6, + }, + component: TorrentNetworkTrafficTile, +}); + +export type ITorrentNetworkTraffic = IWidget; + +interface TorrentNetworkTrafficTileProps { + widget: ITorrentNetworkTraffic; +} + +function TorrentNetworkTrafficTile({ widget }: TorrentNetworkTrafficTileProps) { + const { config } = useConfigContext(); + const { ref: refRoot, height: heightRoot } = useElementSize(); + const { ref: refTitle, height: heightTitle } = useElementSize(); + const { ref: refFooter, height: heightFooter } = useElementSize(); + const { primaryColor, secondaryColor } = useColorTheme(); + const { t } = useTranslation(`modules/${definition.id}`); + + const [clientDataHistory, setClientDataHistory] = useListState(); + + const { data, dataUpdatedAt } = useGetDownloadClientsQueue(); + + useEffect(() => { + if (data) { + setClientDataHistory.append(data); + } + + if (clientDataHistory.length < 30) { + return; + } + setClientDataHistory.remove(0); + }, [dataUpdatedAt]); + + if (!data) { + return null; + } + + const recoredAppsOverTime = clientDataHistory.flatMap((x) => x.apps.map((app) => app)); + + // removing duplicates the "naive" way: https://stackoverflow.com/a/9229821/15257712 + const uniqueRecordedAppsOverTime = recoredAppsOverTime + .map((x) => x.appId) + .filter((item, position) => recoredAppsOverTime.map((y) => y.appId).indexOf(item) === position); + + const lineChartData: Serie[] = uniqueRecordedAppsOverTime.flatMap((appId) => { + const records = recoredAppsOverTime.filter((x) => x.appId === appId); + + const series: Serie[] = [ + { + id: `download_${appId}`, + data: records.map((record, index) => ({ + x: index, + y: record.totalDownload, + })), + }, + ]; + + if (records.some((x) => x.type === 'torrent')) { + const torrentRecords = records.map((record, index): Datum | null => { + if (record.type !== 'torrent') { + return null; + } + + return { + x: index, + y: record.totalUpload, + }; + }); + const filteredRecords = torrentRecords.filter((x) => x !== null) as Datum[]; + series.push({ + id: `upload_${appId}`, + data: filteredRecords, + }); + } + + return series; + }); + + const totalDownload = uniqueRecordedAppsOverTime + .map((appId) => { + const records = recoredAppsOverTime.filter((x) => x.appId === appId); + const lastRecord = records.at(-1); + return lastRecord?.totalDownload ?? 0; + }) + .reduce((acc, n) => acc + n, 0); + + const totalUpload = uniqueRecordedAppsOverTime + .map((appId) => { + const records = recoredAppsOverTime.filter((x) => x.appId === appId && x.type === 'torrent'); + const lastRecord = records.at(-1) as TorrentTotalDownload; + return lastRecord?.totalUpload ?? 0; + }) + .reduce((acc, n) => acc + n, 0); + + const graphHeight = heightRoot - heightFooter - heightTitle; + + const { colors } = useMantineTheme(); + + return ( + + + + {t('card.lineChart.title')} + + + + { + const { points } = slice; + + const recordsFromPoints = uniqueRecordedAppsOverTime.map((appId) => { + const records = recoredAppsOverTime.filter((x) => x.appId === appId); + const point = points.find((x) => x.id.includes(appId)); + const pointIndex = Number(point?.data.x) ?? 0; + const color = point?.serieColor; + return { + record: records[pointIndex], + color, + }; + }); + + return ( + + + + {recordsFromPoints.map((entry, index) => { + const app = config?.apps.find((x) => x.id === entry.record.appId); + + if (!app) { + return null; + } + + return ( + + + + + {app.name} + + + + + {humanFileSize(entry.record.totalDownload, false)} + + + + {entry.record.type === 'torrent' && ( + + + + {humanFileSize(entry.record.totalUpload, false)} + + + )} + + + + ); + })} + + + + ); + }} + data={lineChartData} + curve="monotoneX" + yFormat=" >-.2f" + axisLeft={null} + axisBottom={null} + axisRight={null} + enablePoints={false} + enableGridX={false} + enableGridY={false} + enableArea + defs={[ + linearGradientDef('gradientA', [ + { offset: 0, color: 'inherit' }, + { offset: 100, color: 'inherit', opacity: 0 }, + ]), + ]} + colors={lineChartData.flatMap((data) => + data.id.toString().startsWith('upload_') + ? colors[secondaryColor][5] + : colors[primaryColor][5] + )} + fill={[{ match: '*', id: 'gradientA' }]} + margin={{ bottom: 5 }} + animate={false} + /> + + + + + + + + + {humanFileSize(totalDownload, false)} + + + + + + {humanFileSize(totalUpload, false)} + + + + + {uniqueRecordedAppsOverTime.map((appId, index) => { + const app = config?.apps.find((x) => x.id === appId); + + if (!app) { + return null; + } + + return ( + + + + ); + })} + + + + ); +} + +const AppAvatar = ({ iconUrl }: { iconUrl: string }) => { + const { colors, colorScheme } = useMantineTheme(); + + return ( + + ); +}; + +export default definition; diff --git a/src/widgets/index.ts b/src/widgets/index.ts index bd8e0607f..e38a2ba53 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -4,7 +4,7 @@ import dashdot from './dashDot/DashDotTile'; import usenet from './useNet/UseNetTile'; import weather from './weather/WeatherTile'; import torrent from './torrent/TorrentTile'; -import torrentNetworkTraffic from './torrentNetworkTraffic/TorrentNetworkTrafficTile'; +import torrentNetworkTraffic from './download-speed/TorrentNetworkTrafficTile'; export default { calendar, diff --git a/src/widgets/torrent/TorrentTile.tsx b/src/widgets/torrent/TorrentTile.tsx index cf2a53c1c..41c0a3996 100644 --- a/src/widgets/torrent/TorrentTile.tsx +++ b/src/widgets/torrent/TorrentTile.tsx @@ -1,4 +1,3 @@ -import { NormalizedTorrent } from '@ctrl/shared-torrent'; import { Badge, Center, @@ -18,10 +17,8 @@ import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; import relativeTime from 'dayjs/plugin/relativeTime'; import { useTranslation } from 'next-i18next'; -import { useEffect, useState } from 'react'; -import { useConfigContext } from '../../config/provider'; -import { useGetTorrentData } from '../../hooks/widgets/torrents/useGetTorrentData'; -import { NormalizedTorrentListResponse } from '../../types/api/NormalizedTorrentListResponse'; +import { useGetDownloadClientsQueue } from '../../hooks/widgets/download-speed/useGetNetworkSpeed'; +import { NormalizedDownloadQueueResponse } from '../../types/api/downloads/queue/NormalizedDownloadQueueResponse'; import { AppIntegrationType } from '../../types/app'; import { defineWidget } from '../helper'; import { IWidget } from '../widgets'; @@ -44,13 +41,6 @@ const definition = defineWidget({ type: 'switch', defaultValue: true, }, - refreshInterval: { - type: 'slider', - defaultValue: 10, - min: 1, - max: 60, - step: 1, - }, }, gridstack: { minWidth: 2, @@ -72,43 +62,17 @@ function TorrentTile({ widget }: TorrentTileProps) { const MIN_WIDTH_MOBILE = useMantineTheme().breakpoints.xs; const { width } = useElementSize(); - const { config } = useConfigContext(); - const downloadApps = - config?.apps.filter((x) => x.integration && downloadAppTypes.includes(x.integration.type)) ?? - []; - - const [selectedAppId, setSelectedApp] = useState(downloadApps[0]?.id); const { data, isError, isInitialLoading, dataUpdatedAt, }: { - data: NormalizedTorrentListResponse | undefined; + data: NormalizedDownloadQueueResponse | undefined; isError: boolean; isInitialLoading: boolean; dataUpdatedAt: number; - } = useGetTorrentData({ - appId: selectedAppId!, - refreshInterval: widget.properties.refreshInterval * 1000, - }); - - useEffect(() => { - if (!selectedAppId && downloadApps.length) { - setSelectedApp(downloadApps[0].id); - } - }, [downloadApps, selectedAppId]); - - if (downloadApps.length === 0) { - return ( - - {t('card.errors.noDownloadClients.title')} - - {t('card.errors.noDownloadClients.text')} - - - ); - } + } = useGetDownloadClientsQueue(); if (isError) { return ( @@ -121,7 +85,7 @@ function TorrentTile({ widget }: TorrentTileProps) { ); } - if (isInitialLoading) { + if (isInitialLoading || !data) { return ( + {t('card.errors.noDownloadClients.title')} + + {t('card.errors.noDownloadClients.text')} + + + ); + } + + if (!data || Object.values(data.apps).length < 1) { return (
{t('card.table.body.nothingFound')} @@ -147,17 +122,7 @@ function TorrentTile({ widget }: TorrentTileProps) { ); } - const filter = (torrent: NormalizedTorrent) => { - if (!widget.properties.displayCompletedTorrents && torrent.isCompleted) { - return false; - } - - if (!widget.properties.displayStaleTorrents && !torrent.isCompleted && torrent.eta <= 0) { - return false; - } - - return true; - }; + const torrents = data.apps.flatMap((app) => (app.type === 'torrent' ? app.torrents : [])); const difference = new Date().getTime() - dataUpdatedAt; const duration = dayjs.duration(difference, 'ms'); @@ -178,19 +143,14 @@ function TorrentTile({ widget }: TorrentTileProps) { - {data.torrents.map((concatenatedTorrentList) => { - const app = config?.apps.find((x) => x.id === concatenatedTorrentList.appId); - return concatenatedTorrentList.torrents - .filter(filter) - .map((item: NormalizedTorrent, index: number) => ( - - )); - })} + {torrents.map((torrent, index) => ( + + ))} - {!data.allSuccess && ( + {data.apps.some((x) => !x.success) && ( {t('card.footer.error')} diff --git a/src/widgets/torrentNetworkTraffic/TorrentNetworkTrafficTile.tsx b/src/widgets/torrentNetworkTraffic/TorrentNetworkTrafficTile.tsx deleted file mode 100644 index 3b494d130..000000000 --- a/src/widgets/torrentNetworkTraffic/TorrentNetworkTrafficTile.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import { NormalizedTorrent } from '@ctrl/shared-torrent'; -import { Text, Title, Group, useMantineTheme, Box, Card, ColorSwatch, Stack } from '@mantine/core'; -import { useListState } from '@mantine/hooks'; -import { showNotification } from '@mantine/notifications'; -import { linearGradientDef } from '@nivo/core'; -import { Datum, ResponsiveLine } from '@nivo/line'; -import { IconArrowsUpDown } from '@tabler/icons'; -import axios from 'axios'; -import { useTranslation } from 'next-i18next'; -import { useEffect, useState } from 'react'; -import { useConfigContext } from '../../config/provider'; -import { useSetSafeInterval } from '../../hooks/useSetSafeInterval'; -import { humanFileSize } from '../../tools/humanFileSize'; -import { NormalizedTorrentListResponse } from '../../types/api/NormalizedTorrentListResponse'; -import { defineWidget } from '../helper'; -import { IWidget } from '../widgets'; - -const definition = defineWidget({ - id: 'dlspeed', - icon: IconArrowsUpDown, - options: {}, - - gridstack: { - minWidth: 2, - minHeight: 2, - maxWidth: 12, - maxHeight: 6, - }, - component: TorrentNetworkTrafficTile, -}); - -export type ITorrentNetworkTraffic = IWidget; - -interface TorrentNetworkTrafficTileProps { - widget: ITorrentNetworkTraffic; -} - -function TorrentNetworkTrafficTile({ widget }: TorrentNetworkTrafficTileProps) { - const { t } = useTranslation(`modules/${definition.id}`); - const { colors } = useMantineTheme(); - const setSafeInterval = useSetSafeInterval(); - const { configVersion, config } = useConfigContext(); - - const [torrentHistory, torrentHistoryHandlers] = useListState([]); - const [torrents, setTorrents] = useState([]); - - const downloadServices = - config?.apps.filter( - (app) => - app.integration.type === 'qBittorrent' || - app.integration.type === 'transmission' || - app.integration.type === 'deluge' - ) ?? []; - const totalDownloadSpeed = torrents.reduce((acc, torrent) => acc + torrent.downloadSpeed, 0); - const totalUploadSpeed = torrents.reduce((acc, torrent) => acc + torrent.uploadSpeed, 0); - - useEffect(() => { - if (downloadServices.length === 0) return; - const interval = setSafeInterval(() => { - // Send one request with each download service inside - axios - .post('/api/modules/torrents') - .then((response) => { - const responseData: NormalizedTorrentListResponse = response.data; - setTorrents(responseData.torrents.flatMap((x) => x.torrents)); - }) - .catch((error) => { - if (error.status === 401) return; - setTorrents([]); - // eslint-disable-next-line no-console - console.error('Error while fetching torrents', error.response.data); - showNotification({ - title: 'Torrent speed module failed to fetch torrents', - autoClose: 1000, - disallowClose: true, - id: 'fail-torrent-speed-module', - color: 'red', - message: - 'Error fetching torrents, please check your config for any potential errors, check the console for more info', - }); - clearInterval(interval); - }); - }, 1000); - }, [configVersion]); - - useEffect(() => { - torrentHistoryHandlers.append({ - x: Date.now(), - down: totalDownloadSpeed, - up: totalUploadSpeed, - }); - }, [totalDownloadSpeed, totalUploadSpeed]); - - const history = torrentHistory.slice(-10); - const chartDataUp = history.map((load, i) => ({ - x: load.x, - y: load.up, - })) as Datum[]; - const chartDataDown = history.map((load, i) => ({ - x: load.x, - y: load.down, - })) as Datum[]; - - return ( - - {t('card.lineChart.title')} - - - - - {t('card.lineChart.totalDownload', { download: humanFileSize(totalDownloadSpeed) })} - - - - - - {t('card.lineChart.totalUpload', { upload: humanFileSize(totalUploadSpeed) })} - - - - - { - const Download = slice.points[0].data.y as number; - const Upload = slice.points[1].data.y as number; - // Get the number of seconds since the last update. - const seconds = (Date.now() - (slice.points[0].data.x as number)) / 1000; - // Round to the nearest second. - const roundedSeconds = Math.round(seconds); - return ( - - {t('card.lineChart.timeSpan', { seconds: roundedSeconds })} - - - - - - {t('card.lineChart.download', { download: humanFileSize(Download) })} - - - - - - {t('card.lineChart.upload', { upload: humanFileSize(Upload) })} - - - - - - ); - }} - data={[ - { - id: 'downloads', - data: chartDataUp, - }, - { - id: 'uploads', - data: chartDataDown, - }, - ]} - curve="monotoneX" - yFormat=" >-.2f" - axisTop={null} - axisRight={null} - enablePoints={false} - animate={false} - enableGridX={false} - enableGridY={false} - enableArea - defs={[ - linearGradientDef('gradientA', [ - { offset: 0, color: 'inherit' }, - { offset: 100, color: 'inherit', opacity: 0 }, - ]), - ]} - fill={[{ match: '*', id: 'gradientA' }]} - colors={[colors.blue[5], colors.green[5]]} - /> - - - ); -} - -export default definition; - -interface TorrentHistory { - x: number; - up: number; - down: number; -} diff --git a/src/widgets/useNet/UsenetQueueList.tsx b/src/widgets/useNet/UsenetQueueList.tsx index 5fec4f842..aa3dc99e6 100644 --- a/src/widgets/useNet/UsenetQueueList.tsx +++ b/src/widgets/useNet/UsenetQueueList.tsx @@ -1,13 +1,11 @@ import { ActionIcon, Alert, - Button, Center, Code, Group, Pagination, Progress, - ScrollArea, Skeleton, Stack, Table,