From c6e8b893c815cef2aee7f71c10801b5b8ea39391 Mon Sep 17 00:00:00 2001 From: DaneEveritt Date: Sun, 5 Jun 2022 18:35:53 -0400 Subject: [PATCH] Add basic activity log view --- resources/scripts/api/account/activity.ts | 24 ++++++ resources/scripts/api/definitions/helpers.ts | 31 ++++++-- .../api/definitions/user/transformers.ts | 6 +- resources/scripts/api/http.ts | 56 +++++++++++++- .../activity/ActivityLogContainer.tsx | 73 +++++++++++++++++++ .../elements/button/style.module.css | 10 ++- .../elements/table/PaginationFooter.tsx | 64 ++++++++++++++++ .../components/elements/tooltip/Tooltip.tsx | 15 ++-- resources/scripts/routers/DashboardRouter.tsx | 5 ++ 9 files changed, 267 insertions(+), 17 deletions(-) create mode 100644 resources/scripts/api/account/activity.ts create mode 100644 resources/scripts/components/dashboard/activity/ActivityLogContainer.tsx create mode 100644 resources/scripts/components/elements/table/PaginationFooter.tsx diff --git a/resources/scripts/api/account/activity.ts b/resources/scripts/api/account/activity.ts new file mode 100644 index 000000000..24547a849 --- /dev/null +++ b/resources/scripts/api/account/activity.ts @@ -0,0 +1,24 @@ +import useUserSWRContentKey from '@/plugins/useUserSWRContentKey'; +import useSWR, { ConfigInterface, responseInterface } from 'swr'; +import { ActivityLog, Transformers } from '@definitions/user'; +import { AxiosError } from 'axios'; +import http, { PaginatedResult } from '@/api/http'; +import { toPaginatedSet } from '@definitions/helpers'; + +const useActivityLogs = (page = 1, config?: ConfigInterface, AxiosError>): responseInterface, AxiosError> => { + const key = useUserSWRContentKey([ 'account', 'activity', page.toString() ]); + + return useSWR>(key, async () => { + const { data } = await http.get('/api/client/account/activity', { + params: { + include: [ 'actor' ], + sort: '-timestamp', + page: page, + }, + }); + + return toPaginatedSet(data, Transformers.toActivityLog); + }, { revalidateOnMount: false, ...(config || {}) }); +}; + +export { useActivityLogs }; diff --git a/resources/scripts/api/definitions/helpers.ts b/resources/scripts/api/definitions/helpers.ts index 5e1afd656..8130e2b14 100644 --- a/resources/scripts/api/definitions/helpers.ts +++ b/resources/scripts/api/definitions/helpers.ts @@ -1,13 +1,20 @@ -import { FractalResponseData, FractalResponseList } from '@/api/http'; +import { + FractalPaginatedResponse, + FractalResponseData, + FractalResponseList, + getPaginationSet, + PaginatedResult, +} from '@/api/http'; +import { Model } from '@definitions/index'; -type Transformer = (callback: FractalResponseData) => T; +type TransformerFunc = (callback: FractalResponseData) => T; const isList = (data: FractalResponseList | FractalResponseData): data is FractalResponseList => data.object === 'list'; -function transform(data: null | undefined, transformer: Transformer, missing?: M): M; -function transform(data: FractalResponseData | null | undefined, transformer: Transformer, missing?: M): T | M; -function transform(data: FractalResponseList | null | undefined, transformer: Transformer, missing?: M): T[] | M; -function transform (data: FractalResponseData | FractalResponseList | null | undefined, transformer: Transformer, missing = undefined) { +function transform(data: null | undefined, transformer: TransformerFunc, missing?: M): M; +function transform(data: FractalResponseData | null | undefined, transformer: TransformerFunc, missing?: M): T | M; +function transform(data: FractalResponseList | FractalPaginatedResponse | null | undefined, transformer: TransformerFunc, missing?: M): T[] | M; +function transform (data: FractalResponseData | FractalResponseList | FractalPaginatedResponse | null | undefined, transformer: TransformerFunc, missing = undefined) { if (data === undefined || data === null) { return missing; } @@ -23,4 +30,14 @@ function transform (data: FractalResponseData | FractalResponseList | null | return transformer(data); } -export { transform }; +function toPaginatedSet> ( + response: FractalPaginatedResponse, + transformer: T, +): PaginatedResult> { + return { + items: transform(response, transformer) as ReturnType[], + pagination: getPaginationSet(response.meta.pagination), + }; +} + +export { transform, toPaginatedSet }; diff --git a/resources/scripts/api/definitions/user/transformers.ts b/resources/scripts/api/definitions/user/transformers.ts index 6532cdd93..3941d539e 100644 --- a/resources/scripts/api/definitions/user/transformers.ts +++ b/resources/scripts/api/definitions/user/transformers.ts @@ -3,7 +3,7 @@ import { FractalResponseData } from '@/api/http'; import { transform } from '@definitions/helpers'; export default class Transformers { - static toSSHKey (data: Record): Models.SSHKey { + static toSSHKey = (data: Record): Models.SSHKey => { return { name: data.name, publicKey: data.public_key, @@ -12,7 +12,7 @@ export default class Transformers { }; } - static toUser ({ attributes }: FractalResponseData): Models.User { + static toUser = ({ attributes }: FractalResponseData): Models.User => { return { uuid: attributes.uuid, username: attributes.username, @@ -27,7 +27,7 @@ export default class Transformers { }; } - static toActivityLog ({ attributes }: FractalResponseData): Models.ActivityLog { + static toActivityLog = ({ attributes }: FractalResponseData): Models.ActivityLog => { const { actor } = attributes.relationships || {}; return { diff --git a/resources/scripts/api/http.ts b/resources/scripts/api/http.ts index cd6b8e512..1ba5f7bbf 100644 --- a/resources/scripts/api/http.ts +++ b/resources/scripts/api/http.ts @@ -77,12 +77,26 @@ export interface FractalResponseList { data: FractalResponseData[]; } +export interface FractalPaginatedResponse extends FractalResponseList { + meta: { + pagination: { + total: number; + count: number; + /* eslint-disable camelcase */ + per_page: number; + current_page: number; + total_pages: number; + /* eslint-enable camelcase */ + }; + } +} + export interface PaginatedResult { items: T[]; pagination: PaginationDataSet; } -interface PaginationDataSet { +export interface PaginationDataSet { total: number; count: number; perPage: number; @@ -99,3 +113,43 @@ export function getPaginationSet (data: any): PaginationDataSet { totalPages: data.total_pages, }; } + +type QueryBuilderFilterValue = string | number | boolean | null; + +export interface QueryBuilderParams { + filters?: { + [K in FilterKeys]?: QueryBuilderFilterValue | Readonly; + }; + sorts?: { + [K in SortKeys]?: -1 | 0 | 1 | 'asc' | 'desc' | null; + }; +} + +/** + * Helper function that parses a data object provided and builds query parameters + * for the Laravel Query Builder package automatically. This will apply sorts and + * filters deterministically based on the provided values. + */ +export const withQueryBuilderParams = (data?: QueryBuilderParams): Record => { + if (!data) return {}; + + const filters = Object.keys(data.filters || {}).reduce((obj, key) => { + const value = data.filters?.[key]; + + return !value || value === '' ? obj : { ...obj, [`filter[${key}]`]: value }; + }, {} as NonNullable); + + const sorts = Object.keys(data.sorts || {}).reduce((arr, key) => { + const value = data.sorts?.[key]; + if (!value || ![ 'asc', 'desc', 1, -1 ].includes(value)) { + return arr; + } + + return [ ...arr, (value === -1 || value === 'desc' ? '-' : '') + key ]; + }, [] as string[]); + + return { + ...filters, + sorts: !sorts.length ? undefined : sorts.join(','), + }; +}; diff --git a/resources/scripts/components/dashboard/activity/ActivityLogContainer.tsx b/resources/scripts/components/dashboard/activity/ActivityLogContainer.tsx new file mode 100644 index 000000000..7c4db62f9 --- /dev/null +++ b/resources/scripts/components/dashboard/activity/ActivityLogContainer.tsx @@ -0,0 +1,73 @@ +import React, { useEffect, useState } from 'react'; +import { useActivityLogs } from '@/api/account/activity'; +import { useFlashKey } from '@/plugins/useFlash'; +import PageContentBlock from '@/components/elements/PageContentBlock'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import { format, formatDistanceToNowStrict } from 'date-fns'; +import { Link } from 'react-router-dom'; +import PaginationFooter from '@/components/elements/table/PaginationFooter'; +import { UserIcon } from '@heroicons/react/outline'; +import Tooltip from '@/components/elements/tooltip/Tooltip'; +import { DesktopComputerIcon } from '@heroicons/react/solid'; + +export default () => { + const { clearAndAddHttpError } = useFlashKey('account'); + const [ page, setPage ] = useState(1); + const { data, isValidating: _, error } = useActivityLogs(page, { + revalidateOnMount: true, + revalidateOnFocus: false, + }); + + useEffect(() => { + clearAndAddHttpError(error); + }, [ error ]); + + return ( + + +
+ {data?.items.map((activity) => ( +
+
+
+ {activity.relationships.actor ? + {'User + : + + } +
+
+
+
+ {activity.relationships.actor?.username || 'system'} +  —  + + {activity.event} + + {typeof activity.properties.useragent === 'string' && + + + + } +
+ {/*

{activity.description || JSON.stringify(activity.properties)}

*/} +
+ {activity.ip} +  |  + + + {formatDistanceToNowStrict(activity.timestamp, { addSuffix: true })} + + +
+
+
+ ))} +
+ {data && } +
+ ); +}; diff --git a/resources/scripts/components/elements/button/style.module.css b/resources/scripts/components/elements/button/style.module.css index 6956422ad..fb91dd900 100644 --- a/resources/scripts/components/elements/button/style.module.css +++ b/resources/scripts/components/elements/button/style.module.css @@ -19,10 +19,18 @@ @apply p-1; } } + + &:disabled { + @apply cursor-not-allowed; + } } .text { - @apply bg-transparent focus:ring-neutral-300 focus:ring-opacity-50 hover:bg-neutral-500 active:bg-neutral-500; + @apply text-gray-50 bg-transparent focus:ring-neutral-300 focus:ring-opacity-50 hover:bg-neutral-500 active:bg-neutral-500; + + &:disabled { + @apply hover:bg-transparent text-gray-300; + } } .danger { diff --git a/resources/scripts/components/elements/table/PaginationFooter.tsx b/resources/scripts/components/elements/table/PaginationFooter.tsx new file mode 100644 index 000000000..fb1c53a8d --- /dev/null +++ b/resources/scripts/components/elements/table/PaginationFooter.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { PaginationDataSet } from '@/api/http'; +import classNames from 'classnames'; +import { Button } from '@/components/elements/button/index'; +import { ChevronDoubleLeftIcon, ChevronDoubleRightIcon } from '@heroicons/react/solid'; + +interface Props { + className?: string; + pagination: PaginationDataSet; + onPageSelect: (page: number) => void; +} + +const PaginationFooter = ({ pagination, className, onPageSelect }: Props) => { + const start = (pagination.currentPage - 1) * pagination.perPage; + const end = ((pagination.currentPage - 1) * pagination.perPage) + pagination.count; + + const { currentPage: current, totalPages: total } = pagination; + + const pages = { previous: [] as number[], next: [] as number[] }; + for (let i = 1; i <= 2; i++) { + if (current - i >= 1) { + pages.previous.push(current - i); + } + if (current + i <= total) { + pages.next.push(current + i); + } + } + + return ( +
+

+ Showing  + + {Math.max(start, Math.min(pagination.total, 1))} +  to  + {end} of  + {pagination.total} results. +

+ {pagination.totalPages > 1 && +
+ onPageSelect(1)}> + + + {pages.previous.reverse().map((value) => ( + onPageSelect(value)}> + {value} + + ))} + + {pages.next.map((value) => ( + onPageSelect(value)}> + {value} + + ))} + onPageSelect(total)}> + + +
+ } +
+ ); +}; + +export default PaginationFooter; diff --git a/resources/scripts/components/elements/tooltip/Tooltip.tsx b/resources/scripts/components/elements/tooltip/Tooltip.tsx index 9538bfe4d..d8409e0bd 100644 --- a/resources/scripts/components/elements/tooltip/Tooltip.tsx +++ b/resources/scripts/components/elements/tooltip/Tooltip.tsx @@ -19,6 +19,7 @@ import { AnimatePresence, motion } from 'framer-motion'; interface Props { content: string | React.ReactChild; + disabled?: boolean; arrow?: boolean; placement?: Placement; strategy?: Strategy; @@ -33,8 +34,8 @@ const arrowSides: Record = { right: 'left', }; -export default ({ content, children, ...props }: Props) => { - const arrowEl = useRef(null); +export default ({ content, children, disabled = false, ...props }: Props) => { + const arrowEl = useRef(null); const [ open, setOpen ] = useState(false); const { x, y, reference, floating, middlewareData, strategy, context } = useFloating({ @@ -56,12 +57,16 @@ export default ({ content, children, ...props }: Props) => { const side = arrowSides[(props.placement || 'top').split('-')[0] as Side]; const { x: ax, y: ay } = middlewareData.arrow || {}; + if (disabled) { + return children; + } + return ( <> {cloneElement(children, getReferenceProps({ ref: reference, ...children.props }))} {open && - { > {content} {props.arrow && - { className={'absolute top-0 left-0 bg-gray-900 w-3 h-3 rotate-45'} /> } - + } diff --git a/resources/scripts/routers/DashboardRouter.tsx b/resources/scripts/routers/DashboardRouter.tsx index 2d907221a..f2f41cb62 100644 --- a/resources/scripts/routers/DashboardRouter.tsx +++ b/resources/scripts/routers/DashboardRouter.tsx @@ -9,6 +9,7 @@ import TransitionRouter from '@/TransitionRouter'; import SubNavigation from '@/components/elements/SubNavigation'; import AccountSSHContainer from '@/components/dashboard/ssh/AccountSSHContainer'; import { useLocation } from 'react-router'; +import ActivityLogContainer from '@/components/dashboard/activity/ActivityLogContainer'; export default () => { const location = useLocation(); @@ -22,6 +23,7 @@ export default () => { Settings API Credentials SSH Keys + Activity } @@ -39,6 +41,9 @@ export default () => { + + +