Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<app-common-section-card *ngIf="selectedExperiment$ | async as experiment">
<!-- Search Widget -->
<app-common-section-card-search-header
header-left
[filterOptions]="(filterOptions$ | async) || []"
Comment thread
danoswaltCL marked this conversation as resolved.
[searchString]="(searchString$ | async) || ''"
[searchKey]="(searchKey$ | async) || 'All'"
(search)="onSearch($event)"
>
</app-common-section-card-search-header>

<!-- Action Buttons -->
<app-common-section-card-action-buttons
header-right
[showPrimaryButton]="false"
[isSectionCardExpanded]="isSectionCardExpanded"
(sectionCardExpandChange)="onSectionCardExpandChange($event)"
>
</app-common-section-card-action-buttons>

<!-- Timeline Content -->
<ng-container content *ngIf="isSectionCardExpanded">
<common-audit-log-timeline
[groupedLogs]="timelineDataSource$ | async"
[isLoading]="isLoading$ | async"
[isEmpty]="(experimentLogs$ | async)?.length === 0"
[config]="timelineConfig"
(scrolledToBottom)="fetchLogsOnScroll()"
>
</common-audit-log-timeline>
</ng-container>
</app-common-section-card>
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import { ChangeDetectionStrategy, Component, Input, OnInit, OnDestroy } from '@angular/core';
import {
CommonSectionCardActionButtonsComponent,
CommonSectionCardComponent,
CommonSectionCardSearchHeaderComponent,
} from '../../../../../../../shared-standalone-component-lib/components';
import {
FilterOption,
CommonSearchWidgetSearchParams,
} from '../../../../../../../shared-standalone-component-lib/components/common-section-card-search-header/common-section-card-search-header.component';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { ExperimentService } from '../../../../../../../core/experiments/experiments.service';
import { Experiment } from '../../../../../../../core/experiments/store/experiments.model';
import { LogsService } from '../../../../../../../core/logs/logs.service';
import { SharedModule } from '../../../../../../../shared/shared.module';
import { CommonAuditLogTimelineComponent } from '../../../../../../../shared-standalone-component-lib/components/common-audit-log-timeline/common-audit-log-timeline.component';
import { AuditLogs, LogDateFormatType } from '../../../../../../../core/logs/store/logs.model';
import { LOG_TYPE } from 'upgrade_types';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { combineLatest, Subject, Observable, BehaviorSubject } from 'rxjs';
import { map, filter, takeUntil, switchMap, tap, take } from 'rxjs/operators';
import { groupBy } from 'lodash';
import { AuditLogTimelineConfig } from '../../../../../../../shared-standalone-component-lib/components/common-audit-log-timeline/common-audit-log-timeline-config.model';
import { EXPERIMENT_TIMELINE_LOG_TYPE_CONFIG } from '../../../../../../../shared-standalone-component-lib/components/common-audit-log-timeline/configs/experiment-timeline.config';

