mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-11 07:55:52 +01:00
Merge branch 'dev' into feature/dashdot-consistency-changes
This commit is contained in:
@@ -33,9 +33,10 @@ export const AppTile = ({ className, app }: AppTileProps) => {
|
||||
justify="space-around"
|
||||
align="center"
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
className="dashboard-tile-app"
|
||||
>
|
||||
<Box hidden={false}>
|
||||
<Title order={5} size="md" ta="center" lineClamp={1} className={classes.appName}>
|
||||
<Title order={5} size="md" ta="center" lineClamp={1} className={cx(classes.appName, 'dashboard-tile-app-title')}>
|
||||
{app.name}
|
||||
</Title>
|
||||
</Box>
|
||||
|
||||
@@ -18,7 +18,7 @@ export const HomarrCardWrapper = ({ ...props }: HomarrCardWrapperProps) => {
|
||||
return (
|
||||
<Card
|
||||
{...restProps}
|
||||
className={cx(restProps.className, cardClass)}
|
||||
className={cx(restProps.className, cardClass, 'dashboard-gs-generic-item')}
|
||||
withBorder
|
||||
style={{ cursor: isEditMode ? 'move' : 'default' }}
|
||||
radius="lg"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import create from 'zustand';
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface EditModeState {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -16,7 +16,7 @@ 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 { classes: cardClasses, cx } = useCardStyles(true);
|
||||
|
||||
const categoryList = config?.categories.map((x) => x.name) ?? [];
|
||||
const [toggledCategories, setToggledCategories] = useLocalStorage({
|
||||
@@ -28,7 +28,7 @@ export const DashboardCategory = ({ category }: DashboardCategoryProps) => {
|
||||
return (
|
||||
<Accordion
|
||||
classNames={{
|
||||
item: cardClasses.card,
|
||||
item: cx(cardClasses.card, 'dashboard-gs-category'),
|
||||
}}
|
||||
mx={10}
|
||||
chevronPosition="left"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMantineTheme } from '@mantine/core';
|
||||
import create from 'zustand';
|
||||
import { create } from 'zustand';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { GridstackBreakpoints } from '../../../../constants/gridstack-breakpoints';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Accordion, Grid, Group, Stack, Text } from '@mantine/core';
|
||||
import { IconBrush, IconChartCandle, IconDragDrop, IconLayout } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { Accordion, Checkbox, Grid, Group, Stack, Switch, Text } from '@mantine/core';
|
||||
import { IconBrush, IconChartCandle, IconCode, IconDragDrop, IconLayout } from '@tabler/icons';
|
||||
import { i18n, useTranslation } from 'next-i18next';
|
||||
import { ReactNode } from 'react';
|
||||
import { GridstackConfiguration } from './Layout/GridstackConfiguration';
|
||||
import { LayoutSelector } from './Layout/LayoutSelector';
|
||||
@@ -55,7 +55,7 @@ const getItems = () => {
|
||||
'settings/customization/general',
|
||||
'settings/customization/color-selector',
|
||||
]);
|
||||
return [
|
||||
const items = [
|
||||
{
|
||||
id: 'layout',
|
||||
image: <IconLayout />,
|
||||
@@ -114,4 +114,26 @@ const getItems = () => {
|
||||
),
|
||||
},
|
||||
];
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
items.push({
|
||||
id: 'dev',
|
||||
image: <IconCode />,
|
||||
label: 'Developer options',
|
||||
description: 'Options to help when developing',
|
||||
content: (
|
||||
<Stack>
|
||||
<Checkbox
|
||||
label="Use debug language"
|
||||
defaultChecked={i18n?.language === 'cimode'}
|
||||
description="This will show the translation keys instead of the actual translations"
|
||||
onChange={(e) =>
|
||||
// Change to CI mode language
|
||||
i18n?.changeLanguage(e.target.checked ? 'cimode' : 'en')
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
import {
|
||||
Box,
|
||||
createStyles,
|
||||
Group,
|
||||
Loader,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { Box, createStyles, Group, Loader, Stack, Text, useMantineTheme } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { ChangeEvent, useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Editor from 'react-simple-code-editor';
|
||||
import { highlight, languages } from 'prismjs';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { useConfigStore } from '../../../../config/store';
|
||||
|
||||
const CodeEditor = dynamic(
|
||||
() => import('@uiw/react-textarea-code-editor').then((mod) => mod.default),
|
||||
{ ssr: false }
|
||||
);
|
||||
import 'prismjs/components/prism-css';
|
||||
import 'prismjs/themes/prism.css';
|
||||
|
||||
export const CustomCssChanger = () => {
|
||||
const { t } = useTranslation('settings/customization/page-appearance');
|
||||
@@ -53,22 +42,20 @@ export const CustomCssChanger = () => {
|
||||
<Stack spacing={4} mt="xl">
|
||||
<Text>{t('customCSS.label')}</Text>
|
||||
<Text color="dimmed" size="xs">
|
||||
{t('customCSS.description')}
|
||||
{t('customCSS.description')}
|
||||
</Text>
|
||||
<div className={classes.codeEditorRoot}>
|
||||
<ScrollArea style={{ height: codeEditorHeight }}>
|
||||
<CodeEditor
|
||||
className={classes.codeEditor}
|
||||
placeholder={t('customCSS.placeholder')}
|
||||
value={nonDebouncedCustomCSS}
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setNonDebouncedCustomCSS(event.target.value.trim())
|
||||
}
|
||||
language="css"
|
||||
data-color-mode={colorScheme}
|
||||
minHeight={codeEditorHeight}
|
||||
/>
|
||||
</ScrollArea>
|
||||
<Editor
|
||||
value={nonDebouncedCustomCSS}
|
||||
onValueChange={(code) => setNonDebouncedCustomCSS(code)}
|
||||
highlight={(code) => highlight(code, languages.extend('css', {}), 'css')}
|
||||
padding={10}
|
||||
style={{
|
||||
fontFamily: '"Fira code", "Fira Mono", monospace',
|
||||
fontSize: 12,
|
||||
minHeight: codeEditorHeight,
|
||||
}}
|
||||
/>
|
||||
{codeIsDirty && (
|
||||
<Box className={classes.codeEditorFooter}>
|
||||
<Group p="xs" spacing="xs">
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function Layout({ children }: any) {
|
||||
minHeight: 'calc(100vh - var(--mantine-header-height))',
|
||||
},
|
||||
}}
|
||||
className="dashboard-app-shell"
|
||||
>
|
||||
<Head />
|
||||
<Background />
|
||||
|
||||
@@ -17,12 +17,14 @@ export function Logo({ size = 'md', withoutText = false }: LogoProps) {
|
||||
width={size === 'md' ? 50 : 12}
|
||||
src={config?.settings.customization.logoImageUrl || '/imgs/logo/logo-color.svg'}
|
||||
alt="Homarr Logo"
|
||||
className="dashboard-header-logo-image"
|
||||
/>
|
||||
{withoutText ? null : (
|
||||
<Text
|
||||
size={size === 'md' ? 22 : 10}
|
||||
weight="bold"
|
||||
variant="gradient"
|
||||
className="dashboard-header-logo-text"
|
||||
gradient={primaryGradient}
|
||||
>
|
||||
{config?.settings.customization.pageTitle || 'Homarr'}
|
||||
|
||||
@@ -13,7 +13,7 @@ export const HeaderHeight = 64;
|
||||
|
||||
export function Header(props: any) {
|
||||
const { classes } = useStyles();
|
||||
const { classes: cardClasses } = useCardStyles(false);
|
||||
const { classes: cardClasses, cx } = useCardStyles(false);
|
||||
const { attributes } = usePackageAttributesStore();
|
||||
|
||||
const { isLoading, error, data } = useQuery({
|
||||
@@ -27,12 +27,12 @@ export function Header(props: any) {
|
||||
data?.tag_name > `v${attributes.packageVersion}` ? data?.tag_name : undefined;
|
||||
|
||||
return (
|
||||
<MantineHeader height="auto" className={cardClasses.card}>
|
||||
<MantineHeader height="auto" className={cx(cardClasses.card, 'dashboard-header')}>
|
||||
<Group p="xs" noWrap grow>
|
||||
<Box className={classes.hide}>
|
||||
<Box className={cx(classes.hide, 'dashboard-header-logo-root')}>
|
||||
<Logo />
|
||||
</Box>
|
||||
<Group position="right" style={{ maxWidth: 'none' }} noWrap>
|
||||
<Group className="dashboard-header-group-right" position="right" style={{ maxWidth: 'none' }} noWrap>
|
||||
<Search />
|
||||
<ToggleEditModeAction />
|
||||
<DockerMenuButton />
|
||||
|
||||
@@ -57,7 +57,7 @@ export function Search() {
|
||||
const { config } = useConfigContext();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [debounced] = useDebouncedValue(searchQuery, 250);
|
||||
const { classes: cardClasses } = useCardStyles(true);
|
||||
const { classes: cardClasses, cx } = useCardStyles(true);
|
||||
|
||||
const isOverseerrEnabled = config?.apps.some(
|
||||
(x) => x.integration.type === 'overseerr' || x.integration.type === 'jellyseerr'
|
||||
@@ -216,7 +216,8 @@ export function Search() {
|
||||
}
|
||||
}}
|
||||
classNames={{
|
||||
input: cardClasses.card,
|
||||
input: cx(cardClasses.card, 'dashboard-header-search-input'),
|
||||
root: 'dashboard-header-search-root',
|
||||
}}
|
||||
radius="lg"
|
||||
size="md"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import create from 'zustand';
|
||||
import { create } from 'zustand';
|
||||
import { ConfigType } from '../types/config';
|
||||
|
||||
export const useConfigStore = create<UseConfigStoreType>((set, get) => ({
|
||||
|
||||
@@ -2,19 +2,31 @@ import { NextFetchEvent, NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
export function middleware(req: NextRequest, ev: NextFetchEvent) {
|
||||
const isCorrectPassword = req.cookies.get('password')?.value === process.env.PASSWORD;
|
||||
const { cookies } = req;
|
||||
// Don't even bother with the middleware if there is no defined password
|
||||
if (!process.env.PASSWORD) return NextResponse.next();
|
||||
|
||||
const url = req.nextUrl.clone();
|
||||
const skipURL =
|
||||
url.pathname &&
|
||||
(url.pathname.includes('login') ||
|
||||
url.pathname === '/api/configs/tryPassword' ||
|
||||
(url.pathname.includes('/_next/') && !url.pathname.includes('/pages/')) ||
|
||||
url.pathname === '/favicon.ico' ||
|
||||
url.pathname === '/404' ||
|
||||
url.pathname === '/migrate' ||
|
||||
url.pathname.includes('pages/_app'));
|
||||
if (!skipURL && !isCorrectPassword && process.env.PASSWORD) {
|
||||
const passwordCookie = cookies.get('password')?.value;
|
||||
|
||||
const isCorrectPassword = passwordCookie?.toString() === process.env.PASSWORD;
|
||||
// Skip the middleware if the URL is 'login', 'api/configs/tryPassword', '_next/*', 'favicon.ico', '404', 'migrate' or 'pages/_app'
|
||||
const skippedUrls = [
|
||||
'/login',
|
||||
'/api/configs/tryPassword',
|
||||
'/_next/',
|
||||
'/favicon.ico',
|
||||
'/404',
|
||||
'/migrate',
|
||||
'/pages/_app',
|
||||
];
|
||||
if (skippedUrls.some((skippedUrl) => url.pathname.startsWith(skippedUrl))) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
// If the password is not correct, redirect to the login page
|
||||
if (!isCorrectPassword && process.env.PASSWORD) {
|
||||
url.pathname = '/login';
|
||||
return NextResponse.rewrite(url);
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ import {
|
||||
ServerSidePackageAttributesType,
|
||||
} from '../tools/server/getPackageVersion';
|
||||
import { usePackageAttributesStore } from '../tools/client/zustands/usePackageAttributesStore';
|
||||
import 'video.js/dist/video-js.css';
|
||||
|
||||
import '../styles/global.scss';
|
||||
import '@uiw/react-textarea-code-editor/dist.css';
|
||||
|
||||
function App(
|
||||
this: any,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import create from 'zustand';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { ServerSidePackageAttributesType } from '../../server/getPackageVersion';
|
||||
|
||||
|
||||
@@ -75,6 +75,20 @@ export const languages: Language[] = [
|
||||
translatedName: 'LOLCAT',
|
||||
emoji: '🐱',
|
||||
},
|
||||
// Norwegian
|
||||
{
|
||||
shortName: 'no',
|
||||
originalName: 'Norsk',
|
||||
translatedName: 'Norwegian',
|
||||
emoji: '🇳🇴',
|
||||
},
|
||||
// Slovak
|
||||
{
|
||||
shortName: 'sk',
|
||||
originalName: 'Slovenčina',
|
||||
translatedName: 'Slovak',
|
||||
emoji: '🇸🇰',
|
||||
},
|
||||
{
|
||||
shortName: 'nl',
|
||||
originalName: 'Nederlands',
|
||||
|
||||
@@ -33,6 +33,7 @@ export const dashboardNamespaces = [
|
||||
'modules/dashdot',
|
||||
'modules/overseerr',
|
||||
'modules/common-media-cards',
|
||||
'modules/video-stream',
|
||||
];
|
||||
|
||||
export const loginNamespaces = ['authentication/login'];
|
||||
|
||||
274
src/widgets/download-speed/Tile.tsx
Normal file
274
src/widgets/download-speed/Tile.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Title,
|
||||
Text,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { useElementSize, useListState } from '@mantine/hooks';
|
||||
import { linearGradientDef } from '@nivo/core';
|
||||
import { Serie, Datum, ResponsiveLine } from '@nivo/line';
|
||||
import { 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 definition, { ITorrentNetworkTraffic } from './TorrentNetworkTrafficTile';
|
||||
|
||||
interface TorrentNetworkTrafficTileProps {
|
||||
widget: ITorrentNetworkTraffic;
|
||||
}
|
||||
|
||||
export default 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,31 +1,13 @@
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
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 { IconArrowsUpDown } from '@tabler/icons';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
|
||||
const torrentNetworkTrafficTile = dynamic(() => import('./Tile'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const definition = defineWidget({
|
||||
id: 'dlspeed',
|
||||
icon: IconArrowsUpDown,
|
||||
@@ -37,257 +19,9 @@ const definition = defineWidget({
|
||||
maxWidth: 12,
|
||||
maxHeight: 6,
|
||||
},
|
||||
component: TorrentNetworkTrafficTile,
|
||||
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;
|
||||
|
||||
@@ -5,6 +5,7 @@ import usenet from './useNet/UseNetTile';
|
||||
import weather from './weather/WeatherTile';
|
||||
import torrent from './torrent/TorrentTile';
|
||||
import torrentNetworkTraffic from './download-speed/TorrentNetworkTrafficTile';
|
||||
import videoStream from './video/VideoStreamTile';
|
||||
|
||||
export default {
|
||||
calendar,
|
||||
@@ -14,4 +15,5 @@ export default {
|
||||
'torrents-status': torrent,
|
||||
dlspeed: torrentNetworkTraffic,
|
||||
date,
|
||||
'video-stream': videoStream,
|
||||
};
|
||||
|
||||
67
src/widgets/video/VideoFeed.tsx
Normal file
67
src/widgets/video/VideoFeed.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { createStyles, LoadingOverlay } from '@mantine/core';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import videojs from 'video.js';
|
||||
import 'video.js/dist/video-js.css';
|
||||
|
||||
interface VideoFeedProps {
|
||||
source: string;
|
||||
muted: boolean;
|
||||
autoPlay: boolean;
|
||||
controls: boolean;
|
||||
}
|
||||
|
||||
const VideoFeed = ({ source, controls, autoPlay, muted }: VideoFeedProps) => {
|
||||
const videoRef = useRef(null);
|
||||
const [player, setPlayer] = useState<ReturnType<typeof videojs>>();
|
||||
|
||||
const { classes, cx } = useStyles();
|
||||
|
||||
useEffect(() => {
|
||||
// make sure Video.js player is only initialized once
|
||||
if (player) {
|
||||
return;
|
||||
}
|
||||
|
||||
const videoElement = videoRef.current;
|
||||
if (!videoElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPlayer(videojs(videoElement, { autoplay: autoPlay, muted, controls }, () => {}));
|
||||
}, [videoRef]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (!player) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (player.isDisposed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
player.dispose();
|
||||
},
|
||||
[player]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingOverlay visible={player === undefined} />
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video className={cx('video-js', classes.video)} ref={videoRef}>
|
||||
<source src={source} type="video/mp4" />
|
||||
</video>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = createStyles(({ radius }) => ({
|
||||
video: {
|
||||
height: '100%',
|
||||
borderRadius: radius.md,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
}));
|
||||
|
||||
export default VideoFeed;
|
||||
70
src/widgets/video/VideoStreamTile.tsx
Normal file
70
src/widgets/video/VideoStreamTile.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Center, Group, Stack, Title } from '@mantine/core';
|
||||
import { IconDeviceCctv, IconHeartBroken } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
|
||||
const VideoFeed = dynamic(() => import('./VideoFeed'), { ssr: false });
|
||||
|
||||
const definition = defineWidget({
|
||||
id: 'video-stream',
|
||||
icon: IconDeviceCctv,
|
||||
options: {
|
||||
FeedUrl: {
|
||||
type: 'text',
|
||||
defaultValue: '',
|
||||
},
|
||||
autoPlay: {
|
||||
type: 'switch',
|
||||
defaultValue: true,
|
||||
},
|
||||
muted: {
|
||||
type: 'switch',
|
||||
defaultValue: true,
|
||||
},
|
||||
controls: {
|
||||
type: 'switch',
|
||||
defaultValue: false,
|
||||
},
|
||||
},
|
||||
gridstack: {
|
||||
minWidth: 3,
|
||||
minHeight: 2,
|
||||
maxWidth: 12,
|
||||
maxHeight: 12,
|
||||
},
|
||||
component: VideoStreamWidget,
|
||||
});
|
||||
|
||||
export type VideoStreamWidget = IWidget<(typeof definition)['id'], typeof definition>;
|
||||
|
||||
interface VideoStreamWidgetProps {
|
||||
widget: VideoStreamWidget;
|
||||
}
|
||||
|
||||
function VideoStreamWidget({ widget }: VideoStreamWidgetProps) {
|
||||
const { t } = useTranslation('modules/video-stream');
|
||||
if (!widget.properties.FeedUrl) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center">
|
||||
<IconHeartBroken />
|
||||
<Title order={4}>{t('errors.invalidStream')}</Title>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Group position="center" w="100%" h="100%">
|
||||
<VideoFeed
|
||||
source={widget?.properties.FeedUrl}
|
||||
muted={widget?.properties.muted}
|
||||
autoPlay={widget?.properties.autoPlay}
|
||||
controls={widget?.properties.controls}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default definition;
|
||||
Reference in New Issue
Block a user