From a938650dedcda0cd296c3f7b74c881f31387973f Mon Sep 17 00:00:00 2001 From: Nathan Kerr Date: Tue, 6 May 2025 09:50:11 -0700 Subject: [PATCH 1/6] Add support for rendering React components in legacy views Add name to Contributors --- CONTRIBUTORS.md | 1 + src/utils/reactUtils.tsx | 41 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 src/utils/reactUtils.tsx diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 48b9b147b..a7b330971 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -100,6 +100,7 @@ - [StableCrimson](https://github.com/StableCrimson) - [diegoeche](https://github.com/diegoeche) - [Free O'Toole](https://github.com/freeotoole) +- [TheBosZ](https://github.com/thebosz) ## Emby Contributors diff --git a/src/utils/reactUtils.tsx b/src/utils/reactUtils.tsx new file mode 100644 index 000000000..097f18540 --- /dev/null +++ b/src/utils/reactUtils.tsx @@ -0,0 +1,41 @@ +import { createRoot } from 'react-dom/client'; +import * as React from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { ApiProvider } from 'hooks/useApi'; +import { UserSettingsProvider } from 'hooks/useUserSettings'; +import { WebConfigProvider } from 'hooks/useWebConfig'; +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); + } +}; + +const RootContext: React.FC = ({ children }) => { + const { theme } = useUserTheme(); + return ( + + + + + + {children} + + + + + + ); +}; From 09063d3376f1008da7acc46b775d092e64f3bdb7 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Tue, 3 Jun 2025 16:28:22 -0400 Subject: [PATCH 2/6] 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 000000000..98fe4be57 --- /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 5c449c5e9..55906b688 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 2388ac717..adc062c0b 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 000000000..12db4a171 --- /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 000000000..5d63b6446 --- /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 000000000..4b62d455e --- /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 000000000..2a0a4d83d --- /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 5402157b2..4498cd36e 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 000000000..1f23d4b96 --- /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 0019a5068..b82df8738 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 097f18540..be4e518ec 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} From 0bbede9b074ee3cd8c6cef75d0dea69681aa7e81 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Tue, 3 Jun 2025 16:48:16 -0400 Subject: [PATCH 3/6] Use getReadableSize for all file size text --- src/components/itemMediaInfo/itemMediaInfo.js | 35 +++++++++++-------- src/components/playerstats/playerstats.js | 21 ++++------- 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/src/components/itemMediaInfo/itemMediaInfo.js b/src/components/itemMediaInfo/itemMediaInfo.js index 70c35e848..7ebe7464a 100644 --- a/src/components/itemMediaInfo/itemMediaInfo.js +++ b/src/components/itemMediaInfo/itemMediaInfo.js @@ -5,22 +5,27 @@ */ import escapeHtml from 'escape-html'; -import dialogHelper from '../dialogHelper/dialogHelper'; -import layoutManager from '../layoutManager'; -import toast from '../toast/toast'; -import { copy } from '../../scripts/clipboard'; -import dom from '../../scripts/dom'; -import globalize from '../../lib/globalize'; + +import dialogHelper from 'components/dialogHelper/dialogHelper'; +import itemHelper from 'components/itemHelper'; +import layoutManager from 'components/layoutManager'; +import loading from 'components/loading/loading'; +import toast from 'components/toast/toast'; +import globalize from 'lib/globalize'; import { ServerConnections } from 'lib/jellyfin-apiclient'; -import itemHelper from '../../components/itemHelper'; -import loading from '../loading/loading'; -import '../../elements/emby-select/emby-select'; -import '../listview/listview.scss'; -import '../../elements/emby-button/emby-button'; -import '../../elements/emby-button/paper-icon-button-light'; -import '../formdialog.scss'; +import { copy } from 'scripts/clipboard'; +import dom from 'scripts/dom'; +import { getReadableSize } from 'utils/file'; + +import 'components/formdialog.scss'; +import 'components/listview/listview.scss'; +import 'elements/emby-button/emby-button'; +import 'elements/emby-button/paper-icon-button-light'; +import 'elements/emby-select/emby-select'; import 'material-design-icons-iconfont'; -import '../../styles/flexstyles.scss'; + +import 'styles/flexstyles.scss'; + import template from './itemMediaInfo.template.html'; // Do not add extra spaces between tags - they will be copied into the result @@ -68,7 +73,7 @@ function getMediaSourceHtml(user, item, version) { html += `${createAttribute(globalize.translate('MediaInfoPath'), version.Path, true)}
`; } if (version.Size) { - const size = `${(version.Size / (1024 * 1024)).toFixed(0)} MB`; + const size = getReadableSize(version.Size); html += `${createAttribute(globalize.translate('MediaInfoSize'), size)}
`; } version.MediaStreams.sort(itemHelper.sortTracks); diff --git a/src/components/playerstats/playerstats.js b/src/components/playerstats/playerstats.js index 97d4a7afd..804c97e9c 100644 --- a/src/components/playerstats/playerstats.js +++ b/src/components/playerstats/playerstats.js @@ -1,12 +1,15 @@ -import '../../elements/emby-button/paper-icon-button-light'; -import globalize from '../../lib/globalize'; +import globalize from 'lib/globalize'; import { ServerConnections } from 'lib/jellyfin-apiclient'; -import Events from '../../utils/events.ts'; +import { PluginType } from 'types/plugin'; +import Events from 'utils/events'; +import { getReadableSize } from 'utils/file'; + import layoutManager from '../layoutManager'; import { playbackManager } from '../playback/playbackmanager'; import playMethodHelper from '../playback/playmethodhelper'; import { pluginManager } from '../pluginManager'; -import { PluginType } from '../../types/plugin.ts'; + +import 'elements/emby-button/paper-icon-button-light'; import './playerstats.scss'; @@ -211,16 +214,6 @@ function getDisplayTranscodeFps(session, player) { return `${transcodeFramerate} fps (${(transcodeFramerate / originalFramerate).toFixed(2)}x)`; } -function getReadableSize(size) { - if (size >= 1073741824) { - return parseFloat((size / 1073741824).toFixed(1)) + ' GiB'; - } else if (size >= 1048576) { - return parseFloat((size / 1048576).toFixed(1)) + ' MiB'; - } else { - return Math.floor(size / 1024) + ' KiB'; - } -} - function getMediaSourceStats(session, player) { const sessionStats = []; From afb1f9570dd2059074f045204f44dcd130ee6f51 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Tue, 3 Jun 2025 17:13:38 -0400 Subject: [PATCH 4/6] Add guard against dividing by zero when calculating used percentage Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../features/storage/components/StorageListItem.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/apps/dashboard/features/storage/components/StorageListItem.tsx b/src/apps/dashboard/features/storage/components/StorageListItem.tsx index 5d63b6446..b93696f39 100644 --- a/src/apps/dashboard/features/storage/components/StorageListItem.tsx +++ b/src/apps/dashboard/features/storage/components/StorageListItem.tsx @@ -22,8 +22,14 @@ interface StorageListItemProps { 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 totalSpace = folder.FreeSpace + folder.UsedSpace; + if (totalSpace === 0) return 0; + + // Ensure we don't have negative values + const usedSpace = Math.max(0, folder.UsedSpace); + + return Math.min(100, (usedSpace / totalSpace) * 100); }; const getStatusColor = (percent: number) => { From b705cfc4c3fa98f4b980a9196ca2bf3e364e19d2 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Tue, 3 Jun 2025 17:31:46 -0400 Subject: [PATCH 5/6] Fix components not being unmounted on page hide --- src/apps/dashboard/controllers/dashboard.js | 8 ++++++-- .../storage/components/StorageListItem.tsx | 6 +++--- src/utils/reactUtils.tsx | 16 ++++++++++------ 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/apps/dashboard/controllers/dashboard.js b/src/apps/dashboard/controllers/dashboard.js index adc062c0b..8e91a8f33 100644 --- a/src/apps/dashboard/controllers/dashboard.js +++ b/src/apps/dashboard/controllers/dashboard.js @@ -747,6 +747,9 @@ const DashboardPage = { }; export default function (view) { + const serverId = ApiClient.serverId(); + let unmountPathsWidget; + function onRestartRequired(evt, apiClient) { console.debug('onRestartRequired not implemented', evt, apiClient); } @@ -778,7 +781,6 @@ export default function (view) { } } - const serverId = ApiClient.serverId(); view.querySelector('.activeDevices').addEventListener('click', onActiveDevicesClick); view.addEventListener('viewshow', function () { const page = this; @@ -822,7 +824,7 @@ export default function (view) { button: page.querySelector('.btnRefresh') }); - renderComponent(ServerPathWidget, {}, page.querySelector('#serverPaths')); + unmountPathsWidget = renderComponent(ServerPathWidget, {}, page.querySelector('#serverPaths')); page.querySelector('#btnRestartServer').addEventListener('click', DashboardPage.restart); page.querySelector('#btnShutdown').addEventListener('click', DashboardPage.shutdown); @@ -850,6 +852,8 @@ export default function (view) { button: page.querySelector('.btnRefresh') }); + if (unmountPathsWidget) unmountPathsWidget(); + page.querySelector('#btnRestartServer').removeEventListener('click', DashboardPage.restart); page.querySelector('#btnShutdown').removeEventListener('click', DashboardPage.shutdown); }); diff --git a/src/apps/dashboard/features/storage/components/StorageListItem.tsx b/src/apps/dashboard/features/storage/components/StorageListItem.tsx index b93696f39..9628de615 100644 --- a/src/apps/dashboard/features/storage/components/StorageListItem.tsx +++ b/src/apps/dashboard/features/storage/components/StorageListItem.tsx @@ -22,13 +22,13 @@ interface StorageListItemProps { const calculateUsed = (folder?: FolderStorageDto) => { if (typeof folder?.UsedSpace === 'undefined') return 0; if (typeof folder.FreeSpace === 'undefined') return 100; - + const totalSpace = folder.FreeSpace + folder.UsedSpace; if (totalSpace === 0) return 0; - + // Ensure we don't have negative values const usedSpace = Math.max(0, folder.UsedSpace); - + return Math.min(100, (usedSpace / totalSpace) * 100); }; diff --git a/src/utils/reactUtils.tsx b/src/utils/reactUtils.tsx index be4e518ec..80abdd512 100644 --- a/src/utils/reactUtils.tsx +++ b/src/utils/reactUtils.tsx @@ -14,12 +14,16 @@ export const renderComponent =

( props: P, element: HTMLElement ) => { - createRoot(element) - .render( - - - - ); + const root = createRoot(element); + root.render( + + + + ); + + // NOTE: We need to wrap the unmount in a setTimeout to workaround this issue with nested roots: + // https://github.com/facebook/react/issues/25675 + return () => setTimeout(() => root.unmount()); }; const RootContext: React.FC = ({ children }) => { From f2a1f9e1b87a7530ec0576a4f23b0ca6be218a1a Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Thu, 5 Jun 2025 15:05:13 -0400 Subject: [PATCH 6/6] Add handling of negative space values --- .../storage/components/StorageListItem.tsx | 29 +++++---------- .../features/storage/utils/space.test.ts | 35 +++++++++++++++++++ .../dashboard/features/storage/utils/space.ts | 20 +++++++++++ 3 files changed, 64 insertions(+), 20 deletions(-) create mode 100644 src/apps/dashboard/features/storage/utils/space.test.ts create mode 100644 src/apps/dashboard/features/storage/utils/space.ts diff --git a/src/apps/dashboard/features/storage/components/StorageListItem.tsx b/src/apps/dashboard/features/storage/components/StorageListItem.tsx index 9628de615..cf8226235 100644 --- a/src/apps/dashboard/features/storage/components/StorageListItem.tsx +++ b/src/apps/dashboard/features/storage/components/StorageListItem.tsx @@ -11,6 +11,7 @@ import globalize from 'lib/globalize'; import { getReadableSize } from 'utils/file'; import { StorageType } from '../constants/StorageType'; +import { calculateTotal, calculateUsedPercentage } from '../utils/space'; import StorageTypeIcon from './StorageTypeIcon'; @@ -19,19 +20,6 @@ interface StorageListItemProps { folder?: FolderStorageDto } -const calculateUsed = (folder?: FolderStorageDto) => { - if (typeof folder?.UsedSpace === 'undefined') return 0; - if (typeof folder.FreeSpace === 'undefined') return 100; - - const totalSpace = folder.FreeSpace + folder.UsedSpace; - if (totalSpace === 0) return 0; - - // Ensure we don't have negative values - const usedSpace = Math.max(0, folder.UsedSpace); - - return Math.min(100, (usedSpace / totalSpace) * 100); -}; - const getStatusColor = (percent: number) => { if (percent >= 90) return 'error'; if (percent >= 80) return 'warning'; @@ -52,11 +40,12 @@ 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'; + const readableUsedSpace = (typeof folder?.UsedSpace === 'undefined' || folder.UsedSpace < 0) ? + '?' : getReadableSize(folder.UsedSpace); + const totalSpace = calculateTotal(folder); + const readableTotalSpace = (totalSpace < 0) ? '?' : getReadableSize(totalSpace); + const usedPercentage = calculateUsedPercentage(folder); + const statusColor = folder ? getStatusColor(usedPercentage) : 'primary'; return ( @@ -87,7 +76,7 @@ const StorageListItem: FC = ({ = ({ textAlign: 'end' }} > - {`${usedSpace} / ${totalSpace}`} + {`${readableUsedSpace} / ${readableTotalSpace}`} } diff --git a/src/apps/dashboard/features/storage/utils/space.test.ts b/src/apps/dashboard/features/storage/utils/space.test.ts new file mode 100644 index 000000000..f234a9fd0 --- /dev/null +++ b/src/apps/dashboard/features/storage/utils/space.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { calculateUsedPercentage, calculateTotal } from './space'; + +describe('calculateTotal()', () => { + it('should return the total', () => { + expect(calculateTotal({ FreeSpace: 1, UsedSpace: 2 })).toBe(3); + }); + + it('should return -1 for invalid used space values', () => { + expect(calculateTotal({ FreeSpace: 1 })).toBe(-1); + expect(calculateTotal({ FreeSpace: 1, UsedSpace: -1 })).toBe(-1); + }); + + it('should treat invalid free space values as 0', () => { + expect(calculateTotal({ UsedSpace: 1 })).toBe(1); + expect(calculateTotal({ FreeSpace: -1, UsedSpace: 1 })).toBe(1); + }); +}); + +describe('calculateUsedPercentage', () => { + it('should return the percentage used', () => { + expect(calculateUsedPercentage({ FreeSpace: 1, UsedSpace: 3 })).toBe(75); + }); + + it('should return 0 for invalid used space values', () => { + expect(calculateUsedPercentage({ FreeSpace: 1 })).toBe(0); + expect(calculateUsedPercentage({ FreeSpace: 1, UsedSpace: -1 })).toBe(0); + }); + + it('should return 100 for invalid free space values', () => { + expect(calculateUsedPercentage({ UsedSpace: 1 })).toBe(100); + expect(calculateUsedPercentage({ FreeSpace: -1, UsedSpace: 1 })).toBe(100); + }); +}); diff --git a/src/apps/dashboard/features/storage/utils/space.ts b/src/apps/dashboard/features/storage/utils/space.ts new file mode 100644 index 000000000..d18e447ae --- /dev/null +++ b/src/apps/dashboard/features/storage/utils/space.ts @@ -0,0 +1,20 @@ +import type { FolderStorageDto } from '@jellyfin/sdk/lib/generated-client/models/folder-storage-dto'; + +export const calculateTotal = (folder?: FolderStorageDto) => { + if (typeof folder?.UsedSpace === 'undefined' || folder.UsedSpace < 0) { + return -1; + } + + const freeSpace = Math.max(0, folder.FreeSpace || 0); + const usedSpace = Math.max(0, folder.UsedSpace); + return freeSpace + usedSpace; +}; + +export const calculateUsedPercentage = (folder?: FolderStorageDto) => { + const totalSpace = calculateTotal(folder); + if (totalSpace <= 0) return 0; + + const usedSpace = folder?.UsedSpace || 0; + + return Math.min(100, (usedSpace / totalSpace) * 100); +};