🔀 Merge branch 'dev' into next-13

This commit is contained in:
Manuel
2023-01-31 22:21:15 +01:00
110 changed files with 1900 additions and 566 deletions

View File

@@ -13,6 +13,7 @@ import {
Title,
} from '@mantine/core';
import {
IconAnchor,
IconBrandDiscord,
IconBrandGithub,
IconFile,
@@ -27,9 +28,9 @@ import { InitOptions } from 'i18next';
import { i18n, Trans, useTranslation } from 'next-i18next';
import Image from 'next/image';
import { ReactNode } from 'react';
import { CURRENT_VERSION } from '../../../data/constants';
import { useConfigContext } from '../../config/provider';
import { useConfigStore } from '../../config/store';
import { usePackageAttributesStore } from '../../tools/client/zustands/usePackageAttributesStore';
import { usePrimaryGradient } from '../layout/useGradient';
import Credits from '../Settings/Common/Credits';
@@ -140,6 +141,7 @@ interface ExtendedInitOptions extends InitOptions {
const useInformationTableItems = (newVersionAvailable?: string): InformationTableItem[] => {
// TODO: Fix this to not request. Pass it as a prop.
const colorGradiant = usePrimaryGradient();
const { attributes } = usePackageAttributesStore();
const { configVersion } = useConfigContext();
const { configs } = useConfigStore();
@@ -198,7 +200,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
content: (
<Group position="right">
<Badge variant="gradient" gradient={colorGradiant}>
{CURRENT_VERSION}
{attributes.packageVersion ?? 'Unknown'}
</Badge>
{newVersionAvailable && (
<HoverCard shadow="md" position="top" withArrow>
@@ -226,13 +228,22 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
{newVersionAvailable}
</Anchor>
</b>{' '}
is available ! Current version: {CURRENT_VERSION}
is available ! Current version: {attributes.packageVersion}
</HoverCard.Dropdown>
</HoverCard>
)}
</Group>
),
},
{
icon: <IconAnchor size={20} />,
label: 'Node environment',
content: (
<Badge variant="gradient" gradient={colorGradiant}>
{attributes.environment}
</Badge>
),
},
...items,
];

View File

@@ -45,7 +45,14 @@ export const ChangePositionModal = ({
const width = parseInt(form.values.width, 10);
const height = parseInt(form.values.height, 10);
if (!form.values.x || !form.values.y || Number.isNaN(width) || Number.isNaN(height)) return;
if (
form.values.x === null ||
form.values.y === null ||
Number.isNaN(width) ||
Number.isNaN(height)
) {
return;
}
onSubmit(form.values.x, form.values.y, width, height);
};

View File

@@ -1,5 +1,6 @@
import { createStyles, Flex, Tabs, TextInput } from '@mantine/core';
import { Autocomplete, createStyles, Flex, Tabs } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'next-i18next';
import { AppType } from '../../../../../../types/app';
import { DebouncedAppIcon } from '../Shared/DebouncedAppIcon';
@@ -18,16 +19,21 @@ export const AppearanceTab = ({
}: AppearanceTabProps) => {
const { t } = useTranslation('layout/modals/add-app');
const { classes } = useStyles();
const { isLoading, error, data } = useQuery({
queryKey: ['autocompleteLocale'],
queryFn: () => fetch('/api/getLocalImages').then((res) => res.json()),
});
return (
<Tabs.Panel value="appearance" pt="lg">
<Flex gap={5}>
<TextInput
<Autocomplete
className={classes.textInput}
icon={<DebouncedAppIcon form={form} width={20} height={20} />}
label={t('appearance.icon.label')}
description={t('appearance.icon.description')}
variant="default"
data={data?.files ?? []}
withAsterisk
required
{...form.getInputProps('appearance.iconUrl')}

View File

@@ -31,7 +31,7 @@ interface IconSelectorProps {
}
export const IconSelector = ({ onChange, allowAppNamePropagation, form }: IconSelectorProps) => {
const { t } = useTranslation('layout/tools');
const { t } = useTranslation('layout/modals/icon-picker');
const { data, isLoading } = useRepositoryIconsQuery<WalkxcodeRepositoryIcon>({
url: 'https://api.github.com/repos/walkxcode/Dashboard-Icons/contents/png',

View File

@@ -50,7 +50,7 @@ export const AvailableElementTypes = ({
{
id: uuidv4(),
// Thank you ChatGPT ;)
position: previousConfig.wrappers.length + 1,
position: previousConfig.categories.length + 1,
},
],
categories: [

View File

@@ -1,6 +1,8 @@
import { Group, Title } from '@mantine/core';
import { Accordion, Title } from '@mantine/core';
import { useLocalStorage } from '@mantine/hooks';
import { useConfigContext } from '../../../../config/provider';
import { CategoryType } from '../../../../types/category';
import { HomarrCardWrapper } from '../../Tiles/HomarrCardWrapper';
import { useCardStyles } from '../../../layout/useCardStyles';
import { useEditModeStore } from '../../Views/useEditModeStore';
import { useGridstack } from '../gridstack/use-gridstack';
import { WrapperContent } from '../WrapperContent';
@@ -13,20 +15,47 @@ interface DashboardCategoryProps {
export const DashboardCategory = ({ category }: DashboardCategoryProps) => {
const { refs, apps, widgets } = useGridstack('category', category.id);
const isEditMode = useEditModeStore((x) => x.enabled);
const { config } = useConfigContext();
const { classes: cardClasses } = useCardStyles(true);
const categoryList = config?.categories.map((x) => x.name) ?? [];
const [toggledCategories, setToggledCategories] = useLocalStorage({
key: `${config?.configProperties.name}-app-shelf-toggled`,
// This is a bit of a hack to toggle the categories on the first load, return a string[] of the categories
defaultValue: categoryList,
});
return (
<HomarrCardWrapper pt={10} mx={10} isCategory>
<Group position="apart" align="center">
<Title order={3}>{category.name}</Title>
{isEditMode ? <CategoryEditMenu category={category} /> : null}
</Group>
<div
className="grid-stack grid-stack-category"
data-category={category.id}
ref={refs.wrapper}
>
<WrapperContent apps={apps} refs={refs} widgets={widgets} />
</div>
</HomarrCardWrapper>
<Accordion
classNames={{
item: cardClasses.card,
}}
mx={10}
chevronPosition="left"
multiple
value={isEditMode ? categoryList : toggledCategories}
variant="separated"
radius="lg"
onChange={(state) => {
// Cancel if edit mode is on
if (isEditMode) return;
setToggledCategories([...state]);
}}
>
<Accordion.Item value={category.name}>
<Accordion.Control icon={isEditMode && <CategoryEditMenu category={category} />}>
<Title order={3}>{category.name}</Title>
</Accordion.Control>
<Accordion.Panel>
<div
className="grid-stack grid-stack-category"
data-category={category.id}
ref={refs.wrapper}
>
<WrapperContent apps={apps} refs={refs} widgets={widgets} />
</div>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
};

View File

@@ -22,7 +22,7 @@ export const CategoryEditMenu = ({ category }: CategoryEditMenuProps) => {
useCategoryActions(configName, category);
return (
<Menu withinPortal position="left-start" withArrow>
<Menu withinPortal withArrow>
<Menu.Target>
<ActionIcon>
<IconDots />

View File

@@ -1,8 +1,10 @@
import { v4 as uuidv4 } from 'uuid';
import { useConfigStore } from '../../../../config/store';
import { openContextModalGeneric } from '../../../../tools/mantineModalManagerExtensions';
import { AppType } from '../../../../types/app';
import { CategoryType } from '../../../../types/category';
import { WrapperType } from '../../../../types/wrapper';
import { IWidget } from '../../../../widgets/widgets';
import { CategoryEditModalInnerProps } from './CategoryEditModal';
export const useCategoryActions = (configName: string | undefined, category: CategoryType) => {
@@ -185,29 +187,75 @@ export const useCategoryActions = (configName: string | undefined, category: Cat
const currentItem = previous.categories.find((x) => x.id === category.id);
if (!currentItem) return previous;
// Find the main wrapper
const mainWrapper = previous.wrappers.find((x) => x.position === 1);
const mainWrapper = previous.wrappers.find((x) => x.position === 0);
const mainWrapperId = mainWrapper?.id ?? 'default';
// Check that the app has an area.type or "category" and that the area.id is the current category
const appsToMove = previous.apps.filter(
(x) => x.area && x.area.type === 'category' && x.area.properties.id === currentItem.id
);
appsToMove.forEach((x) => {
// eslint-disable-next-line no-param-reassign
x.area = { type: 'wrapper', properties: { id: mainWrapper?.id ?? 'default' } };
});
const isAppAffectedFilter = (app: AppType): boolean => {
if (!app.area) {
return false;
}
const widgetsToMove = previous.widgets.filter(
(x) => x.area && x.area.type === 'category' && x.area.properties.id === currentItem.id
);
if (app.area.type !== 'category') {
return false;
}
widgetsToMove.forEach((x) => {
// eslint-disable-next-line no-param-reassign
x.area = { type: 'wrapper', properties: { id: mainWrapper?.id ?? 'default' } };
});
if (app.area.properties.id === mainWrapperId) {
return false;
}
return app.area.properties.id === currentItem.id;
};
const isWidgetAffectedFilter = (widget: IWidget<string, any>): boolean => {
if (!widget.area) {
return false;
}
if (widget.area.type !== 'category') {
return false;
}
if (widget.area.properties.id === mainWrapperId) {
return false;
}
return widget.area.properties.id === currentItem.id;
};
return {
...previous,
apps: previous.apps,
apps: [
...previous.apps.filter((x) => !isAppAffectedFilter(x)),
...previous.apps
.filter((x) => isAppAffectedFilter(x))
.map((app): AppType => ({
...app,
area: {
...app.area,
type: 'wrapper',
properties: {
...app.area.properties,
id: mainWrapperId,
},
},
})),
],
widgets: [
...previous.widgets.filter((widget) => !isWidgetAffectedFilter(widget)),
...previous.widgets
.filter((widget) => isWidgetAffectedFilter(widget))
.map((widget): IWidget<string, any> => ({
...widget,
area: {
...widget.area,
type: 'wrapper',
properties: {
...widget.area.properties,
id: mainWrapperId,
},
},
})),
],
categories: previous.categories.filter((x) => x.id !== category.id),
wrappers: previous.wrappers.filter((x) => x.position !== currentItem.position),
};

View File

@@ -4,6 +4,7 @@ import { ActionIcon, Button, Group, Text, Title, Tooltip } from '@mantine/core';
import { IconEditCircle, IconEditCircleOff } from '@tabler/icons';
import { getCookie } from 'cookies-next';
import { Trans, useTranslation } from 'next-i18next';
import { useHotkeys } from '@mantine/hooks';
import { hideNotification, showNotification } from '@mantine/notifications';
import { useConfigContext } from '../../../../../config/provider';
import { useScreenSmallerThan } from '../../../../../hooks/useScreenSmallerThan';
@@ -23,6 +24,8 @@ export const ToggleEditModeAction = () => {
const { config } = useConfigContext();
const { classes } = useCardStyles(true);
useHotkeys([['ctrl+E', toggleEditMode]]);
const toggleButtonClicked = () => {
toggleEditMode();
if (enabled || config === undefined || config?.schemaVersion === undefined) {

View File

@@ -1,10 +1,10 @@
import { Box, createStyles, Group, Header as MantineHeader, Indicator } from '@mantine/core';
import { useEffect, useState } from 'react';
import { CURRENT_VERSION, REPO_URL } from '../../../../data/constants';
import { useConfigContext } from '../../../config/provider';
import { REPO_URL } from '../../../../data/constants';
import DockerMenuButton from '../../../modules/Docker/DockerModule';
import { usePackageAttributesStore } from '../../../tools/client/zustands/usePackageAttributesStore';
import { Logo } from '../Logo';
import { useCardStyles } from '../useCardStyles';
import DockerMenuButton from '../../../modules/Docker/DockerModule';
import { ToggleEditModeAction } from './Actions/ToggleEditMode/ToggleEditMode';
import { Search } from './Search';
import { SettingsMenu } from './SettingsMenu';
@@ -14,23 +14,22 @@ export const HeaderHeight = 64;
export function Header(props: any) {
const { classes } = useStyles();
const { classes: cardClasses } = useCardStyles(false);
const { config } = useConfigContext();
const { attributes } = usePackageAttributesStore();
const [newVersionAvailable, setNewVersionAvailable] = useState<string>('');
useEffect(() => {
// Fetch Data here when component first mounted
fetch(`https://api.github.com/repos/${REPO_URL}/releases/latest`).then((res) => {
res.json().then((data) => {
if (data.tag_name > CURRENT_VERSION) {
if (data.tag_name > `v${attributes.packageVersion}`) {
setNewVersionAvailable(data.tag_name);
}
});
});
}, [CURRENT_VERSION]);
}, []);
return (
<MantineHeader height={HeaderHeight} className={cardClasses.card}>
<MantineHeader height="auto" className={cardClasses.card}>
<Group p="xs" noWrap grow>
<Box className={classes.hide}>
<Logo />

View File

@@ -13,12 +13,14 @@ import {
import { useDebouncedValue, useHotkeys } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { IconBrandYoutube, IconDownload, IconMovie, IconSearch } from '@tabler/icons';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { useTranslation } from 'next-i18next';
import React, { forwardRef, useEffect, useRef, useState } from 'react';
import { useConfigContext } from '../../../config/provider';
import { OverseerrMediaDisplay } from '../../../modules/common';
import { IModule } from '../../../modules/ModuleTypes';
import { ConfigType } from '../../../types/config';
import { searchUrls } from '../../Settings/Common/SearchEngine/SearchEngineSelector';
import Tip from '../Tip';
import { useCardStyles } from '../useCardStyles';
@@ -54,8 +56,8 @@ export function Search() {
const { t } = useTranslation('modules/search');
const { config } = useConfigContext();
const [searchQuery, setSearchQuery] = useState('');
const [debounced, cancel] = useDebouncedValue(searchQuery, 250);
const { classes: cardClasses } = useCardStyles(false);
const [debounced] = useDebouncedValue(searchQuery, 250);
const { classes: cardClasses } = useCardStyles(true);
const isOverseerrEnabled = config?.apps.some(
(x) => x.integration.type === 'overseerr' || x.integration.type === 'jellyseerr'
@@ -136,30 +138,40 @@ export function Search() {
const textInput = useRef<HTMLInputElement>(null);
useHotkeys([['mod+K', () => textInput.current?.focus()]]);
const { classes } = useStyles();
const openInNewTab = config?.settings.common.searchEngine.properties.openInNewTab
? '_blank'
: '_self';
const [OverseerrResults, setOverseerrResults] = useState<any[]>([]);
const openTarget = getOpenTarget(config);
const [opened, setOpened] = useState(false);
useEffect(() => {
if (debounced !== '' && selectedSearchEngine.value === 'overseerr' && searchQuery.length > 3) {
axios.get(`/api/modules/overseerr?query=${searchQuery}`).then((res) => {
setOverseerrResults(res.data.results ?? []);
});
const {
data: OverseerrResults,
isLoading,
error,
} = useQuery(
['overseerr', debounced],
async () => {
if (debounced !== '' && selectedSearchEngine.value === 'overseerr' && debounced.length > 3) {
const res = await axios.get(`/api/modules/overseerr?query=${debounced}`);
return res.data.results ?? [];
}
return [];
},
{
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchInterval: false,
}
}, [debounced]);
);
const isModuleEnabled = config?.settings.customization.layout.enabledSearchbar;
if (!isModuleEnabled) {
return null;
}
//TODO: Fix the bug where clicking anything inside the Modal to ask for a movie
// will close it (Because it closes the underlying Popover)
return (
<Box style={{ width: '100%', maxWidth: 400 }}>
<Popover
opened={OverseerrResults.length > 0 && opened && searchQuery.length > 3}
opened={OverseerrResults && OverseerrResults.length > 0 && opened && searchQuery.length > 3}
position="bottom"
withinPortal
shadow="md"
@@ -182,7 +194,7 @@ export function Search() {
setOpened(false);
if (item.url) {
setSearchQuery('');
window.open(item.openedUrl ? item.openedUrl : item.url, openInNewTab);
window.open(item.openedUrl ? item.openedUrl : item.url, openTarget);
}
}}
// Replace %s if it is in selectedSearchEngine.url with searchQuery, otherwise append searchQuery at the end of it
@@ -193,9 +205,9 @@ export function Search() {
autocompleteData.length === 0
) {
if (selectedSearchEngine.url.includes('%s')) {
window.open(selectedSearchEngine.url.replace('%s', searchQuery), openInNewTab);
window.open(selectedSearchEngine.url.replace('%s', searchQuery), openTarget);
} else {
window.open(selectedSearchEngine.url + searchQuery, openInNewTab);
window.open(selectedSearchEngine.url + searchQuery, openTarget);
}
}
}}
@@ -207,16 +219,17 @@ export function Search() {
/>
</Popover.Target>
<Popover.Dropdown>
<div>
<ScrollArea style={{ height: 400, width: 420 }} offsetScrollbars>
{OverseerrResults.slice(0, 5).map((result, index) => (
<ScrollArea style={{ height: '80vh', maxWidth: '90vw' }} offsetScrollbars>
{OverseerrResults &&
OverseerrResults.slice(0, 4).map((result: any, index: number) => (
<React.Fragment key={index}>
<OverseerrMediaDisplay key={result.id} media={result} />
{index < OverseerrResults.length - 1 && <Divider variant="dashed" my="xl" />}
{index < OverseerrResults.length - 1 && index < 3 && (
<Divider variant="dashed" my="xs" />
)}
</React.Fragment>
))}
</ScrollArea>
</div>
</ScrollArea>
</Popover.Dropdown>
</Popover>
</Box>
@@ -287,3 +300,11 @@ export function Search() {
});
}
}
const getOpenTarget = (config: ConfigType | undefined): '_blank' | '_self' => {
if (!config || config.settings.common.searchEngine.properties.openInNewTab === undefined) {
return '_blank';
}
return config.settings.common.searchEngine.properties.openInNewTab ? '_blank' : '_self';
};

View File

@@ -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<NormalizedDownloadQueueResponse> => {
const response = await fetch('/api/modules/downloads');
return response.json();
},
refetchInterval: 3000,
});

View File

@@ -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<NormalizedTorrentListResponse> => {
const response = await axios.post('/api/modules/torrents');
return response.data as NormalizedTorrentListResponse;
};

View File

@@ -1,4 +1,5 @@
import { ActionIcon, Drawer, Text, Tooltip } from '@mantine/core';
import { useHotkeys } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { IconBrandDocker, IconX } from '@tabler/icons';
import axios from 'axios';
@@ -17,6 +18,7 @@ export default function DockerMenuButton(props: any) {
const [selection, setSelection] = useState<Docker.ContainerInfo[]>([]);
const { config } = useConfigContext();
const { classes } = useCardStyles(true);
useHotkeys([['mod+B', () => setOpened(!opened)]]);
const dockerEnabled = config?.settings.customization.layout.enabledDocker || false;
@@ -60,6 +62,7 @@ export default function DockerMenuButton(props: any) {
<>
<Drawer
opened={opened}
trapFocus={false}
onClose={() => setOpened(false)}
padding="xl"
position="right"

View File

@@ -7,6 +7,7 @@ import {
ScrollArea,
TextInput,
useMantineTheme,
Text,
} from '@mantine/core';
import { useElementSize } from '@mantine/hooks';
import { IconSearch } from '@tabler/icons';
@@ -78,8 +79,16 @@ export default function DockerTable({
transitionDuration={0}
/>
</td>
<td>{element.Names[0].replace('/', '')}</td>
{width > MIN_WIDTH_MOBILE && <td>{element.Image}</td>}
<td>
<Text size="lg" weight={600}>
{element.Names[0].replace('/', '')}
</Text>
</td>
{width > MIN_WIDTH_MOBILE && (
<td>
<Text size="lg">{element.Image}</Text>
</td>
)}
{width > MIN_WIDTH_MOBILE && (
<td>
<Group>
@@ -111,12 +120,13 @@ export default function DockerTable({
});
return (
<ScrollArea style={{ height: '80vh' }}>
<ScrollArea style={{ height: '90vh' }} offsetScrollbars>
<TextInput
placeholder={t('search.placeholder')}
mt="md"
mr="md"
icon={<IconSearch size={14} />}
value={search}
autoFocus
onChange={handleSearchChange}
/>
<Table ref={ref} captionSide="bottom" highlightOnHover verticalSpacing="sm">

View File

@@ -180,7 +180,7 @@ export function MediaDisplay({ media }: { media: IMedia }) {
const { t } = useTranslation('modules/common-media-cards');
return (
<Group mr="xs" align="stretch" noWrap style={{ maxHeight: 250, maxWidth: 400 }} spacing="xs">
<Group noWrap style={{ maxHeight: 250, maxWidth: 400 }} p={0} m={0} spacing="xs">
<Image src={media.poster} height={200} width={150} radius="md" fit="cover" />
<Stack justify="space-around">
<Stack spacing="sm">
@@ -223,7 +223,7 @@ export function MediaDisplay({ media }: { media: IMedia }) {
{media.overview}
</Text>
</Stack>
<Group noWrap>
<Group spacing="xs">
{media.plexUrl && (
<Button
component="a"

View File

@@ -8,8 +8,8 @@ import Layout from '../components/layout/Layout';
import { useInitConfig } from '../config/init';
import { getFallbackConfig } from '../tools/config/getFallbackConfig';
import { getFrontendConfig } from '../tools/config/getFrontendConfig';
import { getServerSideTranslations } from '../tools/getServerSideTranslations';
import { dashboardNamespaces } from '../tools/translation-namespaces';
import { getServerSideTranslations } from '../tools/server/getServerSideTranslations';
import { dashboardNamespaces } from '../tools/server/translation-namespaces';
import { ConfigType } from '../types/config';
import { DashboardServerSideProps } from '../types/dashboardPageType';

View File

@@ -8,7 +8,8 @@ import { GetServerSidePropsContext } from 'next';
import { appWithTranslation } from 'next-i18next';
import { AppProps } from 'next/app';
import Head from 'next/head';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ChangeAppPositionModal } from '../components/Dashboard/Modals/ChangePosition/ChangeAppPositionModal';
import { ChangeWidgetPositionModal } from '../components/Dashboard/Modals/ChangePosition/ChangeWidgetPositionModal';
import { EditAppModal } from '../components/Dashboard/Modals/EditAppModal/EditAppModal';
@@ -21,8 +22,16 @@ import '../styles/global.scss';
import { ColorTheme } from '../tools/color';
import { queryClient } from '../tools/queryClient';
import { theme } from '../tools/theme';
import {
getServiceSidePackageAttributes,
ServerSidePackageAttributesType,
} from '../tools/server/getPackageVersion';
import { usePackageAttributesStore } from '../tools/client/zustands/usePackageAttributesStore';
function App(this: any, props: AppProps & { colorScheme: ColorScheme }) {
function App(
this: any,
props: AppProps & { colorScheme: ColorScheme; packageAttributes: ServerSidePackageAttributesType }
) {
const { Component, pageProps } = props;
const [primaryColor, setPrimaryColor] = useState<MantineTheme['primaryColor']>('red');
const [secondaryColor, setSecondaryColor] = useState<MantineTheme['primaryColor']>('orange');
@@ -45,6 +54,12 @@ function App(this: any, props: AppProps & { colorScheme: ColorScheme }) {
getInitialValueInEffect: true,
});
const { setInitialPackageAttributes } = usePackageAttributesStore();
useEffect(() => {
setInitialPackageAttributes(props.packageAttributes);
}, []);
const toggleColorScheme = (value?: ColorScheme) =>
setColorScheme(value || (colorScheme === 'dark' ? 'light' : 'dark'));
@@ -102,6 +117,7 @@ function App(this: any, props: AppProps & { colorScheme: ColorScheme }) {
</MantineProvider>
</ColorTheme.Provider>
</ColorSchemeProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</>
);
@@ -109,6 +125,7 @@ function App(this: any, props: AppProps & { colorScheme: ColorScheme }) {
App.getInitialProps = ({ ctx }: { ctx: GetServerSidePropsContext }) => ({
colorScheme: getCookie('color-scheme', ctx) || 'light',
packageAttributes: getServiceSidePackageAttributes(),
});
export default appWithTranslation(App);

View File

@@ -0,0 +1,27 @@
import { NextApiRequest, NextApiResponse } from 'next';
import fs from 'fs';
function Get(req: NextApiRequest, res: NextApiResponse) {
// Get the name of all the files in the /public/icons folder handle if the folder doesn't exist
if (!fs.existsSync('./public/icons')) {
return res.status(200).json({
files: [],
});
}
const files = fs.readdirSync('./public/icons');
// Return the list of files with the /public/icons prefix
return res.status(200).json({
files: files.map((file) => `/icons/${file}`),
});
}
export default async (req: NextApiRequest, res: NextApiResponse) => {
// Filter out if the reuqest is a POST or a GET
if (req.method === 'GET') {
return Get(req, res);
}
return res.status(405).json({
statusCode: 405,
message: 'Method not allowed',
});
};

View File

@@ -0,0 +1,220 @@
import { Deluge } from '@ctrl/deluge';
import { QBittorrent } from '@ctrl/qbittorrent';
import { Transmission } from '@ctrl/transmission';
import { AllClientData } from '@ctrl/shared-torrent';
import Consola from 'consola';
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 failedClients: string[] = [];
const clientData: Promise<NormalizedDownloadAppStat>[] = config.apps.map(async (app) => {
try {
const response = await GetDataFromClient(app);
if (!response) {
return {
success: false,
} as NormalizedDownloadAppStat;
}
return response;
} catch (err: any) {
Consola.error(
`Error communicating with your download client '${app.name}' (${app.id}): ${err}`
);
failedClients.push(app.id);
return {
success: false,
} as NormalizedDownloadAppStat;
}
});
const settledPromises = await Promise.allSettled(clientData);
const data: NormalizedDownloadAppStat[] = settledPromises
.filter((x) => x.status === 'fulfilled')
.map((promise) => (promise as PromiseFulfilledResult<NormalizedDownloadAppStat>).value)
.filter((x) => x !== undefined && x.type !== undefined);
const responseBody = { apps: data, failedApps: failedClients } as NormalizedDownloadQueueResponse;
if (failedClients.length > 0) {
Consola.warn(`${failedClients.length} download clients failed. Please check your configuration and the above log`);
}
return response.status(200).json(responseBody);
};
const GetDataFromClient = async (
app: ConfigAppType
): Promise<NormalizedDownloadAppStat | undefined> => {
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 Transmission({
baseUrl: app.url,
username: findField(app, 'username'),
password: findField(app, 'password'),
}).getAllData()
);
}
case 'qBittorrent': {
return reduceTorrent(
await new QBittorrent({
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 {
Consola.error(`Error while listing groups: ${err}`);
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 {
Consola.error(`Error while retrieving NZBGet stats: ${err}`);
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';
}
}

View File

@@ -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<NormalizedTorrentListResponse>) {
// 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<ConcatenatedClientData>[] = [];
for (let i = 0; i < clientApps.length; i += 1) {
const app = clientApps[i];
const getAllData = getAllDataForClient(app);
if (!getAllData) {
continue;
}
const concatenatedPromise = async (): Promise<ConcatenatedClientData> => ({
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<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) => {
// 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',
});
};

View File

@@ -39,7 +39,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
const url = new URL(app.url);
const options = {
host: url.hostname,
port: url.port,
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
};

View File

@@ -38,7 +38,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
const url = new URL(app.url);
const options = {
host: url.hostname,
port: url.port,
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
};

View File

@@ -30,7 +30,7 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
const url = new URL(app.url);
const options = {
host: url.hostname,
port: url.port,
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
};

View File

@@ -39,7 +39,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
const url = new URL(app.url);
const options = {
host: url.hostname,
port: url.port,
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
};

View File

@@ -31,7 +31,7 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
const url = new URL(app.url);
const options = {
host: url.hostname,
port: url.port,
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
};

View File

@@ -1,14 +1,14 @@
import { getCookie, setCookie } from 'cookies-next';
import fs from 'fs';
import { GetServerSidePropsContext } from 'next';
import fs from 'fs';
import { LoadConfigComponent } from '../components/Config/LoadConfig';
import { Dashboard } from '../components/Dashboard/Dashboard';
import Layout from '../components/layout/Layout';
import { useInitConfig } from '../config/init';
import { getFrontendConfig } from '../tools/config/getFrontendConfig';
import { getServerSideTranslations } from '../tools/getServerSideTranslations';
import { dashboardNamespaces } from '../tools/translation-namespaces';
import { getServerSideTranslations } from '../tools/server/getServerSideTranslations';
import { dashboardNamespaces } from '../tools/server/translation-namespaces';
import { DashboardServerSideProps } from '../types/dashboardPageType';
export async function getServerSideProps({
@@ -47,11 +47,14 @@ export async function getServerSideProps({
}
const translations = await getServerSideTranslations(req, res, dashboardNamespaces, locale);
const config = getFrontendConfig(configName as string);
return {
props: { configName: configName as string, config, ...translations },
props: {
configName: configName as string,
config,
...translations,
},
};
}

View File

@@ -8,7 +8,7 @@ import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import { useForm } from '@mantine/form';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { loginNamespaces } from '../tools/translation-namespaces';
import { loginNamespaces } from '../tools/server/translation-namespaces';
// TODO: Add links to the wiki articles about the login process.
export default function AuthenticationTitle() {

View File

@@ -1,14 +1,6 @@
import Dockerode from 'dockerode';
import { MatchingImages, ServiceType, tryMatchPort } from './types';
async function MatchIcon(name: string) {
const res = await fetch(
`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${name
.replace(/\s+/g, '-')
.toLowerCase()}.png`
);
return res.ok ? res.url : '/imgs/favicon/favicon.png';
}
import { MatchingImages, ServiceType, tryMatchPort } from './types';
function tryMatchType(imageName: string): ServiceType {
// Try to find imageName inside MatchingImages

View File

@@ -0,0 +1,15 @@
import create from 'zustand';
import { ServerSidePackageAttributesType } from '../../server/getPackageVersion';
interface PackageAttributesState {
attributes: ServerSidePackageAttributesType;
setInitialPackageAttributes: (attributes: ServerSidePackageAttributesType) => void;
}
export const usePackageAttributesStore = create<PackageAttributesState>((set) => ({
attributes: { packageVersion: undefined, environment: 'test' },
setInitialPackageAttributes(attributes) {
set((state) => ({ ...state, attributes }));
},
}));

View File

@@ -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';

View File

@@ -131,6 +131,12 @@ export const languages: Language[] = [
translatedName: 'Chinese',
emoji: '🇨🇳',
},
{
shortName: 'el',
originalName: 'Ελληνικά',
translatedName: 'Greek',
emoji: '🇬🇷',
},
];
export const getLanguageByCode = (code: string | null) =>

View File

@@ -0,0 +1,14 @@
const getServerPackageVersion = (): string | undefined => process.env.npm_package_version;
const getServerNodeEnvironment = (): 'development' | 'production' | 'test' =>
process.env.NODE_ENV;
export const getServiceSidePackageAttributes = (): ServerSidePackageAttributesType => ({
packageVersion: getServerPackageVersion(),
environment: getServerNodeEnvironment(),
});
export type ServerSidePackageAttributesType = {
packageVersion: string | undefined;
environment: 'development' | 'production' | 'test';
};

View File

@@ -1,9 +1,9 @@
export const dashboardNamespaces = [
'common',
'layout/tools',
'layout/element-selector/selector',
'layout/modals/add-app',
'layout/modals/change-position',
'layout/modals/icon-picker',
'layout/modals/about',
'layout/header/actions/toggle-edit-mode',
'layout/mobile/drawer',

View File

@@ -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[];
};

View File

@@ -0,0 +1,25 @@
import { NormalizedTorrent } from '@ctrl/shared-torrent';
import { UsenetQueueItem } from '../../../../widgets/useNet/types';
export type NormalizedDownloadQueueResponse = {
apps: NormalizedDownloadAppStat[];
failedApps: string[];
};
export type NormalizedDownloadAppStat = {
success: boolean;
appId: string;
} & (TorrentTotalDownload | UsenetTotalDownloas);
export type TorrentTotalDownload = {
type: 'torrent';
torrents: NormalizedTorrent[];
totalDownload: number;
totalUpload: number;
};
export type UsenetTotalDownloas = {
type: 'usenet';
totalDownload: number;
nzbs: UsenetQueueItem[];
};

View File

@@ -1,4 +1,5 @@
import { SSRConfig } from 'next-i18next';
import { ConfigType } from './config';
export type DashboardServerSideProps = {

View File

@@ -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<typeof definition['id'], typeof definition>;
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<NormalizedDownloadQueueResponse>();
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 (
<Stack ref={refRoot} style={{ height: '100%' }}>
<Group ref={refTitle}>
<IconDownload />
<Title order={4}>{t('card.lineChart.title')}</Title>
</Group>
<Box
style={{
height: graphHeight,
width: '100%',
position: 'relative',
}}
>
<Box style={{ height: '100%', width: '100%', position: 'absolute' }}>
<ResponsiveLine
isInteractive
enableSlices="x"
sliceTooltip={({ slice }) => {
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 (
<Card p="xs" radius="md" withBorder>
<Card.Section p="xs">
<Stack spacing="xs">
{recordsFromPoints.map((entry, index) => {
const app = config?.apps.find((x) => x.id === entry.record.appId);
if (!app) {
return null;
}
return (
<Group key={`download-client-tooltip-${index}`}>
<AppAvatar iconUrl={app.appearance.iconUrl} />
<Stack spacing={0}>
<Text size="sm">{app.name}</Text>
<Group>
<Group spacing="xs">
<IconDownload opacity={0.6} size={14} />
<Text size="xs" color="dimmed">
{humanFileSize(entry.record.totalDownload, false)}
</Text>
</Group>
{entry.record.type === 'torrent' && (
<Group spacing="xs">
<IconUpload opacity={0.6} size={14} />
<Text size="xs" color="dimmed">
{humanFileSize(entry.record.totalUpload, false)}
</Text>
</Group>
)}
</Group>
</Stack>
</Group>
);
})}
</Stack>
</Card.Section>
</Card>
);
}}
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}
/>
</Box>
</Box>
<Group position="apart" ref={refFooter}>
<Group>
<Group spacing="xs">
<IconDownload color={colors[primaryColor][5]} opacity={0.6} size={18} />
<Text color="dimmed" size="sm">
{humanFileSize(totalDownload, false)}
</Text>
</Group>
<Group spacing="xs">
<IconUpload color={colors[secondaryColor][5]} opacity={0.6} size={18} />
<Text color="dimmed" size="sm">
{humanFileSize(totalUpload, false)}
</Text>
</Group>
</Group>
<Avatar.Group>
{uniqueRecordedAppsOverTime.map((appId, index) => {
const app = config?.apps.find((x) => x.id === appId);
if (!app) {
return null;
}
return (
<Tooltip
label={app.name}
key={`download-client-app-tooltip-${index}`}
withArrow
withinPortal
>
<AppAvatar iconUrl={app.appearance.iconUrl} />
</Tooltip>
);
})}
</Avatar.Group>
</Group>
</Stack>
);
}
const AppAvatar = ({ iconUrl }: { iconUrl: string }) => {
const { colors, colorScheme } = useMantineTheme();
return (
<Avatar
src={iconUrl}
bg={colorScheme === 'dark' ? colors.gray[8] : colors.gray[2]}
size="sm"
radius="xl"
p={4}
/>
);
};
export default definition;

View File

@@ -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,

View File

@@ -24,7 +24,7 @@ import {
IconUpload,
} from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { calculateETA } from '../../tools/calculateEta';
import { calculateETA } from '../../tools/client/calculateEta';
import { humanFileSize } from '../../tools/humanFileSize';
import { AppType } from '../../types/app';

View File

@@ -1,4 +1,4 @@
import { NormalizedTorrent } from '@ctrl/shared-torrent';
import { TorrentState } from '@ctrl/shared-torrent';
import {
Badge,
Center,
@@ -18,10 +18,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 +42,6 @@ const definition = defineWidget({
type: 'switch',
defaultValue: true,
},
refreshInterval: {
type: 'slider',
defaultValue: 10,
min: 1,
max: 60,
step: 1,
},
},
gridstack: {
minWidth: 2,
@@ -72,43 +63,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<string | null>(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 (
<Stack>
<Title order={3}>{t('card.errors.noDownloadClients.title')}</Title>
<Group>
<Text>{t('card.errors.noDownloadClients.text')}</Text>
</Group>
</Stack>
);
}
} = useGetDownloadClientsQueue();
if (isError) {
return (
@@ -121,7 +86,7 @@ function TorrentTile({ widget }: TorrentTileProps) {
);
}
if (isInitialLoading) {
if (isInitialLoading || !data) {
return (
<Stack
align="center"
@@ -139,7 +104,18 @@ function TorrentTile({ widget }: TorrentTileProps) {
);
}
if (!data || Object.values(data.torrents).length < 1) {
if (data.apps.length === 0) {
return (
<Stack>
<Title order={3}>{t('card.errors.noDownloadClients.title')}</Title>
<Group>
<Text>{t('card.errors.noDownloadClients.text')}</Text>
</Group>
</Stack>
);
}
if (!data || Object.values(data.apps).length < 1) {
return (
<Center style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Title order={3}>{t('card.table.body.nothingFound')}</Title>
@@ -147,17 +123,14 @@ 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 : []))
.filter((torrent) => (widget.properties.displayCompletedTorrents ? true : !torrent.isCompleted))
.filter((torrent) =>
widget.properties.displayStaleTorrents
? true
: torrent.isCompleted || torrent.downloadSpeed > 0
);
const difference = new Date().getTime() - dataUpdatedAt;
const duration = dayjs.duration(difference, 'ms');
@@ -178,19 +151,14 @@ function TorrentTile({ widget }: TorrentTileProps) {
</tr>
</thead>
<tbody>
{data.torrents.map((concatenatedTorrentList) => {
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} />
));
})}
{torrents.map((torrent, index) => (
<BitTorrrentQueueItem key={index} torrent={torrent} app={undefined} />
))}
</tbody>
</Table>
</ScrollArea>
<Group spacing="sm">
{!data.allSuccess && (
{data.apps.some((x) => !x.success) && (
<Badge variant="dot" color="red">
{t('card.footer.error')}
</Badge>

View File

@@ -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<typeof definition['id'], typeof definition>;
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<TorrentHistory>([]);
const [torrents, setTorrents] = useState<NormalizedTorrent[]>([]);
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 (
<Stack>
<Title order={4}>{t('card.lineChart.title')}</Title>
<Stack>
<Group>
<ColorSwatch size={12} color={colors.green[5]} />
<Text>
{t('card.lineChart.totalDownload', { download: humanFileSize(totalDownloadSpeed) })}
</Text>
</Group>
<Group>
<ColorSwatch size={12} color={colors.blue[5]} />
<Text>
{t('card.lineChart.totalUpload', { upload: humanFileSize(totalUploadSpeed) })}
</Text>
</Group>
</Stack>
<Box
style={{
height: 200,
width: '100%',
}}
>
<ResponsiveLine
isInteractive
enableSlices="x"
sliceTooltip={({ slice }) => {
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 (
<Card p="sm" radius="md" withBorder>
<Text size="md">{t('card.lineChart.timeSpan', { seconds: roundedSeconds })}</Text>
<Card.Section p="sm">
<Stack>
<Group>
<ColorSwatch size={10} color={colors.green[5]} />
<Text size="md">
{t('card.lineChart.download', { download: humanFileSize(Download) })}
</Text>
</Group>
<Group>
<ColorSwatch size={10} color={colors.blue[5]} />
<Text size="md">
{t('card.lineChart.upload', { upload: humanFileSize(Upload) })}
</Text>
</Group>
</Stack>
</Card.Section>
</Card>
);
}}
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]]}
/>
</Box>
</Stack>
);
}
export default definition;
interface TorrentHistory {
x: number;
up: number;
down: number;
}

View File

@@ -20,7 +20,7 @@ import { useTranslation } from 'next-i18next';
import { FunctionComponent, useState } from 'react';
import { useGetUsenetHistory } from '../../hooks/widgets/dashDot/api';
import { humanFileSize } from '../../tools/humanFileSize';
import { parseDuration } from '../../tools/parseDuration';
import { parseDuration } from '../../tools/client/parseDuration';
dayjs.extend(duration);