Migrate paths dashboard widget to react and add storage metrics

This commit is contained in:
Bill Thornton
2025-06-03 16:28:22 -04:00
parent a938650ded
commit 09063d3376
11 changed files with 322 additions and 81 deletions

View File

@@ -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 (
<>
<Button
variant='text'
color='inherit'
endIcon={<ChevronRight />}
sx={{
marginTop: 1,
marginBottom: 1
}}
// NOTE: We should use a react-router Link component, but components rendered in legacy views lack the
// routing context
href='#/dashboard/settings'
>
<Typography variant='h3' component='span'>
{globalize.translate('HeaderPaths')}
</Typography>
</Button>
<List sx={{ bgcolor: 'background.paper' }}>
<StorageListItem
label={globalize.translate('LabelCache')}
folder={systemStorage?.CacheFolder}
/>
<StorageListItem
label={globalize.translate('LabelImageCache')}
folder={systemStorage?.ImageCacheFolder}
/>
<StorageListItem
label={globalize.translate('LabelProgramData')}
folder={systemStorage?.ProgramDataFolder}
/>
<StorageListItem
label={globalize.translate('LabelLogs')}
folder={systemStorage?.LogFolder}
/>
<StorageListItem
label={globalize.translate('LabelMetadata')}
folder={systemStorage?.InternalMetadataFolder}
/>
<StorageListItem
label={globalize.translate('LabelTranscodes')}
folder={systemStorage?.TranscodingTempFolder}
/>
<StorageListItem
label={globalize.translate('LabelWeb')}
folder={systemStorage?.WebFolder}
/>
</List>
</>
);
};
export default ServerPathWidget;

View File

@@ -75,44 +75,7 @@
</div>
</div>
<div class="dashboardSection">
<a is="emby-linkbutton" href="#/dashboard/settings" class="button-flat sectionTitleTextButton">
<h3>${HeaderPaths}</h3>
<span class="material-icons chevron_right" aria-hidden="true"></span>
</a>
<div class="paperList">
<div class="listItem listItem-border">
<div class="listItemBody two-line">
<div class="listItemBodyText secondary" style="margin:0;">${LabelCache}</div>
<div class="listItemBodyText" id="cachePath" dir="ltr" style="text-align: left;"></div>
</div>
</div>
<div class="listItem listItem-border">
<div class="listItemBody two-line">
<div class="listItemBodyText secondary" style="margin:0;">${LabelLogs}</div>
<div class="listItemBodyText" id="logPath" dir="ltr" style="text-align: left;"></div>
</div>
</div>
<div class="listItem listItem-border">
<div class="listItemBody two-line">
<div class="listItemBodyText secondary" style="margin:0;">${LabelMetadata}</div>
<div class="listItemBodyText" id="metadataPath" dir="ltr" style="text-align: left;"></div>
</div>
</div>
<div class="listItem listItem-border">
<div class="listItemBody two-line">
<div class="listItemBodyText secondary" style="margin:0;">${LabelTranscodes}</div>
<div class="listItemBodyText" id="transcodePath" dir="ltr" style="text-align: left;"></div>
</div>
</div>
<div class="listItem listItem-border">
<div class="listItemBody two-line">
<div class="listItemBodyText secondary" style="margin:0;">${LabelWeb}</div>
<div class="listItemBodyText" id="webPath" dir="ltr" style="text-align: left;"></div>
</div>
</div>
</div>
</div>
<div id="serverPaths" class="dashboardSection"></div>
</div>
</div>

View File

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

View File

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

View File

@@ -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<StorageListItemProps> = ({
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 (
<ListItem>
<ListItemIcon title={getStorageTypeText(folder?.StorageType)}>
<StorageTypeIcon type={folder?.StorageType} />
</ListItemIcon>
<ListItemText
primary={
<Typography
component='span'
variant='body2'
>
{label}
</Typography>
}
secondary={
<>
<Typography
color='textPrimary'
sx={{
paddingBottom: 0.5
}}
>
{folder ? folder.Path : (
<Skeleton />
)}
</Typography>
<LinearProgress
variant={folder ? 'determinate' : 'indeterminate'}
color={statusColor}
value={usedPercent}
/>
<Typography
variant='body2'
color='textSecondary'
sx={{
textAlign: 'end'
}}
>
{`${usedSpace} / ${totalSpace}`}
</Typography>
</>
}
slots={{
secondary: 'div'
}}
/>
</ListItem>
);
};
export default StorageListItem;

View File

@@ -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<StorageTypeIconProps> = ({
type
}) => {
switch (type) {
case StorageType.CDRom:
return <Album />;
case StorageType.Network:
return <Lan />;
case StorageType.Ram:
return <Memory />;
case StorageType.Removable:
return <Usb />;
default:
return <Storage />;
}
};
export default StorageTypeIcon;

View File

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

View File

@@ -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",

22
src/utils/file.test.ts Normal file
View File

@@ -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');
});
});

View File

@@ -28,3 +28,11 @@ export function readFileAsText(file: File): Promise<string> {
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');
}

View File

@@ -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(
<RootContext>
<Element {...props} />
</RootContext>
);
if (replace) {
element.replaceWith(domNode);
}
export const renderComponent = <P extends object> (
Component: React.FC<P>,
props: P,
element: HTMLElement
) => {
createRoot(element)
.render(
<RootContext>
<Component {...props} />
</RootContext>
);
};
const RootContext: React.FC<React.PropsWithChildren> = ({ children }) => {
const { theme } = useUserTheme();
return (
<QueryClientProvider client={queryClient}>
<ApiProvider>
<UserSettingsProvider>
<WebConfigProvider>
<ThemeProvider theme={getTheme(theme)}>
<ThemeProvider theme={appTheme} defaultMode='dark'>
{children}
</ThemeProvider>
</WebConfigProvider>