0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-18 02:21:47 -05:00

Added first version of Audit Log UI

refs https://github.com/TryGhost/Toolbox/issues/356

- skateboard version w/ loading data from API, settings button guarded
  by labs, and a basic UI populating a table with info
This commit is contained in:
Daniel Lockyer 2022-08-16 12:40:33 +02:00
parent e77fedf9b2
commit 6c2aebbf0c
No known key found for this signature in database
GPG key ID: D21186F0B47295AD
7 changed files with 219 additions and 0 deletions
ghost/admin/app

View file

@ -0,0 +1,4 @@
import Controller from '@ember/controller';
export default class AuditLogController extends Controller {
}

View file

@ -0,0 +1,92 @@
import moment from 'moment';
import {Resource} from 'ember-could-get-used-to-this';
import {TrackedArray} from 'tracked-built-ins';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
export default class AuditLogEventFetcher extends Resource {
@service ajax;
@service ghostPaths;
@service store;
@tracked data = new TrackedArray([]);
@tracked isLoading = false;
@tracked isError = false;
@tracked errorMessage = null;
@tracked hasReachedEnd = false;
cursor = null;
get value() {
return {
isLoading: this.isLoading,
isError: this.isError,
errorMessage: this.errorMessage,
data: this.data,
loadNextPage: this.loadNextPage,
hasReachedEnd: this.hasReachedEnd
};
}
async setup() {
this.cursor = moment.utc().format('YYYY-MM-DD HH:mm:ss');
let filter = `created_at:<'${this.cursor}'`;
// Can't get this working with Promise.all, somehow results in an infinite loop
await this.loadEventsTask.perform({filter});
}
@action
loadNextPage() {
// NOTE: assumes data is always ordered by created_at desc
const lastEvent = this.data[this.data.length - 1];
if (!lastEvent?.data?.created_at) {
this.hasReachedEnd = true;
return;
}
const cursor = moment.utc(lastEvent.data.created_at).format('YYYY-MM-DD HH:mm:ss');
if (cursor === this.cursor) {
this.hasReachedEnd = true;
return;
}
this.cursor = cursor;
let filter = `created_at:<'${this.cursor}'`;
this.loadEventsTask.perform({filter});
}
@task
*loadEventsTask(queryParams) {
try {
this.isLoading = true;
const url = this.ghostPaths.url.api('actions');
const data = Object.assign({}, queryParams, {limit: this.args.named.pageSize});
const {actions} = yield this.ajax.request(url, {data});
if (actions.length < data.limit) {
this.hasReachedEnd = true;
}
this.data.push(...actions);
} catch (e) {
this.isError = true;
const errorMessage = e.payload?.errors?.[0]?.message;
if (errorMessage) {
this.errorMessage = errorMessage;
}
// TODO: log to Sentry
console.error(e); // eslint-disable-line
} finally {
this.isLoading = false;
}
}
}

View file

@ -0,0 +1,31 @@
export default function parseAuditLogEvent(ev) {
const actorName = getActorName(ev);
const action = getAction(ev);
const actionIcon = getActionIcon(ev);
return {
actorName,
actionIcon,
action,
timestamp: ev.created_at
};
}
function getActionIcon(ev) {
switch (ev.event) {
case 'added':
return 'add-stroke';
case 'edited':
return 'content';
case 'deleted':
return 'cross-circle';
}
}
function getActorName(ev) {
return ev.actor_id;
}
function getAction(ev) {
return `${ev.event} ${ev.resource_type}`;
}

View file

@ -49,6 +49,7 @@ Router.map(function () {
this.route('settings.general', {path: '/settings/general'});
this.route('settings.membership', {path: '/settings/members'});
this.route('settings.code-injection', {path: '/settings/code-injection'});
this.route('settings.audit-log', {path: '/settings/audit-log'});
// redirect from old /settings/members-email to /settings/newsletters
this.route('settings.members-email', {path: '/settings/members-email'});

View file

@ -0,0 +1,9 @@
import AdminRoute from 'ghost-admin/routes/admin';
export default class AuditLogRoute extends AdminRoute {
buildRouteInfoMetadata() {
return {
titleToken: 'Settings - Audit log'
};
}
}

View file

@ -82,6 +82,16 @@
<p>Testing ground for new features</p>
</div>
</LinkTo>
{{#if (feature 'auditLog')}}
<LinkTo class="gh-setting-group" @route="settings.audit-log" data-test-nav="audit-log">
<span class="blue">{{svg-jar "TODO"}}</span>
<div>
<h4>Audit log</h4>
<p>View staff user actions</p>
</div>
</LinkTo>
{{/if}}
</div>
</section>
</section>

View file

@ -0,0 +1,72 @@
<section class="gh-canvas gh-members-activity">
<GhCanvasHeader class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>
<LinkTo @route="settings">Settings</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
<LinkTo @route="settings.audit-log" data-test-link="audit-log-back">Audit log</LinkTo>
</h2>
</GhCanvasHeader>
<div class="view-container">
{{#let (audit-log-event-fetcher pageSize=50) as |eventsFetcher|}}
{{#if eventsFetcher.data}}
<div class="gh-list-scrolling">
<table class="gh-list">
<thead>
<tr>
<th>User</th>
<th>Action</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{{#each eventsFetcher.data as |event|}}
{{#let (parse-audit-log-event event) as |ev|}}
<tr>
<div class="gh-list-data">
<div class="flex items-center">
<div class="w-80">
<h3 class="ma0 pa0 gh-members-list-name">{{ev.actorName}}</h3>
</div>
</div>
</div>
<div class="gh-list-data">
<div class="gh-members-activity-container">
<div class="gh-members-activity-icon">{{svg-jar ev.actionIcon}}</div>
<div class="gh-members-activity-event">
<span class="gh-members-activity-description">
{{capitalize-first-letter event.action}}
{{ev.action}}
</span>
</div>
</div>
</div>
<div class="gh-list-data">{{moment-format ev.timestamp "DD MMM YYYY HH:mm:ss"}}</div>
</tr>
{{/let}}
{{/each}}
</tbody>
</table>
{{#if (not (or eventsFetcher.isLoading eventsFetcher.hasReachedEnd))}}
<GhScrollTrigger @enter={{eventsFetcher.loadNextPage}} @triggerOffset={{250}} />
{{/if}}
</div>
{{else}}
{{#unless eventsFetcher.isLoading}}
<div class="no-posts-box">
<div class="no-posts">
{{svg-jar "activity-placeholder" class="gh-members-placeholder"}}
<h4>No staff activity yet</h4>
</div>
</div>
{{/unless}}
{{/if}}
{{#if eventsFetcher.isLoading}}
<div class="no-posts-box"><GhLoadingSpinner /></div>
{{/if}}
{{/let}}
</div>
</section>
{{outlet}}