Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Activity log filter by user #2924

Merged
merged 31 commits into from
Sep 13, 2024
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
4bd60c2
wip
gagandeepb Aug 22, 2024
ede1402
See description
gagandeepb Aug 22, 2024
27127ff
More code deletion; Adds user context function
gagandeepb Aug 23, 2024
91634fd
Renaming
gagandeepb Aug 26, 2024
7f0b2be
Refactors activity_log channel
gagandeepb Aug 26, 2024
ca1ad17
Fixes warning
gagandeepb Aug 26, 2024
c7e768a
Runs formatter
gagandeepb Aug 26, 2024
e7b39c1
Renaming
gagandeepb Aug 26, 2024
fbd3cbc
Adds some tests
gagandeepb Aug 28, 2024
63b4d0c
Runs js code formatter
gagandeepb Aug 28, 2024
acd65cb
Fix for failing tests
gagandeepb Aug 28, 2024
e291eb2
Deletes an unused import; format
gagandeepb Aug 28, 2024
a481824
Some cleanup
gagandeepb Aug 30, 2024
d4ff429
Adds a system user
gagandeepb Sep 2, 2024
8562e4d
Refactors ActivityLogPage test
gagandeepb Sep 5, 2024
03f5a8b
Adds test for activityLog users selector
gagandeepb Sep 5, 2024
800c90d
Adds tests for ActivityLogChannel
gagandeepb Sep 6, 2024
0b29e64
Adds a test for a Users context function
gagandeepb Sep 6, 2024
2f3f066
Addresses PR comments
gagandeepb Sep 9, 2024
bca3093
Handles scenario with deleted users
gagandeepb Sep 9, 2024
6e0dbd1
Renaming
gagandeepb Sep 10, 2024
dcf0da5
Adds test case for non-empty initial state
gagandeepb Sep 10, 2024
d8a0f6b
Formatting
gagandeepb Sep 10, 2024
9a5a4e3
Makes refresh interval a parameter
gagandeepb Sep 11, 2024
cb5b4bf
Addresses PR comment
gagandeepb Sep 11, 2024
0979183
Adds an additional jest test
gagandeepb Sep 12, 2024
bd7ca7c
Updates tests
gagandeepb Sep 12, 2024
726c171
Formatting
gagandeepb Sep 13, 2024
b32e3f6
Adds a saga test
gagandeepb Sep 13, 2024
468347a
Renames users context function
gagandeepb Sep 13, 2024
6fb127b
Reodering imports
gagandeepb Sep 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions assets/js/lib/test-utils/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ const middlewares = [];
const mockStore = configureStore(middlewares);

