mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-11 16:05:47 +01:00
⬇️ Downgrade NextJS and React
Middleware didn't work in v12.2.3. Hopefully the password protection will work again now.
This commit is contained in:
@@ -14,9 +14,9 @@ import { useLocalStorage } from '@mantine/hooks';
|
||||
import { useConfig } from '../../tools/state';
|
||||
|
||||
import { SortableAppShelfItem, AppShelfItem } from './AppShelfItem';
|
||||
import { ModuleMenu, ModuleWrapper } from '../modules/moduleWrapper';
|
||||
import { DownloadsModule } from '../modules';
|
||||
import DownloadComponent from '../modules/downloads/DownloadsModule';
|
||||
import { ModuleMenu, ModuleWrapper } from '../../modules/moduleWrapper';
|
||||
import { DownloadsModule } from '../../modules';
|
||||
import DownloadComponent from '../../modules/downloads/DownloadsModule';
|
||||
|
||||
const useStyles = createStyles((theme, _params) => ({
|
||||
item: {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useState } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { serviceItem } from '../../tools/types';
|
||||
import PingComponent from '../modules/ping/PingModule';
|
||||
import PingComponent from '../../modules/ping/PingModule';
|
||||
import AppShelfMenu from './AppShelfMenu';
|
||||
import { useConfig } from '../../tools/state';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Checkbox, Group, SimpleGrid, Title } from '@mantine/core';
|
||||
import * as Modules from '../modules';
|
||||
import * as Modules from '../../modules';
|
||||
import { useConfig } from '../../tools/state';
|
||||
|
||||
export default function ModuleEnabler(props: any) {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Box, createStyles, Group, Header as Head } from '@mantine/core';
|
||||
import { useBooleanToggle } from '@mantine/hooks';
|
||||
import { AddItemShelfButton } from '../AppShelf/AddAppShelfItem';
|
||||
|
||||
import DockerMenuButton from '../modules/docker/DockerModule';
|
||||
import SearchBar from '../modules/search/SearchModule';
|
||||
import DockerMenuButton from '../../modules/docker/DockerModule';
|
||||
import SearchBar from '../../modules/search/SearchModule';
|
||||
import { SettingsMenuButton } from '../Settings/SettingsMenu';
|
||||
import { Logo } from './Logo';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Group } from '@mantine/core';
|
||||
import { CalendarModule, DateModule, TotalDownloadsModule, WeatherModule } from '../modules';
|
||||
import { DashdotModule } from '../modules/dashdot';
|
||||
import { ModuleWrapper } from '../modules/moduleWrapper';
|
||||
import { CalendarModule, DateModule, TotalDownloadsModule, WeatherModule } from '../../modules';
|
||||
import { DashdotModule } from '../../modules/dashdot';
|
||||
import { ModuleWrapper } from '../../modules/moduleWrapper';
|
||||
|
||||
export default function Widgets(props: any) {
|
||||
return (
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
/* eslint-disable react/no-children-prop */
|
||||
import {
|
||||
Box,
|
||||
Divider,
|
||||
Indicator,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
createStyles,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Calendar } from '@mantine/dates';
|
||||
import { IconCalendar as CalendarIcon } from '@tabler/icons';
|
||||
import axios from 'axios';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { IModule } from '../modules';
|
||||
import {
|
||||
SonarrMediaDisplay,
|
||||
RadarrMediaDisplay,
|
||||
LidarrMediaDisplay,
|
||||
ReadarrMediaDisplay,
|
||||
} from '../common';
|
||||
import { serviceItem } from '../../../tools/types';
|
||||
import { useColorTheme } from '../../../tools/color';
|
||||
|
||||
export const CalendarModule: IModule = {
|
||||
title: 'Calendar',
|
||||
description:
|
||||
'A calendar module for displaying upcoming releases. It interacts with the Sonarr and Radarr API.',
|
||||
icon: CalendarIcon,
|
||||
component: CalendarComponent,
|
||||
options: {
|
||||
sundaystart: {
|
||||
name: 'Start the week on Sunday',
|
||||
value: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function CalendarComponent(props: any) {
|
||||
const { config } = useConfig();
|
||||
const theme = useMantineTheme();
|
||||
const { secondaryColor } = useColorTheme();
|
||||
const useStyles = createStyles((theme) => ({
|
||||
weekend: {
|
||||
color: `${secondaryColor} !important`,
|
||||
},
|
||||
}));
|
||||
|
||||
const [sonarrMedias, setSonarrMedias] = useState([] as any);
|
||||
const [lidarrMedias, setLidarrMedias] = useState([] as any);
|
||||
const [radarrMedias, setRadarrMedias] = useState([] as any);
|
||||
const [readarrMedias, setReadarrMedias] = useState([] as any);
|
||||
const sonarrServices = config.services.filter((service) => service.type === 'Sonarr');
|
||||
const radarrServices = config.services.filter((service) => service.type === 'Radarr');
|
||||
const lidarrServices = config.services.filter((service) => service.type === 'Lidarr');
|
||||
const readarrServices = config.services.filter((service) => service.type === 'Readarr');
|
||||
const today = new Date();
|
||||
|
||||
const { classes, cx } = useStyles();
|
||||
|
||||
function getMedias(service: serviceItem | undefined, type: string) {
|
||||
if (!service || !service.apiKey) {
|
||||
return Promise.resolve({ data: [] });
|
||||
}
|
||||
return axios.post(`/api/modules/calendar?type=${type}`, { id: service.id });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Create each Sonarr service and get the medias
|
||||
const currentSonarrMedias: any[] = [];
|
||||
Promise.all(
|
||||
sonarrServices.map((service) =>
|
||||
getMedias(service, 'sonarr')
|
||||
.then((res) => {
|
||||
currentSonarrMedias.push(...res.data);
|
||||
})
|
||||
.catch(() => {
|
||||
currentSonarrMedias.push([]);
|
||||
})
|
||||
)
|
||||
).then(() => {
|
||||
setSonarrMedias(currentSonarrMedias);
|
||||
});
|
||||
const currentRadarrMedias: any[] = [];
|
||||
Promise.all(
|
||||
radarrServices.map((service) =>
|
||||
getMedias(service, 'radarr')
|
||||
.then((res) => {
|
||||
currentRadarrMedias.push(...res.data);
|
||||
})
|
||||
.catch(() => {
|
||||
currentRadarrMedias.push([]);
|
||||
})
|
||||
)
|
||||
).then(() => {
|
||||
setRadarrMedias(currentRadarrMedias);
|
||||
});
|
||||
const currentLidarrMedias: any[] = [];
|
||||
Promise.all(
|
||||
lidarrServices.map((service) =>
|
||||
getMedias(service, 'lidarr')
|
||||
.then((res) => {
|
||||
currentLidarrMedias.push(...res.data);
|
||||
})
|
||||
.catch(() => {
|
||||
currentLidarrMedias.push([]);
|
||||
})
|
||||
)
|
||||
).then(() => {
|
||||
setLidarrMedias(currentLidarrMedias);
|
||||
});
|
||||
const currentReadarrMedias: any[] = [];
|
||||
Promise.all(
|
||||
readarrServices.map((service) =>
|
||||
getMedias(service, 'readarr')
|
||||
.then((res) => {
|
||||
currentReadarrMedias.push(...res.data);
|
||||
})
|
||||
.catch(() => {
|
||||
currentReadarrMedias.push([]);
|
||||
})
|
||||
)
|
||||
).then(() => {
|
||||
setReadarrMedias(currentReadarrMedias);
|
||||
});
|
||||
}, [config.services]);
|
||||
|
||||
const weekStartsAtSunday =
|
||||
(config?.modules?.[CalendarModule.title]?.options?.sundaystart?.value as boolean) ?? false;
|
||||
return (
|
||||
<Calendar
|
||||
firstDayOfWeek={weekStartsAtSunday ? 'sunday' : 'monday'}
|
||||
onChange={(day: any) => {}}
|
||||
dayStyle={(date) =>
|
||||
date.getDay() === today.getDay() && date.getDate() === today.getDate()
|
||||
? {
|
||||
backgroundColor:
|
||||
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[0],
|
||||
}
|
||||
: {}
|
||||
}
|
||||
styles={{
|
||||
calendarHeader: {
|
||||
marginRight: 15,
|
||||
marginLeft: 15,
|
||||
},
|
||||
}}
|
||||
allowLevelChange={false}
|
||||
dayClassName={(date, modifiers) => cx({ [classes.weekend]: modifiers.weekend })}
|
||||
renderDay={(renderdate) => (
|
||||
<DayComponent
|
||||
renderdate={renderdate}
|
||||
sonarrmedias={sonarrMedias}
|
||||
radarrmedias={radarrMedias}
|
||||
lidarrmedias={lidarrMedias}
|
||||
readarrmedias={readarrMedias}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DayComponent(props: any) {
|
||||
const {
|
||||
renderdate,
|
||||
sonarrmedias,
|
||||
radarrmedias,
|
||||
lidarrmedias,
|
||||
readarrmedias,
|
||||
}: { renderdate: Date; sonarrmedias: []; radarrmedias: []; lidarrmedias: []; readarrmedias: [] } =
|
||||
props;
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const day = renderdate.getDate();
|
||||
|
||||
const readarrFiltered = readarrmedias.filter((media: any) => {
|
||||
const date = new Date(media.releaseDate);
|
||||
return date.toDateString() === renderdate.toDateString();
|
||||
});
|
||||
|
||||
const lidarrFiltered = lidarrmedias.filter((media: any) => {
|
||||
const date = new Date(media.releaseDate);
|
||||
return date.toDateString() === renderdate.toDateString();
|
||||
});
|
||||
const sonarrFiltered = sonarrmedias.filter((media: any) => {
|
||||
const date = new Date(media.airDateUtc);
|
||||
return date.toDateString() === renderdate.toDateString();
|
||||
});
|
||||
const radarrFiltered = radarrmedias.filter((media: any) => {
|
||||
const date = new Date(media.inCinemas);
|
||||
return date.toDateString() === renderdate.toDateString();
|
||||
});
|
||||
if (
|
||||
sonarrFiltered.length === 0 &&
|
||||
radarrFiltered.length === 0 &&
|
||||
lidarrFiltered.length === 0 &&
|
||||
readarrFiltered.length === 0
|
||||
) {
|
||||
return <div>{day}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => {
|
||||
setOpened(true);
|
||||
}}
|
||||
>
|
||||
{readarrFiltered.length > 0 && (
|
||||
<Indicator
|
||||
size={10}
|
||||
withBorder
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
left: 8,
|
||||
}}
|
||||
color="red"
|
||||
children={null}
|
||||
/>
|
||||
)}
|
||||
{radarrFiltered.length > 0 && (
|
||||
<Indicator
|
||||
size={10}
|
||||
withBorder
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
}}
|
||||
color="yellow"
|
||||
children={null}
|
||||
/>
|
||||
)}
|
||||
{sonarrFiltered.length > 0 && (
|
||||
<Indicator
|
||||
size={10}
|
||||
withBorder
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
}}
|
||||
color="blue"
|
||||
children={null}
|
||||
/>
|
||||
)}
|
||||
{lidarrFiltered.length > 0 && (
|
||||
<Indicator
|
||||
size={10}
|
||||
withBorder
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
}}
|
||||
color="green"
|
||||
children={undefined}
|
||||
/>
|
||||
)}
|
||||
<Popover
|
||||
position="bottom"
|
||||
radius="lg"
|
||||
shadow="xl"
|
||||
transition="pop"
|
||||
styles={{
|
||||
body: {
|
||||
boxShadow: '0 0 14px 14px rgba(0, 0, 0, 0.1), 0 14px 11px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
}}
|
||||
width="auto"
|
||||
onClose={() => setOpened(false)}
|
||||
opened={opened}
|
||||
target={day}
|
||||
>
|
||||
<ScrollArea style={{ height: 400 }}>
|
||||
{sonarrFiltered.map((media: any, index: number) => (
|
||||
<React.Fragment key={index}>
|
||||
<SonarrMediaDisplay media={media} />
|
||||
{index < sonarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{radarrFiltered.length > 0 && sonarrFiltered.length > 0 && (
|
||||
<Divider variant="dashed" my="xl" />
|
||||
)}
|
||||
{radarrFiltered.map((media: any, index: number) => (
|
||||
<React.Fragment key={index}>
|
||||
<RadarrMediaDisplay media={media} />
|
||||
{index < radarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{sonarrFiltered.length > 0 && lidarrFiltered.length > 0 && (
|
||||
<Divider variant="dashed" my="xl" />
|
||||
)}
|
||||
{lidarrFiltered.map((media: any, index: number) => (
|
||||
<React.Fragment key={index}>
|
||||
<LidarrMediaDisplay media={media} />
|
||||
{index < lidarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{lidarrFiltered.length > 0 && readarrFiltered.length > 0 && (
|
||||
<Divider variant="dashed" my="xl" />
|
||||
)}
|
||||
{readarrFiltered.map((media: any, index: number) => (
|
||||
<React.Fragment key={index}>
|
||||
<ReadarrMediaDisplay media={media} />
|
||||
{index < readarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</Popover>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { CalendarModule } from './CalendarModule';
|
||||
@@ -1,408 +0,0 @@
|
||||
export const medias = [
|
||||
{
|
||||
title: 'Doctor Strange in the Multiverse of Madness',
|
||||
originalTitle: 'Doctor Strange in the Multiverse of Madness',
|
||||
originalLanguage: {
|
||||
id: 1,
|
||||
name: 'English',
|
||||
},
|
||||
alternateTitles: [
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: 'Doctor Strange 2',
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 1,
|
||||
name: 'English',
|
||||
},
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: 'Доктор Стрэндж 2',
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 11,
|
||||
name: 'Russian',
|
||||
},
|
||||
id: 2,
|
||||
},
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: 'Doutor Estranho 2',
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 1,
|
||||
name: 'English',
|
||||
},
|
||||
id: 3,
|
||||
},
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: 'Doctor Strange v multivesmíre šialenstva',
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 1,
|
||||
name: 'English',
|
||||
},
|
||||
id: 4,
|
||||
},
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: 'Doctor Strange 2: El multiverso de la locura',
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 3,
|
||||
name: 'Spanish',
|
||||
},
|
||||
id: 5,
|
||||
},
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: 'Doktor Strange Deliliğin Çoklu Evreninde',
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 17,
|
||||
name: 'Turkish',
|
||||
},
|
||||
id: 6,
|
||||
},
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: 'মহাবিশ্বের পাগলামিতে অদ্ভুত চিকিৎসক',
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 1,
|
||||
name: 'English',
|
||||
},
|
||||
id: 7,
|
||||
},
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: 'จอมเวทย์มหากาฬ ในมัลติเวิร์สมหาภัย',
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 28,
|
||||
name: 'Thai',
|
||||
},
|
||||
id: 8,
|
||||
},
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: "Marvel Studios' Doctor Strange in the Multiverse of Madness",
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 1,
|
||||
name: 'English',
|
||||
},
|
||||
id: 9,
|
||||
},
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: 'Doctor Strange en el Multiverso de la Locura de Marvel Studios',
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 3,
|
||||
name: 'Spanish',
|
||||
},
|
||||
id: 10,
|
||||
},
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: 'Doktors Streindžs neprāta multivisumā',
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 1,
|
||||
name: 'English',
|
||||
},
|
||||
id: 11,
|
||||
},
|
||||
],
|
||||
secondaryYearSourceId: 0,
|
||||
sortTitle: 'doctor strange in multiverse madness',
|
||||
sizeOnDisk: 0,
|
||||
status: 'announced',
|
||||
overview:
|
||||
'Doctor Strange, with the help of mystical allies both old and new, traverses the mind-bending and dangerous alternate realities of the Multiverse to confront a mysterious new adversary.',
|
||||
inCinemas: '2022-05-04T00:00:00Z',
|
||||
images: [
|
||||
{
|
||||
coverType: 'poster',
|
||||
url: 'https://image.tmdb.org/t/p/original/wRnbWt44nKjsFPrqSmwYki5vZtF.jpg',
|
||||
},
|
||||
{
|
||||
coverType: 'fanart',
|
||||
url: 'https://image.tmdb.org/t/p/original/ndCSoasjIZAMMDIuMxuGnNWu4DU.jpg',
|
||||
},
|
||||
],
|
||||
website: 'https://www.marvel.com/movies/doctor-strange-in-the-multiverse-of-madness',
|
||||
year: 2022,
|
||||
hasFile: false,
|
||||
youTubeTrailerId: 'aWzlQ2N6qqg',
|
||||
studio: 'Marvel Studios',
|
||||
path: '/config/Doctor Strange in the Multiverse of Madness (2022)',
|
||||
qualityProfileId: 1,
|
||||
monitored: true,
|
||||
minimumAvailability: 'announced',
|
||||
isAvailable: true,
|
||||
folderName: '/config/Doctor Strange in the Multiverse of Madness (2022)',
|
||||
runtime: 126,
|
||||
cleanTitle: 'doctorstrangeinmultiversemadness',
|
||||
imdbId: 'tt9419884',
|
||||
tmdbId: 453395,
|
||||
titleSlug: '453395',
|
||||
certification: 'PG-13',
|
||||
genres: ['Fantasy', 'Action', 'Adventure'],
|
||||
tags: [],
|
||||
added: '2022-04-29T20:52:33Z',
|
||||
ratings: {
|
||||
tmdb: {
|
||||
votes: 0,
|
||||
value: 0,
|
||||
type: 'user',
|
||||
},
|
||||
},
|
||||
collection: {
|
||||
name: 'Doctor Strange Collection',
|
||||
tmdbId: 618529,
|
||||
images: [],
|
||||
},
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
title: 'Doctor Strange in the Multiverse of Madness',
|
||||
originalTitle: 'Doctor Strange in the Multiverse of Madness',
|
||||
originalLanguage: {
|
||||
id: 1,
|
||||
name: 'English',
|
||||
},
|
||||
alternateTitles: [
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: 'Doctor Strange 2',
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 1,
|
||||
name: 'English',
|
||||
},
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: 'Доктор Стрэндж 2',
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 11,
|
||||
name: 'Russian',
|
||||
},
|
||||
id: 2,
|
||||
},
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: 'Doutor Estranho 2',
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 1,
|
||||
name: 'English',
|
||||
},
|
||||
id: 3,
|
||||
},
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: 'Doctor Strange v multivesmíre šialenstva',
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 1,
|
||||
name: 'English',
|
||||
},
|
||||
id: 4,
|
||||
},
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: 'Doctor Strange 2: El multiverso de la locura',
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 3,
|
||||
name: 'Spanish',
|
||||
},
|
||||
id: 5,
|
||||
},
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: 'Doktor Strange Deliliğin Çoklu Evreninde',
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 17,
|
||||
name: 'Turkish',
|
||||
},
|
||||
id: 6,
|
||||
},
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: 'মহাবিশ্বের পাগলামিতে অদ্ভুত চিকিৎসক',
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 1,
|
||||
name: 'English',
|
||||
},
|
||||
id: 7,
|
||||
},
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: 'จอมเวทย์มหากาฬ ในมัลติเวิร์สมหาภัย',
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 28,
|
||||
name: 'Thai',
|
||||
},
|
||||
id: 8,
|
||||
},
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: "Marvel Studios' Doctor Strange in the Multiverse of Madness",
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 1,
|
||||
name: 'English',
|
||||
},
|
||||
id: 9,
|
||||
},
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: 'Doctor Strange en el Multiverso de la Locura de Marvel Studios',
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 3,
|
||||
name: 'Spanish',
|
||||
},
|
||||
id: 10,
|
||||
},
|
||||
{
|
||||
sourceType: 'tmdb',
|
||||
movieId: 1,
|
||||
title: 'Doktors Streindžs neprāta multivisumā',
|
||||
sourceId: 0,
|
||||
votes: 0,
|
||||
voteCount: 0,
|
||||
language: {
|
||||
id: 1,
|
||||
name: 'English',
|
||||
},
|
||||
id: 11,
|
||||
},
|
||||
],
|
||||
secondaryYearSourceId: 0,
|
||||
sortTitle: 'doctor strange in multiverse madness',
|
||||
sizeOnDisk: 0,
|
||||
status: 'announced',
|
||||
overview:
|
||||
'Doctor Strange, with the help of mystical allies both old and new, traverses the mind-bending and dangerous alternate realities of the Multiverse to confront a mysterious new adversary.',
|
||||
inCinemas: '2022-05-05T00:00:00Z',
|
||||
images: [
|
||||
{
|
||||
coverType: 'poster',
|
||||
url: 'https://image.tmdb.org/t/p/original/wRnbWt44nKjsFPrqSmwYki5vZtF.jpg',
|
||||
},
|
||||
{
|
||||
coverType: 'fanart',
|
||||
url: 'https://image.tmdb.org/t/p/original/ndCSoasjIZAMMDIuMxuGnNWu4DU.jpg',
|
||||
},
|
||||
],
|
||||
website: 'https://www.marvel.com/movies/doctor-strange-in-the-multiverse-of-madness',
|
||||
year: 2022,
|
||||
hasFile: false,
|
||||
youTubeTrailerId: 'aWzlQ2N6qqg',
|
||||
studio: 'Marvel Studios',
|
||||
path: '/config/Doctor Strange in the Multiverse of Madness (2022)',
|
||||
qualityProfileId: 1,
|
||||
monitored: true,
|
||||
minimumAvailability: 'announced',
|
||||
isAvailable: true,
|
||||
folderName: '/config/Doctor Strange in the Multiverse of Madness (2022)',
|
||||
runtime: 126,
|
||||
cleanTitle: 'doctorstrangeinmultiversemadness',
|
||||
imdbId: 'tt9419884',
|
||||
tmdbId: 453395,
|
||||
titleSlug: '453395',
|
||||
certification: 'PG-13',
|
||||
genres: ['Fantasy', 'Action', 'Adventure'],
|
||||
tags: [],
|
||||
added: '2022-04-29T20:52:33Z',
|
||||
ratings: {
|
||||
tmdb: {
|
||||
votes: 0,
|
||||
value: 0,
|
||||
type: 'user',
|
||||
},
|
||||
},
|
||||
collection: {
|
||||
name: 'Doctor Strange Collection',
|
||||
tmdbId: 618529,
|
||||
images: [],
|
||||
},
|
||||
id: 2,
|
||||
},
|
||||
];
|
||||
@@ -1,193 +0,0 @@
|
||||
import {
|
||||
Image,
|
||||
Group,
|
||||
Title,
|
||||
Badge,
|
||||
Text,
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
ScrollArea,
|
||||
createStyles,
|
||||
} from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { IconLink as Link } from '@tabler/icons';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { serviceItem } from '../../../tools/types';
|
||||
|
||||
export interface IMedia {
|
||||
overview: string;
|
||||
imdbId?: any;
|
||||
artist?: string;
|
||||
title: string;
|
||||
poster?: string;
|
||||
genres: string[];
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
}
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
overview: {
|
||||
[theme.fn.largerThan('sm')]: {
|
||||
width: 400,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export function MediaDisplay(props: { media: IMedia }) {
|
||||
const { media }: { media: IMedia } = props;
|
||||
const { classes, cx } = useStyles();
|
||||
const phone = useMediaQuery('(min-width: 800px)');
|
||||
return (
|
||||
<Group position="apart">
|
||||
<Text>
|
||||
{media.poster && (
|
||||
<Image
|
||||
width={phone ? 250 : 100}
|
||||
height={phone ? 400 : 160}
|
||||
style={{
|
||||
float: 'right',
|
||||
}}
|
||||
radius="md"
|
||||
fit="cover"
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
/>
|
||||
)}
|
||||
<Group direction="column" style={{ minWidth: phone ? 450 : '65vw' }}>
|
||||
<Group noWrap mr="sm" className={classes.overview}>
|
||||
<Title order={3}>{media.title}</Title>
|
||||
{media.imdbId && (
|
||||
<Anchor href={`https://www.imdb.com/title/${media.imdbId}`} target="_blank">
|
||||
<ActionIcon>
|
||||
<Link />
|
||||
</ActionIcon>
|
||||
</Anchor>
|
||||
)}
|
||||
</Group>
|
||||
{media.artist && (
|
||||
<Text
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
color: 'gray',
|
||||
}}
|
||||
>
|
||||
New release from {media.artist}
|
||||
</Text>
|
||||
)}
|
||||
{media.episodeNumber && media.seasonNumber && (
|
||||
<Text
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
color: 'gray',
|
||||
}}
|
||||
>
|
||||
Season {media.seasonNumber} episode {media.episodeNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Group direction="column" position="apart">
|
||||
<ScrollArea style={{ height: 280, maxWidth: 700 }}>{media.overview}</ScrollArea>
|
||||
<Group align="center" position="center" spacing="xs">
|
||||
{media.genres.slice(-5).map((genre: string, i: number) => (
|
||||
<Badge size="sm" key={i}>
|
||||
{genre}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Group>
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReadarrMediaDisplay(props: any) {
|
||||
const { media }: { media: any } = props;
|
||||
const { config } = useConfig();
|
||||
// Find lidarr in services
|
||||
const readarr = config.services.find((service: serviceItem) => service.type === 'Readarr');
|
||||
// Find a poster CoverType
|
||||
const poster = media.images.find((image: any) => image.coverType === 'cover');
|
||||
if (!readarr) {
|
||||
return null;
|
||||
}
|
||||
const baseUrl = new URL(readarr.url).origin;
|
||||
// Remove '/' from the end of the lidarr url
|
||||
const fullLink = `${baseUrl}${poster.url}`;
|
||||
// Return a movie poster containting the title and the description
|
||||
return (
|
||||
<MediaDisplay
|
||||
media={{
|
||||
title: media.title,
|
||||
poster: fullLink,
|
||||
artist: media.author.authorName,
|
||||
overview: media.overview,
|
||||
genres: media.genres,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LidarrMediaDisplay(props: any) {
|
||||
const { media }: { media: any } = props;
|
||||
const { config } = useConfig();
|
||||
// Find lidarr in services
|
||||
const lidarr = config.services.find((service: serviceItem) => service.type === 'Lidarr');
|
||||
// Find a poster CoverType
|
||||
const poster = media.images.find((image: any) => image.coverType === 'cover');
|
||||
if (!lidarr) {
|
||||
return null;
|
||||
}
|
||||
const baseUrl = new URL(lidarr.url).origin;
|
||||
// Remove '/' from the end of the lidarr url
|
||||
const fullLink = poster ? `${baseUrl}${poster.url}` : undefined;
|
||||
// Return a movie poster containting the title and the description
|
||||
return (
|
||||
<MediaDisplay
|
||||
media={{
|
||||
title: media.title,
|
||||
poster: fullLink,
|
||||
artist: media.artist.artistName,
|
||||
overview: media.overview,
|
||||
genres: media.genres,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RadarrMediaDisplay(props: any) {
|
||||
const { media }: { media: any } = props;
|
||||
// Find a poster CoverType
|
||||
const poster = media.images.find((image: any) => image.coverType === 'poster');
|
||||
// Return a movie poster containting the title and the description
|
||||
return (
|
||||
<MediaDisplay
|
||||
media={{
|
||||
imdbId: media.imdbId,
|
||||
title: media.title,
|
||||
overview: media.overview,
|
||||
poster: poster.url,
|
||||
genres: media.genres,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SonarrMediaDisplay(props: any) {
|
||||
const { media }: { media: any } = props;
|
||||
// Find a poster CoverType
|
||||
const poster = media.series.images.find((image: any) => image.coverType === 'poster');
|
||||
// Return a movie poster containting the title and the description
|
||||
return (
|
||||
<MediaDisplay
|
||||
media={{
|
||||
imdbId: media.series.imdbId,
|
||||
title: media.series.title,
|
||||
overview: media.series.overview,
|
||||
poster: poster.url,
|
||||
genres: media.series.genres,
|
||||
seasonNumber: media.seasonNumber,
|
||||
episodeNumber: media.episodeNumber,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './MediaDisplay';
|
||||
@@ -1,233 +0,0 @@
|
||||
import { createStyles, useMantineColorScheme, useMantineTheme } from '@mantine/core';
|
||||
import { IconCalendar as CalendarIcon } from '@tabler/icons';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { serviceItem } from '../../../tools/types';
|
||||
import { IModule } from '../modules';
|
||||
|
||||
const asModule = <T extends IModule>(t: T) => t;
|
||||
export const DashdotModule = asModule({
|
||||
title: 'Dash.',
|
||||
description: 'A module for displaying the graphs of your running Dash. instance.',
|
||||
icon: CalendarIcon,
|
||||
component: DashdotComponent,
|
||||
options: {
|
||||
cpuMultiView: {
|
||||
name: 'CPU Multi-Core View',
|
||||
value: false,
|
||||
},
|
||||
storageMultiView: {
|
||||
name: 'Storage Multi-Drive View',
|
||||
value: false,
|
||||
},
|
||||
useCompactView: {
|
||||
name: 'Use Compact View',
|
||||
value: false,
|
||||
},
|
||||
graphs: {
|
||||
name: 'Graphs',
|
||||
value: ['CPU', 'RAM', 'Storage', 'Network'],
|
||||
options: ['CPU', 'RAM', 'Storage', 'Network', 'GPU'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const useStyles = createStyles((theme, _params) => ({
|
||||
heading: {
|
||||
marginTop: 0,
|
||||
marginBottom: 10,
|
||||
},
|
||||
table: {
|
||||
display: 'table',
|
||||
},
|
||||
tableRow: {
|
||||
display: 'table-row',
|
||||
},
|
||||
tableLabel: {
|
||||
display: 'table-cell',
|
||||
paddingRight: 10,
|
||||
},
|
||||
tableValue: {
|
||||
display: 'table-cell',
|
||||
whiteSpace: 'pre-wrap',
|
||||
paddingBottom: 5,
|
||||
},
|
||||
graphsContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
rowGap: 10,
|
||||
columnGap: 10,
|
||||
},
|
||||
iframe: {
|
||||
flex: '1 0 auto',
|
||||
maxWidth: '100%',
|
||||
height: '140px',
|
||||
borderRadius: theme.radius.lg,
|
||||
},
|
||||
}));
|
||||
|
||||
const bpsPrettyPrint = (bits?: number) =>
|
||||
!bits
|
||||
? '-'
|
||||
: bits > 1000 * 1000 * 1000
|
||||
? `${(bits / 1000 / 1000 / 1000).toFixed(1)} Gb/s`
|
||||
: bits > 1000 * 1000
|
||||
? `${(bits / 1000 / 1000).toFixed(1)} Mb/s`
|
||||
: bits > 1000
|
||||
? `${(bits / 1000).toFixed(1)} Kb/s`
|
||||
: `${bits.toFixed(1)} b/s`;
|
||||
|
||||
const bytePrettyPrint = (byte: number): string =>
|
||||
byte > 1024 * 1024 * 1024
|
||||
? `${(byte / 1024 / 1024 / 1024).toFixed(1)} GiB`
|
||||
: byte > 1024 * 1024
|
||||
? `${(byte / 1024 / 1024).toFixed(1)} MiB`
|
||||
: byte > 1024
|
||||
? `${(byte / 1024).toFixed(1)} KiB`
|
||||
: `${byte.toFixed(1)} B`;
|
||||
|
||||
const useJson = (service: serviceItem | undefined, url: string) => {
|
||||
const [data, setData] = useState<any | undefined>();
|
||||
|
||||
const doRequest = async () => {
|
||||
try {
|
||||
const resp = await axios.get(url, { baseURL: service?.url });
|
||||
|
||||
setData(resp.data);
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (service?.url) {
|
||||
doRequest();
|
||||
}
|
||||
}, [service?.url]);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export function DashdotComponent() {
|
||||
const { config } = useConfig();
|
||||
const theme = useMantineTheme();
|
||||
const { classes } = useStyles();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
|
||||
const dashConfig = config.modules?.[DashdotModule.title]
|
||||
.options as typeof DashdotModule['options'];
|
||||
const isCompact = dashConfig?.useCompactView?.value ?? false;
|
||||
const dashdotService = config.services.filter((service) => service.type === 'Dash.')[0];
|
||||
|
||||
const enabledGraphs = dashConfig?.graphs?.value ?? ['CPU', 'RAM', 'Storage', 'Network'];
|
||||
const cpuEnabled = enabledGraphs.includes('CPU');
|
||||
const storageEnabled = enabledGraphs.includes('Storage');
|
||||
const ramEnabled = enabledGraphs.includes('RAM');
|
||||
const networkEnabled = enabledGraphs.includes('Network');
|
||||
const gpuEnabled = enabledGraphs.includes('GPU');
|
||||
|
||||
const info = useJson(dashdotService, '/info');
|
||||
const storageLoad = useJson(dashdotService, '/load/storage');
|
||||
|
||||
const totalUsed =
|
||||
(storageLoad?.layout as any[])?.reduce((acc, curr) => (curr.load ?? 0) + acc, 0) ?? 0;
|
||||
const totalSize =
|
||||
(info?.storage?.layout as any[])?.reduce((acc, curr) => (curr.size ?? 0) + acc, 0) ?? 0;
|
||||
|
||||
const graphs = [
|
||||
{
|
||||
name: 'CPU',
|
||||
enabled: cpuEnabled,
|
||||
params: {
|
||||
multiView: dashConfig?.cpuMultiView?.value ?? false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Storage',
|
||||
enabled: storageEnabled && !isCompact,
|
||||
params: {
|
||||
multiView: dashConfig?.storageMultiView?.value ?? false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'RAM',
|
||||
enabled: ramEnabled,
|
||||
},
|
||||
{
|
||||
name: 'Network',
|
||||
enabled: networkEnabled,
|
||||
spanTwo: true,
|
||||
},
|
||||
{
|
||||
name: 'GPU',
|
||||
enabled: gpuEnabled,
|
||||
spanTwo: true,
|
||||
},
|
||||
].filter((g) => g.enabled);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className={classes.heading}>Dash.</h2>
|
||||
|
||||
{!dashdotService ? (
|
||||
<p>No dash. service found. Please add one to your Homarr dashboard.</p>
|
||||
) : !info ? (
|
||||
<p>Cannot acquire information from dash. - are you running the latest version?</p>
|
||||
) : (
|
||||
<div className={classes.graphsContainer}>
|
||||
<div className={classes.table}>
|
||||
{storageEnabled && isCompact && (
|
||||
<div className={classes.tableRow}>
|
||||
<p className={classes.tableLabel}>Storage:</p>
|
||||
<p className={classes.tableValue}>
|
||||
{((100 * totalUsed) / (totalSize || 1)).toFixed(1)}%{'\n'}
|
||||
{bytePrettyPrint(totalUsed)} / {bytePrettyPrint(totalSize)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{networkEnabled && (
|
||||
<div className={classes.tableRow}>
|
||||
<p className={classes.tableLabel}>Network:</p>
|
||||
<p className={classes.tableValue}>
|
||||
{bpsPrettyPrint(info?.network?.speedUp)} Up{'\n'}
|
||||
{bpsPrettyPrint(info?.network?.speedDown)} Down
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{graphs.map((graph) => (
|
||||
<iframe
|
||||
className={classes.iframe}
|
||||
style={
|
||||
isCompact
|
||||
? {
|
||||
width: graph.spanTwo ? '100%' : 'calc(50% - 5px)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
key={graph.name}
|
||||
title={graph.name}
|
||||
src={`${
|
||||
dashdotService.url
|
||||
}?singleGraphMode=true&graph=${graph.name.toLowerCase()}&theme=${colorScheme}&surface=${(colorScheme ===
|
||||
'dark'
|
||||
? theme.colors.dark[7]
|
||||
: theme.colors.gray[0]
|
||||
).substring(1)}${isCompact ? '&gap=10' : '&gap=5'}&innerRadius=${theme.radius.lg}${
|
||||
graph.params
|
||||
? `&${Object.entries(graph.params)
|
||||
.map(([key, value]) => `${key}=${value.toString()}`)
|
||||
.join('&')}`
|
||||
: ''
|
||||
}`}
|
||||
frameBorder="0"
|
||||
allowTransparency
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { DashdotModule } from './DashdotModule';
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Group, Text, Title } from '@mantine/core';
|
||||
import dayjs from 'dayjs';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IconClock as Clock } from '@tabler/icons';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { IModule } from '../modules';
|
||||
import { useSetSafeInterval } from '../../../tools/hooks/useSetSafeInterval';
|
||||
|
||||
export const DateModule: IModule = {
|
||||
title: 'Date',
|
||||
description: 'Show the current time and date in a card',
|
||||
icon: Clock,
|
||||
component: DateComponent,
|
||||
options: {
|
||||
full: {
|
||||
name: 'Display full time (24-hour)',
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function DateComponent(props: any) {
|
||||
const [date, setDate] = useState(new Date());
|
||||
const setSafeInterval = useSetSafeInterval();
|
||||
const { config } = useConfig();
|
||||
const isFullTime = config?.modules?.[DateModule.title]?.options?.full?.value ?? true;
|
||||
const formatString = isFullTime ? 'HH:mm' : 'h:mm A';
|
||||
// Change date on minute change
|
||||
// Note: Using 10 000ms instead of 1000ms to chill a little :)
|
||||
useEffect(() => {
|
||||
setSafeInterval(() => {
|
||||
setDate(new Date());
|
||||
}, 1000 * 60);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Group p="sm" spacing="xs" direction="column">
|
||||
<Title>{dayjs(date).format(formatString)}</Title>
|
||||
<Text size="xl">{dayjs(date).format('dddd, MMMM D')}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { DateModule } from './DateModule';
|
||||
@@ -1,164 +0,0 @@
|
||||
import { Button, Group, Modal, Title } from '@mantine/core';
|
||||
import { useBooleanToggle } from '@mantine/hooks';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import {
|
||||
IconCheck,
|
||||
IconPlayerPlay,
|
||||
IconPlayerStop,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconRotateClockwise,
|
||||
IconTrash,
|
||||
} from '@tabler/icons';
|
||||
import axios from 'axios';
|
||||
import Dockerode from 'dockerode';
|
||||
import { tryMatchService } from '../../../tools/addToHomarr';
|
||||
import { AddAppShelfItemForm } from '../../AppShelf/AddAppShelfItem';
|
||||
|
||||
function sendDockerCommand(
|
||||
action: string,
|
||||
containerId: string,
|
||||
containerName: string,
|
||||
reload: () => void
|
||||
) {
|
||||
showNotification({
|
||||
id: containerId,
|
||||
loading: true,
|
||||
title: `${action}ing container ${containerName}`,
|
||||
message: undefined,
|
||||
autoClose: false,
|
||||
disallowClose: true,
|
||||
});
|
||||
axios
|
||||
.get(`/api/docker/container/${containerId}?action=${action}`)
|
||||
.then((res) => {
|
||||
updateNotification({
|
||||
id: containerId,
|
||||
title: `Container ${containerName} ${action}ed`,
|
||||
message: `Your container was successfully ${action}ed`,
|
||||
icon: <IconCheck />,
|
||||
autoClose: 2000,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
updateNotification({
|
||||
id: containerId,
|
||||
color: 'red',
|
||||
title: 'There was an error',
|
||||
message: err.response.data.reason,
|
||||
autoClose: 2000,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
reload();
|
||||
});
|
||||
}
|
||||
|
||||
export interface ContainerActionBarProps {
|
||||
selected: Dockerode.ContainerInfo[];
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) {
|
||||
const [opened, setOpened] = useBooleanToggle(false);
|
||||
return (
|
||||
<Group>
|
||||
<Modal
|
||||
size="xl"
|
||||
radius="md"
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title="Add service"
|
||||
>
|
||||
<AddAppShelfItemForm
|
||||
setOpened={setOpened}
|
||||
{...tryMatchService(selected.at(0))}
|
||||
message="Add service to homarr"
|
||||
/>
|
||||
</Modal>
|
||||
<Button
|
||||
leftIcon={<IconRotateClockwise />}
|
||||
onClick={() =>
|
||||
Promise.all(
|
||||
selected.map((container) =>
|
||||
sendDockerCommand('restart', container.Id, container.Names[0].substring(1), reload)
|
||||
)
|
||||
)
|
||||
}
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="md"
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<IconPlayerStop />}
|
||||
onClick={() =>
|
||||
Promise.all(
|
||||
selected.map((container) =>
|
||||
sendDockerCommand('stop', container.Id, container.Names[0].substring(1), reload)
|
||||
)
|
||||
)
|
||||
}
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<IconPlayerPlay />}
|
||||
onClick={() =>
|
||||
Promise.all(
|
||||
selected.map((container) =>
|
||||
sendDockerCommand('start', container.Id, container.Names[0].substring(1), reload)
|
||||
)
|
||||
)
|
||||
}
|
||||
variant="light"
|
||||
color="green"
|
||||
radius="md"
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
<Button leftIcon={<IconRefresh />} onClick={() => reload()} variant="light" radius="md">
|
||||
Refresh data
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<IconPlus />}
|
||||
color="indigo"
|
||||
variant="light"
|
||||
radius="md"
|
||||
onClick={() => {
|
||||
if (selected.length !== 1) {
|
||||
showNotification({
|
||||
autoClose: 5000,
|
||||
title: <Title order={5}>Please only add one service at a time!</Title>,
|
||||
color: 'red',
|
||||
message: undefined,
|
||||
});
|
||||
} else {
|
||||
setOpened(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add to Homarr
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<IconTrash />}
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
onClick={() =>
|
||||
Promise.all(
|
||||
selected.map((container) =>
|
||||
sendDockerCommand('remove', container.Id, container.Names[0].substring(1), reload)
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Badge, BadgeVariant, MantineSize } from '@mantine/core';
|
||||
import Dockerode from 'dockerode';
|
||||
|
||||
export interface ContainerStateProps {
|
||||
state: Dockerode.ContainerInfo['State'];
|
||||
}
|
||||
|
||||
export default function ContainerState(props: ContainerStateProps) {
|
||||
const { state } = props;
|
||||
const options: {
|
||||
size: MantineSize;
|
||||
radius: MantineSize;
|
||||
variant: BadgeVariant;
|
||||
} = {
|
||||
size: 'md',
|
||||
radius: 'md',
|
||||
variant: 'outline',
|
||||
};
|
||||
switch (state) {
|
||||
case 'running': {
|
||||
return (
|
||||
<Badge color="green" {...options}>
|
||||
Running
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
case 'created': {
|
||||
return (
|
||||
<Badge color="cyan" {...options}>
|
||||
Created
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
case 'exited': {
|
||||
return (
|
||||
<Badge color="red" {...options}>
|
||||
Stopped
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return (
|
||||
<Badge color="purple" {...options}>
|
||||
Unknown
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { ActionIcon, Drawer, Group, LoadingOverlay, Text } from '@mantine/core';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Docker from 'dockerode';
|
||||
import { IconBrandDocker, IconX } from '@tabler/icons';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import ContainerActionBar from './ContainerActionBar';
|
||||
import DockerTable from './DockerTable';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { IModule } from '../modules';
|
||||
|
||||
export const DockerModule: IModule = {
|
||||
title: 'Docker',
|
||||
description: 'Allows you to easily manage your torrents',
|
||||
icon: IconBrandDocker,
|
||||
component: DockerMenuButton,
|
||||
};
|
||||
|
||||
export default function DockerMenuButton(props: any) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [containers, setContainers] = useState<Docker.ContainerInfo[]>([]);
|
||||
const [selection, setSelection] = useState<Docker.ContainerInfo[]>([]);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { config } = useConfig();
|
||||
const moduleEnabled = config.modules?.[DockerModule.title]?.enabled ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [config.modules]);
|
||||
|
||||
function reload() {
|
||||
if (!moduleEnabled) {
|
||||
return;
|
||||
}
|
||||
setVisible(true);
|
||||
setTimeout(() => {
|
||||
axios
|
||||
.get('/api/docker/containers')
|
||||
.then((res) => {
|
||||
setContainers(res.data);
|
||||
setSelection([]);
|
||||
setVisible(false);
|
||||
})
|
||||
.catch(() =>
|
||||
// Send an Error notification
|
||||
showNotification({
|
||||
autoClose: 1500,
|
||||
title: <Text>Docker integration failed</Text>,
|
||||
color: 'red',
|
||||
icon: <IconX />,
|
||||
message: 'Did you forget to mount the docker socket ?',
|
||||
})
|
||||
);
|
||||
}, 300);
|
||||
}
|
||||
const exists = config.modules?.[DockerModule.title]?.enabled ?? false;
|
||||
if (!exists) {
|
||||
return null;
|
||||
}
|
||||
// Check if the user has at least one container
|
||||
if (containers.length < 1) return null;
|
||||
return (
|
||||
<>
|
||||
<Drawer opened={opened} onClose={() => setOpened(false)} padding="xl" size="full">
|
||||
<ContainerActionBar selected={selection} reload={reload} />
|
||||
<div style={{ position: 'relative' }}>
|
||||
<LoadingOverlay transitionDuration={500} visible={visible} />
|
||||
<DockerTable containers={containers} selection={selection} setSelection={setSelection} />
|
||||
</div>
|
||||
</Drawer>
|
||||
<Group position="center">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="xl"
|
||||
color="blue"
|
||||
onClick={() => setOpened(true)}
|
||||
>
|
||||
<IconBrandDocker />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { Table, Checkbox, Group, Badge, createStyles, ScrollArea, TextInput } from '@mantine/core';
|
||||
import { IconSearch } from '@tabler/icons';
|
||||
import Dockerode from 'dockerode';
|
||||
import { useEffect, useState } from 'react';
|
||||
import ContainerState from './ContainerState';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
rowSelected: {
|
||||
backgroundColor:
|
||||
theme.colorScheme === 'dark'
|
||||
? theme.fn.rgba(theme.colors[theme.primaryColor][7], 0.2)
|
||||
: theme.colors[theme.primaryColor][0],
|
||||
},
|
||||
}));
|
||||
|
||||
export default function DockerTable({
|
||||
containers,
|
||||
selection,
|
||||
setSelection,
|
||||
}: {
|
||||
setSelection: any;
|
||||
containers: Dockerode.ContainerInfo[];
|
||||
selection: Dockerode.ContainerInfo[];
|
||||
}) {
|
||||
const [usedContainers, setContainers] = useState<Dockerode.ContainerInfo[]>(containers);
|
||||
const { classes, cx } = useStyles();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setContainers(containers);
|
||||
}, [containers]);
|
||||
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.currentTarget;
|
||||
setSearch(value);
|
||||
setContainers(filterContainers(containers, value));
|
||||
};
|
||||
|
||||
function filterContainers(data: Dockerode.ContainerInfo[], search: string) {
|
||||
const query = search.toLowerCase().trim();
|
||||
return data.filter((item) =>
|
||||
item.Names.some((name) => name.toLowerCase().includes(query) || item.Image.includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
const toggleRow = (container: Dockerode.ContainerInfo) =>
|
||||
setSelection((current: Dockerode.ContainerInfo[]) =>
|
||||
current.includes(container) ? current.filter((c) => c !== container) : [...current, container]
|
||||
);
|
||||
const toggleAll = () =>
|
||||
setSelection((current: any) =>
|
||||
current.length === usedContainers.length ? [] : usedContainers.map((c) => c)
|
||||
);
|
||||
|
||||
const rows = usedContainers.map((element) => {
|
||||
const selected = selection.includes(element);
|
||||
return (
|
||||
<tr key={element.Id} className={cx({ [classes.rowSelected]: selected })}>
|
||||
<td>
|
||||
<Checkbox
|
||||
checked={selection.includes(element)}
|
||||
onChange={() => toggleRow(element)}
|
||||
transitionDuration={0}
|
||||
/>
|
||||
</td>
|
||||
<td>{element.Names[0].replace('/', '')}</td>
|
||||
<td>{element.Image}</td>
|
||||
<td>
|
||||
<Group>
|
||||
{element.Ports.sort((a, b) => a.PrivatePort - b.PrivatePort)
|
||||
// Remove duplicates with filter function
|
||||
.filter(
|
||||
(port, index, self) =>
|
||||
index === self.findIndex((t) => t.PrivatePort === port.PrivatePort)
|
||||
)
|
||||
.slice(-3)
|
||||
.map((port) => (
|
||||
<Badge key={port.PrivatePort} variant="outline">
|
||||
{port.PrivatePort}:{port.PublicPort}
|
||||
</Badge>
|
||||
))}
|
||||
{element.Ports.length > 3 && (
|
||||
<Badge variant="filled">{element.Ports.length - 3} more</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</td>
|
||||
<td>
|
||||
<ContainerState state={element.State} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollArea style={{ height: '80vh' }}>
|
||||
<TextInput
|
||||
placeholder="Search by container or image name"
|
||||
mt="md"
|
||||
icon={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
<Table captionSide="bottom" highlightOnHover sx={{ minWidth: 800 }} verticalSpacing="sm">
|
||||
<caption>your docker containers</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 40 }}>
|
||||
<Checkbox
|
||||
onChange={toggleAll}
|
||||
checked={selection.length === usedContainers.length}
|
||||
indeterminate={selection.length > 0 && selection.length !== usedContainers.length}
|
||||
transitionDuration={0}
|
||||
/>
|
||||
</th>
|
||||
<th>Name</th>
|
||||
<th>Image</th>
|
||||
<th>Ports</th>
|
||||
<th>State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { DockerModule } from './DockerModule';
|
||||
@@ -1,209 +0,0 @@
|
||||
import {
|
||||
Table,
|
||||
Text,
|
||||
Tooltip,
|
||||
Title,
|
||||
Group,
|
||||
Progress,
|
||||
Skeleton,
|
||||
ScrollArea,
|
||||
Center,
|
||||
Image,
|
||||
} from '@mantine/core';
|
||||
import { IconDownload as Download } from '@tabler/icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { NormalizedTorrent } from '@ctrl/shared-torrent';
|
||||
import { useViewportSize } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IModule } from '../modules';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { AddItemShelfButton } from '../../AppShelf/AddAppShelfItem';
|
||||
import { useSetSafeInterval } from '../../../tools/hooks/useSetSafeInterval';
|
||||
import { humanFileSize } from '../../../tools/humanFileSize';
|
||||
|
||||
export const DownloadsModule: IModule = {
|
||||
title: 'Torrent',
|
||||
description: 'Show the current download speed of supported services',
|
||||
icon: Download,
|
||||
component: DownloadComponent,
|
||||
options: {
|
||||
hidecomplete: {
|
||||
name: 'Hide completed torrents',
|
||||
value: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function DownloadComponent() {
|
||||
const { config } = useConfig();
|
||||
const { height, width } = useViewportSize();
|
||||
const downloadServices =
|
||||
config.services.filter(
|
||||
(service) =>
|
||||
service.type === 'qBittorrent' ||
|
||||
service.type === 'Transmission' ||
|
||||
service.type === 'Deluge'
|
||||
) ?? [];
|
||||
const hideComplete: boolean =
|
||||
(config?.modules?.[DownloadsModule.title]?.options?.hidecomplete?.value as boolean) ?? false;
|
||||
const [torrents, setTorrents] = useState<NormalizedTorrent[]>([]);
|
||||
const setSafeInterval = useSetSafeInterval();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
if (downloadServices.length === 0) return;
|
||||
const interval = setInterval(() => {
|
||||
// Send one request with each download service inside
|
||||
axios
|
||||
.post('/api/modules/downloads')
|
||||
.then((response) => {
|
||||
setTorrents(response.data);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
setTorrents([]);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error while fetching torrents', error.response.data);
|
||||
setIsLoading(false);
|
||||
showNotification({
|
||||
title: 'Error fetching torrents',
|
||||
autoClose: 1000,
|
||||
disallowClose: true,
|
||||
id: 'fail-torrent-downloads-module',
|
||||
color: 'red',
|
||||
message:
|
||||
'Please check your config for any potential errors, check the console for more info',
|
||||
});
|
||||
clearInterval(interval);
|
||||
});
|
||||
}, 5000);
|
||||
}, []);
|
||||
|
||||
if (downloadServices.length === 0) {
|
||||
return (
|
||||
<Group direction="column">
|
||||
<Title order={3}>No supported download clients found!</Title>
|
||||
<Group>
|
||||
<Text>Add a download service to view your current downloads...</Text>
|
||||
<AddItemShelfButton />
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Skeleton height={40} mt={10} />
|
||||
<Skeleton height={40} mt={10} />
|
||||
<Skeleton height={40} mt={10} />
|
||||
<Skeleton height={40} mt={10} />
|
||||
<Skeleton height={40} mt={10} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
const DEVICE_WIDTH = 576;
|
||||
const ths = (
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
{width > 576 ? <th>Down</th> : ''}
|
||||
{width > 576 ? <th>Up</th> : ''}
|
||||
<th>ETA</th>
|
||||
<th>Progress</th>
|
||||
</tr>
|
||||
);
|
||||
// Convert Seconds to readable format.
|
||||
function calculateETA(givenSeconds: number) {
|
||||
// If its superior than one day return > 1 day
|
||||
if (givenSeconds > 86400) {
|
||||
return '> 1 day';
|
||||
}
|
||||
// Transform the givenSeconds into a readable format. e.g. 1h 2m 3s
|
||||
const hours = Math.floor(givenSeconds / 3600);
|
||||
const minutes = Math.floor((givenSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(givenSeconds % 60);
|
||||
// Only show hours if it's greater than 0.
|
||||
const hoursString = hours > 0 ? `${hours}h ` : '';
|
||||
const minutesString = minutes > 0 ? `${minutes}m ` : '';
|
||||
const secondsString = seconds > 0 ? `${seconds}s` : '';
|
||||
return `${hoursString}${minutesString}${secondsString}`;
|
||||
}
|
||||
// Loop over qBittorrent torrents merging with deluge torrents
|
||||
const rows = torrents
|
||||
.filter((torrent) => !(torrent.progress === 1 && hideComplete))
|
||||
.map((torrent) => {
|
||||
const downloadSpeed = torrent.downloadSpeed / 1024 / 1024;
|
||||
const uploadSpeed = torrent.uploadSpeed / 1024 / 1024;
|
||||
const size = torrent.totalSelected;
|
||||
return (
|
||||
<tr key={torrent.id}>
|
||||
<td>
|
||||
<Tooltip position="top" label={torrent.name}>
|
||||
<Text
|
||||
style={{
|
||||
maxWidth: '30vw',
|
||||
}}
|
||||
size="xs"
|
||||
lineClamp={1}
|
||||
>
|
||||
{torrent.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="xs">{humanFileSize(size)}</Text>
|
||||
</td>
|
||||
{width > 576 ? (
|
||||
<td>
|
||||
<Text size="xs">{downloadSpeed > 0 ? `${downloadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
|
||||
</td>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
{width > 576 ? (
|
||||
<td>
|
||||
<Text size="xs">{uploadSpeed > 0 ? `${uploadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
|
||||
</td>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<td>
|
||||
<Text size="xs">{torrent.eta <= 0 ? '∞' : calculateETA(torrent.eta)}</Text>
|
||||
</td>
|
||||
<td>
|
||||
<Text>{(torrent.progress * 100).toFixed(1)}%</Text>
|
||||
<Progress
|
||||
radius="lg"
|
||||
color={
|
||||
torrent.progress === 1 ? 'green' : torrent.state === 'paused' ? 'yellow' : 'blue'
|
||||
}
|
||||
value={torrent.progress * 100}
|
||||
size="lg"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
const easteregg = (
|
||||
<Center style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Image fit="cover" height={300} src="https://danjohnvelasco.github.io/images/empty.png" />
|
||||
</Center>
|
||||
);
|
||||
return (
|
||||
<Group noWrap grow direction="column" mt="xl">
|
||||
<ScrollArea sx={{ height: 300 }}>
|
||||
{rows.length > 0 ? (
|
||||
<Table highlightOnHover>
|
||||
<thead>{ths}</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</Table>
|
||||
) : (
|
||||
easteregg
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import { Text, Title, Group, useMantineTheme, Box, Card, ColorSwatch } from '@mantine/core';
|
||||
import { IconDownload as Download } from '@tabler/icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { NormalizedTorrent } from '@ctrl/shared-torrent';
|
||||
import { linearGradientDef } from '@nivo/core';
|
||||
import { Datum, ResponsiveLine } from '@nivo/line';
|
||||
import { useListState } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { AddItemShelfButton } from '../../AppShelf/AddAppShelfItem';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { humanFileSize } from '../../../tools/humanFileSize';
|
||||
import { IModule } from '../modules';
|
||||
import { useSetSafeInterval } from '../../../tools/hooks/useSetSafeInterval';
|
||||
|
||||
export const TotalDownloadsModule: IModule = {
|
||||
title: 'Download Speed',
|
||||
description: 'Show the current download speed of supported services',
|
||||
icon: Download,
|
||||
component: TotalDownloadsComponent,
|
||||
};
|
||||
|
||||
interface torrentHistory {
|
||||
x: number;
|
||||
up: number;
|
||||
down: number;
|
||||
}
|
||||
|
||||
export default function TotalDownloadsComponent() {
|
||||
const setSafeInterval = useSetSafeInterval();
|
||||
const { config } = useConfig();
|
||||
const downloadServices =
|
||||
config.services.filter(
|
||||
(service) =>
|
||||
service.type === 'qBittorrent' ||
|
||||
service.type === 'Transmission' ||
|
||||
service.type === 'Deluge'
|
||||
) ?? [];
|
||||
|
||||
const [torrentHistory, torrentHistoryHandlers] = useListState<torrentHistory>([]);
|
||||
const [torrents, setTorrents] = useState<NormalizedTorrent[]>([]);
|
||||
|
||||
const totalDownloadSpeed = torrents.reduce((acc, torrent) => acc + torrent.downloadSpeed, 0);
|
||||
const totalUploadSpeed = torrents.reduce((acc, torrent) => acc + torrent.uploadSpeed, 0);
|
||||
useEffect(() => {
|
||||
const interval = setSafeInterval(() => {
|
||||
// Send one request with each download service inside
|
||||
axios
|
||||
.post('/api/modules/downloads')
|
||||
.then((response) => {
|
||||
setTorrents(response.data);
|
||||
})
|
||||
.catch((error) => {
|
||||
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);
|
||||
}, [config.services]);
|
||||
|
||||
useEffect(() => {
|
||||
torrentHistoryHandlers.append({
|
||||
x: Date.now(),
|
||||
down: totalDownloadSpeed,
|
||||
up: totalUploadSpeed,
|
||||
});
|
||||
}, [totalDownloadSpeed, totalUploadSpeed]);
|
||||
|
||||
if (downloadServices.length === 0) {
|
||||
return (
|
||||
<Group direction="column">
|
||||
<Title order={4}>No supported download clients found!</Title>
|
||||
<Group noWrap>
|
||||
<Text>Add a download service to view your current downloads...</Text>
|
||||
<AddItemShelfButton />
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const theme = useMantineTheme();
|
||||
// Load the last 10 values from the history
|
||||
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 (
|
||||
<Group noWrap direction="column" grow>
|
||||
<Title order={4}>Current download speed</Title>
|
||||
<Group direction="column">
|
||||
<Group>
|
||||
<ColorSwatch size={12} color={theme.colors.green[5]} />
|
||||
<Text>Download: {humanFileSize(totalDownloadSpeed)}/s</Text>
|
||||
</Group>
|
||||
<Group>
|
||||
<ColorSwatch size={12} color={theme.colors.blue[5]} />
|
||||
<Text>Upload: {humanFileSize(totalUploadSpeed)}/s</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<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">{roundedSeconds} seconds ago</Text>
|
||||
<Card.Section p="sm">
|
||||
<Group direction="column">
|
||||
<Group>
|
||||
<ColorSwatch size={10} color={theme.colors.green[5]} />
|
||||
<Text size="md">Download: {humanFileSize(Download)}</Text>
|
||||
</Group>
|
||||
<Group>
|
||||
<ColorSwatch size={10} color={theme.colors.blue[5]} />
|
||||
<Text size="md">Upload: {humanFileSize(Upload)}</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</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={[
|
||||
// Blue
|
||||
theme.colors.blue[5],
|
||||
// Green
|
||||
theme.colors.green[5],
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { DownloadsModule } from './DownloadsModule';
|
||||
export { TotalDownloadsModule } from './TotalDownloadsModule';
|
||||
@@ -1,8 +0,0 @@
|
||||
export * from './calendar';
|
||||
export * from './dashdot';
|
||||
export * from './date';
|
||||
export * from './downloads';
|
||||
export * from './ping';
|
||||
export * from './search';
|
||||
export * from './weather';
|
||||
export * from './docker';
|
||||
@@ -1,209 +0,0 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Menu,
|
||||
MultiSelect,
|
||||
Switch,
|
||||
TextInput,
|
||||
useMantineColorScheme,
|
||||
} from '@mantine/core';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { IModule } from './modules';
|
||||
|
||||
function getItems(module: IModule) {
|
||||
const { config, setConfig } = useConfig();
|
||||
const items: JSX.Element[] = [];
|
||||
if (module.options) {
|
||||
const keys = Object.keys(module.options);
|
||||
const values = Object.values(module.options);
|
||||
// Get the value and the name of the option
|
||||
const types = values.map((v) => typeof v.value);
|
||||
// Loop over all the types with a for each loop
|
||||
types.forEach((type, index) => {
|
||||
const optionName = `${module.title}.${keys[index]}`;
|
||||
const moduleInConfig = config.modules?.[module.title];
|
||||
if (type === 'object') {
|
||||
items.push(
|
||||
<MultiSelect
|
||||
label={module.options?.[keys[index]].name}
|
||||
data={module.options?.[keys[index]].options ?? []}
|
||||
defaultValue={
|
||||
(moduleInConfig?.options?.[keys[index]]?.value as string[]) ??
|
||||
(values[index].value as string[]) ??
|
||||
[]
|
||||
}
|
||||
searchable
|
||||
onChange={(value) => {
|
||||
setConfig({
|
||||
...config,
|
||||
modules: {
|
||||
...config.modules,
|
||||
[module.title]: {
|
||||
...moduleInConfig,
|
||||
options: {
|
||||
...moduleInConfig?.options,
|
||||
[keys[index]]: {
|
||||
...moduleInConfig?.options?.[keys[index]],
|
||||
value,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (type === 'string') {
|
||||
items.push(
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setConfig({
|
||||
...config,
|
||||
modules: {
|
||||
...config.modules,
|
||||
[module.title]: {
|
||||
...config.modules[module.title],
|
||||
options: {
|
||||
...config.modules[module.title].options,
|
||||
[keys[index]]: {
|
||||
...config.modules[module.title].options?.[keys[index]],
|
||||
value: (e.target as any)[0].value,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Group noWrap align="end" position="center" mt={0}>
|
||||
<TextInput
|
||||
key={optionName}
|
||||
id={optionName}
|
||||
name={optionName}
|
||||
label={values[index].name}
|
||||
defaultValue={
|
||||
(moduleInConfig?.options?.[keys[index]]?.value as string) ??
|
||||
(values[index].value as string) ??
|
||||
''
|
||||
}
|
||||
onChange={(e) => {}}
|
||||
/>
|
||||
|
||||
<Button type="submit">Save</Button>
|
||||
</Group>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
// TODO: Add support for other types
|
||||
if (type === 'boolean') {
|
||||
items.push(
|
||||
<Switch
|
||||
defaultChecked={
|
||||
// Set default checked to the value of the option if it exists
|
||||
(moduleInConfig?.options?.[keys[index]]?.value as boolean) ??
|
||||
(values[index].value as boolean) ??
|
||||
false
|
||||
}
|
||||
key={keys[index]}
|
||||
onClick={(e) => {
|
||||
setConfig({
|
||||
...config,
|
||||
modules: {
|
||||
...config.modules,
|
||||
[module.title]: {
|
||||
...config.modules[module.title],
|
||||
options: {
|
||||
...config.modules[module.title].options,
|
||||
[keys[index]]: {
|
||||
...config.modules[module.title].options?.[keys[index]],
|
||||
value: e.currentTarget.checked,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
label={values[index].name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function ModuleWrapper(props: any) {
|
||||
const { module }: { module: IModule } = props;
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const { config, setConfig } = useConfig();
|
||||
const enabledModules = config.modules ?? {};
|
||||
// Remove 'Module' from enabled modules titles
|
||||
const isShown = enabledModules[module.title]?.enabled ?? false;
|
||||
|
||||
if (!isShown) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
{...props}
|
||||
hidden={!isShown}
|
||||
withBorder
|
||||
radius="lg"
|
||||
shadow="sm"
|
||||
style={{
|
||||
background: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '255, 255, 255,'} \
|
||||
${(config.settings.appOpacity || 100) / 100}`,
|
||||
borderColor: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '233, 236, 239,'} \
|
||||
${(config.settings.appOpacity || 100) / 100}`,
|
||||
}}
|
||||
>
|
||||
<ModuleMenu
|
||||
module={module}
|
||||
styles={{
|
||||
root: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<module.component />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModuleMenu(props: any) {
|
||||
const { module, styles } = props;
|
||||
const items: JSX.Element[] = getItems(module);
|
||||
return (
|
||||
<>
|
||||
{module.options && (
|
||||
<Menu
|
||||
size="lg"
|
||||
shadow="xl"
|
||||
closeOnItemClick={false}
|
||||
radius="md"
|
||||
position="left"
|
||||
styles={{
|
||||
root: {
|
||||
...props?.styles?.root,
|
||||
},
|
||||
body: {
|
||||
// Add shadow and elevation to the body
|
||||
boxShadow: '0 0 14px 14px rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Menu.Label>Settings</Menu.Label>
|
||||
{items.map((item) => (
|
||||
<Menu.Item key={item.key}>{item}</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// This interface is to be used in all the modules of the project
|
||||
// Each module should have its own interface and call the following function:
|
||||
// TODO: Add a function to register a module
|
||||
|
||||
import { TablerIcon } from '@tabler/icons';
|
||||
|
||||
// Note: Maybe use context to keep track of the modules
|
||||
export interface IModule {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: TablerIcon;
|
||||
component: React.ComponentType;
|
||||
options?: Option;
|
||||
}
|
||||
|
||||
interface Option {
|
||||
[x: string]: OptionValues;
|
||||
}
|
||||
|
||||
export interface OptionValues {
|
||||
name: string;
|
||||
value: boolean | string | string[];
|
||||
options?: string[];
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { Indicator, Tooltip } from '@mantine/core';
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IconPlug as Plug } from '@tabler/icons';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { IModule } from '../modules';
|
||||
|
||||
export const PingModule: IModule = {
|
||||
title: 'Ping Services',
|
||||
description: 'Pings your services and shows their status as an indicator',
|
||||
icon: Plug,
|
||||
component: PingComponent,
|
||||
};
|
||||
|
||||
export default function PingComponent(props: any) {
|
||||
type State = 'loading' | 'down' | 'online';
|
||||
const { config } = useConfig();
|
||||
|
||||
const { url }: { url: string } = props;
|
||||
const [isOnline, setOnline] = useState<State>('loading');
|
||||
const [response, setResponse] = useState(500);
|
||||
const exists = config.modules?.[PingModule.title]?.enabled ?? false;
|
||||
|
||||
function statusCheck(response: AxiosResponse) {
|
||||
const { status }: { status: string[] } = props;
|
||||
//Default Status
|
||||
let acceptableStatus = ['200'];
|
||||
if (status !== undefined && status.length) {
|
||||
acceptableStatus = status;
|
||||
}
|
||||
// Checks if reported status is in acceptable status array
|
||||
if (acceptableStatus.indexOf(response.status.toString()) >= 0) {
|
||||
setOnline('online');
|
||||
setResponse(response.status);
|
||||
} else {
|
||||
setOnline('down');
|
||||
setResponse(response.status);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!exists) {
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.get('/api/modules/ping', { params: { url } })
|
||||
.then((response) => {
|
||||
statusCheck(response);
|
||||
})
|
||||
.catch((error) => {
|
||||
statusCheck(error.response);
|
||||
});
|
||||
}, [config.modules?.[PingModule.title]?.enabled]);
|
||||
if (!exists) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Tooltip
|
||||
radius="lg"
|
||||
style={{ position: 'absolute', bottom: 20, right: 20 }}
|
||||
label={
|
||||
isOnline === 'loading'
|
||||
? 'Loading...'
|
||||
: isOnline === 'online'
|
||||
? `Online - ${response}`
|
||||
: `Offline - ${response}`
|
||||
}
|
||||
>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: isOnline === 'online' ? [1, 0.8, 1] : 1,
|
||||
}}
|
||||
transition={{ repeat: Infinity, duration: 2.5, ease: 'easeInOut' }}
|
||||
>
|
||||
<Indicator
|
||||
size={13}
|
||||
color={isOnline === 'online' ? 'green' : isOnline === 'down' ? 'red' : 'yellow'}
|
||||
>
|
||||
{null}
|
||||
</Indicator>
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { PingModule } from './PingModule';
|
||||
@@ -1,9 +0,0 @@
|
||||
**Each module has a set of rules:**
|
||||
- Exported Typed IModule element (Unique Name, description, component, ...)
|
||||
- Needs to be in a new folder
|
||||
- Needs to be exported in the modules/newmodule/index.tsx of the new folder
|
||||
- Needs to be imported in the modules/index.tsx file
|
||||
- Needs to look good when wrapped with the modules/ModuleWrapper component
|
||||
- Needs to be put somewhere fitting in the app (While waiting for the big AppStore overhall)
|
||||
- Any API Calls need to be safe and done on the widget itself (via useEffect or similar)
|
||||
- You can't add a package (unless there is a very specific need for it. Contact [@Ajnart](ajnart@pm.me) or make a [Discussion](https://github.com/ajnart/homarr/discussions/new).
|
||||
@@ -1,127 +0,0 @@
|
||||
import { Kbd, createStyles, Autocomplete } from '@mantine/core';
|
||||
import { useDebouncedValue, useForm, useHotkeys } from '@mantine/hooks';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
IconSearch as Search,
|
||||
IconBrandYoutube as BrandYoutube,
|
||||
IconDownload as Download,
|
||||
} from '@tabler/icons';
|
||||
import axios from 'axios';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { IModule } from '../modules';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
hide: {
|
||||
[theme.fn.smallerThan('sm')]: {
|
||||
display: 'none',
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
}));
|
||||
|
||||
export const SearchModule: IModule = {
|
||||
title: 'Search Bar',
|
||||
description: 'Show the current time and date in a card',
|
||||
icon: Search,
|
||||
component: SearchBar,
|
||||
};
|
||||
|
||||
export default function SearchBar(props: any) {
|
||||
const { config, setConfig } = useConfig();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [icon, setIcon] = useState(<Search />);
|
||||
const queryUrl = config.settings.searchUrl ?? 'https://www.google.com/search?q=';
|
||||
const textInput = useRef<HTMLInputElement>();
|
||||
// Find a service with the type of 'Overseerr'
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
query: '',
|
||||
},
|
||||
});
|
||||
|
||||
const [debounced, cancel] = useDebouncedValue(form.values.query, 250);
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
if (form.values.query !== debounced || form.values.query === '') return;
|
||||
axios
|
||||
.get(`/api/modules/search?q=${form.values.query}`)
|
||||
.then((res) => setResults(res.data ?? []));
|
||||
}, [debounced]);
|
||||
useHotkeys([['ctrl+K', () => textInput.current && textInput.current.focus()]]);
|
||||
const { classes, cx } = useStyles();
|
||||
const rightSection = (
|
||||
<div className={classes.hide}>
|
||||
<Kbd>Ctrl</Kbd>
|
||||
<span style={{ margin: '0 5px' }}>+</span>
|
||||
<Kbd>K</Kbd>
|
||||
</div>
|
||||
);
|
||||
|
||||
// If enabled modules doesn't contain the module, return null
|
||||
// If module in enabled
|
||||
|
||||
const exists = config.modules?.[SearchModule.title]?.enabled ?? false;
|
||||
if (!exists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const autocompleteData = results.map((result) => ({
|
||||
label: result.phrase,
|
||||
value: result.phrase,
|
||||
}));
|
||||
return (
|
||||
<form
|
||||
onChange={() => {
|
||||
// If query contains !yt or !t add "Searching on YouTube" or "Searching torrent"
|
||||
const query = form.values.query.trim();
|
||||
const isYoutube = query.startsWith('!yt');
|
||||
const isTorrent = query.startsWith('!t');
|
||||
if (isYoutube) {
|
||||
setIcon(<BrandYoutube size={22} />);
|
||||
} else if (isTorrent) {
|
||||
setIcon(<Download size={22} />);
|
||||
} else {
|
||||
setIcon(<Search size={22} />);
|
||||
}
|
||||
}}
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
const query = values.query.trim();
|
||||
const isYoutube = query.startsWith('!yt');
|
||||
const isTorrent = query.startsWith('!t');
|
||||
form.setValues({ query: '' });
|
||||
setTimeout(() => {
|
||||
if (isYoutube) {
|
||||
window.open(`https://www.youtube.com/results?search_query=${query.substring(3)}`);
|
||||
} else if (isTorrent) {
|
||||
window.open(`https://bitsearch.to/search?q=${query.substring(3)}`);
|
||||
} else {
|
||||
window.open(
|
||||
`${
|
||||
queryUrl.includes('%s')
|
||||
? queryUrl.replace('%s', values.query)
|
||||
: queryUrl + values.query
|
||||
}`
|
||||
);
|
||||
}
|
||||
}, 20);
|
||||
})}
|
||||
>
|
||||
<Autocomplete
|
||||
autoFocus
|
||||
variant="filled"
|
||||
data={autocompleteData}
|
||||
icon={icon}
|
||||
ref={textInput}
|
||||
rightSectionWidth={90}
|
||||
rightSection={rightSection}
|
||||
radius="md"
|
||||
size="md"
|
||||
styles={{ rightSection: { pointerEvents: 'none' } }}
|
||||
placeholder="Search the web..."
|
||||
{...props}
|
||||
{...form.getInputProps('query')}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { SearchModule } from './SearchModule';
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Center, Group, RingProgress, Title, useMantineTheme } from '@mantine/core';
|
||||
import { IconCpu } from '@tabler/icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import si from 'systeminformation';
|
||||
import { useListState } from '@mantine/hooks';
|
||||
import { IModule } from '../modules';
|
||||
import { useSetSafeInterval } from '../../../tools/hooks/useSetSafeInterval';
|
||||
|
||||
export const SystemModule: IModule = {
|
||||
title: 'System info',
|
||||
description: 'Show the current CPU usage and memory usage',
|
||||
icon: IconCpu,
|
||||
component: SystemInfo,
|
||||
};
|
||||
|
||||
interface ApiResponse {
|
||||
cpu: si.Systeminformation.CpuData;
|
||||
os: si.Systeminformation.OsData;
|
||||
memory: si.Systeminformation.MemData;
|
||||
load: si.Systeminformation.CurrentLoadData;
|
||||
}
|
||||
|
||||
export default function SystemInfo(args: any) {
|
||||
const [data, setData] = useState<ApiResponse>();
|
||||
const setSafeInterval = useSetSafeInterval();
|
||||
// Refresh data every second
|
||||
useEffect(() => {
|
||||
setSafeInterval(() => {
|
||||
axios.get('/api/modules/systeminfo').then((res) => setData(res.data));
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
// Update data every time data changes
|
||||
const [cpuLoadHistory, cpuLoadHistoryHandlers] =
|
||||
useListState<si.Systeminformation.CurrentLoadData>([]);
|
||||
|
||||
// useEffect(() => {
|
||||
|
||||
// }, [data]);
|
||||
|
||||
const theme = useMantineTheme();
|
||||
const currentLoad = data?.load?.currentLoad ?? 0;
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<Group p="sm" direction="column" align="center">
|
||||
<Title order={3}>Current CPU load</Title>
|
||||
<RingProgress
|
||||
size={150}
|
||||
label={<Center>{`${currentLoad.toFixed(2)}%`}</Center>}
|
||||
thickness={15}
|
||||
roundCaps
|
||||
sections={[{ value: currentLoad ?? 0, color: 'cyan' }]}
|
||||
/>
|
||||
</Group>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { SystemModule } from './SystemModule';
|
||||
@@ -1,41 +0,0 @@
|
||||
// To parse this data:
|
||||
//
|
||||
// import { Convert, WeatherResponse } from "./file";
|
||||
//
|
||||
// const weatherResponse = Convert.toWeatherResponse(json);
|
||||
//
|
||||
// These functions will throw an error if the JSON doesn't
|
||||
// match the expected interface, even if the JSON is valid.
|
||||
|
||||
export interface WeatherResponse {
|
||||
current_weather: CurrentWeather;
|
||||
utc_offset_seconds: number;
|
||||
latitude: number;
|
||||
elevation: number;
|
||||
longitude: number;
|
||||
generationtime_ms: number;
|
||||
daily_units: DailyUnits;
|
||||
daily: Daily;
|
||||
}
|
||||
|
||||
export interface CurrentWeather {
|
||||
winddirection: number;
|
||||
windspeed: number;
|
||||
time: string;
|
||||
weathercode: number;
|
||||
temperature: number;
|
||||
}
|
||||
|
||||
export interface Daily {
|
||||
temperature_2m_max: number[];
|
||||
time: Date[];
|
||||
temperature_2m_min: number[];
|
||||
weathercode: number[];
|
||||
}
|
||||
|
||||
export interface DailyUnits {
|
||||
temperature_2m_max: string;
|
||||
temperature_2m_min: string;
|
||||
time: string;
|
||||
weathercode: string;
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import { Group, Space, Title, Tooltip, Skeleton } from '@mantine/core';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
IconArrowDownRight as ArrowDownRight,
|
||||
IconArrowUpRight as ArrowUpRight,
|
||||
IconCloud as Cloud,
|
||||
IconCloudFog as CloudFog,
|
||||
IconCloudRain as CloudRain,
|
||||
IconCloudSnow as CloudSnow,
|
||||
IconCloudStorm as CloudStorm,
|
||||
IconQuestionMark as QuestionMark,
|
||||
IconSnowflake as Snowflake,
|
||||
IconSun as Sun,
|
||||
} from '@tabler/icons';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { IModule } from '../modules';
|
||||
import { WeatherResponse } from './WeatherInterface';
|
||||
|
||||
export const WeatherModule: IModule = {
|
||||
title: 'Weather',
|
||||
description: 'Look up the current weather in your location',
|
||||
icon: Sun,
|
||||
component: WeatherComponent,
|
||||
options: {
|
||||
freedomunit: {
|
||||
name: 'Display in Fahrenheit',
|
||||
value: false,
|
||||
},
|
||||
location: {
|
||||
name: 'Current location',
|
||||
value: 'Paris',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 0 Clear sky
|
||||
// 1, 2, 3 Mainly clear, partly cloudy, and overcast
|
||||
// 45, 48 Fog and depositing rime fog
|
||||
// 51, 53, 55 Drizzle: Light, moderate, and dense intensity
|
||||
// 56, 57 Freezing Drizzle: Light and dense intensity
|
||||
// 61, 63, 65 Rain: Slight, moderate and heavy intensity
|
||||
// 66, 67 Freezing Rain: Light and heavy intensity
|
||||
// 71, 73, 75 Snow fall: Slight, moderate, and heavy intensity
|
||||
// 77 Snow grains
|
||||
// 80, 81, 82 Rain showers: Slight, moderate, and violent
|
||||
// 85, 86Snow showers slight and heavy
|
||||
// 95 *Thunderstorm: Slight or moderate
|
||||
// 96, 99 *Thunderstorm with slight and heavy hail
|
||||
export function WeatherIcon(props: any) {
|
||||
const { code } = props;
|
||||
let data: { icon: any; name: string };
|
||||
switch (code) {
|
||||
case 0: {
|
||||
data = { icon: Sun, name: 'Clear' };
|
||||
break;
|
||||
}
|
||||
case 1:
|
||||
case 2:
|
||||
case 3: {
|
||||
data = { icon: Cloud, name: 'Mainly clear' };
|
||||
break;
|
||||
}
|
||||
case 45:
|
||||
case 48: {
|
||||
data = { icon: CloudFog, name: 'Fog' };
|
||||
break;
|
||||
}
|
||||
case 51:
|
||||
case 53:
|
||||
case 55: {
|
||||
data = { icon: Cloud, name: 'Drizzle' };
|
||||
break;
|
||||
}
|
||||
case 56:
|
||||
case 57: {
|
||||
data = { icon: Snowflake, name: 'Freezing drizzle' };
|
||||
break;
|
||||
}
|
||||
case 61:
|
||||
case 63:
|
||||
case 65: {
|
||||
data = { icon: CloudRain, name: 'Rain' };
|
||||
break;
|
||||
}
|
||||
case 66:
|
||||
case 67: {
|
||||
data = { icon: CloudRain, name: 'Freezing rain' };
|
||||
break;
|
||||
}
|
||||
case 71:
|
||||
case 73:
|
||||
case 75: {
|
||||
data = { icon: CloudSnow, name: 'Snow fall' };
|
||||
break;
|
||||
}
|
||||
case 77: {
|
||||
data = { icon: CloudSnow, name: 'Snow grains' };
|
||||
break;
|
||||
}
|
||||
case 80:
|
||||
case 81:
|
||||
case 82: {
|
||||
data = { icon: CloudRain, name: 'Rain showers' };
|
||||
|
||||
break;
|
||||
}
|
||||
case 85:
|
||||
case 86: {
|
||||
data = { icon: CloudSnow, name: 'Snow showers' };
|
||||
break;
|
||||
}
|
||||
case 95: {
|
||||
data = { icon: CloudStorm, name: 'Thunderstorm' };
|
||||
break;
|
||||
}
|
||||
case 96:
|
||||
case 99: {
|
||||
data = { icon: CloudStorm, name: 'Thunderstorm with hail' };
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
data = { icon: QuestionMark, name: 'Unknown' };
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Tooltip label={data.name}>
|
||||
<data.icon size={50} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WeatherComponent(props: any) {
|
||||
// Get location from browser
|
||||
const { config } = useConfig();
|
||||
const [weather, setWeather] = useState({} as WeatherResponse);
|
||||
const cityInput: string =
|
||||
(config?.modules?.[WeatherModule.title]?.options?.location?.value as string) ?? 'Paris';
|
||||
const isFahrenheit: boolean =
|
||||
(config?.modules?.[WeatherModule.title]?.options?.freedomunit?.value as boolean) ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
axios
|
||||
.get(`https://geocoding-api.open-meteo.com/v1/search?name=${cityInput}`)
|
||||
.then((response) => {
|
||||
// Check if results exists
|
||||
const { latitude, longitude } = response.data.results
|
||||
? response.data.results[0]
|
||||
: { latitude: 0, longitude: 0 };
|
||||
axios
|
||||
.get(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min¤t_weather=true&timezone=Europe%2FLondon`
|
||||
)
|
||||
.then((res) => {
|
||||
setWeather(res.data);
|
||||
});
|
||||
});
|
||||
}, [cityInput]);
|
||||
if (!weather.current_weather) {
|
||||
return (
|
||||
<>
|
||||
<Skeleton height={40} width={100} mb="xl" />
|
||||
<Group noWrap direction="row">
|
||||
<Skeleton height={50} circle />
|
||||
<Group>
|
||||
<Skeleton height={25} width={70} mr="lg" />
|
||||
<Skeleton height={25} width={70} />
|
||||
</Group>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
function usePerferedUnit(value: number): string {
|
||||
return isFahrenheit ? `${(value * (9 / 5) + 32).toFixed(1)}°F` : `${value.toFixed(1)}°C`;
|
||||
}
|
||||
return (
|
||||
<Group p="sm" spacing="xs" direction="column">
|
||||
<Title>{usePerferedUnit(weather.current_weather.temperature)}</Title>
|
||||
<Group spacing={0}>
|
||||
<WeatherIcon code={weather.current_weather.weathercode} />
|
||||
<Space mx="sm" />
|
||||
<span>{usePerferedUnit(weather.daily.temperature_2m_max[0])}</span>
|
||||
<ArrowUpRight size={16} style={{ right: 15 }} />
|
||||
<Space mx="sm" />
|
||||
<span>{usePerferedUnit(weather.daily.temperature_2m_min[0])}</span>
|
||||
<ArrowDownRight size={16} />
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { WeatherModule } from './WeatherModule';
|
||||
Reference in New Issue
Block a user