/**
* Section card component for displaying experiment-specific audit logs in a timeline format.
* Features:
* - Dynamic filter dropdown with action types and users from logs
* - Text search capability
* - Timeline view grouped by date
* - Infinite scroll pagination
*/
@Component({
selector: 'app-experiment-log-section-card',
imports: [
CommonModule,
CommonSectionCardComponent,
CommonSectionCardActionButtonsComponent,
CommonSectionCardSearchHeaderComponent,
TranslateModule,
SharedModule,
CommonAuditLogTimelineComponent,
MatProgressSpinnerModule,
],
standalone: true,
templateUrl: './experiment-log-section-card.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
Comment thread
danoswaltCL marked this conversation as resolved.
export class ExperimentLogSectionCardComponent implements OnInit, OnDestroy {
@Input() isSectionCardExpanded = true;

selectedExperiment$: Observable<Experiment> = this.experimentService.selectedExperiment$;

// Search state
searchString$ = new BehaviorSubject<string>('');
searchKey$ = new BehaviorSubject<string>('All');
filterOptions$ = new BehaviorSubject<FilterOption[]>([{ value: 'All' }]);

// Logs data
experimentLogs$: Observable<AuditLogs[]>;
timelineDataSource$: Observable<{ dates: string[]; dateGroups: Record<string, AuditLogs[]> }>;
isLoading$: Observable<boolean>;
allLogsFetched$: Observable<boolean>;

private destroy$ = new Subject<void>();
private currentExperimentId: string | null = null;

LogDateFormatType = LogDateFormatType;
timelineConfig: AuditLogTimelineConfig = EXPERIMENT_TIMELINE_LOG_TYPE_CONFIG;

constructor(private readonly experimentService: ExperimentService, private readonly logsService: LogsService) {}

ngOnInit(): void {
// Fetch logs when experiment loads
this.selectedExperiment$
.pipe(
filter((exp): exp is Experiment => !!exp),
tap((exp) => {
this.currentExperimentId = exp.id;
this.logsService.fetchExperimentLogs(exp.id, true);
}),
takeUntil(this.destroy$)
)
.subscribe();

// Get raw logs observable
this.experimentLogs$ = this.selectedExperiment$.pipe(
filter((exp): exp is Experiment => !!exp),
switchMap((exp) => this.logsService.getExperimentLogsById(exp.id)),
tap((logs) => {
this.buildFilterOptions(logs);
}),
takeUntil(this.destroy$)
);

// Get loading state
this.isLoading$ = this.selectedExperiment$.pipe(
filter((exp): exp is Experiment => !!exp),
switchMap((exp) => this.logsService.getExperimentLogsLoadingState(exp.id)),
takeUntil(this.destroy$)
);

// Get pagination state
this.allLogsFetched$ = this.selectedExperiment$.pipe(
filter((exp): exp is Experiment => !!exp),
switchMap((exp) => this.logsService.isAllExperimentLogsFetched(exp.id)),
takeUntil(this.destroy$)
);

// Apply search and group by date
// TODO: prefer doing this on backend
this.timelineDataSource$ = combineLatest([this.experimentLogs$, this.searchString$, this.searchKey$]).pipe(
map(([logs, searchString, searchKey]) => {
// TODO: prefer doing this on backend
const filtered = this.filterLogs(logs, searchString, searchKey);
const dateGroups = this.groupLogsByDate(filtered);
const dates = Object.keys(dateGroups);
return {
dates,
dateGroups,
};
}),
takeUntil(this.destroy$)
);
}

ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}

onSectionCardExpandChange(isSectionCardExpanded: boolean): void {
this.isSectionCardExpanded = isSectionCardExpanded;
}

onSearch(params: CommonSearchWidgetSearchParams<string>): void {
this.searchKey$.next(params.searchKey);
this.searchString$.next(params.searchString);
}

fetchLogsOnScroll(): void {
if (!this.currentExperimentId) return;

// Check if all logs are fetched and if not currently loading
combineLatest([this.allLogsFetched$, this.isLoading$])
.pipe(
take(1),
filter(([allFetched, isLoading]) => !allFetched && !isLoading),
takeUntil(this.destroy$)
)
.subscribe(() => {
this.logsService.fetchExperimentLogs(this.currentExperimentId);
});
Comment thread
danoswaltCL marked this conversation as resolved.
}

/**
* Build dynamic filter options from logs data
*/
private buildFilterOptions(logs: AuditLogs[]): void {
if (!logs || logs.length === 0) {
this.filterOptions$.next([{ value: 'All' }]);
return;
}

// Get unique action types (only experiment-related)
const experimentLogTypes = [
LOG_TYPE.EXPERIMENT_CREATED,
LOG_TYPE.EXPERIMENT_UPDATED,
LOG_TYPE.EXPERIMENT_STATE_CHANGED,
LOG_TYPE.EXPERIMENT_DELETED,
LOG_TYPE.EXPERIMENT_DATA_EXPORTED,
LOG_TYPE.EXPERIMENT_DESIGN_EXPORTED,
];

const actionTypes = [...new Set(logs.map((log) => log.type))].filter((type) => experimentLogTypes.includes(type));

// Get unique users (firstName + lastName)
const userSet = new Set<string>();
logs.forEach((log) => {
if (log.user?.firstName && log.user?.lastName) {
userSet.add(`${log.user.firstName} ${log.user.lastName}`);
}
});
const users = Array.from(userSet);

// Build grouped filter options
const options: FilterOption[] = [
{ value: 'All' }, // Standalone option
...actionTypes.map((type) => ({
value: type,
group: 'Event Type', // Group name
})),
];

if (users.length > 0) {
users.forEach((user) => {
options.push({ value: user, group: 'Users' }); // Group name
});
}

this.filterOptions$.next(options);
}

/**
* Filter logs based on search criteria
*/
private filterLogs(logs: AuditLogs[], searchString: string, searchKey: string): AuditLogs[] {
let filtered = logs;

// Apply dropdown filter
if (searchKey && searchKey !== 'All') {
// Check if it's an action type
if (Object.values(LOG_TYPE).includes(searchKey as LOG_TYPE)) {
filtered = filtered.filter((log) => log.type === searchKey);
}
// Check if it's a user name
else {
filtered = filtered.filter((log) => {
const userName = `${log.user?.firstName || ''} ${log.user?.lastName || ''}`.trim();
return userName === searchKey;
});
}
}

// Apply text search
if (searchString) {
const searchLower = searchString.toLowerCase();
filtered = filtered.filter((log) => {
const userName = `${log.user?.firstName || ''} ${log.user?.lastName || ''}`.toLowerCase();
const actionType = log.type.toLowerCase();
const dataStr = JSON.stringify(log.data).toLowerCase();

return userName.includes(searchLower) || actionType.includes(searchLower) || dataStr.includes(searchLower);
});
}
Comment thread
danoswaltCL marked this conversation as resolved.

return filtered;
}

/**
* Group logs by date (format: YYYY/M/D)
*/
private groupLogsByDate(logs: AuditLogs[]): Record<string, AuditLogs[]> {
return groupBy(logs, (log) => {
const date = new Date(log.createdAt);
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
});
}
}
7 changes: 5 additions & 2 deletions packages/backend/src/api/controllers/LogController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export class AuditLogController {
* filter:
* type: string
* enum: [experimentCreated, experimentUpdated, experimentStateChanged, experimentDeleted]
* experimentId:
* type: string
* format: uuid
* description: number of audit logs to requests
* tags:
* - Logs
Expand All @@ -64,8 +67,8 @@ export class AuditLogController {
@Body({ validate: true }) logParams: AuditLogParamsValidator
): Promise<ExperimentAuditPaginationInfo> {
const [nodes, total] = await Promise.all([
this.auditService.getAuditLogs(logParams.take, logParams.skip, logParams.filter),
this.auditService.getTotalLogs(logParams.filter),
this.auditService.getAuditLogs(logParams.take, logParams.skip, logParams.filter, logParams.experimentId),
this.auditService.getTotalLogs(logParams.filter, logParams.experimentId),
]);
return {
total,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IsNumber, IsNotEmpty, IsEnum, IsOptional } from 'class-validator';
import { IsNumber, IsNotEmpty, IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
import { LOG_TYPE } from 'upgrade_types';
export class AuditLogParamsValidator {
@IsNumber()
Expand All @@ -12,4 +12,9 @@ export class AuditLogParamsValidator {
@IsOptional()
@IsEnum(LOG_TYPE)
public filter?: LOG_TYPE;

@IsOptional()
@IsString()
@IsUUID()
public experimentId?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import repositoryError from './utils/repositoryError';

@EntityRepository(ExperimentAuditLog)
export class ExperimentAuditLogRepository extends Repository<ExperimentAuditLog> {
public async paginatedFind(limit: number, offset: number, filter: LOG_TYPE): Promise<ExperimentAuditLog[]> {
public async paginatedFind(
limit: number,
offset: number,
filter?: LOG_TYPE,
experimentId?: string
): Promise<ExperimentAuditLog[]> {
Comment thread
danoswaltCL marked this conversation as resolved.
let queryBuilder = this.createQueryBuilder('audit')
Comment thread
danoswaltCL marked this conversation as resolved.
.offset(offset)
.limit(limit)
Expand All @@ -17,20 +22,43 @@ export class ExperimentAuditLogRepository extends Repository<ExperimentAuditLog>
if (filter) {
queryBuilder = queryBuilder.where('audit.type = :filter', { filter });
}

if (experimentId) {
const experimentIdCondition = "(audit.data->>'experimentId' = :experimentId)";
queryBuilder = filter
? queryBuilder.andWhere(experimentIdCondition, { experimentId })
: queryBuilder.where(experimentIdCondition, { experimentId });
}

return queryBuilder.getMany().catch((error: any) => {
const errorMsg = repositoryError('ExperimentAuditLogRepository', 'paginatedFind', { limit, offset }, error);
const errorMsg = repositoryError(
'ExperimentAuditLogRepository',
'paginatedFind',
{ limit, offset, filter, experimentId },
error
);
throw errorMsg;
});
}

public getTotalLogs(filter: LOG_TYPE): Promise<number> {
return this.createQueryBuilder('audit')
.where('audit.type = :filter', { filter })
.getCount()
.catch((error: any) => {
const errorMsg = repositoryError('ExperimentAuditLogRepository', 'paginatedFind', { filter }, error);
throw errorMsg;
});
public getTotalLogs(filter?: LOG_TYPE, experimentId?: string): Promise<number> {
let queryBuilder = this.createQueryBuilder('audit');

if (filter) {
queryBuilder = queryBuilder.where('audit.type = :filter', { filter });
}

if (experimentId) {
const experimentIdCondition = "(audit.data->>'experimentId' = :experimentId)";
queryBuilder = filter
? queryBuilder.andWhere(experimentIdCondition, { experimentId })
: queryBuilder.where(experimentIdCondition, { experimentId });
}

return queryBuilder.getCount().catch((error: any) => {
const errorMsg = repositoryError('ExperimentAuditLogRepository', 'getTotalLogs', { filter, experimentId }, error);
throw errorMsg;
});
}

public async saveRawJson(
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/api/services/AnalyticsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,7 @@ export class AnalyticsService {
}
await this.experimentAuditLogRepository.saveRawJson(
LOG_TYPE.EXPERIMENT_DATA_EXPORTED,
{ experimentName: experimentDetails[0].experimentName },
{ experimentId: experimentDetails[0].experimentId, experimentName: experimentDetails[0].experimentName },
user
);
logger.info({ message: `Exported Data emailed successfully to ${email}` });
Expand Down
Loading