mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-09 15:05:48 +01:00
✨ Add detail popover for torrents list
This commit is contained in:
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
28
src/types/api/NormalizedTorrentListResponse.ts
Normal file
28
src/types/api/NormalizedTorrentListResponse.ts
Normal 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[];
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user