Skip to content

Commit 2f1c8ae

Browse files
committed
Add basic server activity log view
1 parent 0b4936f commit 2f1c8ae

File tree

8 files changed

+153
-2
lines changed

8 files changed

+153
-2
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
4+
5+
use Pterodactyl\Models\Server;
6+
use Pterodactyl\Models\Permission;
7+
use Spatie\QueryBuilder\QueryBuilder;
8+
use Spatie\QueryBuilder\AllowedFilter;
9+
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
10+
use Pterodactyl\Transformers\Api\Client\ActivityLogTransformer;
11+
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
12+
13+
class ActivityLogController extends ClientApiController
14+
{
15+
/**
16+
* Returns the activity logs for a server.
17+
*/
18+
public function __invoke(ClientApiRequest $request, Server $server): array
19+
{
20+
$this->authorize(Permission::ACTION_ACTIVITY_READ, $server);
21+
22+
$activity = QueryBuilder::for($server->activity())
23+
->with('actor')
24+
->allowedSorts(['timestamp'])
25+
->allowedFilters([
26+
AllowedFilter::exact('ip'),
27+
AllowedFilter::partial('event'),
28+
])
29+
->paginate(min($request->query('per_page', 25), 100))
30+
->appends($request->query());
31+
32+
return $this->fractal->collection($activity)
33+
->transformWith($this->getTransformer(ActivityLogTransformer::class))
34+
->toArray();
35+
}
36+
}

