Skip to content

Commit 373bf69

Browse files
authored
Merge pull request #2981 from CarnegieLearningWeb/feature/experiment-logs-tab
Feature/experiment logs tab
2 parents b543eb2 + c3e94e1 commit 373bf69

51 files changed

Lines changed: 2317 additions & 48 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<app-common-section-card *ngIf="selectedExperiment$ | async as experiment">
2+
<!-- Search Widget -->
3+
<app-common-section-card-search-header
4+
header-left
5+
[filterOptions]="(filterOptions$ | async) || []"
6+
[searchString]="(searchString$ | async) || ''"
7+
[searchKey]="(searchKey$ | async) || 'All'"
8+
(search)="onSearch($event)"
9+
>
10+
</app-common-section-card-search-header>
11+
12+
<!-- Action Buttons -->
13+
<app-common-section-card-action-buttons
14+
header-right
15+
[showPrimaryButton]="false"
16+
[isSectionCardExpanded]="isSectionCardExpanded"
17+
(sectionCardExpandChange)="onSectionCardExpandChange($event)"
18+
>
19+
</app-common-section-card-action-buttons>
20+
21+
<!-- Timeline Content -->
22+
<ng-container content *ngIf="isSectionCardExpanded">
23+
<common-audit-log-timeline
24+
[groupedLogs]="timelineDataSource$ | async"
25+
[isLoading]="isLoading$ | async"
26+
[isEmpty]="(experimentLogs$ | async)?.length === 0"
27+
[config]="timelineConfig"
28+
(scrolledToBottom)="fetchLogsOnScroll()"
29+
>
30+
</common-audit-log-timeline>
31+
</ng-container>
32+
</app-common-section-card>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { ChangeDetectionStrategy, Component, Input, OnInit, OnDestroy } from '@angular/core';
2+
import {
3+
CommonSectionCardActionButtonsComponent,
4+
CommonSectionCardComponent,
5+
CommonSectionCardSearchHeaderComponent,
6+
} from '../../../../../../../shared-standalone-component-lib/components';
7+
import {
8+
FilterOption,
9+
CommonSearchWidgetSearchParams,
10+
} from '../../../../../../../shared-standalone-component-lib/components/common-section-card-search-header/common-section-card-search-header.component';
11+
import { CommonModule } from '@angular/common';
12+
import { TranslateModule } from '@ngx-translate/core';
13+
import { ExperimentService } from '../../../../../../../core/experiments/experiments.service';
14+
import { Experiment } from '../../../../../../../core/experiments/store/experiments.model';
15+
import { LogsService } from '../../../../../../../core/logs/logs.service';
16+
import { SharedModule } from '../../../../../../../shared/shared.module';
17+
import { CommonAuditLogTimelineComponent } from '../../../../../../../shared-standalone-component-lib/components/common-audit-log-timeline/common-audit-log-timeline.component';
18+
import { AuditLogs, LogDateFormatType } from '../../../../../../../core/logs/store/logs.model';
19+
import { LOG_TYPE } from 'upgrade_types';
20+
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
21+
import { combineLatest, Subject, Observable, BehaviorSubject } from 'rxjs';
22+
import { map, filter, takeUntil, switchMap, tap, take } from 'rxjs/operators';
23+
import { groupBy } from 'lodash';
24+
import { AuditLogTimelineConfig } from '../../../../../../../shared-standalone-component-lib/components/common-audit-log-timeline/common-audit-log-timeline-config.model';
25+
import { EXPERIMENT_TIMELINE_LOG_TYPE_CONFIG } from '../../../../../../../shared-standalone-component-lib/components/common-audit-log-timeline/configs/experiment-timeline.config';
26+
27+
/**
28+
* Section card component for displaying experiment-specific audit logs in a timeline format.
29+
* Features:
30+
* - Dynamic filter dropdown with action types and users from logs
31+
* - Text search capability
32+
* - Timeline view grouped by date
33+
* - Infinite scroll pagination
34+
*/
35+
@Component({
36+
selector: 'app-experiment-log-section-card',
37+
imports: [
38+
CommonModule,
39+
CommonSectionCardComponent,
40+
CommonSectionCardActionButtonsComponent,
41+
CommonSectionCardSearchHeaderComponent,
42+
TranslateModule,
43+
SharedModule,
44+
CommonAuditLogTimelineComponent,
45+
MatProgressSpinnerModule,
46+
],
47+
standalone: true,
48+
templateUrl: './experiment-log-section-card.component.html',
49+
changeDetection: ChangeDetectionStrategy.OnPush,
50+
})
51+
export class ExperimentLogSectionCardComponent implements OnInit, OnDestroy {
52+
@Input() isSectionCardExpanded = true;
53+
54+
selectedExperiment$: Observable<Experiment> = this.experimentService.selectedExperiment$;
55+
56+
// Search state
57+
searchString$ = new BehaviorSubject<string>('');
58+
searchKey$ = new BehaviorSubject<string>('All');
59+
filterOptions$ = new BehaviorSubject<FilterOption[]>([{ value: 'All' }]);
60+
61+
// Logs data
62+
experimentLogs$: Observable<AuditLogs[]>;
63+
timelineDataSource$: Observable<{ dates: string[]; dateGroups: Record<string, AuditLogs[]> }>;
64+
isLoading$: Observable<boolean>;
65+
allLogsFetched$: Observable<boolean>;
66+
67+
private destroy$ = new Subject<void>();
68+
private currentExperimentId: string | null = null;
69+
70+
LogDateFormatType = LogDateFormatType;
71+
timelineConfig: AuditLogTimelineConfig = EXPERIMENT_TIMELINE_LOG_TYPE_CONFIG;
72+
73+
constructor(private readonly experimentService: ExperimentService, private readonly logsService: LogsService) {}
74+
75+
ngOnInit(): void {
76+
// Fetch logs when experiment loads
77+
this.selectedExperiment$
78+
.pipe(
79+
filter((exp): exp is Experiment => !!exp),
80+
tap((exp) => {
81+
this.currentExperimentId = exp.id;
82+
this.logsService.fetchExperimentLogs(exp.id, true);
83+
}),
84+
takeUntil(this.destroy$)
85+
)
86+
.subscribe();
87+
88+
// Get raw logs observable
89+
this.experimentLogs$ = this.selectedExperiment$.pipe(
90+
filter((exp): exp is Experiment => !!exp),
91+
switchMap((exp) => this.logsService.getExperimentLogsById(exp.id)),
92+
tap((logs) => {
93+
this.buildFilterOptions(logs);
94+
}),
95+
takeUntil(this.destroy$)
96+
);
97+
98+
// Get loading state
99+
this.isLoading$ = this.selectedExperiment$.pipe(
100+
filter((exp): exp is Experiment => !!exp),
101+
switchMap((exp) => this.logsService.getExperimentLogsLoadingState(exp.id)),
102+
takeUntil(this.destroy$)
103+
);
104+
105+
// Get pagination state
106+
this.allLogsFetched$ = this.selectedExperiment$.pipe(
107+
filter((exp): exp is Experiment => !!exp),
108+
switchMap((exp) => this.logsService.isAllExperimentLogsFetched(exp.id)),
109+
takeUntil(this.destroy$)
110+
);
111+
112+
// Apply search and group by date
113+
// TODO: prefer doing this on backend
114+
this.timelineDataSource$ = combineLatest([this.experimentLogs$, this.searchString$, this.searchKey$]).pipe(
115+
map(([logs, searchString, searchKey]) => {
116+
// TODO: prefer doing this on backend
117+
const filtered = this.filterLogs(logs, searchString, searchKey);
118+
const dateGroups = this.groupLogsByDate(filtered);
119+
const dates = Object.keys(dateGroups);
120+
return {
121+
dates,
122+
dateGroups,
123+
};
124+
}),
125+
takeUntil(this.destroy$)
126+
);
127+
}
128+
129+
ngOnDestroy(): void {
130+
this.destroy$.next();
131+
this.destroy$.complete();
132+
}
133+
134+
onSectionCardExpandChange(isSectionCardExpanded: boolean): void {
135+
this.isSectionCardExpanded = isSectionCardExpanded;
136+
}
137+
138+
onSearch(params: CommonSearchWidgetSearchParams<string>): void {
139+
this.searchKey$.next(params.searchKey);
140+
this.searchString$.next(params.searchString);
141+
}
142+
143+
fetchLogsOnScroll(): void {
144+
if (!this.currentExperimentId) return;
145+
146+
// Check if all logs are fetched and if not currently loading
147+
combineLatest([this.allLogsFetched$, this.isLoading$])
148+
.pipe(
149+
take(1),
150+
filter(([allFetched, isLoading]) => !allFetched && !isLoading),
151+
takeUntil(this.destroy$)
152+
)
153+
.subscribe(() => {
154+
this.logsService.fetchExperimentLogs(this.currentExperimentId);
155+
});
156+
}
157+
158+
/**
159+
* Build dynamic filter options from logs data
160+
*/
161+
private buildFilterOptions(logs: AuditLogs[]): void {
162+
if (!logs || logs.length === 0) {
163+
this.filterOptions$.next([{ value: 'All' }]);
164+
return;
165+
}
166+
167+
// Get unique action types (only experiment-related)
168+
const experimentLogTypes = [
169+
LOG_TYPE.EXPERIMENT_CREATED,
170+
LOG_TYPE.EXPERIMENT_UPDATED,
171+
LOG_TYPE.EXPERIMENT_STATE_CHANGED,
172+
LOG_TYPE.EXPERIMENT_DELETED,
173+
LOG_TYPE.EXPERIMENT_DATA_EXPORTED,
174+
LOG_TYPE.EXPERIMENT_DESIGN_EXPORTED,
175+
];
176+
177+
const actionTypes = [...new Set(logs.map((log) => log.type))].filter((type) => experimentLogTypes.includes(type));
178+
179+
// Get unique users (firstName + lastName)
180+
const userSet = new Set<string>();
181+
logs.forEach((log) => {
182+
if (log.user?.firstName && log.user?.lastName) {
183+
userSet.add(`${log.user.firstName} ${log.user.lastName}`);
184+
}
185+
});
186+
const users = Array.from(userSet);
187+
188+
// Build grouped filter options
189+
const options: FilterOption[] = [
190+
{ value: 'All' }, // Standalone option
191+
...actionTypes.map((type) => ({
192+
value: type,
193+
group: 'Event Type', // Group name
194+
})),
195+
];
196+
197+
if (users.length > 0) {
198+
users.forEach((user) => {
199+
options.push({ value: user, group: 'Users' }); // Group name
200+
});
201+
}
202+
203+
this.filterOptions$.next(options);
204+
}
205+
206+
/**
207+
* Filter logs based on search criteria
208+
*/
209+
private filterLogs(logs: AuditLogs[], searchString: string, searchKey: string): AuditLogs[] {
210+
let filtered = logs;
211+
212+
// Apply dropdown filter
213+
if (searchKey && searchKey !== 'All') {
214+
// Check if it's an action type
215+
if (Object.values(LOG_TYPE).includes(searchKey as LOG_TYPE)) {
216+
filtered = filtered.filter((log) => log.type === searchKey);
217+
}
218+
// Check if it's a user name
219+
else {
220+
filtered = filtered.filter((log) => {
221+
const userName = `${log.user?.firstName || ''} ${log.user?.lastName || ''}`.trim();
222+
return userName === searchKey;
223+
});
224+
}
225+
}
226+
227+
// Apply text search
228+
if (searchString) {
229+
const searchLower = searchString.toLowerCase();
230+
filtered = filtered.filter((log) => {
231+
const userName = `${log.user?.firstName || ''} ${log.user?.lastName || ''}`.toLowerCase();
232+
const actionType = log.type.toLowerCase();
233+
const dataStr = JSON.stringify(log.data).toLowerCase();
234+
235+
return userName.includes(searchLower) || actionType.includes(searchLower) || dataStr.includes(searchLower);
236+
});
237+
}
238+
239+
return filtered;
240+
}
241+
242+
/**
243+
* Group logs by date (format: YYYY/M/D)
244+
*/
245+
private groupLogsByDate(logs: AuditLogs[]): Record<string, AuditLogs[]> {
246+
return groupBy(logs, (log) => {
247+
const date = new Date(log.createdAt);
248+
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
249+
});
250+
}
251+
}

