From 3bad9f03782a271eeaf64b579e3b7d4dd35d0906 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Sun, 4 Jun 2023 02:34:40 -0400 Subject: [PATCH] Rewrite activity log page in dashboard --- package-lock.json | 48 +++++ package.json | 1 + .../components/AppToolbar/UserMenuButton.tsx | 19 +- .../experimental/routes/asyncRoutes/admin.ts | 1 + src/apps/stable/routes/asyncRoutes/admin.ts | 1 + src/apps/stable/routes/dashboard/activity.tsx | 175 ++++++++++++++++++ src/components/UserAvatar.tsx | 34 ++++ src/strings/en-us.json | 9 + 8 files changed, 272 insertions(+), 16 deletions(-) create mode 100644 src/apps/stable/routes/dashboard/activity.tsx create mode 100644 src/components/UserAvatar.tsx diff --git a/package-lock.json b/package-lock.json index 64c7e6147..60da03f2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 0a3d6f7e6..a9f275083 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/apps/experimental/components/AppToolbar/UserMenuButton.tsx b/src/apps/experimental/components/AppToolbar/UserMenuButton.tsx index 85db4d1f2..89f18669e 100644 --- a/src/apps/experimental/components/AppToolbar/UserMenuButton.tsx +++ b/src/apps/experimental/components/AppToolbar/UserMenuButton.tsx @@ -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); const isUserMenuOpen = Boolean(userMenuAnchorEl); @@ -37,18 +35,7 @@ const UserMenuButton = () => { color='inherit' sx={{ padding: 0 }} > - + diff --git a/src/apps/experimental/routes/asyncRoutes/admin.ts b/src/apps/experimental/routes/asyncRoutes/admin.ts index 72bcc6f32..b06c8cac8 100644 --- a/src/apps/experimental/routes/asyncRoutes/admin.ts +++ b/src/apps/experimental/routes/asyncRoutes/admin.ts @@ -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' }, diff --git a/src/apps/stable/routes/asyncRoutes/admin.ts b/src/apps/stable/routes/asyncRoutes/admin.ts index 72bcc6f32..b06c8cac8 100644 --- a/src/apps/stable/routes/asyncRoutes/admin.ts +++ b/src/apps/stable/routes/asyncRoutes/admin.ts @@ -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' }, diff --git a/src/apps/stable/routes/dashboard/activity.tsx b/src/apps/stable/routes/dashboard/activity.tsx new file mode 100644 index 000000000..8d8bea978 --- /dev/null +++ b/src/apps/stable/routes/dashboard/activity.tsx @@ -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 ( + + ); +}; + +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 ? ( + + ) : undefined + ) + }, + { + field: 'User', + headerName: globalize.translate('LabelUser'), + width: 60, + valueGetter: ({ row }) => users[row.UserId]?.Name, + renderCell: ({ row }) => ( + + ) + }, + { + 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([]); + const [ users, setUsers ] = useState>({}); + + useEffect(() => { + if (api) { + const fetchUsers = async () => { + const { data } = await getUserApi(api).getUsers(); + const usersById: Record = {}; + 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 ( + +
+

{globalize.translate('HeaderActivity')}

+ +
+
+ ); +}; + +export default Activity; diff --git a/src/components/UserAvatar.tsx b/src/components/UserAvatar.tsx new file mode 100644 index 000000000..0f1780280 --- /dev/null +++ b/src/components/UserAvatar.tsx @@ -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 = ({ user, showTitle = false }) => { + const { api } = useApi(); + const theme = useTheme(); + + return user ? ( + + ) : null; +}; + +export default UserAvatar; diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 5cc2db0ca..c42505095 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -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",