From 09063d3376f1008da7acc46b775d092e64f3bdb7 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Tue, 3 Jun 2025 16:28:22 -0400 Subject: [PATCH] Migrate paths dashboard widget to react and add storage metrics --- .../components/widgets/ServerPathWidget.tsx | 67 +++++++++++ src/apps/dashboard/controllers/dashboard.html | 39 +------ src/apps/dashboard/controllers/dashboard.js | 45 ++++---- .../features/storage/api/useSystemStorage.ts | 28 +++++ .../storage/components/StorageListItem.tsx | 105 ++++++++++++++++++ .../storage/components/StorageTypeIcon.tsx | 31 ++++++ .../features/storage/constants/StorageType.ts | 13 +++ src/strings/en-us.json | 9 ++ src/utils/file.test.ts | 22 ++++ src/utils/file.ts | 8 ++ src/utils/reactUtils.tsx | 36 +++--- 11 files changed, 322 insertions(+), 81 deletions(-) create mode 100644 src/apps/dashboard/components/widgets/ServerPathWidget.tsx create mode 100644 src/apps/dashboard/features/storage/api/useSystemStorage.ts create mode 100644 src/apps/dashboard/features/storage/components/StorageListItem.tsx create mode 100644 src/apps/dashboard/features/storage/components/StorageTypeIcon.tsx create mode 100644 src/apps/dashboard/features/storage/constants/StorageType.ts create mode 100644 src/utils/file.test.ts diff --git a/src/apps/dashboard/components/widgets/ServerPathWidget.tsx b/src/apps/dashboard/components/widgets/ServerPathWidget.tsx new file mode 100644 index 0000000000..98fe4be579 --- /dev/null +++ b/src/apps/dashboard/components/widgets/ServerPathWidget.tsx @@ -0,0 +1,67 @@ +import ChevronRight from '@mui/icons-material/ChevronRight'; +import Button from '@mui/material/Button'; +import List from '@mui/material/List'; +import Typography from '@mui/material/Typography'; +import React from 'react'; + +import { useSystemStorage } from 'apps/dashboard/features/storage/api/useSystemStorage'; +import StorageListItem from 'apps/dashboard/features/storage/components/StorageListItem'; +import globalize from 'lib/globalize'; + +const ServerPathWidget = () => { + const { data: systemStorage } = useSystemStorage(); + + return ( + <> + + + + + + + + + + + + + ); +}; + +export default ServerPathWidget; diff --git a/src/apps/dashboard/controllers/dashboard.html b/src/apps/dashboard/controllers/dashboard.html index 5c449c5e91..55906b6882 100644 --- a/src/apps/dashboard/controllers/dashboard.html +++ b/src/apps/dashboard/controllers/dashboard.html @@ -75,44 +75,7 @@ -
- -

${HeaderPaths}

