Rewrite activity log page in dashboard

This commit is contained in:
Bill Thornton 2023-06-04 02:34:40 -04:00
parent 13aa3c9efa
commit 3bad9f0378
8 changed files with 272 additions and 16 deletions

48
package-lock.json generated
View file

@ -21,6 +21,7 @@
"@loadable/component": "5.15.3",
"@mui/icons-material": "5.11.16",
"@mui/material": "5.13.3",
"@mui/x-data-grid": "6.6.0",
"blurhash": "2.0.5",
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
"classnames": "2.3.2",
@ -3329,6 +3330,31 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w=="
},
"node_modules/@mui/x-data-grid": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.6.0.tgz",
"integrity": "sha512-RCAdQM4D0RWLnFCtuv6pJEFqUtH3WmlTU2Av+p3Ir2Utw03FQzc81oOpOZNv/6SXv+rMa+i42pb6fW0U5Xz0AQ==",
"dependencies": {
"@babel/runtime": "^7.21.0",
"@mui/utils": "^5.13.1",
"clsx": "^1.2.1",
"prop-types": "^15.8.1",
"reselect": "^4.1.8"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui"
},
"peerDependencies": {
"@mui/material": "^5.4.1",
"@mui/system": "^5.4.1",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -14521,6 +14547,11 @@
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
"dev": true
},
"node_modules/reselect": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz",
"integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@ -22526,6 +22557,18 @@
}
}
},
"@mui/x-data-grid": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.6.0.tgz",
"integrity": "sha512-RCAdQM4D0RWLnFCtuv6pJEFqUtH3WmlTU2Av+p3Ir2Utw03FQzc81oOpOZNv/6SXv+rMa+i42pb6fW0U5Xz0AQ==",
"requires": {
"@babel/runtime": "^7.21.0",
"@mui/utils": "^5.13.1",
"clsx": "^1.2.1",
"prop-types": "^15.8.1",
"reselect": "^4.1.8"
}
},
"@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -30822,6 +30865,11 @@
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
"dev": true
},
"reselect": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz",
"integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="
},
"resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",

View file

@ -77,6 +77,7 @@
"@loadable/component": "5.15.3",
"@mui/icons-material": "5.11.16",
"@mui/material": "5.13.3",
"@mui/x-data-grid": "6.6.0",
"blurhash": "2.0.5",
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
"classnames": "2.3.2",

View file

@ -1,6 +1,4 @@
import Avatar from '@mui/material/Avatar';
import IconButton from '@mui/material/IconButton';
import { useTheme } from '@mui/material/styles';
import Tooltip from '@mui/material/Tooltip';
import React, { useCallback, useState } from 'react';
@ -8,10 +6,10 @@ import { useApi } from 'hooks/useApi';
import globalize from 'scripts/globalize';
import AppUserMenu, { ID } from './menus/AppUserMenu';
import UserAvatar from 'components/UserAvatar';
const UserMenuButton = () => {
const theme = useTheme();
const { api, user } = useApi();
const { user } = useApi();
const [ userMenuAnchorEl, setUserMenuAnchorEl ] = useState<null | HTMLElement>(null);
const isUserMenuOpen = Boolean(userMenuAnchorEl);
@ -37,18 +35,7 @@ const UserMenuButton = () => {
color='inherit'
sx={{ padding: 0 }}
>
<Avatar
alt={user?.Name || undefined}
src={
api && user?.Id ?
`${api.basePath}/Users/${user.Id}/Images/Primary?tag=${user.PrimaryImageTag}` :
undefined
}
sx={{
bgcolor: theme.palette.primary.dark,
color: 'inherit'
}}
/>
<UserAvatar user={user} />
</IconButton>
</Tooltip>

View file

@ -1,6 +1,7 @@
import { AsyncRoute } from '../../../../components/router/AsyncRoute';
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'dashboard/activity', page: 'dashboard/activity' },
{ path: 'notificationsettings.html', page: 'dashboard/notifications' },
{ path: 'usernew.html', page: 'user/usernew' },
{ path: 'userprofiles.html', page: 'user/userprofiles' },

View file

@ -1,6 +1,7 @@
import { AsyncRoute } from '../../../../components/router/AsyncRoute';
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'dashboard/activity', page: 'dashboard/activity' },
{ path: 'notificationsettings.html', page: 'dashboard/notifications' },
{ path: 'usernew.html', page: 'user/usernew' },
{ path: 'userprofiles.html', page: 'user/userprofiles' },

View file

