diff --git a/data/constants.ts b/data/constants.ts index 25f52f298cd..8054f1409d2 100644 --- a/data/constants.ts +++ b/data/constants.ts @@ -1,2 +1,2 @@ export const REPO_URL = 'ajnart/homarr'; -export const CURRENT_VERSION = 'v0.7.2'; +export const CURRENT_VERSION = 'v0.8.0'; diff --git a/next.config.js b/next.config.js index 31fc7b641c2..a6c9032b0ae 100644 --- a/next.config.js +++ b/next.config.js @@ -9,8 +9,6 @@ module.exports = withBundleAnalyzer({ eslint: { ignoreDuringBuilds: true, }, - experimental: { - outputStandalone: true, - }, + output: 'standalone', basePath: env.BASE_URL, }); diff --git a/package.json b/package.json index 3c02b406784..6ef4d50e498 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homarr", - "version": "0.7.2", + "version": "0.8.0", "description": "Homarr - A homepage for your server.", "repository": { "type": "git", @@ -43,11 +43,12 @@ "@nivo/line": "^0.79.1", "@tabler/icons": "^1.68.0", "axios": "^0.27.2", - "cookies-next": "^2.0.4", + "cookies-next": "^2.1.1", "dayjs": "^1.11.3", + "dockerode": "^3.3.2", "framer-motion": "^6.3.1", "js-file-download": "^0.4.12", - "next": "12.1.6", + "next": "^12.2.0", "prism-react-renderer": "^1.3.1", "react": "^17.0.1", "react-dom": "^17.0.1", @@ -56,9 +57,10 @@ }, "devDependencies": { "@babel/core": "^7.17.8", - "@next/bundle-analyzer": "^12.1.4", - "@next/eslint-plugin-next": "^12.1.4", + "@next/bundle-analyzer": "^12.2.0", + "@next/eslint-plugin-next": "^12.2.0", "@storybook/react": "^6.5.4", + "@types/dockerode": "^3.3.9", "@types/node": "^17.0.23", "@types/react": "17.0.43", "@types/uuid": "^8.3.4", diff --git a/src/components/AppShelf/AddAppShelfItem.tsx b/src/components/AppShelf/AddAppShelfItem.tsx index 206cb5ee469..c9024d47c7f 100644 --- a/src/components/AppShelf/AddAppShelfItem.tsx +++ b/src/components/AppShelf/AddAppShelfItem.tsx @@ -1,29 +1,30 @@ import { - Modal, + ActionIcon, + Anchor, + Button, Center, Group, - TextInput, Image, - Button, - Select, LoadingOverlay, - ActionIcon, - Tooltip, - Title, - Anchor, - Text, - Tabs, + Modal, MultiSelect, ScrollArea, + Select, Switch, + Tabs, + Text, + TextInput, + Title, + Tooltip, } from '@mantine/core'; import { useForm } from '@mantine/form'; -import { useEffect, useState } from 'react'; +import { useDebouncedValue } from '@mantine/hooks'; import { IconApps as Apps } from '@tabler/icons'; +import { useEffect, useState } from 'react'; import { v4 as uuidv4 } from 'uuid'; -import { useDebouncedValue } from '@mantine/hooks'; import { useConfig } from '../../tools/state'; import { ServiceTypeList, StatusCodes } from '../../tools/types'; +import Tip from '../layout/Tip'; export function AddItemShelfButton(props: any) { const [opened, setOpened] = useState(false); @@ -58,7 +59,8 @@ function MatchIcon(name: string, form: any) { fetch( `https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/${name .replace(/\s+/g, '-') - .toLowerCase()}.png` + .toLowerCase() + .replace(/^dash\.$/, 'dashdot')}.png` ).then((res) => { if (res.ok) { form.setFieldValue('icon', res.url); @@ -81,9 +83,10 @@ function MatchPort(name: string, form: any) { { name: 'sonarr', value: '8989' }, { name: 'radarr', value: '7878' }, { name: 'lidarr', value: '8686' }, - { name: 'readarr', value: '8686' }, + { name: 'readarr', value: '8787' }, { name: 'deluge', value: '8112' }, { name: 'transmission', value: '9091' }, + { name: 'dash.', value: '3001' }, ]; // Match name with portmap key const port = portmap.find((p) => p.name === name.toLowerCase()); @@ -92,6 +95,8 @@ function MatchPort(name: string, form: any) { } } +const DEFAULT_ICON = '/favicon.svg'; + export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & any) { const { setOpened } = props; const { config, setConfig } = useConfig(); @@ -111,7 +116,7 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & type: props.type ?? 'Other', category: props.category ?? undefined, name: props.name ?? '', - icon: props.icon ?? '/favicon.svg', + icon: props.icon ?? DEFAULT_ICON, url: props.url ?? '', apiKey: props.apiKey ?? (undefined as unknown as string), username: props.username ?? (undefined as unknown as string), @@ -146,7 +151,7 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & const [debounced, cancel] = useDebouncedValue(form.values.name, 250); useEffect(() => { - if (form.values.name !== debounced || props.name || props.type) return; + if (form.values.name !== debounced || form.values.icon !== DEFAULT_ICON) return; MatchIcon(form.values.name, form); MatchService(form.values.name, form); MatchPort(form.values.name, form); @@ -219,7 +224,7 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & void } & }} error={form.errors.apiKey && 'Invalid API key'} /> - - Tip: Get your API key{' '} + + Get your API key{' '} void } & > here. - + )} {form.values.type === 'qBittorrent' && ( diff --git a/src/components/AppShelf/AppShelf.tsx b/src/components/AppShelf/AppShelf.tsx index 083de462656..abfed55cc52 100644 --- a/src/components/AppShelf/AppShelf.tsx +++ b/src/components/AppShelf/AppShelf.tsx @@ -152,6 +152,7 @@ const AppShelf = (props: any) => { const noCategory = config.services.filter( (e) => e.category === undefined || e.category === null ); + const downloadEnabled = config.modules?.[DownloadsModule.title]?.enabled ?? false; // Create an item with 0: true, 1: true, 2: true... For each category return ( // Return one item for each category @@ -176,6 +177,7 @@ const AppShelf = (props: any) => { {item()} ) : null} + {downloadEnabled ? ( { + ) : null} ); diff --git a/src/components/Docker/ContainerActionBar.tsx b/src/components/Docker/ContainerActionBar.tsx new file mode 100644 index 00000000000..b4c17dd477b --- /dev/null +++ b/src/components/Docker/ContainerActionBar.tsx @@ -0,0 +1,182 @@ +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, + IconX, +} from '@tabler/icons'; +import axios from 'axios'; +import Dockerode from 'dockerode'; +import { tryMatchService } from '../../tools/addToHomarr'; +import { useConfig } from '../../tools/state'; +import { AddAppShelfItemForm } from '../AppShelf/AddAppShelfItem'; + +function sendDockerCommand(action: string, containerId: string, containerName: string) { + showNotification({ + id: containerId, + loading: true, + title: `${action}ing container ${containerName.substring(1)}`, + message: undefined, + autoClose: false, + disallowClose: true, + }); + axios.get(`/api/docker/container/${containerId}?action=${action}`).then((res) => { + setTimeout(() => { + if (res.data.success === true) { + updateNotification({ + id: containerId, + title: `Container ${containerName} ${action}ed`, + message: `Your container was successfully ${action}ed`, + icon: , + autoClose: 2000, + }); + } + if (res.data.success === false) { + updateNotification({ + id: containerId, + color: 'red', + title: 'There was an error with your container.', + message: undefined, + icon: , + autoClose: 2000, + }); + } + }, 500); + }); +} + +export interface ContainerActionBarProps { + selected: Dockerode.ContainerInfo[]; + reload: () => void; +} + +export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) { + const { config, setConfig } = useConfig(); + const [opened, setOpened] = useBooleanToggle(false); + return ( + + setOpened(false)} + title="Add service" + > + + + + + + + + + + ); +} diff --git a/src/components/Docker/ContainerState.tsx b/src/components/Docker/ContainerState.tsx new file mode 100644 index 00000000000..d5c6b50773b --- /dev/null +++ b/src/components/Docker/ContainerState.tsx @@ -0,0 +1,49 @@ +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 ( + + Running + + ); + } + case 'created': { + return ( + + Created + + ); + } + case 'exited': { + return ( + + Stopped + + ); + } + default: { + return ( + + Unknown + + ); + } + } +} diff --git a/src/components/Docker/DockerDrawer.tsx b/src/components/Docker/DockerDrawer.tsx new file mode 100644 index 00000000000..678990cbca1 --- /dev/null +++ b/src/components/Docker/DockerDrawer.tsx @@ -0,0 +1,53 @@ +import { ActionIcon, Drawer, Group, LoadingOverlay } from '@mantine/core'; +import { IconBrandDocker } from '@tabler/icons'; +import axios from 'axios'; +import { useEffect, useState } from 'react'; +import Docker from 'dockerode'; +import ContainerActionBar from './ContainerActionBar'; +import DockerTable from './DockerTable'; + +export default function DockerDrawer(props: any) { + const [opened, setOpened] = useState(false); + const [containers, setContainers] = useState([]); + const [selection, setSelection] = useState([]); + const [visible, setVisible] = useState(false); + + function reload() { + setVisible(true); + setTimeout(() => { + axios.get('/api/docker/containers').then((res) => { + setContainers(res.data); + setSelection([]); + setVisible(false); + }); + }, 300); + } + + useEffect(() => { + reload(); + }, []); + // Check if the user has at least one container + if (containers.length < 1) return null; + return ( + <> + setOpened(false)} padding="xl" size="full"> + +
+ + +
+
+ + setOpened(true)} + > + + + + + ); +} diff --git a/src/components/Docker/DockerMenu.tsx b/src/components/Docker/DockerMenu.tsx new file mode 100644 index 00000000000..6b9bdc8feef --- /dev/null +++ b/src/components/Docker/DockerMenu.tsx @@ -0,0 +1,91 @@ +import { Menu, Text, useMantineTheme } from '@mantine/core'; +import { showNotification, updateNotification } from '@mantine/notifications'; +import { + IconCheck, + IconCodePlus, + IconPlayerPlay, + IconPlayerStop, + IconRotateClockwise, + IconX, +} from '@tabler/icons'; +import axios from 'axios'; +import Dockerode from 'dockerode'; + +function sendNotification(action: string, containerId: string, containerName: string) { + showNotification({ + id: 'load-data', + loading: true, + title: `${action}ing container ${containerName}`, + message: 'Your password is being checked...', + autoClose: false, + disallowClose: true, + }); + axios.get(`/api/docker/container/${containerId}?action=${action}`).then((res) => { + setTimeout(() => { + if (res.data.success === true) { + updateNotification({ + id: 'load-data', + title: 'Container restarted', + message: 'Your container was successfully restarted', + icon: , + autoClose: 2000, + }); + } + if (res.data.success === false) { + updateNotification({ + id: 'load-data', + color: 'red', + title: 'There was an error restarting your container.', + message: 'Your container has encountered issues while restarting.', + icon: , + autoClose: 2000, + }); + } + }, 500); + }); +} + +function restart(container: Dockerode.ContainerInfo) { + sendNotification('restart', container.Id, container.Names[0]); +} +function stop(container: Dockerode.ContainerInfo) { + console.log('stoping container', container.Id); +} +function start(container: Dockerode.ContainerInfo) { + console.log('starting container', container.Id); +} + +export default function DockerMenu(props: any) { + const { container }: { container: Dockerode.ContainerInfo } = props; + const theme = useMantineTheme(); + if (container === undefined) { + return null; + } + return ( + + Actions + } onClick={() => restart(container)}> + Restart + + {container.State === 'running' ? ( + }> + Stop + + ) : ( + }> + Start + + )} + {/* }> + Pull latest image + + }> + Logs + */} + Homarr + }> + Add to Homarr + + + ); +} diff --git a/src/components/Docker/DockerTable.tsx b/src/components/Docker/DockerTable.tsx new file mode 100644 index 00000000000..2f7d6707b9a --- /dev/null +++ b/src/components/Docker/DockerTable.tsx @@ -0,0 +1,90 @@ +import { Table, Checkbox, Group, Badge, createStyles } from '@mantine/core'; +import Dockerode from 'dockerode'; +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 { classes, cx } = useStyles(); + + 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 === containers.length ? [] : containers.map((c) => c) + ); + + const rows = containers.map((element) => { + const selected = selection.includes(element); + return ( + + + toggleRow(element)} + transitionDuration={0} + /> + + {element.Names[0].replace('/', '')} + {element.Image} + + + {element.Ports.sort((a, b) => a.PrivatePort - b.PrivatePort) + .slice(-3) + .map((port) => ( + + {port.PrivatePort}:{port.PublicPort} + + ))} + {element.Ports.length > 3 && ( + {element.Ports.length - 3} more + )} + + + + + + + ); + }); + + return ( + + + + + + + + + + + + {rows} +
your docker containers
+ 0 && selection.length !== containers.length} + transitionDuration={0} + /> + NameImagePortsState
+ ); +} diff --git a/src/components/Settings/AdvancedSettings.tsx b/src/components/Settings/AdvancedSettings.tsx index ad45174579a..4c7d6a50ef5 100644 --- a/src/components/Settings/AdvancedSettings.tsx +++ b/src/components/Settings/AdvancedSettings.tsx @@ -37,7 +37,7 @@ export default function TitleChanger() { }; return ( - +
saveChanges(values))}> diff --git a/src/components/Settings/CommonSettings.tsx b/src/components/Settings/CommonSettings.tsx index 55c710359b2..4d55eee18d5 100644 --- a/src/components/Settings/CommonSettings.tsx +++ b/src/components/Settings/CommonSettings.tsx @@ -1,13 +1,12 @@ -import { ActionIcon, Group, Text, SegmentedControl, TextInput, Anchor } from '@mantine/core'; +import { Group, Text, SegmentedControl, TextInput } from '@mantine/core'; import { useState } from 'react'; -import { IconBrandGithub as BrandGithub, IconBrandDiscord as BrandDiscord } from '@tabler/icons'; -import { CURRENT_VERSION } from '../../../data/constants'; import { useConfig } from '../../tools/state'; import { ColorSchemeSwitch } from '../ColorSchemeToggle/ColorSchemeSwitch'; import { WidgetsPositionSwitch } from '../WidgetsPositionSwitch/WidgetsPositionSwitch'; import ConfigChanger from '../Config/ConfigChanger'; import SaveConfigComponent from '../Config/SaveConfig'; import ModuleEnabler from './ModuleEnabler'; +import Tip from '../layout/Tip'; export default function CommonSettings(args: any) { const { config, setConfig } = useConfig(); @@ -25,20 +24,16 @@ export default function CommonSettings(args: any) { ); return ( - + Search engine - - Tip: %s can be used as a placeholder for the query. - + + Use the prefixes !yt and !t in front of your query to search on YouTube or + for a Torrent respectively. + {searchUrl === 'Custom' && ( - { - setCustomSearchUrl(event.currentTarget.value); - setConfig({ - ...config, - settings: { - ...config.settings, - searchUrl: event.currentTarget.value, - }, - }); - }} - /> + <> + %s can be used as a placeholder for the query. + { + setCustomSearchUrl(event.currentTarget.value); + setConfig({ + ...config, + settings: { + ...config.settings, + searchUrl: event.currentTarget.value, + }, + }); + }} + /> + )} @@ -82,52 +80,7 @@ export default function CommonSettings(args: any) { - - Tip: You can upload your config file by dragging and dropping it onto the page! - - - - component="a" href="https://github.com/ajnart/homarr" size="lg"> - - - - {CURRENT_VERSION} - - - - - Made with ❤️ by @ - - ajnart - - - component="a" href="https://discord.gg/aCsmEV5RgA" size="lg"> - - - - + Upload your config file by dragging and dropping it onto the page! ); } diff --git a/src/components/Settings/Credits.tsx b/src/components/Settings/Credits.tsx new file mode 100644 index 00000000000..1d627147992 --- /dev/null +++ b/src/components/Settings/Credits.tsx @@ -0,0 +1,44 @@ +import { Group, ActionIcon, Anchor, Text } from '@mantine/core'; +import { IconBrandDiscord, IconBrandGithub } from '@tabler/icons'; +import { CURRENT_VERSION } from '../../../data/constants'; + +export default function Credits(props: any) { + return ( + + + component="a" href="https://github.com/ajnart/homarr" size="lg"> + + + + {CURRENT_VERSION} + + + + + Made with ❤️ by @ + + ajnart + + + component="a" href="https://discord.gg/aCsmEV5RgA" size="lg"> + + + + + ); +} diff --git a/src/components/Settings/SettingsMenu.tsx b/src/components/Settings/SettingsMenu.tsx index e6bcb2bed2d..fcd6d1b914e 100644 --- a/src/components/Settings/SettingsMenu.tsx +++ b/src/components/Settings/SettingsMenu.tsx @@ -1,18 +1,23 @@ -import { ActionIcon, Title, Tooltip, Drawer, Tabs } from '@mantine/core'; +import { ActionIcon, Title, Tooltip, Drawer, Tabs, ScrollArea } from '@mantine/core'; import { useHotkeys } from '@mantine/hooks'; import { useState } from 'react'; import { IconSettings } from '@tabler/icons'; import AdvancedSettings from './AdvancedSettings'; import CommonSettings from './CommonSettings'; +import Credits from './Credits'; function SettingsMenu(props: any) { return ( - + + + - + + + ); @@ -26,13 +31,14 @@ export function SettingsMenuButton(props: any) { <> Settings} + title={Settings} opened={props.opened || opened} onClose={() => setOpened(false)} > + + @@ -84,6 +91,7 @@ export function Header(props: any) { + diff --git a/src/components/layout/Tip.tsx b/src/components/layout/Tip.tsx new file mode 100644 index 00000000000..d21d709f892 --- /dev/null +++ b/src/components/layout/Tip.tsx @@ -0,0 +1,19 @@ +import { Text } from '@mantine/core'; + +interface TipProps { + children: React.ReactNode; +} + +export default function Tip(props: TipProps) { + return ( + + Tip: {props.children} + + ); +} diff --git a/src/components/layout/Widgets.tsx b/src/components/layout/Widgets.tsx index b82f489a9ec..0eceae4c42d 100644 --- a/src/components/layout/Widgets.tsx +++ b/src/components/layout/Widgets.tsx @@ -1,6 +1,7 @@ import { Group } from '@mantine/core'; import { useMediaQuery } from '@mantine/hooks'; import { CalendarModule, DateModule, TotalDownloadsModule, WeatherModule } from '../modules'; +import { DashdotModule } from '../modules/dash.'; import { ModuleWrapper } from '../modules/moduleWrapper'; export default function Widgets(props: any) { @@ -14,6 +15,7 @@ export default function Widgets(props: any) { + )} diff --git a/src/components/modules/dash./DashdotModule.tsx b/src/components/modules/dash./DashdotModule.tsx new file mode 100644 index 00000000000..ff617562962 --- /dev/null +++ b/src/components/modules/dash./DashdotModule.tsx @@ -0,0 +1,233 @@ +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: 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(); + + 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 ( +
+

Dash.

+ + {!dashdotService ? ( +

No dash. service found. Please add one to your Homarr dashboard.

+ ) : !info ? ( +

Cannot acquire information from dash. - are you running the latest version?

+ ) : ( +
+
+ {storageEnabled && isCompact && ( +
+

Storage:

+

+ {(totalUsed / (totalSize || 1)).toFixed(1)}%{'\n'} + {bytePrettyPrint(totalUsed)} / {bytePrettyPrint(totalSize)} +

+
+ )} + {networkEnabled && ( +
+

Network:

+

+ {bpsPrettyPrint(info?.network?.speedUp)} Up{'\n'} + {bpsPrettyPrint(info?.network?.speedDown)} Down +

+
+ )} +
+ + {graphs.map((graph) => ( +