export const defaultInitialState = {
activityLog: {
users: [],
},
user: {
abilities: [{ name: 'all', resource: 'all' }],
},
Expand Down
20 changes: 13 additions & 7 deletions assets/js/pages/ActivityLogPage/ActivityLogPage.jsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import React, { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
nelsonkopliku marked this conversation as resolved.
Show resolved Hide resolved
import { useSearchParams } from 'react-router-dom';

import { map, pipe } from 'lodash/fp';

import ActivityLogOverview from '@common/ActivityLogOverview';
import ComposedFilter from '@common/ComposedFilter';
nelsonkopliku marked this conversation as resolved.
Show resolved Hide resolved
import PageHeader from '@common/PageHeader';
import { getActivityLog } from '@lib/api/activityLogs';
import { allowedActivities } from '@lib/model/activityLog';

import { getActivityLogUsers } from '@state/selectors/activityLog';
import { getUserProfile } from '@state/selectors/user';

import PageHeader from '@common/PageHeader';
import ActivityLogOverview from '@common/ActivityLogOverview';
import ComposedFilter from '@common/ComposedFilter';

import {
filterValueToSearchParams,
searchParamsToAPIParams,
searchParamsToFilterValue,
} from './searchParams';

function ActivityLogPage() {
const users = useSelector(getActivityLogUsers);
const [searchParams, setSearchParams] = useSearchParams();
const [activityLog, setActivityLog] = useState([]);
const [isLoading, setLoading] = useState(true);
Expand All @@ -37,6 +37,12 @@ function ActivityLogPage() {
map(([key, value]) => [key, value.label])
)(abilities),
},
{
key: 'actor',
type: 'select',
title: 'User',
options: users,
},
{
key: 'to_date',
title: 'newer than',
Expand Down
39 changes: 32 additions & 7 deletions assets/js/pages/ActivityLogPage/ActivityLogPage.test.jsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import React, { act } from 'react';
import { screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';

import MockAdapter from 'axios-mock-adapter';
import { renderWithRouter, withDefaultState } from '@lib/test-utils';

import { networkClient } from '@lib/network';
import {
renderWithRouter,
withDefaultState,
withState,
defaultInitialState,
} from '@lib/test-utils';
import { activityLogEntryFactory } from '@lib/test-utils/factories/activityLog';
import { userFactory } from '@lib/test-utils/factories/users';

import ActivityLogPage from './ActivityLogPage';

Expand All @@ -15,8 +22,8 @@ const axiosMock = new MockAdapter(networkClient);
describe('ActivityLogPage', () => {
nelsonkopliku marked this conversation as resolved.
Show resolved Hide resolved
it('should render table without data', async () => {
axiosMock.onGet('/api/v1/activity_log').reply(200, { data: [] });
const [StatefulActivityLogPage] = withDefaultState(<ActivityLogPage />);
await act(async () => renderWithRouter(StatefulActivityLogPage));
const [StatefulActivityLogPage, _] = withDefaultState(<ActivityLogPage />);
await act(() => renderWithRouter(StatefulActivityLogPage));
expect(screen.getByText('No data available')).toBeVisible();
});

Expand All @@ -39,7 +46,9 @@ describe('ActivityLogPage', () => {
axiosMock
.onGet('/api/v1/activity_log')
.reply(responseStatus, responseBody);
const [StatefulActivityLogPage] = withDefaultState(<ActivityLogPage />);
const [StatefulActivityLogPage, _] = withDefaultState(
<ActivityLogPage />
);
await act(() => renderWithRouter(StatefulActivityLogPage));

expect(screen.getByText('No data available')).toBeVisible();
Expand All @@ -50,13 +59,29 @@ describe('ActivityLogPage', () => {
axiosMock
.onGet('/api/v1/activity_log')
.reply(200, { data: activityLogEntryFactory.buildList(5) });
const [StatefulActivityLogPage, _] = withDefaultState(<ActivityLogPage />);
const { container } = await act(() =>
renderWithRouter(StatefulActivityLogPage)
);
expect(container.querySelectorAll('tbody > tr')).toHaveLength(5);
});

const [StatefulActivityLogPage] = withDefaultState(<ActivityLogPage />);

it('should render tracked activity log and the users filter with non-default/non-empty state', async () => {
const users = userFactory.buildList(5).map((user) => user.username);
axiosMock
.onGet('/api/v1/activity_log')
.reply(200, { data: activityLogEntryFactory.buildList(5) });
const [StatefulActivityLogPage, _] = withState(<ActivityLogPage />, {
...defaultInitialState,
activityLog: { users },
});
const { container } = await act(() =>
renderWithRouter(StatefulActivityLogPage)
);

expect(container.querySelectorAll('tbody > tr')).toHaveLength(5);
await userEvent.click(screen.getByTestId('filter-User'));
expect(container.querySelectorAll('ul > li[role="option"]')).toHaveLength(
users.length
);
});
});
21 changes: 21 additions & 0 deletions assets/js/state/activityLog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createAction, createSlice } from '@reduxjs/toolkit';

export const initialState = {
users: [],
};

export const activityLogSlice = createSlice({
name: 'activityLog',
initialState,
reducers: {
setUsers(state, { payload: { users } }) {
state.users = users;
},
},
});

export const ACTIVITY_LOG_USERS_PUSHED = 'ACTIVITY_LOG_USERS_PUSHED';
export const activityLogUsersPushed = createAction(ACTIVITY_LOG_USERS_PUSHED);
export const { setUsers } = activityLogSlice.actions;

export default activityLogSlice.reducer;
27 changes: 27 additions & 0 deletions assets/js/state/activityLog.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { userFactory } from '@lib/test-utils/factories/users';

import activityLogReducer, { setUsers, initialState } from './activityLog';

describe('activityLog reducer', () => {
it('should set the users for activity log when setUsers is dispatched', () => {
gagandeepb marked this conversation as resolved.
Show resolved Hide resolved
const { username: username1 } = userFactory.build();
const { username: username2 } = userFactory.build();
const users = [username1, username2];
const action = setUsers({ users });
expect(activityLogReducer(initialState, action)).toEqual({
users,
});
});

it('should set the users for activity log when setUsers is dispatched with non empty initial state', () => {
const { username: username1 } = userFactory.build();
const { username: username2 } = userFactory.build();
const { username: username3 } = userFactory.build();
const users = [username1, username2];
const nonEmptyInitialState = { users: [username3] };
const action = setUsers({ users });
expect(activityLogReducer(nonEmptyInitialState, action)).toEqual({
users,
});
});
});
2 changes: 2 additions & 0 deletions assets/js/state/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import settingsReducer from './settings';
import userReducer from './user';
import softwareUpdatesReducer from './softwareUpdates';
import activityLogsSettingsReducer from './activityLogsSettings';
import activityLogReducer from './activityLog';
import rootSaga from './sagas';

export const createStore = (router) => {
Expand All @@ -38,6 +39,7 @@ export const createStore = (router) => {
user: userReducer,
softwareUpdates: softwareUpdatesReducer,
activityLogsSettings: activityLogsSettingsReducer,
activityLog: activityLogReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(sagaMiddleware),
Expand Down
10 changes: 10 additions & 0 deletions assets/js/state/sagas/activityLog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { put, takeEvery } from 'redux-saga/effects';
import { setUsers, ACTIVITY_LOG_USERS_PUSHED } from '@state/activityLog';

export function* activityLogUsersUpdate({ payload: { users } }) {
nelsonkopliku marked this conversation as resolved.
Show resolved Hide resolved
yield put(setUsers({ users }));
}

export function* watchActivityLogActions() {
yield takeEvery(ACTIVITY_LOG_USERS_PUSHED, activityLogUsersUpdate);
}
16 changes: 16 additions & 0 deletions assets/js/state/sagas/activityLog.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { recordSaga } from '@lib/test-utils';
import { userFactory } from '@lib/test-utils/factories/users';
import { setUsers } from '@state/activityLog';
import { activityLogUsersUpdate } from './activityLog';

describe('Activity Logs saga', () => {
it('should set users when activity log users are updated', async () => {
const users = userFactory.buildList(5).map((user) => user.username);

const dispatched = await recordSaga(activityLogUsersUpdate, {
payload: { users },
});

expect(dispatched).toEqual([setUsers({ users })]);
});
});
14 changes: 14 additions & 0 deletions assets/js/state/sagas/channels.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
} from '@state/lastExecutions';

import { userUpdated, userLocked, userDeleted } from '@state/user';
import { activityLogUsersPushed } from '@state/activityLog';

import { getUserProfile } from '@state/selectors/user';

Expand Down Expand Up @@ -236,6 +237,13 @@ const userEvents = [
},
];

const activityLogEvents = [
{
name: 'al_users_pushed',
action: activityLogUsersPushed,
},
];

const createEventChannel = (channel, events) =>
eventChannel((emitter) => {
events.forEach((event) => {
Expand Down Expand Up @@ -281,5 +289,11 @@ export function* watchSocketEvents(socket) {
fork(watchChannelEvents, socket, 'monitoring:databases', databaseEvents),
fork(watchChannelEvents, socket, 'monitoring:executions', executionEvents),
fork(watchChannelEvents, socket, `users:${userID}`, userEvents),
fork(
watchChannelEvents,
socket,
`activity_log:${userID}`,
activityLogEvents
),
]);
}
16 changes: 16 additions & 0 deletions assets/js/state/sagas/channels.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
setExecutionStarted,
} from '@state/lastExecutions';
import { userUpdated } from '@state/user';
import { activityLogUsersPushed } from '@state/activityLog';

import { watchSocketEvents } from './channels';

Expand Down Expand Up @@ -48,6 +49,7 @@ const channels = {
'monitoring:databases': new MockChannel(),
'monitoring:executions': new MockChannel(),
[`users:${USER_ID}`]: new MockChannel(),
[`activity_log:${USER_ID}`]: new MockChannel(),
};

const mockSocket = {
Expand Down Expand Up @@ -125,4 +127,18 @@ describe('Channels saga', () => {

expect(dispatched).toEqual([userUpdated({ email: '[email protected]' })]);
});
it('should listen to specific activity log events', async () => {
const { saga, dispatched } = runWatchSocketEventsSaga(mockSocket);

channels[`activity_log:${USER_ID}`].emit('al_users_pushed', {
users: ['user1', 'user2', 'user3'],
});

closeSocket();
await saga;

expect(dispatched).toEqual([
activityLogUsersPushed({ users: ['user1', 'user2', 'user3'] }),
]);
});
});
2 changes: 2 additions & 0 deletions assets/js/state/sagas/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import { watchActivityLogsSettings } from '@state/sagas/activityLogsSettings';
import { watchSoftwareUpdates } from '@state/sagas/softwareUpdates';

import { watchSocketEvents } from '@state/sagas/channels';
import { watchActivityLogActions } from '@state/sagas/activityLog';
import { checkApiKeyExpiration } from '@state/sagas/settings';

const RESET_STATE = 'RESET_STATE';
Expand Down Expand Up @@ -249,5 +250,6 @@ export default function* rootSaga() {
watchUserLoggedIn(),
watchActivityLogsSettings(),
watchSoftwareUpdates(),
watchActivityLogActions(),
]);
}
6 changes: 6 additions & 0 deletions assets/js/state/selectors/activityLog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createSelector } from '@reduxjs/toolkit';

export const getActivityLogUsers = createSelector(
[(state) => state.activityLog],
gagandeepb marked this conversation as resolved.
Show resolved Hide resolved
(activityLog) => activityLog.users
);
12 changes: 12 additions & 0 deletions assets/js/state/selectors/activityLog.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { getActivityLogUsers } from './activityLog';

describe('Activity Log users selector', () => {
it('should return a list of users from activity log state', () => {
const users = ['user1', 'user2', 'user3'];
const state = {
activityLog: { users },
};

expect(getActivityLogUsers(state)).toEqual(users);
});
});
2 changes: 2 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@ config :flop, repo: Trento.Repo
config :trento,
admin_user: "admin"

config :trento, :activity_log, refresh_interval: 60_000

# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"
2 changes: 2 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,5 @@ config :trento, Trento.Infrastructure.SoftwareUpdates.MockSuma,
# 448 matches to "test" fqdn
448
]

config :trento, :activity_log, refresh_interval: 1
10 changes: 10 additions & 0 deletions lib/trento/users.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ defmodule Trento.Users do
|> Repo.all()
end

@doc """
Returns all usernames tupled with the deleted_at timestamp, including those for users that are soft-deleted.
"""
@spec list_all_usernames :: list({String.t(), DateTime.t()})
def list_all_usernames do
User
|> select([u], {u.username, u.deleted_at})
|> Repo.all()
end

def get_user(id) do
case User
|> where([u], is_nil(u.deleted_at) and u.id == ^id)
Expand Down
Loading
Loading