@ -0,0 +1,175 @@
import React, { useEffect, useState } from 'react';
import { getActivityLogApi } from '@jellyfin/sdk/lib/utils/api/activity-log-api';
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
import Chip from '@mui/material/Chip';
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
import Page from 'components/Page';
import UserAvatar from 'components/UserAvatar';
import { useApi } from 'hooks/useApi';
import globalize from 'scripts/globalize';
const DEFAULT_PAGE_SIZE = 25;
const getRowId = (row: ActivityLogEntry) => row.Id ?? -1;
const LogLevelChip = ({ level }: { level: LogLevel }) => {
let color: 'info' | 'warning' | 'error' | undefined = undefined;
switch (level) {
case LogLevel.Information:
color = 'info';
break;
case LogLevel.Warning:
color = 'warning';
break;
case LogLevel.Error:
case LogLevel.Critical:
color = 'error';
break;
}
const levelText = globalize.translate(`LogLevel.${level}`);
return (
<Chip
size='small'
color={color}
label={levelText}
title={levelText}
/>
);
};
const Activity = () => {
const { api } = useApi();
const columns: GridColDef[] = [
{
field: 'Date',
headerName: globalize.translate('LabelDate'),
width: 180,
type: 'dateTime',
valueGetter: ({ row }) => new Date(row.Date)
},
{
field: 'Severity',
headerName: globalize.translate('LabelLevel'),
width: 110,
renderCell: ({ row }) => (
row.Severity ? (
<LogLevelChip level={row.Severity} />
) : undefined
)
},
{
field: 'User',
headerName: globalize.translate('LabelUser'),
width: 60,
valueGetter: ({ row }) => users[row.UserId]?.Name,
renderCell: ({ row }) => (
<UserAvatar
user={users[row.UserId]}
showTitle
/>
)
},
{
field: 'Name',
headerName: globalize.translate('LabelName'),
width: 200
},
{
field: 'Overview',
headerName: globalize.translate('LabelOverview'),
width: 200,
valueGetter: ({ row }) => row.Overview ?? row.ShortOverview
},
{
field: 'Type',
headerName: globalize.translate('LabelType'),
width: 150
}
];
const [ isLoading, setIsLoading ] = useState(true);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: DEFAULT_PAGE_SIZE
});
const [ rowCount, setRowCount ] = useState(0);
const [ rows, setRows ] = useState<ActivityLogEntry[]>([]);
const [ users, setUsers ] = useState<Record<string, UserDto>>({});
useEffect(() => {
if (api) {
const fetchUsers = async () => {
const { data } = await getUserApi(api).getUsers();
const usersById: Record<string, UserDto> = {};
data.forEach(user => {
if (user.Id) {
usersById[user.Id] = user;
}
});
setUsers(usersById);
};
fetchUsers()
.catch(err => {
console.error('[activity] failed to fetch users', err);
});
}
}, [ api ]);
useEffect(() => {
if (api) {
const fetchActivity = async () => {
const { data } = await getActivityLogApi(api)
.getLogEntries({
startIndex: paginationModel.page * paginationModel.pageSize,
limit: paginationModel.pageSize
});
setRowCount(data.TotalRecordCount ?? 0);
setRows(data.Items ?? []);
setIsLoading(false);
};
fetchActivity()
.catch(err => {
console.error('[activity] failed to fetch activity log entries', err);
});
}
}, [ api, paginationModel ]);
return (
<Page
id='serverActivityPage'
title={globalize.translate('HeaderActivity')}
className='mainAnimatedPage type-interior'
>
<div className='content-primary'>
<h2>{globalize.translate('HeaderActivity')}</h2>
<DataGrid
columns={columns}
rows={rows}
pageSizeOptions={[ 10, 25, 50, 100 ]}
paginationMode='server'
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
rowCount={rowCount}
getRowId={getRowId}
loading={isLoading}
sx={{
minHeight: 500
}}
/>
</div>
</Page>
);
};
export default Activity;

View file

@ -0,0 +1,34 @@
import React, { FC } from 'react';
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
import Avatar from '@mui/material/Avatar';
import { useTheme } from '@mui/material/styles';
import { useApi } from 'hooks/useApi';
interface UserAvatarProps {
user?: UserDto
showTitle?: boolean
}
const UserAvatar: FC<UserAvatarProps> = ({ user, showTitle = false }) => {
const { api } = useApi();
const theme = useTheme();
return user ? (
<Avatar
alt={user.Name ?? undefined}
title={showTitle && user.Name ? user.Name : undefined}
src={
api && user.Id && user.PrimaryImageTag ?
`${api.basePath}/Users/${user.Id}/Images/Primary?tag=${user.PrimaryImageTag}` :
undefined
}
sx={{
bgcolor: theme.palette.primary.dark,
color: 'inherit'
}}
/>
) : null;
};
export default UserAvatar;

View file

@ -602,6 +602,7 @@
"LabelCustomDeviceDisplayNameHelp": "Supply a custom display name or leave empty to use the name reported by the device.",
"LabelCustomRating": "Custom rating",
"LabelDashboardTheme": "Server Dashboard theme",
"LabelDate": "Date",
"LabelDateAdded": "Date added",
"LabelDateAddedBehavior": "Date added behavior for new content",
"LabelDateAddedBehaviorHelp": "If a metadata value is present, it will always be used before either of these options.",
@ -723,6 +724,7 @@
"LabelKodiMetadataUserHelp": "Save watch data to NFO files for other applications to use.",
"LabelLanguage": "Language",
"LabelLanNetworks": "LAN networks",
"LabelLevel": "Level",
"LabelLibraryPageSize": "Library page size",
"LabelLibraryPageSizeHelp": "Set the amount of items to show on a library page. Set to 0 in order to disable paging.",
"LabelMaxDaysForNextUp": "Max days in 'Next Up'",
@ -1006,6 +1008,13 @@
"LiveBroadcasts": "Live broadcasts",
"LiveTV": "Live TV",
"Localization": "Localization",
"LogLevel.Trace": "Trace",
"LogLevel.Debug": "Debug",
"LogLevel.Information": "Information",
"LogLevel.Warning": "Warning",
"LogLevel.Error": "Error",
"LogLevel.Critical": "Critical",
"LogLevel.None": "None",
"Logo": "Logo",
"Lyricist": "Lyricist",
"ManageLibrary": "Manage library",