Merge branch 'dev' into feature/dashdot-consistency-changes

This commit is contained in:
Thomas Camlong
2023-02-14 10:53:46 +09:00
committed by GitHub
157 changed files with 3054 additions and 1772 deletions

View 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}
/>
);
};

View File

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

View File

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

View 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;

View 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;