mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-15 17:56:21 +01:00
🔀 Merge v0.11.3
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
export const REPO_URL = 'ajnart/homarr';
|
||||
export const CURRENT_VERSION = 'v0.11.2';
|
||||
export const CURRENT_VERSION = 'v0.11.3';
|
||||
export const ICON_PICKER_SLICE_LIMIT = 36;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "0.11.2",
|
||||
"version": "0.11.3",
|
||||
"description": "Homarr - A homepage for your server.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
7
public/locales/da/layout/modals/icon-picker.json
Normal file
7
public/locales/da/layout/modals/icon-picker.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"textInputPlaceholder": "",
|
||||
"searchLimitationTitle": "",
|
||||
"searchLimitationMessage": ""
|
||||
}
|
||||
}
|
||||
7
public/locales/de/layout/modals/icon-picker.json
Normal file
7
public/locales/de/layout/modals/icon-picker.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"textInputPlaceholder": "",
|
||||
"searchLimitationTitle": "",
|
||||
"searchLimitationMessage": ""
|
||||
}
|
||||
}
|
||||
7
public/locales/el/layout/modals/icon-picker.json
Normal file
7
public/locales/el/layout/modals/icon-picker.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"textInputPlaceholder": "",
|
||||
"searchLimitationTitle": "",
|
||||
"searchLimitationMessage": ""
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"title": "Ρυθμίσεις",
|
||||
"tooltip": "Ρυθμίσεις",
|
||||
"tabs": {
|
||||
"common": "Συχνά",
|
||||
"common": "Συχνές επιλογές",
|
||||
"customizations": "Παραμετροποιήσεις"
|
||||
},
|
||||
"tips": {
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"saveCopy": "Αποθήκευση αντιγράφου"
|
||||
"saveCopy": "Αποθηκεύστε ένα αντίγραφο"
|
||||
},
|
||||
"dropzone": {
|
||||
"notifications": {
|
||||
|
||||
7
public/locales/en/layout/modals/icon-picker.json
Normal file
7
public/locales/en/layout/modals/icon-picker.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"textInputPlaceholder": "Search something...",
|
||||
"searchLimitationTitle": "Limited to 30 results",
|
||||
"searchLimitationMessage": "Search results were limited to 30 because there were too many matches"
|
||||
}
|
||||
}
|
||||
7
public/locales/es/layout/modals/icon-picker.json
Normal file
7
public/locales/es/layout/modals/icon-picker.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"textInputPlaceholder": "",
|
||||
"searchLimitationTitle": "",
|
||||
"searchLimitationMessage": ""
|
||||
}
|
||||
}
|
||||
7
public/locales/fr/layout/modals/icon-picker.json
Normal file
7
public/locales/fr/layout/modals/icon-picker.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"textInputPlaceholder": "",
|
||||
"searchLimitationTitle": "",
|
||||
"searchLimitationMessage": ""
|
||||
}
|
||||
}
|
||||
7
public/locales/he/layout/modals/icon-picker.json
Normal file
7
public/locales/he/layout/modals/icon-picker.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"textInputPlaceholder": "",
|
||||
"searchLimitationTitle": "",
|
||||
"searchLimitationMessage": ""
|
||||
}
|
||||
}
|
||||
7
public/locales/it/layout/modals/icon-picker.json
Normal file
7
public/locales/it/layout/modals/icon-picker.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"textInputPlaceholder": "",
|
||||
"searchLimitationTitle": "",
|
||||
"searchLimitationMessage": ""
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
"card": {
|
||||
"footer": {
|
||||
"error": "Errore",
|
||||
"lastUpdated": "Ultimo aggiornamento {{time}} ago"
|
||||
"lastUpdated": "Ultimo aggiornamento {{time}} fa"
|
||||
},
|
||||
"table": {
|
||||
"header": {
|
||||
@@ -30,7 +30,7 @@
|
||||
"progress": "Avanzamento"
|
||||
},
|
||||
"item": {
|
||||
"text": "Gestito da {{appName}}, rapporto {{ratio}}"
|
||||
"text": "Gestito da {{appName}}, {{ratio}} ratio"
|
||||
},
|
||||
"body": {
|
||||
"nothingFound": "Nessun torrent trovato"
|
||||
@@ -61,10 +61,10 @@
|
||||
"introductionPrefix": "Gestito da",
|
||||
"metrics": {
|
||||
"queuePosition": "Posizione in coda - {{position}}",
|
||||
"progress": "Progressi - {{progress}}%",
|
||||
"progress": "Progresso - {{progress}}%",
|
||||
"totalSelectedSize": "Totale - {{totalSize}}",
|
||||
"state": "Stato - {{state}}",
|
||||
"ratio": "Rapporto -",
|
||||
"ratio": "Ratio -",
|
||||
"completed": "Completato"
|
||||
}
|
||||
}
|
||||
|
||||
7
public/locales/ja/layout/modals/icon-picker.json
Normal file
7
public/locales/ja/layout/modals/icon-picker.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"textInputPlaceholder": "",
|
||||
"searchLimitationTitle": "",
|
||||
"searchLimitationMessage": ""
|
||||
}
|
||||
}
|
||||
7
public/locales/ko/layout/modals/icon-picker.json
Normal file
7
public/locales/ko/layout/modals/icon-picker.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"textInputPlaceholder": "",
|
||||
"searchLimitationTitle": "",
|
||||
"searchLimitationMessage": ""
|
||||
}
|
||||
}
|
||||
7
public/locales/lol/layout/modals/icon-picker.json
Normal file
7
public/locales/lol/layout/modals/icon-picker.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"textInputPlaceholder": "",
|
||||
"searchLimitationTitle": "",
|
||||
"searchLimitationMessage": ""
|
||||
}
|
||||
}
|
||||
7
public/locales/nl/layout/modals/icon-picker.json
Normal file
7
public/locales/nl/layout/modals/icon-picker.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"textInputPlaceholder": "",
|
||||
"searchLimitationTitle": "",
|
||||
"searchLimitationMessage": ""
|
||||
}
|
||||
}
|
||||
7
public/locales/pl/layout/modals/icon-picker.json
Normal file
7
public/locales/pl/layout/modals/icon-picker.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"textInputPlaceholder": "",
|
||||
"searchLimitationTitle": "",
|
||||
"searchLimitationMessage": ""
|
||||
}
|
||||
}
|
||||
7
public/locales/pt/layout/modals/icon-picker.json
Normal file
7
public/locales/pt/layout/modals/icon-picker.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"textInputPlaceholder": "",
|
||||
"searchLimitationTitle": "",
|
||||
"searchLimitationMessage": ""
|
||||
}
|
||||
}
|
||||
7
public/locales/ru/layout/modals/icon-picker.json
Normal file
7
public/locales/ru/layout/modals/icon-picker.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"textInputPlaceholder": "",
|
||||
"searchLimitationTitle": "",
|
||||
"searchLimitationMessage": ""
|
||||
}
|
||||
}
|
||||
7
public/locales/sl/layout/modals/icon-picker.json
Normal file
7
public/locales/sl/layout/modals/icon-picker.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"textInputPlaceholder": "",
|
||||
"searchLimitationTitle": "",
|
||||
"searchLimitationMessage": ""
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,5 @@
|
||||
"i18n": "Laddade namnområden för I18n-översättningar",
|
||||
"locales": "Konfigurerade I18n lokalspråk",
|
||||
"contact": "Har du problem eller frågor? Kontakta oss!",
|
||||
"addToDashboard": "Lägg till i instrumentpanel"
|
||||
"addToDashboard": "Lägg till på instrumentpanel"
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"appearance": {
|
||||
"icon": {
|
||||
"label": "Appikon",
|
||||
"description": "Ikonen som kommer att visas på instrumentpanelen."
|
||||
"description": "Ikon som kommer att visas på instrumentpanelen."
|
||||
}
|
||||
},
|
||||
"integration": {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"xPosition": "X axel position",
|
||||
"xPosition": "Position X-axel",
|
||||
"width": "Bredd",
|
||||
"height": "Höjd",
|
||||
"yPosition": "Y axel position",
|
||||
"yPosition": "Position Y-axel",
|
||||
"zeroOrHigher": "0 eller högre",
|
||||
"betweenXandY": "Mellan {{min}} och {{max}}"
|
||||
}
|
||||
7
public/locales/sv/layout/modals/icon-picker.json
Normal file
7
public/locales/sv/layout/modals/icon-picker.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"textInputPlaceholder": "",
|
||||
"searchLimitationTitle": "",
|
||||
"searchLimitationMessage": ""
|
||||
}
|
||||
}
|
||||
7
public/locales/uk/layout/modals/icon-picker.json
Normal file
7
public/locales/uk/layout/modals/icon-picker.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"textInputPlaceholder": "",
|
||||
"searchLimitationTitle": "",
|
||||
"searchLimitationMessage": ""
|
||||
}
|
||||
}
|
||||
@@ -58,12 +58,12 @@
|
||||
"title": "Завантаження..."
|
||||
},
|
||||
"popover": {
|
||||
"introductionPrefix": "Під керівництвом",
|
||||
"introductionPrefix": "Керується",
|
||||
"metrics": {
|
||||
"queuePosition": "Позиція в черзі - {{position}}",
|
||||
"progress": "Прогрес - {{progress}}%.",
|
||||
"totalSelectedSize": "Всього - {{totalSize}}",
|
||||
"state": "Держава - {{state}}",
|
||||
"state": "Стан - {{state}}",
|
||||
"ratio": "Коефіцієнт -",
|
||||
"completed": "Завершено"
|
||||
}
|
||||
|
||||
7
public/locales/vi/layout/modals/icon-picker.json
Normal file
7
public/locales/vi/layout/modals/icon-picker.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"textInputPlaceholder": "",
|
||||
"searchLimitationTitle": "",
|
||||
"searchLimitationMessage": ""
|
||||
}
|
||||
}
|
||||
7
public/locales/zh/layout/modals/icon-picker.json
Normal file
7
public/locales/zh/layout/modals/icon-picker.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"textInputPlaceholder": "",
|
||||
"searchLimitationTitle": "",
|
||||
"searchLimitationMessage": ""
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
"enablelsidebar": "启用左边的侧边栏",
|
||||
"enablesearchbar": "启用搜索栏",
|
||||
"enabledocker": "启用docker集成",
|
||||
"enableping": "启用平移功能",
|
||||
"enableping": "启用Ping功能",
|
||||
"enablelsidebardesc": "可选的。只能用于应用程序和集成",
|
||||
"enablersidebardesc": "可选的。只能用于应用程序和集成"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createStyles, Flex, Tabs, TextInput } from '@mantine/core';
|
||||
import { Autocomplete, createStyles, Flex, Tabs, TextInput } 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')}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -50,7 +50,7 @@ export const AvailableElementTypes = ({
|
||||
{
|
||||
id: uuidv4(),
|
||||
// Thank you ChatGPT ;)
|
||||
position: previousConfig.wrappers.length + 1,
|
||||
position: previousConfig.categories.length + 1,
|
||||
},
|
||||
],
|
||||
categories: [
|
||||
|
||||
@@ -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,13 +15,38 @@ 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">
|
||||
<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>
|
||||
{isEditMode ? <CategoryEditMenu category={category} /> : null}
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<div
|
||||
className="grid-stack grid-stack-category"
|
||||
data-category={category.id}
|
||||
@@ -27,6 +54,8 @@ export const DashboardCategory = ({ category }: DashboardCategoryProps) => {
|
||||
>
|
||||
<WrapperContent apps={apps} refs={refs} widgets={widgets} />
|
||||
</div>
|
||||
</HomarrCardWrapper>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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 { ConfigType } from '../../../../types/config';
|
||||
import { WrapperType } from '../../../../types/wrapper';
|
||||
import { IWidget } from '../../../../widgets/widgets';
|
||||
import { CategoryEditModalInnerProps } from './CategoryEditModal';
|
||||
|
||||
export const useCategoryActions = (configName: string | undefined, category: CategoryType) => {
|
||||
@@ -185,29 +188,67 @@ 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' } };
|
||||
});
|
||||
return app.area.properties.id !== mainWrapperId;
|
||||
};
|
||||
|
||||
const isWidgetAffectedFilter = (widget: IWidget<string, any>): boolean => {
|
||||
if (!widget.area) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (widget.area.type !== 'category') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return widget.area.properties.id !== mainWrapperId;
|
||||
};
|
||||
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ 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';
|
||||
@@ -139,16 +140,27 @@ export function Search() {
|
||||
const openInNewTab = config?.settings.common.searchEngine.properties.openInNewTab
|
||||
? '_blank'
|
||||
: '_self';
|
||||
const [OverseerrResults, setOverseerrResults] = useState<any[]>([]);
|
||||
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 ?? [];
|
||||
}
|
||||
}, [debounced]);
|
||||
return [];
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchInterval: false,
|
||||
}
|
||||
);
|
||||
|
||||
const isModuleEnabled = config?.settings.customization.layout.enabledSearchbar;
|
||||
if (!isModuleEnabled) {
|
||||
@@ -159,7 +171,7 @@ export function Search() {
|
||||
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"
|
||||
@@ -207,16 +219,16 @@ 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>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Box>
|
||||
|
||||
11
src/hooks/widgets/download-speed/useGetNetworkSpeed.tsx
Normal file
11
src/hooks/widgets/download-speed/useGetNetworkSpeed.tsx
Normal 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,
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
27
src/pages/api/getLocalImages.ts
Normal file
27
src/pages/api/getLocalImages.ts
Normal 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',
|
||||
});
|
||||
};
|
||||
191
src/pages/api/modules/downloads/index.ts
Normal file
191
src/pages/api/modules/downloads/index.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Deluge } from '@ctrl/deluge';
|
||||
import { AllClientData } from '@ctrl/shared-torrent';
|
||||
import { getCookie } from 'cookies-next';
|
||||
import dayjs from 'dayjs';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { Client } from 'sabnzbd-api';
|
||||
import { getConfig } from '../../../../tools/config/getConfig';
|
||||
import {
|
||||
NormalizedDownloadAppStat,
|
||||
NormalizedDownloadQueueResponse,
|
||||
} from '../../../../types/api/downloads/queue/NormalizedDownloadQueueResponse';
|
||||
import { ConfigAppType, IntegrationField } from '../../../../types/app';
|
||||
import { UsenetQueueItem } from '../../../../widgets/useNet/types';
|
||||
import { NzbgetClient } from '../usenet/nzbget/nzbget-client';
|
||||
import { NzbgetQueueItem, NzbgetStatus } from '../usenet/nzbget/types';
|
||||
|
||||
const Get = async (request: NextApiRequest, response: NextApiResponse) => {
|
||||
const configName = getCookie('config-name', { req: request });
|
||||
const config = getConfig(configName?.toString() ?? 'default');
|
||||
|
||||
const clientData: Promise<NormalizedDownloadAppStat | undefined>[] = config.apps.map((app) =>
|
||||
GetDataFromClient(app)
|
||||
);
|
||||
|
||||
const settledPromises = await Promise.allSettled(clientData);
|
||||
|
||||
const data: NormalizedDownloadAppStat[] = settledPromises
|
||||
.filter((x) => x.status === 'fulfilled')
|
||||
.map((promise) => (promise as PromiseFulfilledResult<NormalizedDownloadAppStat>).value)
|
||||
.filter((x) => x !== undefined);
|
||||
|
||||
const responseBody = { apps: data } as NormalizedDownloadQueueResponse;
|
||||
|
||||
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 Deluge({
|
||||
baseUrl: app.url,
|
||||
username: findField(app, 'username'),
|
||||
password: findField(app, 'password'),
|
||||
}).getAllData()
|
||||
);
|
||||
}
|
||||
case 'qBittorrent': {
|
||||
return reduceTorrent(
|
||||
await new Deluge({
|
||||
baseUrl: app.url,
|
||||
username: findField(app, 'username'),
|
||||
password: findField(app, 'password'),
|
||||
}).getAllData()
|
||||
);
|
||||
}
|
||||
case 'sabnzbd': {
|
||||
const { origin } = new URL(app.url);
|
||||
const client = new Client(origin, findField(app, 'apiKey') ?? '');
|
||||
const queue = await client.queue();
|
||||
const items: UsenetQueueItem[] = queue.slots.map((slot) => {
|
||||
const [hours, minutes, seconds] = slot.timeleft.split(':');
|
||||
const eta = dayjs.duration({
|
||||
hour: parseInt(hours, 10),
|
||||
minutes: parseInt(minutes, 10),
|
||||
seconds: parseInt(seconds, 10),
|
||||
} as any);
|
||||
|
||||
return {
|
||||
id: slot.nzo_id,
|
||||
eta: eta.asSeconds(),
|
||||
name: slot.filename,
|
||||
progress: parseFloat(slot.percentage),
|
||||
size: parseFloat(slot.mb) * 1000 * 1000,
|
||||
state: slot.status.toLowerCase() as any,
|
||||
};
|
||||
});
|
||||
const killobitsPerSecond = Number(queue.kbpersec);
|
||||
const bytesPerSecond = killobitsPerSecond * 1024; // convert killobytes to bytes
|
||||
return {
|
||||
type: 'usenet',
|
||||
appId: app.id,
|
||||
totalDownload: bytesPerSecond,
|
||||
nzbs: items,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
case 'nzbGet': {
|
||||
const url = new URL(app.url);
|
||||
const options = {
|
||||
host: url.hostname,
|
||||
port: url.port,
|
||||
login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
|
||||
hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
|
||||
};
|
||||
|
||||
const nzbGet = NzbgetClient(options);
|
||||
const nzbgetQueue: NzbgetQueueItem[] = await new Promise((resolve, reject) => {
|
||||
nzbGet.listGroups((err: any, result: NzbgetQueueItem[]) => {
|
||||
if (!err) {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (!nzbgetQueue) {
|
||||
throw new Error('Error while getting NZBGet queue');
|
||||
}
|
||||
|
||||
const nzbgetStatus: NzbgetStatus = await new Promise((resolve, reject) => {
|
||||
nzbGet.status((err: any, result: NzbgetStatus) => {
|
||||
if (!err) {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!nzbgetStatus) {
|
||||
throw new Error('Error while getting NZBGet status');
|
||||
}
|
||||
|
||||
const nzbgetItems: UsenetQueueItem[] = nzbgetQueue.map((item: NzbgetQueueItem) => ({
|
||||
id: item.NZBID.toString(),
|
||||
name: item.NZBName,
|
||||
progress: (item.DownloadedSizeMB / item.FileSizeMB) * 100,
|
||||
eta: (item.RemainingSizeMB * 1000000) / nzbgetStatus.DownloadRate,
|
||||
// Multiple MB to get bytes
|
||||
size: item.FileSizeMB * 1000 * 1000,
|
||||
state: getNzbgetState(item.Status),
|
||||
}));
|
||||
|
||||
return {
|
||||
type: 'usenet',
|
||||
appId: app.id,
|
||||
nzbs: nzbgetItems,
|
||||
success: true,
|
||||
totalDownload: 0,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export default async (request: NextApiRequest, response: NextApiResponse) => {
|
||||
if (request.method === 'GET') {
|
||||
return Get(request, response);
|
||||
}
|
||||
|
||||
return response.status(405);
|
||||
};
|
||||
|
||||
function getNzbgetState(status: string) {
|
||||
switch (status) {
|
||||
case 'QUEUED':
|
||||
return 'queued';
|
||||
case 'PAUSED ':
|
||||
return 'paused';
|
||||
default:
|
||||
return 'downloading';
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NormalizedTorrent } from '@ctrl/shared-torrent';
|
||||
import { UsenetQueueItem } from '../../../../widgets/useNet/types';
|
||||
|
||||
export type NormalizedDownloadQueueResponse = {
|
||||
apps: NormalizedDownloadAppStat[];
|
||||
};
|
||||
|
||||
export type NormalizedDownloadAppStat = {
|
||||
success: boolean;
|
||||
appId: string;
|
||||
totalDownload: number;
|
||||
} & (TorrentTotalDownload | UsenetTotalDownloas);
|
||||
|
||||
export type TorrentTotalDownload = {
|
||||
type: 'torrent';
|
||||
torrents: NormalizedTorrent[];
|
||||
totalUpload: number;
|
||||
};
|
||||
|
||||
export type UsenetTotalDownloas = {
|
||||
type: 'usenet';
|
||||
nzbs: UsenetQueueItem[];
|
||||
};
|
||||
294
src/widgets/download-speed/TorrentNetworkTrafficTile.tsx
Normal file
294
src/widgets/download-speed/TorrentNetworkTrafficTile.tsx
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { NormalizedTorrent } from '@ctrl/shared-torrent';
|
||||
import {
|
||||
Badge,
|
||||
Center,
|
||||
@@ -18,10 +17,8 @@ import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useConfigContext } from '../../config/provider';
|
||||
import { useGetTorrentData } from '../../hooks/widgets/torrents/useGetTorrentData';
|
||||
import { NormalizedTorrentListResponse } from '../../types/api/NormalizedTorrentListResponse';
|
||||
import { useGetDownloadClientsQueue } from '../../hooks/widgets/download-speed/useGetNetworkSpeed';
|
||||
import { NormalizedDownloadQueueResponse } from '../../types/api/downloads/queue/NormalizedDownloadQueueResponse';
|
||||
import { AppIntegrationType } from '../../types/app';
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
@@ -44,13 +41,6 @@ const definition = defineWidget({
|
||||
type: 'switch',
|
||||
defaultValue: true,
|
||||
},
|
||||
refreshInterval: {
|
||||
type: 'slider',
|
||||
defaultValue: 10,
|
||||
min: 1,
|
||||
max: 60,
|
||||
step: 1,
|
||||
},
|
||||
},
|
||||
gridstack: {
|
||||
minWidth: 2,
|
||||
@@ -72,43 +62,17 @@ function TorrentTile({ widget }: TorrentTileProps) {
|
||||
const MIN_WIDTH_MOBILE = useMantineTheme().breakpoints.xs;
|
||||
const { width } = useElementSize();
|
||||
|
||||
const { config } = useConfigContext();
|
||||
const downloadApps =
|
||||
config?.apps.filter((x) => x.integration && downloadAppTypes.includes(x.integration.type)) ??
|
||||
[];
|
||||
|
||||
const [selectedAppId, setSelectedApp] = useState<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 +85,7 @@ function TorrentTile({ widget }: TorrentTileProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (isInitialLoading) {
|
||||
if (isInitialLoading || !data) {
|
||||
return (
|
||||
<Stack
|
||||
align="center"
|
||||
@@ -139,7 +103,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 +122,7 @@ function TorrentTile({ widget }: TorrentTileProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const filter = (torrent: NormalizedTorrent) => {
|
||||
if (!widget.properties.displayCompletedTorrents && torrent.isCompleted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!widget.properties.displayStaleTorrents && !torrent.isCompleted && torrent.eta <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
const torrents = data.apps.flatMap((app) => (app.type === 'torrent' ? app.torrents : []));
|
||||
|
||||
const difference = new Date().getTime() - dataUpdatedAt;
|
||||
const duration = dayjs.duration(difference, 'ms');
|
||||
@@ -178,19 +143,14 @@ function TorrentTile({ widget }: TorrentTileProps) {
|
||||
</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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Button,
|
||||
Center,
|
||||
Code,
|
||||
Group,
|
||||
Pagination,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
|
||||
Reference in New Issue
Block a user