packages/backend/src/api/controllers/LogController.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ export class AuditLogController {
5050
* filter:
5151
* type: string
5252
* enum: [experimentCreated, experimentUpdated, experimentStateChanged, experimentDeleted]
53+
* experimentId:
54+
* type: string
55+
* format: uuid
5356
* description: number of audit logs to requests
5457
* tags:
5558
* - Logs
@@ -64,8 +67,8 @@ export class AuditLogController {
6467
@Body({ validate: true }) logParams: AuditLogParamsValidator
6568
): Promise<ExperimentAuditPaginationInfo> {
6669
const [nodes, total] = await Promise.all([
67-
this.auditService.getAuditLogs(logParams.take, logParams.skip, logParams.filter),
68-
this.auditService.getTotalLogs(logParams.filter),
70+
this.auditService.getAuditLogs(logParams.take, logParams.skip, logParams.filter, logParams.experimentId),
71+
this.auditService.getTotalLogs(logParams.filter, logParams.experimentId),
6972
]);
7073
return {
7174
total,

packages/backend/src/api/controllers/validators/AuditLogParamsValidators.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { IsNumber, IsNotEmpty, IsEnum, IsOptional } from 'class-validator';
1+
import { IsNumber, IsNotEmpty, IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
22
import { LOG_TYPE } from 'upgrade_types';
33
export class AuditLogParamsValidator {
44
@IsNumber()
@@ -12,4 +12,9 @@ export class AuditLogParamsValidator {
1212
@IsOptional()
1313
@IsEnum(LOG_TYPE)
1414
public filter?: LOG_TYPE;
15+
16+
@IsOptional()
17+
@IsString()
18+
@IsUUID()
19+
public experimentId?: string;
1520
}

packages/backend/src/api/repositories/ExperimentAuditLogRepository.ts

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import repositoryError from './utils/repositoryError';
77

88
@EntityRepository(ExperimentAuditLog)
99
export class ExperimentAuditLogRepository extends Repository<ExperimentAuditLog> {
10-
public async paginatedFind(limit: number, offset: number, filter: LOG_TYPE): Promise<ExperimentAuditLog[]> {
10+
public async paginatedFind(
11+
limit: number,
12+
offset: number,
13+
filter?: LOG_TYPE,
14+
experimentId?: string
15+
): Promise<ExperimentAuditLog[]> {
1116
let queryBuilder = this.createQueryBuilder('audit')
1217
.offset(offset)
1318
.limit(limit)
@@ -17,20 +22,43 @@ export class ExperimentAuditLogRepository extends Repository<ExperimentAuditLog>
1722
if (filter) {
1823
queryBuilder = queryBuilder.where('audit.type = :filter', { filter });
1924
}
25+
26+
if (experimentId) {
27+
const experimentIdCondition = "(audit.data->>'experimentId' = :experimentId)";
28+
queryBuilder = filter
29+
? queryBuilder.andWhere(experimentIdCondition, { experimentId })
30+
: queryBuilder.where(experimentIdCondition, { experimentId });
31+
}
32+
2033
return queryBuilder.getMany().catch((error: any) => {
21-
const errorMsg = repositoryError('ExperimentAuditLogRepository', 'paginatedFind', { limit, offset }, error);
34+
const errorMsg = repositoryError(
35+
'ExperimentAuditLogRepository',
36+
'paginatedFind',
37+
{ limit, offset, filter, experimentId },
38+
error
39+
);
2240
throw errorMsg;
2341
});
2442
}
2543

26-
public getTotalLogs(filter: LOG_TYPE): Promise<number> {
27-
return this.createQueryBuilder('audit')
28-
.where('audit.type = :filter', { filter })
29-
.getCount()
30-
.catch((error: any) => {
31-
const errorMsg = repositoryError('ExperimentAuditLogRepository', 'paginatedFind', { filter }, error);
32-
throw errorMsg;
33-
});
44+
public getTotalLogs(filter?: LOG_TYPE, experimentId?: string): Promise<number> {
45+
let queryBuilder = this.createQueryBuilder('audit');
46+
47+
if (filter) {
48+
queryBuilder = queryBuilder.where('audit.type = :filter', { filter });
49+
}
50+
51+
if (experimentId) {
52+
const experimentIdCondition = "(audit.data->>'experimentId' = :experimentId)";
53+
queryBuilder = filter
54+
? queryBuilder.andWhere(experimentIdCondition, { experimentId })
55+
: queryBuilder.where(experimentIdCondition, { experimentId });
56+
}
57+
58+
return queryBuilder.getCount().catch((error: any) => {
59+
const errorMsg = repositoryError('ExperimentAuditLogRepository', 'getTotalLogs', { filter, experimentId }, error);
60+
throw errorMsg;
61+
});
3462
}
3563

3664
public async saveRawJson(

packages/backend/src/api/services/AnalyticsService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -529,7 +529,7 @@ export class AnalyticsService {
529529
}
530530
await this.experimentAuditLogRepository.saveRawJson(
531531
LOG_TYPE.EXPERIMENT_DATA_EXPORTED,
532-
{ experimentName: experimentDetails[0].experimentName },
532+
{ experimentId: experimentDetails[0].experimentId, experimentName: experimentDetails[0].experimentName },
533533
user
534534
);
535535
logger.info({ message: `Exported Data emailed successfully to ${email}` });

0 commit comments

Comments
 (0)