Add detail popover for torrents list

This commit is contained in:
Manuel
2023-01-18 21:24:55 +01:00
parent e950987359
commit 1bf3b1312b
8 changed files with 328 additions and 85 deletions

View File

@@ -29,6 +29,7 @@ module.exports = {
'@typescript-eslint/no-shadow': 'off', '@typescript-eslint/no-shadow': 'off',
'@typescript-eslint/no-use-before-define': 'off', '@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/no-non-null-assertion': 'off',
'no-continue': 'off',
'linebreak-style': 0, 'linebreak-style': 0,
}, },
}; };

View File

@@ -16,6 +16,10 @@
} }
}, },
"card": { "card": {
"footer": {
"error": "Error",
"lastUpdated": "Last updated {{time}} ago"
},
"table": { "table": {
"header": { "header": {
"name": "Name", "name": "Name",
@@ -49,6 +53,16 @@
}, },
"loading": { "loading": {
"title": "Loading..." "title": "Loading..."
},
"popover": {
"introductionPrefix": "Managed by",
"metrics": {
"queuePosition": "Queue position - {{position}}",
"progress": "Progress - {{progress}}%",
"totalSelectedSize": "Total - {{totalSize}}",
"state": "State - {{state}}",
"completed": "Completed"
}
} }
} }
} }

View File

@@ -1,8 +1,6 @@
import { NormalizedTorrent } from '@ctrl/shared-torrent';
import { Query, useQuery } from '@tanstack/react-query'; import { Query, useQuery } from '@tanstack/react-query';
import axios from 'axios'; import axios from 'axios';
import { NormalizedTorrentListResponse } from '../../../types/api/NormalizedTorrentListResponse';
const POLLING_INTERVAL = 2000;
interface TorrentsDataRequestParams { interface TorrentsDataRequestParams {
appId: string; appId: string;
@@ -23,7 +21,7 @@ export const useGetTorrentData = (params: TorrentsDataRequestParams) =>
enabled: !!params.appId, enabled: !!params.appId,
}); });
const fetchData = async (): Promise<NormalizedTorrent[]> => { const fetchData = async (): Promise<NormalizedTorrentListResponse> => {
const response = await axios.post('/api/modules/torrents'); const response = await axios.post('/api/modules/torrents');
return response.data as NormalizedTorrent[]; return response.data as NormalizedTorrentListResponse;
}; };

View File