- -
-
-
-
-
${LabelCache}
-
-
-
-
-
-
${LabelLogs}
-
-
-
-
-
-
${LabelMetadata}
-
-
-
-
-
-
${LabelTranscodes}
-
-
-
-
-
-
${LabelWeb}
-
-
-
-
-
+
diff --git a/src/apps/dashboard/controllers/dashboard.js b/src/apps/dashboard/controllers/dashboard.js index 2388ac717d..adc062c0b7 100644 --- a/src/apps/dashboard/controllers/dashboard.js +++ b/src/apps/dashboard/controllers/dashboard.js @@ -1,30 +1,31 @@ import escapeHtml from 'escape-html'; -import datetime from 'scripts/datetime'; -import Events from 'utils/events.ts'; +import ServerPathWidget from 'apps/dashboard/components/widgets/ServerPathWidget'; +import ActivityLog from 'components/activitylog'; +import alert from 'components/alert'; +import cardBuilder from 'components/cardbuilder/cardBuilder'; +import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils'; +import confirm from 'components/confirm/confirm'; +import imageLoader from 'components/images/imageLoader'; +import indicators from 'components/indicators/indicators'; import itemHelper from 'components/itemHelper'; -import serverNotifications from 'scripts/serverNotifications'; -import dom from 'scripts/dom'; -import globalize from 'lib/globalize'; -import { formatDistanceToNow } from 'date-fns'; -import { getLocaleWithSuffix } from 'utils/dateFnsLocale.ts'; import loading from 'components/loading/loading'; import playMethodHelper from 'components/playback/playmethodhelper'; -import cardBuilder from 'components/cardbuilder/cardBuilder'; -import imageLoader from 'components/images/imageLoader'; -import ActivityLog from 'components/activitylog'; -import imageHelper from 'utils/image'; -import indicators from 'components/indicators/indicators'; +import { formatDistanceToNow } from 'date-fns'; +import { getSystemInfoQuery } from 'hooks/useSystemInfo'; +import globalize from 'lib/globalize'; +import { ServerConnections } from 'lib/jellyfin-apiclient'; +import datetime from 'scripts/datetime'; +import dom from 'scripts/dom'; +import serverNotifications from 'scripts/serverNotifications'; import taskButton from 'scripts/taskbutton'; import Dashboard from 'utils/dashboard'; -import alert from 'components/alert'; -import confirm from 'components/confirm/confirm'; -import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils'; - -import { getSystemInfoQuery } from 'hooks/useSystemInfo'; -import { ServerConnections } from 'lib/jellyfin-apiclient'; +import { getLocaleWithSuffix } from 'utils/dateFnsLocale.ts'; +import Events from 'utils/events.ts'; +import imageHelper from 'utils/image'; import { toApi } from 'utils/jellyfin-apiclient/compat'; import { queryClient } from 'utils/query/queryClient'; +import { renderComponent } from 'utils/reactUtils'; import 'elements/emby-button/emby-button'; import 'elements/emby-itemscontainer/emby-itemscontainer'; @@ -220,12 +221,6 @@ function reloadSystemInfo(view, apiClient) { .then(systemInfo => { view.querySelector('#serverName').innerText = systemInfo.ServerName; view.querySelector('#versionNumber').innerText = systemInfo.Version; - - view.querySelector('#cachePath').innerText = systemInfo.CachePath; - view.querySelector('#logPath').innerText = systemInfo.LogPath; - view.querySelector('#transcodePath').innerText = systemInfo.TranscodingTempPath; - view.querySelector('#metadataPath').innerText = systemInfo.InternalMetadataPath; - view.querySelector('#webPath').innerText = systemInfo.WebPath; }); } @@ -827,6 +822,8 @@ export default function (view) { button: page.querySelector('.btnRefresh') }); + renderComponent(ServerPathWidget, {}, page.querySelector('#serverPaths')); + page.querySelector('#btnRestartServer').addEventListener('click', DashboardPage.restart); page.querySelector('#btnShutdown').addEventListener('click', DashboardPage.shutdown); }); diff --git a/src/apps/dashboard/features/storage/api/useSystemStorage.ts b/src/apps/dashboard/features/storage/api/useSystemStorage.ts new file mode 100644 index 0000000000..12db4a1713 --- /dev/null +++ b/src/apps/dashboard/features/storage/api/useSystemStorage.ts @@ -0,0 +1,28 @@ +import type { Api } from '@jellyfin/sdk'; +import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api'; +import { queryOptions, useQuery } from '@tanstack/react-query'; +import type { AxiosRequestConfig } from 'axios'; + +import { useApi } from 'hooks/useApi'; + +const fetchSystemStorage = async ( + api: Api, + options?: AxiosRequestConfig +) => { + const response = await getSystemApi(api) + .getSystemStorage(options); + return response.data; +}; + +const getSystemStorageQuery = ( + api?: Api +) => queryOptions({ + queryKey: [ 'SystemStorage' ], + queryFn: ({ signal }) => fetchSystemStorage(api!, { signal }), + enabled: !!api +}); + +export const useSystemStorage = () => { + const { api } = useApi(); + return useQuery(getSystemStorageQuery(api)); +}; diff --git a/src/apps/dashboard/features/storage/components/StorageListItem.tsx b/src/apps/dashboard/features/storage/components/StorageListItem.tsx new file mode 100644 index 0000000000..5d63b64460 --- /dev/null +++ b/src/apps/dashboard/features/storage/components/StorageListItem.tsx @@ -0,0 +1,105 @@ +import type { FolderStorageDto } from '@jellyfin/sdk/lib/generated-client'; +import LinearProgress from '@mui/material/LinearProgress'; +import ListItem from '@mui/material/ListItem'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Skeleton from '@mui/material/Skeleton'; +import Typography from '@mui/material/Typography'; +import React, { type FC } from 'react'; + +import globalize from 'lib/globalize'; +import { getReadableSize } from 'utils/file'; + +import { StorageType } from '../constants/StorageType'; + +import StorageTypeIcon from './StorageTypeIcon'; + +interface StorageListItemProps { + label: string + folder?: FolderStorageDto +} + +const calculateUsed = (folder?: FolderStorageDto) => { + if (typeof folder?.UsedSpace === 'undefined') return 0; + if (typeof folder.FreeSpace === 'undefined') return 100; + + return folder.UsedSpace / (folder.FreeSpace + folder.UsedSpace) * 100; +}; + +const getStatusColor = (percent: number) => { + if (percent >= 90) return 'error'; + if (percent >= 80) return 'warning'; + return 'success'; +}; + +const getStorageTypeText = (type?: string | null) => { + if (!type) return undefined; + + if (Object.keys(StorageType).includes(type)) { + return globalize.translate(`StorageType.${type}`); + } + + return type; +}; + +const StorageListItem: FC = ({ + label, + folder +}) => { + const usedSpace = (typeof folder?.UsedSpace === 'undefined') ? '?' : getReadableSize(folder.UsedSpace); + const totalSpace = (typeof folder?.FreeSpace === 'undefined' || typeof folder.UsedSpace === 'undefined') ? + '?' : getReadableSize(folder.FreeSpace + folder.UsedSpace); + const usedPercent = calculateUsed(folder); + const statusColor = folder ? getStatusColor(usedPercent) : 'primary'; + + return ( + + + + + + {label} + + } + secondary={ + <> + + {folder ? folder.Path : ( + + )} + + + + {`${usedSpace} / ${totalSpace}`} + + + } + slots={{ + secondary: 'div' + }} + /> + + ); +}; + +export default StorageListItem; diff --git a/src/apps/dashboard/features/storage/components/StorageTypeIcon.tsx b/src/apps/dashboard/features/storage/components/StorageTypeIcon.tsx new file mode 100644 index 0000000000..4b62d455ed --- /dev/null +++ b/src/apps/dashboard/features/storage/components/StorageTypeIcon.tsx @@ -0,0 +1,31 @@ +import Album from '@mui/icons-material/Album'; +import Lan from '@mui/icons-material/Lan'; +import Memory from '@mui/icons-material/Memory'; +import Storage from '@mui/icons-material/Storage'; +import Usb from '@mui/icons-material/Usb'; +import React, { type FC } from 'react'; + +import { StorageType } from '../constants/StorageType'; + +interface StorageTypeIconProps { + type?: string | null +} + +const StorageTypeIcon: FC = ({ + type +}) => { + switch (type) { + case StorageType.CDRom: + return ; + case StorageType.Network: + return ; + case StorageType.Ram: + return ; + case StorageType.Removable: + return ; + default: + return ; + } +}; + +export default StorageTypeIcon; diff --git a/src/apps/dashboard/features/storage/constants/StorageType.ts b/src/apps/dashboard/features/storage/constants/StorageType.ts new file mode 100644 index 0000000000..2a0a4d83d1 --- /dev/null +++ b/src/apps/dashboard/features/storage/constants/StorageType.ts @@ -0,0 +1,13 @@ +/** + * Common storage type values returned by the API. + * NOTE: This list is not comprehensive as .NET might return any string. + */ +export enum StorageType { + CDRom = 'CDRom', + Fixed = 'Fixed', + Network = 'Network', + NoRootDirectory = 'NoRootDirectory', + Ram = 'Ram', + Removable = 'Removable', + Unknown = 'Unknown' +}; diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 5402157b2e..4498cd36eb 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -743,6 +743,7 @@ "LabelHomeScreenSectionValue": "Home screen section {0}", "LabelHttpsPort": "Local HTTPS port number", "LabelHttpsPortHelp": "The TCP port number for the HTTPS server.", + "LabelImageCache": "Image Cache", "LabelImageFetchersHelp": "Enable and rank your preferred image fetchers in order of priority.", "LabelImageType": "Image type", "LabelImportOnlyFavoriteChannels": "Restrict to channels marked as favorite", @@ -856,6 +857,7 @@ "LabelPreferredDisplayLanguage": "Preferred display language", "LabelPreferredSubtitleLanguage": "Preferred subtitle language", "LabelProfileContainer": "Container", + "LabelProgramData": "Program Data", "LabelProtocol": "Protocol", "LabelPublicHttpPort": "Public HTTP port number", "LabelPublicHttpPortHelp": "The public port number that should be mapped to the local HTTP port.", @@ -1545,6 +1547,13 @@ "StoryArc": "Story Arc", "StopPlayback": "Stop playback", "StopRecording": "Stop recording", + "StorageType.CDRom": "CD-ROM", + "StorageType.Fixed": "Fixed", + "StorageType.Network": "Network", + "StorageType.NoRootDirectory": "No Root Directory", + "StorageType.Ram": "RAM", + "StorageType.Removable": "Removable", + "StorageType.Unknown": "Unknown", "StreamCountExceedsLimit": "The number of streams exceeds the limit", "Studio": "Studio", "Studios": "Studios", diff --git a/src/utils/file.test.ts b/src/utils/file.test.ts new file mode 100644 index 0000000000..1f23d4b967 --- /dev/null +++ b/src/utils/file.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { getReadableSize } from './file'; + +describe('getReadableSize()', () => { + it('should return the correct units', () => { + expect(getReadableSize(5)).toBe('5.0 Bytes'); + expect(getReadableSize(1024)).toBe('1.0 KiB'); + expect(getReadableSize(1024 * 1024)).toBe('1.0 MiB'); + expect(getReadableSize(1024 * 1024 * 1024)).toBe('1.0 GiB'); + expect(getReadableSize(1024 * 1024 * 1024 * 1024)).toBe('1.0 TiB'); + expect(getReadableSize(1024 * 1024 * 1024 * 1024 * 1024)).toBe('1.0 PiB'); + expect(getReadableSize(1024 * 1024 * 1024 * 1024 * 1024 * 1024)).toBe('1.0 EiB'); + expect(getReadableSize(1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024)).toBe('1.0 ZiB'); + expect(getReadableSize(1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024)).toBe('1.0 YiB'); + }); + + it('should return the correct precision', () => { + expect(getReadableSize(12345, 0)).toBe('12 KiB'); + expect(getReadableSize(12345, 2)).toBe('12.06 KiB'); + expect(getReadableSize(12345, 3)).toBe('12.056 KiB'); + }); +}); diff --git a/src/utils/file.ts b/src/utils/file.ts index 0019a5068f..b82df87380 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -28,3 +28,11 @@ export function readFileAsText(file: File): Promise { reader.readAsText(file); }); } + +/** Gets a human readable string representing a file size in bytes */ +export function getReadableSize(value: number, precision = 1) { + let d = Math.log(value) / Math.log(1024) | 0; + + return (value / Math.pow(1024, d)).toFixed(precision) + + ' ' + (d ? 'KMGTPEZY'[--d] + 'iB' : 'Bytes'); +} diff --git a/src/utils/reactUtils.tsx b/src/utils/reactUtils.tsx index 097f185406..be4e518ec0 100644 --- a/src/utils/reactUtils.tsx +++ b/src/utils/reactUtils.tsx @@ -1,36 +1,34 @@ -import { createRoot } from 'react-dom/client'; -import * as React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; import { QueryClientProvider } from '@tanstack/react-query'; +import * as React from 'react'; +import { createRoot } from 'react-dom/client'; + import { ApiProvider } from 'hooks/useApi'; import { UserSettingsProvider } from 'hooks/useUserSettings'; import { WebConfigProvider } from 'hooks/useWebConfig'; +import appTheme from 'themes/themes'; import { queryClient } from 'utils/query/queryClient'; -import { useUserTheme } from 'hooks/useUserTheme'; -import { ThemeProvider } from '@mui/material/styles'; -import { getTheme } from 'themes/themes'; -export const attachReactElement = (Element: (props: object) => React.ReactNode, props: object, element: HTMLElement, replace = false) => { - const domNode = document.createElement('div'); - const root = createRoot(replace ? domNode : element); - root.render( - - - - ); - - if (replace) { - element.replaceWith(domNode); - } +export const renderComponent =

( + Component: React.FC

, + props: P, + element: HTMLElement +) => { + createRoot(element) + .render( + + + + ); }; const RootContext: React.FC = ({ children }) => { - const { theme } = useUserTheme(); return ( - + {children}