diff --git a/app/Http/Controllers/Api/Client/Servers/ActivityLogController.php b/app/Http/Controllers/Api/Client/Servers/ActivityLogController.php new file mode 100644 index 000000000..979384db0 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/ActivityLogController.php @@ -0,0 +1,36 @@ +authorize(Permission::ACTION_ACTIVITY_READ, $server); + + $activity = QueryBuilder::for($server->activity()) + ->with('actor') + ->allowedSorts(['timestamp']) + ->allowedFilters([ + AllowedFilter::exact('ip'), + AllowedFilter::partial('event'), + ]) + ->paginate(min($request->query('per_page', 25), 100)) + ->appends($request->query()); + + return $this->fractal->collection($activity) + ->transformWith($this->getTransformer(ActivityLogTransformer::class)) + ->toArray(); + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/DatabaseController.php b/app/Http/Controllers/Api/Client/Servers/DatabaseController.php index dc9186640..34f2139f6 100644 --- a/app/Http/Controllers/Api/Client/Servers/DatabaseController.php +++ b/app/Http/Controllers/Api/Client/Servers/DatabaseController.php @@ -101,7 +101,10 @@ class DatabaseController extends ClientApiController $this->passwordService->handle($database); $database->refresh(); - Activity::event('server:database.rotate-password')->subject($database)->log(); + Activity::event('server:database.rotate-password') + ->subject($database) + ->property('name', $database->database) + ->log(); return $this->fractal->item($database) ->parseIncludes(['password']) diff --git a/app/Models/Permission.php b/app/Models/Permission.php index 5a0fc95f8..26dc610dc 100644 --- a/app/Models/Permission.php +++ b/app/Models/Permission.php @@ -63,6 +63,8 @@ class Permission extends Model public const ACTION_SETTINGS_RENAME = 'settings.rename'; public const ACTION_SETTINGS_REINSTALL = 'settings.reinstall'; + public const ACTION_ACTIVITY_READ = 'activity.read'; + /** * Should timestamps be used on this model. * @@ -210,6 +212,13 @@ class Permission extends Model 'reinstall' => 'Allows a user to trigger a reinstall of this server.', ], ], + + 'activity' => [ + 'description' => 'Permissions that control a user\'s access to the server activity logs.', + 'keys' => [ + 'read' => 'Allows a user to view the activity logs for the server.', + ], + ], ]; /** diff --git a/resources/scripts/api/server/activity.ts b/resources/scripts/api/server/activity.ts new file mode 100644 index 000000000..4c195b3df --- /dev/null +++ b/resources/scripts/api/server/activity.ts @@ -0,0 +1,28 @@ +import useUserSWRContentKey from '@/plugins/useUserSWRContentKey'; +import useSWR, { ConfigInterface, responseInterface } from 'swr'; +import { ActivityLog, Transformers } from '@definitions/user'; +import { AxiosError } from 'axios'; +import http, { PaginatedResult, QueryBuilderParams, withQueryBuilderParams } from '@/api/http'; +import { toPaginatedSet } from '@definitions/helpers'; +import useFilteredObject from '@/plugins/useFilteredObject'; +import { ServerContext } from '@/state/server'; + +export type ActivityLogFilters = QueryBuilderParams<'ip' | 'event', 'timestamp'>; + +const useActivityLogs = (filters?: ActivityLogFilters, config?: ConfigInterface, AxiosError>): responseInterface, AxiosError> => { + const uuid = ServerContext.useStoreState(state => state.server.data?.uuid); + const key = useUserSWRContentKey([ 'server', 'activity', JSON.stringify(useFilteredObject(filters || {})) ]); + + return useSWR>(key, async () => { + const { data } = await http.get(`/api/client/servers/${uuid}/activity`, { + params: { + ...withQueryBuilderParams(filters), + include: [ 'actor' ], + }, + }); + + return toPaginatedSet(data, Transformers.toActivityLog); + }, { revalidateOnMount: false, ...(config || {}) }); +}; + +export { useActivityLogs }; diff --git a/resources/scripts/components/elements/activity/ActivityLogMetaButton.tsx b/resources/scripts/components/elements/activity/ActivityLogMetaButton.tsx index bcb576990..e893a5008 100644 --- a/resources/scripts/components/elements/activity/ActivityLogMetaButton.tsx +++ b/resources/scripts/components/elements/activity/ActivityLogMetaButton.tsx @@ -19,7 +19,9 @@ export default ({ meta }: { meta: Record }) => { hideCloseIcon title={'Metadata'} > -
{JSON.stringify(meta, null, 2)}
+
+                    {JSON.stringify(meta, null, 2)}
+                
setOpen(false)}>Close diff --git a/resources/scripts/components/server/ServerActivityLogContainer.tsx b/resources/scripts/components/server/ServerActivityLogContainer.tsx new file mode 100644 index 000000000..46c959d73 --- /dev/null +++ b/resources/scripts/components/server/ServerActivityLogContainer.tsx @@ -0,0 +1,65 @@ +import React, { useEffect, useState } from 'react'; +import { useActivityLogs } from '@/api/server/activity'; +import ServerContentBlock from '@/components/elements/ServerContentBlock'; +import { useFlashKey } from '@/plugins/useFlash'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import Spinner from '@/components/elements/Spinner'; +import ActivityLogEntry from '@/components/elements/activity/ActivityLogEntry'; +import PaginationFooter from '@/components/elements/table/PaginationFooter'; +import { ActivityLogFilters } from '@/api/account/activity'; +import { Link } from 'react-router-dom'; +import classNames from 'classnames'; +import { styles as btnStyles } from '@/components/elements/button'; +import { XCircleIcon } from '@heroicons/react/solid'; + +export default () => { + const { clearAndAddHttpError } = useFlashKey('server:activity'); + const [ filters, setFilters ] = useState({ page: 1, sorts: { timestamp: -1 } }); + + const { data, isValidating, error } = useActivityLogs(filters, { + revalidateOnMount: true, + revalidateOnFocus: false, + }); + + useEffect(() => { + const parsed = new URLSearchParams(location.search); + + setFilters(value => ({ ...value, filters: { ip: parsed.get('ip'), event: parsed.get('event') } })); + }, [ location.search ]); + + useEffect(() => { + clearAndAddHttpError(error); + }, [ error ]); + + return ( + + + {(filters.filters?.event || filters.filters?.ip) && +
+ setFilters(value => ({ ...value, filters: {} }))} + > + Clear Filters + +
+ } + {!data && isValidating ? + + : +
+ {data?.items.map((activity) => ( + + + + ))} +
+ } + {data && setFilters(value => ({ ...value, page }))} + />} +
+ ); +}; diff --git a/resources/scripts/routers/routes.ts b/resources/scripts/routers/routes.ts index 51c6997c7..56b7b0461 100644 --- a/resources/scripts/routers/routes.ts +++ b/resources/scripts/routers/routes.ts @@ -12,6 +12,7 @@ import AccountOverviewContainer from '@/components/dashboard/AccountOverviewCont import AccountApiContainer from '@/components/dashboard/AccountApiContainer'; import AccountSSHContainer from '@/components/dashboard/ssh/AccountSSHContainer'; import ActivityLogContainer from '@/components/dashboard/activity/ActivityLogContainer'; +import ServerActivityLogContainer from '@/components/server/ServerActivityLogContainer'; // Each of the router files is already code split out appropriately — so // all of the items above will only be loaded in when that router is loaded. @@ -133,5 +134,11 @@ export default { name: 'Settings', component: SettingsContainer, }, + { + path: '/activity', + permission: 'activity.*', + name: 'Activity', + component: ServerActivityLogContainer, + }, ], } as Routes; diff --git a/routes/api-client.php b/routes/api-client.php index 58312eb2a..b839e2aa7 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -62,6 +62,7 @@ Route::group([ Route::get('/', [Client\Servers\ServerController::class, 'index'])->name('api:client:server.view'); Route::get('/websocket', Client\Servers\WebsocketController::class)->name('api:client:server.ws'); Route::get('/resources', Client\Servers\ResourceUtilizationController::class)->name('api:client:server.resources'); + Route::get('/activity', Client\Servers\ActivityLogController::class)->name('api:client:server.activity'); Route::post('/command', [Client\Servers\CommandController::class, 'index']); Route::post('/power', [Client\Servers\PowerController::class, 'index']);