@@ -1,79 +1,124 @@
import Consola from 'consola';
import { Deluge } from '@ctrl/deluge'; import { Deluge } from '@ctrl/deluge';
import { QBittorrent } from '@ctrl/qbittorrent'; import { QBittorrent } from '@ctrl/qbittorrent';
import { NormalizedTorrent } from '@ctrl/shared-torrent'; import { AllClientData } from '@ctrl/shared-torrent';
import { Transmission } from '@ctrl/transmission'; import { Transmission } from '@ctrl/transmission';
import Consola from 'consola';
import { getCookie } from 'cookies-next'; import { getCookie } from 'cookies-next';
import { NextApiRequest, NextApiResponse } from 'next'; import { NextApiRequest, NextApiResponse } from 'next';
import { getConfig } from '../../../tools/config/getConfig'; import { getConfig } from '../../../tools/config/getConfig';
import { NormalizedTorrentListResponse } from '../../../types/api/NormalizedTorrentListResponse';
import { ConfigAppType, IntegrationType } from '../../../types/app';
async function Post(req: NextApiRequest, res: NextApiResponse) { const supportedTypes: IntegrationType[] = ['deluge', 'qBittorrent', 'transmission'];
async function Post(req: NextApiRequest, res: NextApiResponse<NormalizedTorrentListResponse>) {
// Get the type of app from the request url // Get the type of app from the request url
const configName = getCookie('config-name', { req }); const configName = getCookie('config-name', { req });
const config = getConfig(configName?.toString() ?? 'default'); const config = getConfig(configName?.toString() ?? 'default');
const qBittorrentApp = config.apps.filter((app) => app.integration?.type === 'qBittorrent');
const delugeApp = config.apps.filter((app) => app.integration?.type === 'deluge');
const transmissionApp = config.apps.filter((app) => app.integration?.type === 'transmission');
const torrents: NormalizedTorrent[] = []; const clientApps = config.apps.filter(
(app) =>
app.integration && app.integration.type && supportedTypes.includes(app.integration.type)
);
if (!qBittorrentApp && !delugeApp && !transmissionApp) { if (clientApps.length < 1) {
return res.status(500).json({ return res.status(500).json({
statusCode: 500, allSuccess: false,
message: 'Missing apps', missingDownloadClients: true,
labels: [],
torrents: [],
}); });
} }
try { const promiseList: Promise<ConcatenatedClientData>[] = [];
await Promise.all(
qBittorrentApp.map((app) => for (let i = 0; i < clientApps.length; i += 1) {
new QBittorrent({ const app = clientApps[i];
baseUrl: app.url, const getAllData = getAllDataForClient(app);
username: if (!getAllData) {
app.integration!.properties.find((x) => x.field === 'username')?.value ?? undefined, continue;
password: }
app.integration!.properties.find((x) => x.field === 'password')?.value ?? undefined, const concatenatedPromise = async (): Promise<ConcatenatedClientData> => ({
}) clientData: await getAllData,
.getAllData() appId: app.id,
.then((e) => torrents.push(...e.torrents)) });
) promiseList.push(concatenatedPromise());
);
await Promise.all(
delugeApp.map((app) => {
const password =
app.integration?.properties.find((x) => x.field === 'password')?.value ?? undefined;
const test = new Deluge({
baseUrl: app.url,
password,
})
.getAllData()
.then((e) => torrents.push(...e.torrents));
return test;
})
);
// Map transmissionApps
await Promise.all(
transmissionApp.map((app) =>
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()
.then((e) => torrents.push(...e.torrents))
)
);
} catch (e: any) {
Consola.error('Error while communicating with your torrent applications:\n', e);
return res.status(401).json(e);
} }
Consola.debug(`Retrieved ${torrents.length} from all download clients`); const settledPromises = await Promise.allSettled(promiseList);
return res.status(200).json(torrents); const fulfilledPromises = settledPromises.filter(
(settledPromise) => settledPromise.status === 'fulfilled'
);
const fulfilledClientData: ConcatenatedClientData[] = fulfilledPromises.map(
(fulfilledPromise) => (fulfilledPromise as PromiseFulfilledResult<ConcatenatedClientData>).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) => { export default async (req: NextApiRequest, res: NextApiResponse) => {
// Filter out if the reuqest is a POST or a GET // Filter out if the reuqest is a POST or a GET
if (req.method === 'POST') { if (req.method === 'POST') {

View File

@@ -0,0 +1,28 @@
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[];
};

View File

@@ -1,14 +1,37 @@
/* eslint-disable @next/next/no-img-element */
import { NormalizedTorrent } from '@ctrl/shared-torrent'; import { NormalizedTorrent } from '@ctrl/shared-torrent';
import { Tooltip, Text, Progress, useMantineTheme } from '@mantine/core'; import {
import { useElementSize } from '@mantine/hooks'; Badge,
Flex,
Group,
MantineColor,
Popover,
Progress,
Text,
useMantineTheme,
} from '@mantine/core';
import { useDisclosure, useElementSize } from '@mantine/hooks';
import {
IconAffiliate,
IconDatabase,
IconDownload,
IconInfoCircle,
IconPercentage,
IconSortDescending,
IconUpload,
} from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { calculateETA } from '../../tools/calculateEta'; import { calculateETA } from '../../tools/calculateEta';
import { humanFileSize } from '../../tools/humanFileSize'; import { humanFileSize } from '../../tools/humanFileSize';
import { AppType } from '../../types/app';
interface TorrentQueueItemProps { interface TorrentQueueItemProps {
torrent: NormalizedTorrent; torrent: NormalizedTorrent;
app?: AppType;
} }
export const BitTorrrentQueueItem = ({ torrent }: TorrentQueueItemProps) => { export const BitTorrrentQueueItem = ({ torrent, app }: TorrentQueueItemProps) => {
const [popoverOpened, { open: openPopover, close: closePopover }] = useDisclosure(false);
const MIN_WIDTH_MOBILE = useMantineTheme().breakpoints.xs; const MIN_WIDTH_MOBILE = useMantineTheme().breakpoints.xs;
const { width } = useElementSize(); const { width } = useElementSize();
@@ -18,7 +41,12 @@ export const BitTorrrentQueueItem = ({ torrent }: TorrentQueueItemProps) => {
return ( return (
<tr key={torrent.id}> <tr key={torrent.id}>
<td> <td>
<Tooltip position="top" withinPortal label={torrent.name}> <Popover opened={popoverOpened} width={350} position="top" withinPortal>
<Popover.Dropdown>
<TorrentQueuePopover torrent={torrent} app={app} />
</Popover.Dropdown>
<Popover.Target>
<div onMouseEnter={openPopover} onMouseLeave={closePopover}>
<Text <Text
style={{ style={{
maxWidth: '30vw', maxWidth: '30vw',
@@ -28,7 +56,14 @@ export const BitTorrrentQueueItem = ({ torrent }: TorrentQueueItemProps) => {
> >
{torrent.name} {torrent.name}
</Text> </Text>
</Tooltip> {app && (
<Text size="xs" color="dimmed">
Managed by {app.name}, {torrent.ratio.toFixed(2)} ratio
</Text>
)}
</div>
</Popover.Target>
</Popover>
</td> </td>
<td> <td>
<Text size="xs">{humanFileSize(size)}</Text> <Text size="xs">{humanFileSize(size)}</Text>
@@ -60,3 +95,98 @@ export const BitTorrrentQueueItem = ({ torrent }: TorrentQueueItemProps) => {
</tr> </tr>
); );
}; };
const TorrentQueuePopover = ({ torrent, app }: TorrentQueueItemProps) => {
const { t } = useTranslation('modules/torrents-status');
const { colors } = useMantineTheme();
const RatioMetric = () => {
const color = (): MantineColor => {
if (torrent.ratio < 1) {
return colors.red[7];
}
if (torrent.ratio < 1.15) {
return colors.orange[7];
}
return colors.green[7];
};
return (
<Group spacing="xs">
<IconAffiliate size={16} />
<Group spacing={3}>
<Text>Ratio -</Text>
<Text color={color()} weight="bold">
{torrent.ratio.toFixed(2)}
</Text>
</Group>
</Group>
);
};
return (
<>
{app && (
<Group spacing={3}>
<Text size="xs" color="dimmed">
{t('card.popover.introductionPrefix')}
</Text>
<img src={app.appearance.iconUrl} alt="download client logo" width={15} height={15} />
<Text size="xs" color="dimmed">
{app.name}
</Text>
</Group>
)}
<Text mb="md" weight="bold">
{torrent.name}
</Text>
<RatioMetric />
<Group spacing="xs">
<IconSortDescending size={16} />
<Text>{t('card.popover.metrics.queuePosition', { position: torrent.queuePosition })}</Text>
</Group>
<Group spacing="xs">
<IconPercentage size={16} />
<Text>
{t('card.popover.metrics.progress', { progress: (torrent.progress * 100).toFixed(2) })}
</Text>
</Group>
<Group spacing="xs">
<IconDatabase size={16} />
<Text>
{t('card.popover.metrics.totalSelectedSize', {
totalSize: humanFileSize(torrent.totalSelected),
})}
</Text>
</Group>
<Group>
<Group spacing="xs">
<IconDownload size={16} />
<Text>{humanFileSize(torrent.totalDownloaded)}</Text>
</Group>
<Group spacing="xs">
<IconUpload size={16} />
<Text>{humanFileSize(torrent.totalUploaded)}</Text>
</Group>
</Group>
<Group spacing="xs">
<IconInfoCircle size={16} />
<Text>{t('card.popover.metrics.state', { state: torrent.stateMessage })}</Text>
</Group>
<Flex mt="md" mb="xs" gap="sm">
{torrent.label && <Badge variant="outline">{torrent.label}</Badge>}
{torrent.isCompleted && (
<Badge variant="dot" color="green">
{t('card.popover.metrics.completed')}
</Badge>
)}
</Flex>
</>
);
};

View File

@@ -1,5 +1,6 @@
import { NormalizedTorrent } from '@ctrl/shared-torrent'; import { NormalizedTorrent } from '@ctrl/shared-torrent';
import { import {
Badge,
Center, Center,
Flex, Flex,
Group, Group,
@@ -20,6 +21,7 @@ import { useTranslation } from 'next-i18next';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useConfigContext } from '../../config/provider'; import { useConfigContext } from '../../config/provider';
import { useGetTorrentData } from '../../hooks/widgets/torrents/useGetTorrentData'; import { useGetTorrentData } from '../../hooks/widgets/torrents/useGetTorrentData';
import { NormalizedTorrentListResponse } from '../../types/api/NormalizedTorrentListResponse';
import { AppIntegrationType } from '../../types/app'; import { AppIntegrationType } from '../../types/app';
import { defineWidget } from '../helper'; import { defineWidget } from '../helper';
import { IWidget } from '../widgets'; import { IWidget } from '../widgets';
@@ -76,7 +78,17 @@ function TorrentTile({ widget }: TorrentTileProps) {
[]; [];
const [selectedAppId, setSelectedApp] = useState<string | null>(downloadApps[0]?.id); const [selectedAppId, setSelectedApp] = useState<string | null>(downloadApps[0]?.id);
const { data, isError, isInitialLoading, dataUpdatedAt } = useGetTorrentData({ const {
data,
isError,
isInitialLoading,
dataUpdatedAt,
}: {
data: NormalizedTorrentListResponse | undefined;
isError: boolean;
isInitialLoading: boolean;
dataUpdatedAt: number;
} = useGetTorrentData({
appId: selectedAppId!, appId: selectedAppId!,
refreshInterval: widget.properties.refreshInterval * 1000, refreshInterval: widget.properties.refreshInterval * 1000,
}); });
@@ -127,7 +139,7 @@ function TorrentTile({ widget }: TorrentTileProps) {
); );
} }
if (!data || data.length < 1) { if (!data || Object.values(data.torrents).length < 1) {
return ( return (
<Center style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <Center style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Title order={3}>{t('card.table.body.nothingFound')}</Title> <Title order={3}>{t('card.table.body.nothingFound')}</Title>
@@ -153,7 +165,7 @@ function TorrentTile({ widget }: TorrentTileProps) {
return ( return (
<Flex direction="column" sx={{ height: '100%' }}> <Flex direction="column" sx={{ height: '100%' }}>
<ScrollArea sx={{ height: '100%', width: '100%' }}> <ScrollArea sx={{ height: '100%', width: '100%' }} mb="xs">
<Table highlightOnHover p="sm"> <Table highlightOnHover p="sm">
<thead> <thead>
<tr> <tr>
@@ -166,15 +178,28 @@ function TorrentTile({ widget }: TorrentTileProps) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.filter(filter).map((item: NormalizedTorrent, index: number) => ( {data.torrents.map((concatenatedTorrentList) => {
<BitTorrrentQueueItem key={index} torrent={item} /> const app = config?.apps.find((x) => x.id === concatenatedTorrentList.appId);
))} return concatenatedTorrentList.torrents
.filter(filter)
.map((item: NormalizedTorrent, index: number) => (
<BitTorrrentQueueItem key={index} torrent={item} app={app} />
));
})}
</tbody> </tbody>
</Table> </Table>
</ScrollArea> </ScrollArea>
<Group spacing="sm">
{!data.allSuccess && (
<Badge variant="dot" color="red">
{t('card.footer.error')}
</Badge>
)}
<Text color="dimmed" size="xs"> <Text color="dimmed" size="xs">
Last updated {humanizedDuration} ago {t('card.footer.lastUpdated', { time: humanizedDuration })}
</Text> </Text>
</Group>
</Flex> </Flex>
); );
} }

View File

@@ -11,6 +11,7 @@ import { useEffect, useState } from 'react';
import { useConfigContext } from '../../config/provider'; import { useConfigContext } from '../../config/provider';
import { useSetSafeInterval } from '../../hooks/useSetSafeInterval'; import { useSetSafeInterval } from '../../hooks/useSetSafeInterval';
import { humanFileSize } from '../../tools/humanFileSize'; import { humanFileSize } from '../../tools/humanFileSize';
import { NormalizedTorrentListResponse } from '../../types/api/NormalizedTorrentListResponse';
import { defineWidget } from '../helper'; import { defineWidget } from '../helper';
import { IWidget } from '../widgets'; import { IWidget } from '../widgets';
@@ -60,7 +61,8 @@ function TorrentNetworkTrafficTile({ widget }: TorrentNetworkTrafficTileProps) {
axios axios
.post('/api/modules/torrents') .post('/api/modules/torrents')
.then((response) => { .then((response) => {
setTorrents(response.data); const responseData: NormalizedTorrentListResponse = response.data;
setTorrents(responseData.torrents.flatMap((x) => x.torrents));
}) })
.catch((error) => { .catch((error) => {
if (error.status === 401) return; if (error.status === 401) return;