mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-25 09:03:12 +03:00
Added basic filtering and searching to audit log
refs https://github.com/TryGhost/Toolbox/issues/356 - this adds some basic filtering and search across the audit log events - not all of it works, but filtering by objects and searching for users should work
This commit is contained in:
parent
66438ff4ed
commit
68030d4d52
@ -0,0 +1,29 @@
|
||||
<GhBasicDropdown @verticalPosition="below" as |dd|>
|
||||
<dd.Trigger class="gh-btn gh-btn-icon gh-btn-action-icon">
|
||||
<span class={{if @excludedEvents "gh-btn-label-green"}}>
|
||||
{{svg-jar "filter"}}
|
||||
Filter events
|
||||
</span>
|
||||
</dd.Trigger>
|
||||
|
||||
<dd.Content class="gh-member-activity-actions-menu dropdown-menu dropdown-triangle-top-right">
|
||||
{{!-- NOTE: re-using ember-power-select-options styles --}}
|
||||
<ul class="ember-power-select-options" role="listbox">
|
||||
{{#each this.eventTypes as |type idx|}}
|
||||
<li class="form-group ember-power-select-option mb0 for-checkbox">
|
||||
<label class="checkbox" for="type-{{idx}}">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={{type.isSelected}}
|
||||
id="type-{{idx}}"
|
||||
name="eventTypes"
|
||||
class="gh-input post-settings-featured"
|
||||
{{on "input" (fn this.toggleEventType type.event)}}>
|
||||
<span class="input-toggle-component"></span>
|
||||
<p>{{type.name}}</p>
|
||||
</label>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</dd.Content>
|
||||
</GhBasicDropdown>
|
@ -0,0 +1,56 @@
|
||||
import Component from '@glimmer/component';
|
||||
import {action} from '@ember/object';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
const ALL_EVENT_TYPES = [
|
||||
{event: 'created', icon: 'event-filter-signup', name: 'Created'},
|
||||
{event: 'edited', icon: 'event-filter-login', name: 'Edited'},
|
||||
{event: 'deleted', icon: 'event-filter-subscription', name: 'Deleted'},
|
||||
{event: 'post', icon: 'event-filter-payment', name: 'Posts'},
|
||||
{event: 'pages', icon: 'event-filter-payment', name: 'Pages'},
|
||||
{event: 'tag', icon: 'event-filter-newsletter', name: 'Tags'},
|
||||
{event: 'integration', icon: 'event-filter-newsletter', name: 'Integrations'},
|
||||
{event: 'api_key', icon: 'event-filter-newsletter', name: 'API keys'},
|
||||
{event: 'label', icon: 'event-filter-newsletter', name: 'Labels'}
|
||||
];
|
||||
|
||||
export default class AuditLogEventFilter extends Component {
|
||||
@service settings;
|
||||
@service feature;
|
||||
|
||||
get availableEventTypes() {
|
||||
const extended = [...ALL_EVENT_TYPES];
|
||||
|
||||
if (this.args.hiddenEvents?.length) {
|
||||
return extended.filter(t => !this.args.hiddenEvents.includes(t.event));
|
||||
} else {
|
||||
return extended;
|
||||
}
|
||||
}
|
||||
|
||||
get eventTypes() {
|
||||
const excludedEvents = (this.args.excludedEvents || '').split(',');
|
||||
|
||||
return this.availableEventTypes.map(type => ({
|
||||
event: type.event,
|
||||
icon: type.icon,
|
||||
name: type.name,
|
||||
isSelected: !excludedEvents.includes(type.event)
|
||||
}));
|
||||
}
|
||||
|
||||
@action
|
||||
toggleEventType(eventType) {
|
||||
const excludedEvents = new Set(this.eventTypes.reject(type => type.isSelected).map(type => type.event));
|
||||
|
||||
if (excludedEvents.has(eventType)) {
|
||||
excludedEvents.delete(eventType);
|
||||
} else {
|
||||
excludedEvents.add(eventType);
|
||||
}
|
||||
|
||||
const excludeString = Array.from(excludedEvents).join(',');
|
||||
|
||||
this.args.onChange(excludeString || null);
|
||||
}
|
||||
}
|
13
ghost/admin/app/components/settings/audit-log/no-events.hbs
Normal file
13
ghost/admin/app/components/settings/audit-log/no-events.hbs
Normal file
@ -0,0 +1,13 @@
|
||||
<div class="no-posts-box">
|
||||
<div class="no-posts">
|
||||
{{svg-jar "activity-placeholder" class="gh-members-placeholder"}}
|
||||
{{#if @filter}}
|
||||
<h4>No actions match the current filter</h4>
|
||||
<LinkTo @route="settings.audit-log" @query={{reset-query-params "audit-log"}} class="gh-btn gh-btn-lg">
|
||||
<span>Show all actions</span>
|
||||
</LinkTo>
|
||||
{{else}}
|
||||
<h4>No actions yet</h4>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1 @@
|
||||
<div class="gh-btn"><span>Filter member {{svg-jar "arrow-down-small"}}</span></div>
|
23
ghost/admin/app/components/settings/audit-log/search.hbs
Normal file
23
ghost/admin/app/components/settings/audit-log/search.hbs
Normal file
@ -0,0 +1,23 @@
|
||||
{{#if @selected}}
|
||||
<button class="gh-btn" {{on "click" (fn @onChange null)}} type="button">
|
||||
<span class="gh-btn-label-green">
|
||||
Clear search ×
|
||||
</span>
|
||||
</button>
|
||||
{{else}}
|
||||
<PowerSelect
|
||||
@searchEnabled={{false}}
|
||||
@search={{perform this.searchUsersTask}}
|
||||
@selected={{@selected}}
|
||||
@onChange={{@onChange}}
|
||||
@triggerClass="gh-member-filter-search-trigger"
|
||||
@dropdownClass="gh-member-filter-search-dropdown"
|
||||
@triggerComponent="gh-input-with-select/trigger"
|
||||
@placeholder="Search users"
|
||||
@extra={{hash showSearchMessage=false}}
|
||||
as |member|
|
||||
>
|
||||
{{#if member.name}}<strong>{{member.name}}</strong>{{/if}}
|
||||
<span>{{member.email}}</span>
|
||||
</PowerSelect>
|
||||
{{/if}}
|
20
ghost/admin/app/components/settings/audit-log/search.js
Normal file
20
ghost/admin/app/components/settings/audit-log/search.js
Normal file
@ -0,0 +1,20 @@
|
||||
import Component from '@glimmer/component';
|
||||
import {action} from '@ember/object';
|
||||
import {inject as service} from '@ember/service';
|
||||
import {task, timeout} from 'ember-concurrency';
|
||||
|
||||
export default class AuditLogSearch extends Component {
|
||||
@service store;
|
||||
|
||||
@action
|
||||
clear() {
|
||||
this.args.onChange(null);
|
||||
}
|
||||
|
||||
@task
|
||||
*searchUsersTask(term) {
|
||||
yield timeout(300); // debounce
|
||||
|
||||
return yield this.store.query('user', {search: term, limit: 20});
|
||||
}
|
||||
}
|
41
ghost/admin/app/components/settings/audit-log/table.hbs
Normal file
41
ghost/admin/app/components/settings/audit-log/table.hbs
Normal file
@ -0,0 +1,41 @@
|
||||
<table class="gh-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Action</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each @events as |event|}}
|
||||
{{#let (parse-audit-log-event event) as |ev|}}
|
||||
<tr>
|
||||
<div class="gh-list-data gh-list-cellwidth-30">
|
||||
<div class="flex items-center">
|
||||
<span class="user-list-item-figure" style={{background-image-style (or ev.actor.profileImageUrl ev.actor.iconImage)}}>
|
||||
<span class="hidden">Photo of {{ev.actor.name}}</span>
|
||||
</span>
|
||||
<h3 class="ma0 pa0 gh-members-list-name">{{ev.actor.name}}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gh-list-data gh-list-cellwidth-50">
|
||||
<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 ev.action}}
|
||||
{{#if (or ev.resource.title ev.resource.name)}}
|
||||
<LinkTo @route="editor.edit" @models={{array ev.resource.displayName ev.resource.id}} class="permalink">
|
||||
<strong>{{if ev.resource.title ev.resource.title ev.resource.name}}</strong>
|
||||
</LinkTo>
|
||||
{{/if}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gh-list-data">{{moment-format ev.original.created_at "DD MMM YYYY HH:mm:ss"}}</div>
|
||||
</tr>
|
||||
{{/let}}
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
@ -1,4 +1,39 @@
|
||||
import Controller from '@ember/controller';
|
||||
import {action} from '@ember/object';
|
||||
import {inject as service} from '@ember/service';
|
||||
import {tracked} from '@glimmer/tracking';
|
||||
|
||||
export default class AuditLogController extends Controller {
|
||||
@service router;
|
||||
@service settings;
|
||||
@service store;
|
||||
|
||||
queryParams = ['excludedResources', 'user'];
|
||||
|
||||
@tracked excludedResources = null;
|
||||
@tracked user = null;
|
||||
|
||||
get fullExcludedResources() {
|
||||
return (this.excludedResources || '').split(',');
|
||||
}
|
||||
|
||||
get userRecord() {
|
||||
if (!this.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: {reload: true} here shouldn't be needed but without it
|
||||
// the template renders nothing if the record is already in the store
|
||||
return this.store.findRecord('user', this.user, {reload: true});
|
||||
}
|
||||
|
||||
@action
|
||||
changeExcludedResources(newList) {
|
||||
this.router.transitionTo({queryParams: {excludedResources: newList}});
|
||||
}
|
||||
|
||||
@action
|
||||
changeUser(user) {
|
||||
this.router.transitionTo({queryParams: {user: user?.id}});
|
||||
}
|
||||
}
|
||||
|
@ -34,6 +34,10 @@ export default class AuditLogEventFetcher extends Resource {
|
||||
this.cursor = moment.utc().format('YYYY-MM-DD HH:mm:ss');
|
||||
let filter = `created_at:<'${this.cursor}'`;
|
||||
|
||||
if (this.args.named.filter) {
|
||||
filter += `+${this.args.named.filter}`;
|
||||
}
|
||||
|
||||
// Can't get this working with Promise.all, somehow results in an infinite loop
|
||||
await this.loadEventsTask.perform({filter});
|
||||
}
|
||||
@ -58,6 +62,10 @@ export default class AuditLogEventFetcher extends Resource {
|
||||
this.cursor = cursor;
|
||||
let filter = `created_at:<'${this.cursor}'`;
|
||||
|
||||
if (this.args.named.filter) {
|
||||
filter += `+${this.args.named.filter}`;
|
||||
}
|
||||
|
||||
this.loadEventsTask.perform({filter});
|
||||
}
|
||||
|
||||
|
34
ghost/admin/app/helpers/audit-log-event-filter.js
Normal file
34
ghost/admin/app/helpers/audit-log-event-filter.js
Normal file
@ -0,0 +1,34 @@
|
||||
import Helper from '@ember/component/helper';
|
||||
import classic from 'ember-classic-decorator';
|
||||
import {isBlank} from '@ember/utils';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
@classic
|
||||
export default class AuditLogEventFilter extends Helper {
|
||||
@service settings;
|
||||
@service feature;
|
||||
|
||||
compute(
|
||||
positionalParams,
|
||||
{excludedEvents = [], user = ''}
|
||||
) {
|
||||
const excludedEventsSet = new Set();
|
||||
|
||||
if (excludedEvents.length) {
|
||||
excludedEvents.forEach(type => excludedEventsSet.add(type));
|
||||
}
|
||||
|
||||
let filterParts = [];
|
||||
|
||||
const excludedEventsArray = Array.from(excludedEventsSet).reject(isBlank);
|
||||
if (excludedEventsArray.length > 0) {
|
||||
filterParts.push(`resource_type:-[${excludedEventsArray.join(',')}]`);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
filterParts.push(`actor_id:${user}`);
|
||||
}
|
||||
|
||||
return filterParts.join('+');
|
||||
}
|
||||
}
|
@ -4,74 +4,41 @@
|
||||
<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>
|
||||
{{#if this.userRecord}}
|
||||
<span>{{svg-jar "arrow-right"}}</span>
|
||||
<span class="truncate">{{or this.userRecord.name this.userRecord.email}}</span>
|
||||
{{/if}}
|
||||
</h2>
|
||||
<div class="view-actions">
|
||||
<Settings::AuditLog::EventFilter
|
||||
@excludedEvents={{this.excludedResources}}
|
||||
@hiddenEvents={{this.hiddenResources}}
|
||||
@onChange={{this.changeExcludedResources}} />
|
||||
|
||||
<Settings::AuditLog::Search
|
||||
@selected={{this.userRecord}}
|
||||
@onChange={{this.changeUser}} />
|
||||
</div>
|
||||
</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>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each eventsFetcher.data as |event|}}
|
||||
{{#let (parse-audit-log-event event) as |ev|}}
|
||||
<tr>
|
||||
<div class="gh-list-data gh-list-cellwidth-30">
|
||||
<div class="flex items-center">
|
||||
<span class="user-list-item-figure" style={{background-image-style (or ev.actor.profileImageUrl ev.actor.iconImage)}}>
|
||||
<span class="hidden">Photo of {{ev.actor.name}}</span>
|
||||
</span>
|
||||
<h3 class="ma0 pa0 gh-members-list-name">{{ev.actor.name}}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gh-list-data gh-list-cellwidth-50">
|
||||
<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 ev.action}}
|
||||
{{#if (or ev.resource.title ev.resource.name)}}
|
||||
<LinkTo @route="editor.edit" @models={{array ev.resource.displayName ev.resource.id}} class="permalink">
|
||||
<strong>{{if ev.resource.title ev.resource.title ev.resource.name}}</strong>
|
||||
</LinkTo>
|
||||
{{else}}
|
||||
<br/><small>{{ev.original.resource_id}}</small>
|
||||
{{/if}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gh-list-data">{{moment-format ev.original.created_at "DD MMM YYYY HH:mm:ss"}}</div>
|
||||
</tr>
|
||||
{{/let}}
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{#let (audit-log-event-fetcher filter=(audit-log-event-filter excludedEvents=this.fullExcludedResources user=this.user) pageSize=50) as |eventsFetcher|}}
|
||||
{{#if eventsFetcher.data}}
|
||||
<div class="gh-list-scrolling">
|
||||
<Settings::AuditLog::Table @events={{eventsFetcher.data}} />
|
||||
|
||||
{{#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>
|
||||
{{#if (not (or eventsFetcher.isLoading eventsFetcher.hasReachedEnd))}}
|
||||
<GhScrollTrigger @enter={{eventsFetcher.loadNextPage}} @triggerOffset={{250}} />
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
{{else}}
|
||||
{{#unless eventsFetcher.isLoading}}
|
||||
<Settings::AuditLog::NoEvents @filter={{or this.user this.excludedResources}} />
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
|
||||
{{#if eventsFetcher.isLoading}}
|
||||
<div class="no-posts-box"><GhLoadingSpinner /></div>
|
||||
{{/if}}
|
||||
{{#if eventsFetcher.isLoading}}
|
||||
<div class="no-posts-box"><GhLoadingSpinner /></div>
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
</div>
|
||||
</section>
|
||||
|
Loading…
Reference in New Issue
Block a user