Add basic server activity log view
This commit is contained in:
parent
0b4936ff1c
commit
2f1c8ae91d
8 changed files with 153 additions and 2 deletions
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
|
||||||
|
|
||||||
|
use Pterodactyl\Models\Server;
|
||||||
|
use Pterodactyl\Models\Permission;
|
||||||
|
use Spatie\QueryBuilder\QueryBuilder;
|
||||||
|
use Spatie\QueryBuilder\AllowedFilter;
|
||||||
|
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
|
||||||
|
use Pterodactyl\Transformers\Api\Client\ActivityLogTransformer;
|
||||||
|
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
|
||||||
|
|
||||||
|
class ActivityLogController extends ClientApiController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Returns the activity logs for a server.
|
||||||
|
*/
|
||||||
|
public function __invoke(ClientApiRequest $request, Server $server): array
|
||||||
|
{
|
||||||
|
$this->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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -101,7 +101,10 @@ class DatabaseController extends ClientApiController
|
||||||
$this->passwordService->handle($database);
|
$this->passwordService->handle($database);
|
||||||
$database->refresh();
|
$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)
|
return $this->fractal->item($database)
|
||||||
->parseIncludes(['password'])
|
->parseIncludes(['password'])
|
||||||
|
|
|
@ -63,6 +63,8 @@ class Permission extends Model
|
||||||
public const ACTION_SETTINGS_RENAME = 'settings.rename';
|
public const ACTION_SETTINGS_RENAME = 'settings.rename';
|
||||||
public const ACTION_SETTINGS_REINSTALL = 'settings.reinstall';
|
public const ACTION_SETTINGS_REINSTALL = 'settings.reinstall';
|
||||||
|
|
||||||
|
public const ACTION_ACTIVITY_READ = 'activity.read';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Should timestamps be used on this model.
|
* 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.',
|
'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.',
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
28
resources/scripts/api/server/activity.ts
Normal file
28
resources/scripts/api/server/activity.ts
Normal file
|
@ -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<PaginatedResult<ActivityLog>, AxiosError>): responseInterface<PaginatedResult<ActivityLog>, AxiosError> => {
|
||||||
|
const uuid = ServerContext.useStoreState(state => state.server.data?.uuid);
|
||||||
|
const key = useUserSWRContentKey([ 'server', 'activity', JSON.stringify(useFilteredObject(filters || {})) ]);
|
||||||
|
|
||||||
|
return useSWR<PaginatedResult<ActivityLog>>(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 };
|
|
@ -19,7 +19,9 @@ export default ({ meta }: { meta: Record<string, unknown> }) => {
|
||||||
hideCloseIcon
|
hideCloseIcon
|
||||||
title={'Metadata'}
|
title={'Metadata'}
|
||||||
>
|
>
|
||||||
<pre>{JSON.stringify(meta, null, 2)}</pre>
|
<pre className={'bg-gray-900 rounded p-2 overflow-x-scroll font-mono text-sm leading-relaxed'}>
|
||||||
|
{JSON.stringify(meta, null, 2)}
|
||||||
|
</pre>
|
||||||
<Dialog.Buttons>
|
<Dialog.Buttons>
|
||||||
<Button.Text onClick={() => setOpen(false)}>Close</Button.Text>
|
<Button.Text onClick={() => setOpen(false)}>Close</Button.Text>
|
||||||
</Dialog.Buttons>
|
</Dialog.Buttons>
|
||||||
|
|
|
@ -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<ActivityLogFilters>({ 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 (
|
||||||
|
<ServerContentBlock title={'Activity Log'}>
|
||||||
|
<FlashMessageRender byKey={'server:activity'}/>
|
||||||
|
{(filters.filters?.event || filters.filters?.ip) &&
|
||||||
|
<div className={'flex justify-end mb-2'}>
|
||||||
|
<Link
|
||||||
|
to={'#'}
|
||||||
|
className={classNames(btnStyles.button, btnStyles.text, 'w-full sm:w-auto')}
|
||||||
|
onClick={() => setFilters(value => ({ ...value, filters: {} }))}
|
||||||
|
>
|
||||||
|
Clear Filters <XCircleIcon className={'w-4 h-4 ml-2'}/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{!data && isValidating ?
|
||||||
|
<Spinner centered/>
|
||||||
|
:
|
||||||
|
<div className={'bg-gray-700'}>
|
||||||
|
{data?.items.map((activity) => (
|
||||||
|
<ActivityLogEntry key={activity.timestamp.toString() + activity.event} activity={activity}>
|
||||||
|
<span/>
|
||||||
|
</ActivityLogEntry>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{data && <PaginationFooter
|
||||||
|
pagination={data.pagination}
|
||||||
|
onPageSelect={page => setFilters(value => ({ ...value, page }))}
|
||||||
|
/>}
|
||||||
|
</ServerContentBlock>
|
||||||
|
);
|
||||||
|
};
|
|
@ -12,6 +12,7 @@ import AccountOverviewContainer from '@/components/dashboard/AccountOverviewCont
|
||||||
import AccountApiContainer from '@/components/dashboard/AccountApiContainer';
|
import AccountApiContainer from '@/components/dashboard/AccountApiContainer';
|
||||||
import AccountSSHContainer from '@/components/dashboard/ssh/AccountSSHContainer';
|
import AccountSSHContainer from '@/components/dashboard/ssh/AccountSSHContainer';
|
||||||
import ActivityLogContainer from '@/components/dashboard/activity/ActivityLogContainer';
|
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
|
// 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.
|
// all of the items above will only be loaded in when that router is loaded.
|
||||||
|
@ -133,5 +134,11 @@ export default {
|
||||||
name: 'Settings',
|
name: 'Settings',
|
||||||
component: SettingsContainer,
|
component: SettingsContainer,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/activity',
|
||||||
|
permission: 'activity.*',
|
||||||
|
name: 'Activity',
|
||||||
|
component: ServerActivityLogContainer,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
} as Routes;
|
} as Routes;
|
||||||
|
|
|
@ -62,6 +62,7 @@ Route::group([
|
||||||
Route::get('/', [Client\Servers\ServerController::class, 'index'])->name('api:client:server.view');
|
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('/websocket', Client\Servers\WebsocketController::class)->name('api:client:server.ws');
|
||||||
Route::get('/resources', Client\Servers\ResourceUtilizationController::class)->name('api:client:server.resources');
|
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('/command', [Client\Servers\CommandController::class, 'index']);
|
||||||
Route::post('/power', [Client\Servers\PowerController::class, 'index']);
|
Route::post('/power', [Client\Servers\PowerController::class, 'index']);
|
||||||
|
|
Loading…
Reference in a new issue