diff --git a/assets/js/common/ActivityLogDetailsModal/ActivityLogDetailModal.jsx b/assets/js/common/ActivityLogDetailsModal/ActivityLogDetailModal.jsx
new file mode 100644
index 0000000000..f2a6356cc1
--- /dev/null
+++ b/assets/js/common/ActivityLogDetailsModal/ActivityLogDetailModal.jsx
@@ -0,0 +1,140 @@
+import React from 'react';
+import { noop } from 'lodash';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+
+import Button from '@common/Button';
+import Modal from '@common/Modal';
+import ListView from '@common/ListView';
+
+import {
+ API_KEY_GENERATION,
+ CHANGING_SUMA_SETTINGS,
+ CLEARING_SUMA_SETTINGS,
+ CLUSTER_CHECKS_EXECUTION_REQUEST,
+ LOGIN_ATTEMPT,
+ PROFILE_UPDATE,
+ RESOURCE_TAGGING,
+ RESOURCE_UNTAGGING,
+ SAVING_SUMA_SETTINGS,
+ USER_CREATION,
+ USER_DELETION,
+ USER_MODIFICATION,
+} from '@lib/model/activityLog';
+import classNames from 'classnames';
+
+const activityTypesLabels = {
+ [LOGIN_ATTEMPT]: 'Login Attempt',
+ [RESOURCE_TAGGING]: 'Tag Added',
+ [RESOURCE_UNTAGGING]: 'Tag Removed',
+ [API_KEY_GENERATION]: 'API Key Generated',
+ [SAVING_SUMA_SETTINGS]: 'SUMA Settings Saved',
+ [CHANGING_SUMA_SETTINGS]: 'SUMA Settings Changed',
+ [CLEARING_SUMA_SETTINGS]: 'SUMA Settings Cleared',
+ [USER_CREATION]: 'User Created',
+ [USER_MODIFICATION]: 'User Modified',
+ [USER_DELETION]: 'User Deleted',
+ [PROFILE_UPDATE]: 'Profile Updated',
+ [CLUSTER_CHECKS_EXECUTION_REQUEST]: 'Checks Execution Requested',
+};
+
+const resourceTypesLabels = {
+ host: 'Host',
+ cluster: 'Cluster',
+ database: 'Database',
+ sap_system: 'SAP System',
+};
+
+const keys = ['id', 'type', 'resource', 'user', 'message', 'time', 'metadata'];
+
+const keyToLabel = {
+ id: 'ID',
+ type: 'Activity Type',
+ resource: 'Resource',
+ user: 'User',
+ time: 'Created at',
+ message: 'Message',
+ metadata: 'Data',
+};
+
+const toResource = (activityLogEntry) => {
+ const { metadata, type } = activityLogEntry;
+ switch (type) {
+ case LOGIN_ATTEMPT:
+ return 'Application';
+ case RESOURCE_TAGGING:
+ case RESOURCE_UNTAGGING:
+ return (
+ resourceTypesLabels[metadata.resource_type] ??
+ 'Unable to determine resource type'
+ );
+ case USER_CREATION:
+ case USER_MODIFICATION:
+ case USER_DELETION:
+ return 'User';
+ case PROFILE_UPDATE:
+ return 'Profile';
+ case SAVING_SUMA_SETTINGS:
+ case CHANGING_SUMA_SETTINGS:
+ case CLEARING_SUMA_SETTINGS:
+ return 'SUMA Settings';
+ case API_KEY_GENERATION:
+ return 'API Key';
+ case CLUSTER_CHECKS_EXECUTION_REQUEST:
+ return 'Cluster Checks';
+ default:
+ return 'Unrecognized resource';
+ }
+};
+
+const renderMetadata = (metadata) => (
+
+ {`\`\`\`json\n${JSON.stringify(metadata, null, 2)}\n\`\`\``}
+
+);
+
+const renderType = (type) => activityTypesLabels[type] ?? type;
+
+const renderResource = (entry) => (
+ {toResource(entry)}
+);
+
+const keyRenderers = {
+ metadata: renderMetadata,
+ type: renderType,
+ resource: renderResource,
+};
+
+function ActivityLogDetailModal({ open = false, entry, onClose = noop }) {
+ const data = keys.map((key) => ({
+ title: keyToLabel[key] || key,
+ content: key === 'resource' ? entry : entry[key],
+ render: keyRenderers[key] || undefined,
+ className: classNames('col-span-5', {
+ 'text-gray-500': key !== 'metadata',
+ }),
+ }));
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+export default ActivityLogDetailModal;
diff --git a/assets/js/common/ActivityLogDetailsModal/ActivityLogDetailModal.stories.jsx b/assets/js/common/ActivityLogDetailsModal/ActivityLogDetailModal.stories.jsx
new file mode 100644
index 0000000000..e235e28b11
--- /dev/null
+++ b/assets/js/common/ActivityLogDetailsModal/ActivityLogDetailModal.stories.jsx
@@ -0,0 +1,58 @@
+import { action } from '@storybook/addon-actions';
+import {
+ activityLogEntryFactory,
+ taggingMetadataFactory,
+} from '@lib/test-utils/factories/activityLog';
+import { RESOURCE_TAGGING } from '@lib/model/activityLog';
+import { toRenderedEntry } from '@common/ActivityLogOverview/ActivityLogOverview';
+import ActivityLogDetailModal from '.';
+
+export default {
+ title: 'Components/ActivityLogDetailModal',
+ component: ActivityLogDetailModal,
+ argTypes: {
+ open: {
+ description: 'Whether the dialog is open or not',
+ control: {
+ type: 'boolean',
+ },
+ },
+ entry: {
+ description: 'An Avtivity Log entry.',
+ control: {
+ type: 'object',
+ },
+ },
+ onClose: {
+ description: 'Callback when the Cancel button is clicked',
+ control: { type: 'function' },
+ },
+ },
+};
+
+export const Default = {
+ args: {
+ open: false,
+ entry: toRenderedEntry(activityLogEntryFactory.build()),
+ onClose: action('cancel clicked'),
+ },
+};
+
+export const UnknwonActivityType = {
+ args: {
+ ...Default.args,
+ entry: toRenderedEntry(activityLogEntryFactory.build({ type: 'foo_bar' })),
+ },
+};
+
+export const UnknwonResourceType = {
+ args: {
+ ...Default.args,
+ entry: toRenderedEntry(
+ activityLogEntryFactory.build({
+ type: RESOURCE_TAGGING,
+ metadata: taggingMetadataFactory.build({ resource_type: 'foo_bar' }),
+ })
+ ),
+ },
+};
diff --git a/assets/js/common/ActivityLogDetailsModal/ActivityLogDetailModal.test.jsx b/assets/js/common/ActivityLogDetailsModal/ActivityLogDetailModal.test.jsx
new file mode 100644
index 0000000000..b829f6b80f
--- /dev/null
+++ b/assets/js/common/ActivityLogDetailsModal/ActivityLogDetailModal.test.jsx
@@ -0,0 +1,204 @@
+import React from 'react';
+import { render, screen, act } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import '@testing-library/jest-dom';
+
+import {
+ activityLogEntryFactory,
+ taggingMetadataFactory,
+ untaggingMetadataFactory,
+} from '@lib/test-utils/factories/activityLog';
+
+import {
+ LOGIN_ATTEMPT,
+ RESOURCE_TAGGING,
+ RESOURCE_UNTAGGING,
+ API_KEY_GENERATION,
+ CHANGING_SUMA_SETTINGS,
+ CLEARING_SUMA_SETTINGS,
+ SAVING_SUMA_SETTINGS,
+ CLUSTER_CHECKS_EXECUTION_REQUEST,
+ PROFILE_UPDATE,
+ USER_CREATION,
+ USER_DELETION,
+ USER_MODIFICATION,
+ LEVEL_WARNING,
+} from '@lib/model/activityLog';
+import { toRenderedEntry } from '@common/ActivityLogOverview/ActivityLogOverview';
+import ActivityLogDetailModal from '.';
+
+describe('ActivityLogDetailModal component', () => {
+ const scenarios = [
+ {
+ name: LOGIN_ATTEMPT,
+ entry: activityLogEntryFactory.build({
+ type: LOGIN_ATTEMPT,
+ metadata: {},
+ }),
+ expectedActivityType: 'Login Attempt',
+ expectedResource: 'Application',
+ },
+ {
+ name: RESOURCE_TAGGING,
+ entry: activityLogEntryFactory.build({
+ type: RESOURCE_TAGGING,
+ metadata: taggingMetadataFactory.build({
+ resource_type: 'host',
+ }),
+ }),
+ expectedActivityType: 'Tag Added',
+ expectedResource: 'Host',
+ },
+ {
+ name: RESOURCE_UNTAGGING,
+ entry: activityLogEntryFactory.build({
+ type: RESOURCE_UNTAGGING,
+ level: LEVEL_WARNING,
+ metadata: untaggingMetadataFactory.build({
+ resource_type: 'cluster',
+ }),
+ }),
+ expectedActivityType: 'Tag Removed',
+ expectedResource: 'Cluster',
+ },
+ {
+ name: API_KEY_GENERATION,
+ entry: activityLogEntryFactory.build({
+ type: API_KEY_GENERATION,
+ }),
+ expectedActivityType: 'API Key Generated',
+ expectedResource: 'API Key',
+ },
+ {
+ name: SAVING_SUMA_SETTINGS,
+ entry: activityLogEntryFactory.build({
+ type: SAVING_SUMA_SETTINGS,
+ }),
+ expectedActivityType: 'SUMA Settings Saved',
+ expectedResource: 'SUMA Settings',
+ },
+ {
+ name: CHANGING_SUMA_SETTINGS,
+ entry: activityLogEntryFactory.build({
+ type: CHANGING_SUMA_SETTINGS,
+ }),
+ expectedActivityType: 'SUMA Settings Changed',
+ expectedResource: 'SUMA Settings',
+ },
+ {
+ name: CLEARING_SUMA_SETTINGS,
+ entry: activityLogEntryFactory.build({
+ type: CLEARING_SUMA_SETTINGS,
+ }),
+ expectedActivityType: 'SUMA Settings Cleared',
+ expectedResource: 'SUMA Settings',
+ },
+ {
+ name: USER_CREATION,
+ entry: activityLogEntryFactory.build({
+ type: USER_CREATION,
+ }),
+ expectedActivityType: 'User Created',
+ expectedResource: 'User',
+ userRelatedActivity: true,
+ },
+ {
+ name: USER_MODIFICATION,
+ entry: activityLogEntryFactory.build({
+ type: USER_MODIFICATION,
+ }),
+ expectedActivityType: 'User Modified',
+ expectedResource: 'User',
+ userRelatedActivity: true,
+ },
+ {
+ name: USER_DELETION,
+ entry: activityLogEntryFactory.build({
+ type: USER_DELETION,
+ }),
+ expectedActivityType: 'User Deleted',
+ expectedResource: 'User',
+ userRelatedActivity: true,
+ },
+ {
+ name: PROFILE_UPDATE,
+ entry: activityLogEntryFactory.build({
+ type: PROFILE_UPDATE,
+ }),
+ expectedActivityType: 'Profile Updated',
+ expectedResource: 'Profile',
+ },
+ {
+ name: CLUSTER_CHECKS_EXECUTION_REQUEST,
+ entry: activityLogEntryFactory.build({
+ type: CLUSTER_CHECKS_EXECUTION_REQUEST,
+ }),
+ expectedActivityType: 'Checks Execution Requested',
+ expectedResource: 'Cluster Checks',
+ },
+ ];
+
+ it.each(scenarios)(
+ 'should render detail for activity entry `$name`',
+ async ({
+ entry,
+ expectedActivityType,
+ expectedResource,
+ userRelatedActivity = false,
+ }) => {
+ const { id } = entry;
+ await act(async () => {
+ render();
+ });
+
+ expect(screen.getByText('ID')).toBeVisible();
+ expect(screen.getByText(id)).toBeVisible();
+
+ expect(screen.getByText('Activity Type')).toBeVisible();
+ expect(screen.getByText(expectedActivityType)).toBeVisible();
+
+ expect(screen.getByText('Resource')).toBeVisible();
+ const resource = screen.getByLabelText('activity-log-resource');
+ expect(resource).toBeVisible();
+ expect(resource).toHaveTextContent(expectedResource);
+
+ const userReferences = screen.getAllByText('User');
+ if (userRelatedActivity) {
+ expect(userReferences).toHaveLength(2);
+ }
+ userReferences.forEach((userReference) => {
+ expect(userReference).toBeVisible();
+ });
+
+ expect(screen.getByText('Message')).toBeVisible();
+ expect(screen.getByText('Created at')).toBeVisible();
+ }
+ );
+
+ it('should render detail for unknown activity type', async () => {
+ const entry = activityLogEntryFactory.build({ type: 'foo_bar' });
+ await act(async () => {
+ render();
+ });
+
+ expect(screen.getByText('foo_bar')).toBeVisible();
+ expect(screen.getByText('Unrecognized resource')).toBeVisible();
+ expect(screen.getByText('Unrecognized activity')).toBeVisible();
+ });
+
+ it('should call onClose when the close button is clicked', async () => {
+ const onClose = jest.fn();
+ await act(async () => {
+ render(
+
+ );
+ });
+
+ await userEvent.click(screen.getByText('Close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+});
diff --git a/assets/js/common/ActivityLogDetailsModal/index.js b/assets/js/common/ActivityLogDetailsModal/index.js
new file mode 100644
index 0000000000..a2c54267ef
--- /dev/null
+++ b/assets/js/common/ActivityLogDetailsModal/index.js
@@ -0,0 +1,3 @@
+import ActivityLogDetailModal from './ActivityLogDetailModal';
+
+export default ActivityLogDetailModal;
diff --git a/assets/js/common/ActivityLogOverview/ActivityLogOverview.jsx b/assets/js/common/ActivityLogOverview/ActivityLogOverview.jsx
new file mode 100644
index 0000000000..3b3e612479
--- /dev/null
+++ b/assets/js/common/ActivityLogOverview/ActivityLogOverview.jsx
@@ -0,0 +1,164 @@
+import React, { useState } from 'react';
+import { noop } from 'lodash';
+import {
+ EOS_BUG_REPORT_OUTLINED,
+ EOS_ERROR_OUTLINED,
+ EOS_INFO_OUTLINED,
+ EOS_KEYBOARD_ARROW_RIGHT_FILLED,
+ EOS_WARNING_OUTLINED,
+} from 'eos-icons-react';
+import Table from '@common/Table';
+import Tooltip from '@common/Tooltip';
+
+import {
+ API_KEY_GENERATION,
+ CHANGING_SUMA_SETTINGS,
+ CLEARING_SUMA_SETTINGS,
+ CLUSTER_CHECKS_EXECUTION_REQUEST,
+ LOGIN_ATTEMPT,
+ PROFILE_UPDATE,
+ RESOURCE_TAGGING,
+ RESOURCE_UNTAGGING,
+ SAVING_SUMA_SETTINGS,
+ USER_CREATION,
+ USER_DELETION,
+ USER_MODIFICATION,
+ ACTIVITY_LOG_LEVELS,
+ LEVEL_DEBUG,
+ LEVEL_ERROR,
+ LEVEL_INFO,
+ LEVEL_WARNING,
+} from '@lib/model/activityLog';
+
+import ActivityLogDetailModal from '@common/ActivityLogDetailsModal';
+import { format } from 'date-fns';
+
+const toMessage = (activityLogEntry) => {
+ const { metadata, type } = activityLogEntry;
+
+ switch (type) {
+ case LOGIN_ATTEMPT:
+ return metadata?.reason ? 'Login failed' : 'User logged in';
+ case RESOURCE_TAGGING:
+ return `Tag "${metadata.added_tag}" added to "${metadata.resource_id}"`;
+ case RESOURCE_UNTAGGING:
+ return `Tag "${metadata.removed_tag}" removed from "${metadata.resource_id}"`;
+ case USER_CREATION:
+ return 'User was created';
+ case USER_MODIFICATION:
+ return 'User was modified';
+ case USER_DELETION:
+ return 'User was deleted';
+ case PROFILE_UPDATE:
+ return 'User modified profile';
+ case SAVING_SUMA_SETTINGS:
+ return 'SUMA Settings was saved';
+ case CHANGING_SUMA_SETTINGS:
+ return 'SUMA Settings was changed';
+ case CLEARING_SUMA_SETTINGS:
+ return 'SUMA Settings was cleared';
+ case API_KEY_GENERATION:
+ return 'API Key was generated';
+ case CLUSTER_CHECKS_EXECUTION_REQUEST:
+ return 'Checks execution requested for cluster';
+ default:
+ return 'Unrecognized activity';
+ }
+};
+
+const logLevelToIcon = {
+ [LEVEL_DEBUG]: ,
+ [LEVEL_INFO]: ,
+ [LEVEL_WARNING]: ,
+ [LEVEL_ERROR]: ,
+};
+const logLevelToLabel = {
+ [LEVEL_DEBUG]: 'Debug',
+ [LEVEL_INFO]: 'Info',
+ [LEVEL_WARNING]: 'Warning',
+ [LEVEL_ERROR]: 'Error',
+};
+
+export const toRenderedEntry = (entry) => ({
+ id: entry.id,
+ type: entry.type,
+ time: format(entry.occurred_on, 'yyyy-MM-dd HH:mm:ss'),
+ message: toMessage(entry),
+ user: entry.actor,
+ level: ACTIVITY_LOG_LEVELS.includes(entry?.level) ? entry?.level : LEVEL_INFO,
+ metadata: entry.metadata,
+});
+
+function ActivityLogOverview({
+ activityLog,
+ activityLogDetailModalOpen = false,
+ onActivityLogEntryClick = noop,
+ onCloseActivityLogEntryDetails = noop,
+}) {
+ const [selectedEntry, setEntry] = useState({});
+
+ const activityLogTableConfig = {
+ pagination: true,
+ usePadding: false,
+ columns: [
+ {
+ title: 'Time',
+ key: 'time',
+ },
+ {
+ title: 'Message',
+ key: 'message',
+ },
+ {
+ title: 'User',
+ key: 'user',
+ },
+ {
+ title: 'Level',
+ key: 'level',
+ className: 'text-center',
+ render: (level) => (
+
+
+ {logLevelToIcon[level]}
+
+
+ ),
+ },
+ {
+ title: '',
+ key: 'metadata',
+ render: (_metadata, logEntry) => (
+
{
+ setEntry(logEntry);
+ onActivityLogEntryClick();
+ }}
+ onKeyDown={() => {}}
+ >
+
+
+ ),
+ },
+ ],
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+}
+
+export default ActivityLogOverview;
diff --git a/assets/js/common/ActivityLogOverview/ActivityLogOverview.stories.jsx b/assets/js/common/ActivityLogOverview/ActivityLogOverview.stories.jsx
new file mode 100644
index 0000000000..19e0e069ae
--- /dev/null
+++ b/assets/js/common/ActivityLogOverview/ActivityLogOverview.stories.jsx
@@ -0,0 +1,49 @@
+import { activityLogEntryFactory } from '@lib/test-utils/factories/activityLog';
+import _ from 'lodash';
+import ActivityLogOverview from './ActivityLogOverview';
+
+export default {
+ title: 'Components/ActivityLogOverview',
+ component: ActivityLogOverview,
+ argTypes: {
+ activityLog: {
+ description: 'List of the activity log entries',
+ control: {
+ type: 'array',
+ },
+ },
+ },
+};
+
+export const Default = {
+ args: {
+ activityLog: activityLogEntryFactory.buildList(20),
+ },
+};
+
+export const Empty = {
+ args: {
+ activityLog: [],
+ },
+};
+
+export const UnknwonActivityType = {
+ args: {
+ ...Default.args,
+ activityLog: [activityLogEntryFactory.build({ type: 'foo_bar' })],
+ },
+};
+
+export const UnknwonLevel = {
+ args: {
+ ...Default.args,
+ activityLog: [activityLogEntryFactory.build({ level: 'foo_bar' })],
+ },
+};
+
+export const MissingLevel = {
+ args: {
+ ...Default.args,
+ activityLog: [_.omit(activityLogEntryFactory.build(), 'level')],
+ },
+};
diff --git a/assets/js/common/ActivityLogOverview/ActivityLogOverview.test.jsx b/assets/js/common/ActivityLogOverview/ActivityLogOverview.test.jsx
new file mode 100644
index 0000000000..2bcb91c591
--- /dev/null
+++ b/assets/js/common/ActivityLogOverview/ActivityLogOverview.test.jsx
@@ -0,0 +1,198 @@
+import React from 'react';
+import { faker } from '@faker-js/faker';
+
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import {
+ activityLogEntryFactory,
+ taggingMetadataFactory,
+ untaggingMetadataFactory,
+} from '@lib/test-utils/factories/activityLog';
+import {
+ LOGIN_ATTEMPT,
+ RESOURCE_TAGGING,
+ RESOURCE_UNTAGGING,
+ API_KEY_GENERATION,
+ CHANGING_SUMA_SETTINGS,
+ CLEARING_SUMA_SETTINGS,
+ SAVING_SUMA_SETTINGS,
+ CLUSTER_CHECKS_EXECUTION_REQUEST,
+ PROFILE_UPDATE,
+ USER_CREATION,
+ USER_DELETION,
+ USER_MODIFICATION,
+ LEVEL_DEBUG,
+ LEVEL_ERROR,
+ LEVEL_INFO,
+ LEVEL_WARNING,
+} from '@lib/model/activityLog';
+import '@testing-library/jest-dom';
+
+import ActivityLogOverview from '.';
+
+describe('Activity Log Overview', () => {
+ it('should render an empty activity log', () => {
+ render();
+
+ expect(screen.getByText('No data available')).toBeVisible();
+ });
+
+ const scenarios = [
+ {
+ name: LOGIN_ATTEMPT,
+ entry: activityLogEntryFactory.build({
+ actor: 'admin',
+ type: LOGIN_ATTEMPT,
+ level: LEVEL_DEBUG,
+ metadata: {},
+ }),
+ expectedUser: 'admin',
+ expectedMessage: 'User logged in',
+ expectedLevel: 'debug',
+ },
+ {
+ name: RESOURCE_TAGGING,
+ entry: activityLogEntryFactory.build({
+ actor: 'foo',
+ type: RESOURCE_TAGGING,
+ level: LEVEL_INFO,
+ metadata: taggingMetadataFactory.build({
+ resource_type: 'host',
+ added_tag: 'bar',
+ resource_id: 'foo-bar',
+ }),
+ }),
+ expectedUser: 'foo',
+ expectedMessage: 'Tag "bar" added to "foo-bar"',
+ expectedLevel: 'info',
+ },
+ {
+ name: RESOURCE_UNTAGGING,
+ entry: activityLogEntryFactory.build({
+ actor: 'bar',
+ type: RESOURCE_UNTAGGING,
+ level: LEVEL_WARNING,
+ metadata: untaggingMetadataFactory.build({
+ resource_type: 'cluster',
+ removed_tag: 'foo',
+ resource_id: 'bar-foo',
+ }),
+ }),
+ expectedUser: 'bar',
+ expectedMessage: 'Tag "foo" removed from "bar-foo"',
+ expectedLevel: 'warning',
+ },
+ {
+ name: API_KEY_GENERATION,
+ entry: activityLogEntryFactory.build({
+ actor: 'baz',
+ type: API_KEY_GENERATION,
+ level: LEVEL_ERROR,
+ }),
+ expectedUser: 'baz',
+ expectedMessage: 'API Key was generated',
+ expectedLevel: 'error',
+ },
+ {
+ name: SAVING_SUMA_SETTINGS,
+ entry: activityLogEntryFactory.build({
+ actor: 'user-1',
+ type: SAVING_SUMA_SETTINGS,
+ }),
+ expectedUser: 'user-1',
+ expectedMessage: 'SUMA Settings was saved',
+ },
+ {
+ name: CHANGING_SUMA_SETTINGS,
+ entry: activityLogEntryFactory.build({
+ actor: 'user-2',
+ type: CHANGING_SUMA_SETTINGS,
+ }),
+ expectedUser: 'user-2',
+ expectedMessage: 'SUMA Settings was changed',
+ },
+ {
+ name: CLEARING_SUMA_SETTINGS,
+ entry: activityLogEntryFactory.build({
+ actor: 'user-3',
+ type: CLEARING_SUMA_SETTINGS,
+ }),
+ expectedUser: 'user-3',
+ expectedMessage: 'SUMA Settings was cleared',
+ },
+ {
+ name: USER_CREATION,
+ entry: activityLogEntryFactory.build({
+ actor: 'user-4',
+ type: USER_CREATION,
+ }),
+ expectedUser: 'user-4',
+ expectedMessage: 'User was created',
+ },
+ {
+ name: USER_MODIFICATION,
+ entry: activityLogEntryFactory.build({
+ actor: 'user-5',
+ type: USER_MODIFICATION,
+ }),
+ expectedUser: 'user-5',
+ expectedMessage: 'User was modified',
+ },
+ {
+ name: USER_DELETION,
+ entry: activityLogEntryFactory.build({
+ actor: 'user-6',
+ type: USER_DELETION,
+ }),
+ expectedUser: 'user-6',
+ expectedMessage: 'User was deleted',
+ },
+ {
+ name: PROFILE_UPDATE,
+ entry: activityLogEntryFactory.build({
+ actor: 'user-7',
+ type: PROFILE_UPDATE,
+ }),
+ expectedUser: 'user-7',
+ expectedMessage: 'User modified profile',
+ },
+ {
+ name: CLUSTER_CHECKS_EXECUTION_REQUEST,
+ entry: activityLogEntryFactory.build({
+ actor: 'user-8',
+ type: CLUSTER_CHECKS_EXECUTION_REQUEST,
+ }),
+ expectedUser: 'user-8',
+ expectedMessage: 'Checks execution requested for cluster',
+ },
+ ];
+
+ it.each(scenarios)(
+ 'should render log entry for activity `$name`',
+ ({ entry, expectedUser, expectedMessage, expectedLevel }) => {
+ render();
+
+ expect(screen.getByText(expectedMessage)).toBeVisible();
+ expect(screen.getByText(expectedUser)).toBeVisible();
+ if (expectedLevel) {
+ expect(
+ screen.getByLabelText(`log-level-${expectedLevel}`)
+ ).toBeVisible();
+ }
+ }
+ );
+
+ it('should call onActivityLogEntryClick when clicking on an entry in the table', async () => {
+ const onActivityLogEntryClick = jest.fn();
+ const id = faker.string.uuid();
+ render(
+
+ );
+
+ await userEvent.click(screen.getByLabelText(`entry-${id}`));
+ expect(onActivityLogEntryClick).toHaveBeenCalled();
+ });
+});
diff --git a/assets/js/common/ActivityLogOverview/index.js b/assets/js/common/ActivityLogOverview/index.js
new file mode 100644
index 0000000000..9c66b43746
--- /dev/null
+++ b/assets/js/common/ActivityLogOverview/index.js
@@ -0,0 +1,3 @@
+import ActivityLogOverview from './ActivityLogOverview';
+
+export default ActivityLogOverview;
diff --git a/assets/js/lib/model/activityLog.js b/assets/js/lib/model/activityLog.js
new file mode 100644
index 0000000000..0d840eef63
--- /dev/null
+++ b/assets/js/lib/model/activityLog.js
@@ -0,0 +1,40 @@
+export const LOGIN_ATTEMPT = 'login_attempt';
+export const RESOURCE_TAGGING = 'resource_tagging';
+export const RESOURCE_UNTAGGING = 'resource_untagging';
+export const API_KEY_GENERATION = 'api_key_generation';
+export const SAVING_SUMA_SETTINGS = 'saving_suma_settings';
+export const CHANGING_SUMA_SETTINGS = 'changing_suma_settings';
+export const CLEARING_SUMA_SETTINGS = 'clearing_suma_settings';
+export const USER_CREATION = 'user_creation';
+export const USER_MODIFICATION = 'user_modification';
+export const USER_DELETION = 'user_deletion';
+export const PROFILE_UPDATE = 'profile_update';
+export const CLUSTER_CHECKS_EXECUTION_REQUEST =
+ 'cluster_checks_execution_request';
+
+export const ACTIVITY_TYPES = [
+ LOGIN_ATTEMPT,
+ RESOURCE_TAGGING,
+ RESOURCE_UNTAGGING,
+ API_KEY_GENERATION,
+ SAVING_SUMA_SETTINGS,
+ CHANGING_SUMA_SETTINGS,
+ CLEARING_SUMA_SETTINGS,
+ USER_CREATION,
+ USER_MODIFICATION,
+ USER_DELETION,
+ PROFILE_UPDATE,
+ CLUSTER_CHECKS_EXECUTION_REQUEST,
+];
+
+export const LEVEL_DEBUG = 'debug';
+export const LEVEL_INFO = 'info';
+export const LEVEL_WARNING = 'warning';
+export const LEVEL_ERROR = 'error';
+
+export const ACTIVITY_LOG_LEVELS = [
+ LEVEL_DEBUG,
+ LEVEL_INFO,
+ LEVEL_WARNING,
+ LEVEL_ERROR,
+];
diff --git a/assets/js/lib/test-utils/factories/activityLog.js b/assets/js/lib/test-utils/factories/activityLog.js
new file mode 100644
index 0000000000..3fb88a8f9e
--- /dev/null
+++ b/assets/js/lib/test-utils/factories/activityLog.js
@@ -0,0 +1,47 @@
+import { faker } from '@faker-js/faker';
+import { Factory } from 'fishery';
+import {
+ ACTIVITY_TYPES,
+ RESOURCE_TAGGING,
+ RESOURCE_UNTAGGING,
+ ACTIVITY_LOG_LEVELS,
+} from '@lib/model/activityLog';
+import { randomObjectFactory } from '.';
+
+const taggableResourceTypes = ['host', 'cluster', 'database', 'sap_system'];
+
+export const taggingMetadataFactory = Factory.define(() => ({
+ resource_id: faker.string.uuid(),
+ resource_type: faker.helpers.arrayElement(taggableResourceTypes),
+ added_tag: faker.lorem.word(),
+}));
+
+export const untaggingMetadataFactory = Factory.define(() => ({
+ resource_id: faker.string.uuid(),
+ resource_type: faker.helpers.arrayElement(taggableResourceTypes),
+ removed_tag: faker.lorem.word(),
+}));
+
+const metadataForActivity = (activityType) => {
+ switch (activityType) {
+ case RESOURCE_TAGGING:
+ return taggingMetadataFactory.build();
+ case RESOURCE_UNTAGGING:
+ return untaggingMetadataFactory.build();
+ default:
+ return randomObjectFactory.build();
+ }
+};
+
+export const activityLogEntryFactory = Factory.define(() => {
+ const activityType = faker.helpers.arrayElement(ACTIVITY_TYPES);
+
+ return {
+ id: faker.string.uuid(),
+ actor: faker.internet.userName(),
+ type: activityType,
+ occurred_on: faker.date.anytime(),
+ metadata: metadataForActivity(activityType),
+ level: faker.helpers.arrayElement(ACTIVITY_LOG_LEVELS),
+ };
+});