app/Http/Controllers/Api/Client/Servers/DatabaseController.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,10 @@ public function rotatePassword(RotatePasswordRequest $request, Server $server, D
101101
$this->passwordService->handle($database);
102102
$database->refresh();
103103

104-
Activity::event('server:database.rotate-password')->subject($database)->log();
104+
Activity::event('server:database.rotate-password')
105+
->subject($database)
106+
->property('name', $database->database)
107+
->log();
105108

106109
return $this->fractal->item($database)
107110
->parseIncludes(['password'])

app/Models/Permission.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ class Permission extends Model
6363
public const ACTION_SETTINGS_RENAME = 'settings.rename';
6464
public const ACTION_SETTINGS_REINSTALL = 'settings.reinstall';
6565

66+
public const ACTION_ACTIVITY_READ = 'activity.read';
67+
6668
/**
6769
* Should timestamps be used on this model.
6870
*
@@ -210,6 +212,13 @@ class Permission extends Model
210212
'reinstall' => 'Allows a user to trigger a reinstall of this server.',
211213
],
212214
],
215+
216+
'activity' => [
217+
'description' => 'Permissions that control a user\'s access to the server activity logs.',
218+
'keys' => [
219+
'read' => 'Allows a user to view the activity logs for the server.',
220+
],
221+
],
213222
];
214223

215224
/**
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import useUserSWRContentKey from '@/plugins/useUserSWRContentKey';
2+
import useSWR, { ConfigInterface, responseInterface } from 'swr';
3+
import { ActivityLog, Transformers } from '@definitions/user';
4+
import { AxiosError } from 'axios';
5+
import http, { PaginatedResult, QueryBuilderParams, withQueryBuilderParams } from '@/api/http';
6+
import { toPaginatedSet } from '@definitions/helpers';
7+
import useFilteredObject from '@/plugins/useFilteredObject';
8+
import { ServerContext } from '@/state/server';
9+
10+
export type ActivityLogFilters = QueryBuilderParams<'ip' | 'event', 'timestamp'>;
11+
12+
const useActivityLogs = (filters?: ActivityLogFilters, config?: ConfigInterface<PaginatedResult<ActivityLog>, AxiosError>): responseInterface<PaginatedResult<ActivityLog>, AxiosError> => {
13+
const uuid = ServerContext.useStoreState(state => state.server.data?.uuid);
14+
const key = useUserSWRContentKey([ 'server', 'activity', JSON.stringify(useFilteredObject(filters || {})) ]);
15+
16+
return useSWR<PaginatedResult<ActivityLog>>(key, async () => {
17+
const { data } = await http.get(`/api/client/servers/${uuid}/activity`, {
18+
params: {
19+
...withQueryBuilderParams(filters),
20+
include: [ 'actor' ],
21+
},
22+
});
23+
24+
return toPaginatedSet(data, Transformers.toActivityLog);
25+
}, { revalidateOnMount: false, ...(config || {}) });
26+
};
27+
28+
export { useActivityLogs };

resources/scripts/components/elements/activity/ActivityLogMetaButton.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ export default ({ meta }: { meta: Record<string, unknown> }) => {
1919
hideCloseIcon
2020
title={'Metadata'}
2121
>
22-
<pre>{JSON.stringify(meta, null, 2)}</pre>
22+
<pre className={'bg-gray-900 rounded p-2 overflow-x-scroll font-mono text-sm leading-relaxed'}>
23+
{JSON.stringify(meta, null, 2)}
24+
</pre>
2325
<Dialog.Buttons>
2426
<Button.Text onClick={() => setOpen(false)}>Close</Button.Text>
2527
</Dialog.Buttons>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import React, { useEffect, useState } from 'react';
2+
import { useActivityLogs } from '@/api/server/activity';
3+
import ServerContentBlock from '@/components/elements/ServerContentBlock';
4+
import { useFlashKey } from '@/plugins/useFlash';
5+
import FlashMessageRender from '@/components/FlashMessageRender';
6+
import Spinner from '@/components/elements/Spinner';
7+
import ActivityLogEntry from '@/components/elements/activity/ActivityLogEntry';
8+
import PaginationFooter from '@/components/elements/table/PaginationFooter';
9+
import { ActivityLogFilters } from '@/api/account/activity';
10+
import { Link } from 'react-router-dom';
11+
import classNames from 'classnames';
12+
import { styles as btnStyles } from '@/components/elements/button';
13+
import { XCircleIcon } from '@heroicons/react/solid';
14+
15+
export default () => {
16+
const { clearAndAddHttpError } = useFlashKey('server:activity');
17+
const [ filters, setFilters ] = useState<ActivityLogFilters>({ page: 1, sorts: { timestamp: -1 } });
18+
19+
const { data, isValidating, error } = useActivityLogs(filters, {
20+
revalidateOnMount: true,
21+
revalidateOnFocus: false,
22+
});
23+
24+
useEffect(() => {
25+
const parsed = new URLSearchParams(location.search);
26+
27+
setFilters(value => ({ ...value, filters: { ip: parsed.get('ip'), event: parsed.get('event') } }));
28+
}, [ location.search ]);
29+
30+
useEffect(() => {
31+
clearAndAddHttpError(error);
32+
}, [ error ]);
33+
34+
return (
35+
<ServerContentBlock title={'Activity Log'}>
36+
<FlashMessageRender byKey={'server:activity'}/>
37+
{(filters.filters?.event || filters.filters?.ip) &&
38+
<div className={'flex justify-end mb-2'}>
39+
<Link
40+
to={'#'}
41+
className={classNames(btnStyles.button, btnStyles.text, 'w-full sm:w-auto')}
42+
onClick={() => setFilters(value => ({ ...value, filters: {} }))}
43+
>
44+
Clear Filters <XCircleIcon className={'w-4 h-4 ml-2'}/>
45+
</Link>
46+
</div>
47+
}
48+
{!data && isValidating ?
49+
<Spinner centered/>
50+
:
51+
<div className={'bg-gray-700'}>
52+
{data?.items.map((activity) => (
53+
<ActivityLogEntry key={activity.timestamp.toString() + activity.event} activity={activity}>
54+
<span/>
55+
</ActivityLogEntry>
56+
))}
57+
</div>
58+
}
59+
{data && <PaginationFooter
60+
pagination={data.pagination}
61+
onPageSelect={page => setFilters(value => ({ ...value, page }))}
62+
/>}
63+
</ServerContentBlock>
64+
);
65+
};

resources/scripts/routers/routes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import AccountOverviewContainer from '@/components/dashboard/AccountOverviewCont
1212
import AccountApiContainer from '@/components/dashboard/AccountApiContainer';
1313
import AccountSSHContainer from '@/components/dashboard/ssh/AccountSSHContainer';
1414
import ActivityLogContainer from '@/components/dashboard/activity/ActivityLogContainer';
15+
import ServerActivityLogContainer from '@/components/server/ServerActivityLogContainer';
1516

1617
// Each of the router files is already code split out appropriately — so
1718
// all of the items above will only be loaded in when that router is loaded.
@@ -133,5 +134,11 @@ export default {
133134
name: 'Settings',
134135
component: SettingsContainer,
135136
},
137+
{
138+
path: '/activity',
139+
permission: 'activity.*',
140+
name: 'Activity',
141+
component: ServerActivityLogContainer,
142+
},
136143
],
137144
} as Routes;

routes/api-client.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
Route::get('/', [Client\Servers\ServerController::class, 'index'])->name('api:client:server.view');
6363
Route::get('/websocket', Client\Servers\WebsocketController::class)->name('api:client:server.ws');
6464
Route::get('/resources', Client\Servers\ResourceUtilizationController::class)->name('api:client:server.resources');
65+
Route::get('/activity', Client\Servers\ActivityLogController::class)->name('api:client:server.activity');
6566

6667
Route::post('/command', [Client\Servers\CommandController::class, 'index']);
6768
Route::post('/power', [Client\Servers\PowerController::class, 'index']);

0 commit comments

Comments
 (0)