From e4f2c8f1c07a16c67c132a298db7cebfd9914da3 Mon Sep 17 00:00:00 2001 From: Raymond Luong Date: Thu, 24 Sep 2026 16:28:13 -0600 Subject: [PATCH 1/4] SF-3935 Do not add a date filter to project specific serval builds --- .../date-range-picker.component.spec.ts | 4 +- .../date-range-picker.component.ts | 56 +++++++++---------- .../draft-jobs-export.service.ts | 8 +-- .../draft-jobs.component.html | 5 +- .../onboarding-request-detail.component.ts | 4 +- .../serval-administration.component.ts | 9 +++ .../serval-builds.component.html | 6 +- .../serval-builds.component.spec.ts | 25 ++++++++- .../serval-builds.component.ts | 47 ++++++++++------ .../serval-project.component.ts | 3 +- .../serval-projects.component.ts | 13 +++-- 11 files changed, 117 insertions(+), 63 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/date-range-picker.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/date-range-picker.component.spec.ts index 98c07d1a74a..dde0975ce93 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/date-range-picker.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/date-range-picker.component.spec.ts @@ -146,9 +146,9 @@ class TestEnvironment { this.fixture = TestBed.createComponent(DateRangePickerComponent); this.component = this.fixture.componentInstance; - this.component.startWithEmptyRange = startWithEmptyRange; - // Spy before the first change detection so that emissions from ngOnInit are recorded. + // Spy before setting the input, since setting it can emit the initial range. this.emitSpy = spyOn(this.component.dateRangeChange, 'emit').and.callThrough(); + this.component.startWithEmptyRange = startWithEmptyRange; this.fixture.detectChanges(); } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/date-range-picker.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/date-range-picker.component.ts index e21f3fb78cd..dba3da31a1e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/date-range-picker.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/date-range-picker.component.ts @@ -64,7 +64,34 @@ export class DateRangePickerComponent implements OnInit { @Input() showReset: boolean = false; /** When true, initialize the control with null as the selected default range. */ - @Input() startWithEmptyRange: boolean = false; + @Input() set startWithEmptyRange(value: boolean | null) { + if (!!value) { + this.dateRangeForm.setValue({ start: null, end: null }, { emitEvent: false }); + this.defaultRange = undefined; + this.isDefaultRange = true; + } else { + // Set initial range + const initialEndDate = new Date(this.maxSelectableDate); + const initialStartDate = new Date(this.maxSelectableDate); + initialStartDate.setDate(initialStartDate.getDate() - this.defaultDaysBack); + + const normalizedStart = this.beginningOfTheDayOf(initialStartDate); + const normalizedEnd = this.endOfTheDayOf(initialEndDate); + + this.dateRangeForm.setValue({ start: normalizedStart, end: normalizedEnd }, { emitEvent: false }); + this.defaultRange = { + start: new Date(normalizedStart), + end: new Date(normalizedEnd) + }; + this.isDefaultRange = true; + + // Tell parent of the initial range. + this.dateRangeChange.emit({ + start: new Date(normalizedStart), + end: new Date(normalizedEnd) + }); + } + } /** Maximum selectable date (today) */ readonly maxSelectableDate: Date; @@ -115,33 +142,6 @@ export class DateRangePickerComponent implements OnInit { } ngOnInit(): void { - if (this.startWithEmptyRange) { - this.dateRangeForm.setValue({ start: null, end: null }, { emitEvent: false }); - this.defaultRange = undefined; - this.isDefaultRange = true; - } else { - // Set initial range - const initialEndDate = new Date(this.maxSelectableDate); - const initialStartDate = new Date(this.maxSelectableDate); - initialStartDate.setDate(initialStartDate.getDate() - this.defaultDaysBack); - - const normalizedStart = this.beginningOfTheDayOf(initialStartDate); - const normalizedEnd = this.endOfTheDayOf(initialEndDate); - - this.dateRangeForm.setValue({ start: normalizedStart, end: normalizedEnd }, { emitEvent: false }); - this.defaultRange = { - start: new Date(normalizedStart), - end: new Date(normalizedEnd) - }; - this.isDefaultRange = true; - - // Tell parent of the initial range. - this.dateRangeChange.emit({ - start: new Date(normalizedStart), - end: new Date(normalizedEnd) - }); - } - // Update format hint based on current locale this.updateLocaleSensitiveSettings(this.i18nService.localeCode); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs-export.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs-export.service.ts index 620c4b73b9e..b21e79186d9 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs-export.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs-export.service.ts @@ -42,7 +42,7 @@ export class DraftJobsExportService extends BaseExportService { */ exportCsv( rows: SpreadsheetRow[], - dateRangeForFilename: NormalizedDateRange, + dateRangeForFilename: NormalizedDateRange | undefined, meanDuration: number, maxDuration: number, filenamePrefix: string @@ -64,7 +64,7 @@ export class DraftJobsExportService extends BaseExportService { */ exportTsv( rows: SpreadsheetRow[], - dateRangeForFilename: NormalizedDateRange, + dateRangeForFilename: NormalizedDateRange | undefined, meanDuration: number, maxDuration: number, filenamePrefix: string @@ -86,7 +86,7 @@ export class DraftJobsExportService extends BaseExportService { */ exportRsv( rows: SpreadsheetRow[], - dateRangeForFilename: NormalizedDateRange, + dateRangeForFilename: NormalizedDateRange | undefined, meanDuration: number, maxDuration: number, filenamePrefix: string @@ -184,7 +184,7 @@ export class DraftJobsExportService extends BaseExportService { private exportSeparatedValues( rows: SpreadsheetRow[], - dateRangeForFilename: NormalizedDateRange, + dateRangeForFilename: NormalizedDateRange | undefined, meanDuration: number, maxDuration: number, filenamePrefix: string, diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.html index 3baccb8d196..9a91e15e543 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.html @@ -4,7 +4,10 @@ @if (isOnline) {
- +
+ @if (currentProjectFilter) { + +
+ + Showing draft jobs for project {{ filteredProjectName || currentProjectFilter }} + + +
+
+ } @if (summaryDisplayItems.length > 0) {
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.scss index 13bb3ef9bff..81beec769c9 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.scss @@ -192,6 +192,33 @@ a { } } +app-notice { + margin-top: 1rem; + margin-bottom: 1rem; +} + +.filter-notice-content { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.clear-filter-btn { + margin-left: 8px; + opacity: 0.8; + + &:hover { + opacity: 1; + } + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } +} + .status-icon.status-is-userrequested { color: var(--sf-serval-builds-status-userrequested); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.spec.ts index c0f1a792e3a..8d6f367f659 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.spec.ts @@ -390,25 +390,17 @@ describe('ServalBuildsComponent', () => { expect(env.component['rows']).toEqual([matchingRow]); })); - it('uses unspecified date range', fakeAsync(() => { + it('requests only builds for the project in the URL', fakeAsync(() => { const env = new TestEnvironment(); - const matchingRow: ServalBuildRow = env.createRow({ projectId: 'sf-project-123' }); - const otherRow: ServalBuildRow = env.createRow({ projectId: 'sf-project-999' }); - - // The date range mode is only read from the initial query params. - env.queryParams$.next({ q: 'sf-project-123', noDateRange: true }); + env.queryParams$.next({ projectId: 'sf-project-123' }); // SUT env.component.ngOnInit(); env.waitForRowUpdate(); - env.component['allRows'] = [matchingRow, otherRow]; - env.component['applyFiltersAndStats'](); - env.waitForRowUpdate(); - expect(env.component['searchControl'].value).toBe('sf-project-123'); - expect(env.component['rows']).toEqual([matchingRow]); + verify(mockDraftGenerationService.getBuildsSince(anything(), 'sf-project-123')).once(); let useUnspecifiedDateRange: boolean | undefined; - env.component.useUnspecifiedDateRange$.pipe(take(1)).subscribe(v => (useUnspecifiedDateRange = v)); + env.component.useUnspecifiedDateRange$.subscribe(v => (useUnspecifiedDateRange = v)); expect(useUnspecifiedDateRange).toBe(true); })); @@ -2181,7 +2173,7 @@ class TestEnvironment { when(mockNoticeService.loadingStarted(anything())).thenReturn(undefined); when(mockNoticeService.loadingFinished(anything())).thenReturn(undefined); - when(mockDraftGenerationService.getBuildsSince(anything())).thenReturn(this.builds$); + when(mockDraftGenerationService.getBuildsSince(anything(), anything())).thenReturn(this.builds$); when(mockDialogService.openMatDialog(anything(), anything())).thenReturn(undefined as never); when(mockDialogService.openGenericDialog(anything())).thenReturn({ dialogRef: undefined as never, diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.ts index d73026de8b2..4ebe23878ff 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.ts @@ -39,8 +39,7 @@ import { ReplaySubject, shareReplay, startWith, - switchMap, - take + switchMap } from 'rxjs'; import { CopyComponent } from 'xforge-common/copy/copy.component'; import { DataLoadingComponent } from 'xforge-common/data-loading-component'; @@ -53,6 +52,7 @@ import { OwnerComponent } from 'xforge-common/owner/owner.component'; import { UserService } from 'xforge-common/user.service'; import { isPopulatedString, isString, notNull } from '../../type-utils'; import { InfoComponent } from '../shared/info/info.component'; +import { NoticeComponent } from '../shared/notice/notice.component'; import { ChapterSet, trainingSourceRangesWithTargetDetail } from '../shared/scripture-range'; import { formatScriptureRangeTokensCompact } from '../shared/scripture-range-display'; import { @@ -192,6 +192,7 @@ export interface BuildInputItem { MatCardHeader, MatCardContent, MatCardTitleGroup, + NoticeComponent, CopyComponent, MatSlideToggle, TranslocoModule, @@ -206,6 +207,7 @@ export class ServalBuildsComponent extends DataLoadingComponent implements OnIni /** Max problems to preview in problems card. */ public readonly problemPreviewLimit: number = 8; useUnspecifiedDateRange$: ReplaySubject = new ReplaySubject(); + protected currentProjectFilter: string | null = null; /** Help template access static methods. */ protected ServalBuildsComponent = ServalBuildsComponent; @@ -259,11 +261,7 @@ export class ServalBuildsComponent extends DataLoadingComponent implements OnIni } ngOnInit(): void { - combineLatest([ - this.onlineStatusService.onlineStatus$, - this.dateRange$, - this.useUnspecifiedDateRange$.pipe(take(1)) - ]) + combineLatest([this.onlineStatusService.onlineStatus$, this.dateRange$, this.useUnspecifiedDateRange$]) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(([isOnline, range, noDateRange]) => { if (range != null || noDateRange) { @@ -285,8 +283,8 @@ export class ServalBuildsComponent extends DataLoadingComponent implements OnIni if (this.searchControl.value !== searchTextValue) { this.searchControl.setValue(searchTextValue, { emitEvent: false }); } - - this.useUnspecifiedDateRange$.next(!!saParams.noDateRange); + this.currentProjectFilter = isPopulatedString(saParams.projectId) ? saParams.projectId : null; + this.useUnspecifiedDateRange$.next(this.currentProjectFilter != null); this.applyFiltersAndStats(); }); } @@ -295,6 +293,25 @@ export class ServalBuildsComponent extends DataLoadingComponent implements OnIni this.dateRange$.next(range); } + /** Display name of the project that builds are filtered to, if known from the loaded builds. */ + protected get filteredProjectName(): string | undefined { + if (this.currentProjectFilter == null) return undefined; + const report: ServalBuildReportDto | undefined = this.allRows.find( + (row: ServalBuildRow) => row.report.project?.sfProjectId === this.currentProjectFilter + )?.report; + return report == null + ? undefined + : buildProjectDisplayName(report.project?.shortName, report.project?.name, this.currentProjectFilter); + } + + protected clearProjectFilter(): void { + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { projectId: null }, + queryParamsHandling: 'merge' + }); + } + protected clearSearch(): void { this.searchControl.setValue(''); } @@ -577,9 +594,8 @@ export class ServalBuildsComponent extends DataLoadingComponent implements OnIni return; } - const rangeStart: Date = range != null ? range.start : new Date('2025-12-01'); const reports: ServalBuildReportDto[] | undefined = await firstValueFrom( - this.draftGenerationService.getBuildsSince(rangeStart) + this.draftGenerationService.getBuildsSince(range?.start, this.currentProjectFilter ?? undefined) ); const reportsInRange: ServalBuildReportDto[] = (reports ?? []).filter( diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.ts index c74ff533230..ac76f8556a0 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.ts @@ -376,8 +376,7 @@ export class ServalProjectComponent extends DataLoadingComponent implements OnIn void this.router.navigate(['/serval-administration'], { queryParams: { tab: 'serval-builds', - q: this.activatedProjectService.projectId!, - noDateRange: true + projectId: this.activatedProjectService.projectId! } }); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-projects.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-projects.component.ts index c8879b3676e..12e029c75b9 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-projects.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-projects.component.ts @@ -174,8 +174,7 @@ export class ServalProjectsComponent extends DataLoadingComponent implements OnI viewDraftJobs(projectId: string): void { const queryParams: ServalAdminParams = { tab: 'serval-builds', - q: projectId, - noDateRange: true + projectId: projectId }; void this.router.navigate(['/serval-administration'], { queryParams }); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.spec.ts index 4a1a6ce8c8e..c46e6bf35dc 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.spec.ts @@ -462,7 +462,39 @@ describe('DraftGenerationService', () => { tick(); const req = httpTestingController.expectOne( - `${MACHINE_API_BASE_URL}translation/builds/since:${sinceDate.toISOString()}` + `${MACHINE_API_BASE_URL}translation/builds/since?dateTime=${encodeURIComponent(sinceDate.toISOString())}` + ); + expect(req.request.method).toEqual('GET'); + req.flush([sampleReport]); + tick(); + })); + + it('should request builds without a date', fakeAsync(() => { + // SUT + service.getBuildsSince(undefined, 'project01').subscribe(result => { + expect(result).toEqual([sampleReport]); + }); + tick(); + + const req = httpTestingController.expectOne( + `${MACHINE_API_BASE_URL}translation/builds/since?projectId=project01` + ); + expect(req.request.method).toEqual('GET'); + req.flush([sampleReport]); + tick(); + })); + + it('should request builds for a specific project', fakeAsync(() => { + const sinceDate = new Date('2025-01-01T00:00:00.000Z'); + + // SUT + service.getBuildsSince(sinceDate, 'project01').subscribe(result => { + expect(result).toEqual([sampleReport]); + }); + tick(); + + const req = httpTestingController.expectOne( + `${MACHINE_API_BASE_URL}translation/builds/since?dateTime=${encodeURIComponent(sinceDate.toISOString())}&projectId=project01` ); expect(req.request.method).toEqual('GET'); req.flush([sampleReport]); @@ -493,7 +525,7 @@ describe('DraftGenerationService', () => { // Setup the HTTP request const req = httpTestingController.expectOne( - `${MACHINE_API_BASE_URL}translation/builds/since:${sinceDate.toISOString()}` + `${MACHINE_API_BASE_URL}translation/builds/since?dateTime=${encodeURIComponent(sinceDate.toISOString())}` ); expect(req.request.method).toEqual('GET'); req.flush(null, { status: HttpStatusCode.Forbidden, statusText: 'Forbidden' }); @@ -514,7 +546,7 @@ describe('DraftGenerationService', () => { // Setup the HTTP request const req = httpTestingController.expectOne( - `${MACHINE_API_BASE_URL}translation/builds/since:${sinceDate.toISOString()}` + `${MACHINE_API_BASE_URL}translation/builds/since?dateTime=${encodeURIComponent(sinceDate.toISOString())}` ); expect(req.request.method).toEqual('GET'); req.flush(null, { status: HttpStatusCode.NotFound, statusText: 'Not Found' }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.ts index 3ea20b81a44..6a442cb604e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.ts @@ -141,14 +141,26 @@ export class DraftGenerationService { /** * Gets Serval builds created since the specified timestamp. + * @param since (Optional) Only builds created at or after this time are returned. + * @param projectId (Optional) If specified, only builds relating to this SF project are returned. */ - getBuildsSince(since: Date): Observable { + getBuildsSince(since?: Date, projectId?: string): Observable { if (!this.onlineStatusService.isOnline) { return of(undefined); } - const sinceIso: string = since.toISOString(); - return this.httpClient.get(`translation/builds/since:${sinceIso}`).pipe( + const params = new URLSearchParams(); + if (since != null) { + params.append('dateTime', since.toISOString()); + } + if (projectId != null) { + params.append('projectId', projectId); + } + let url: string = 'translation/builds/since'; + if (params.size > 0) { + url += `?${params.toString()}`; + } + return this.httpClient.get(url).pipe( map(res => this.interpretTypesMany(res.data)), catchError(err => { if (err.status === 404) { diff --git a/src/SIL.XForge.Scripture/Controllers/MachineApiController.cs b/src/SIL.XForge.Scripture/Controllers/MachineApiController.cs index 22e2097c0ba..1a25404cfdb 100644 --- a/src/SIL.XForge.Scripture/Controllers/MachineApiController.cs +++ b/src/SIL.XForge.Scripture/Controllers/MachineApiController.cs @@ -261,7 +261,8 @@ await _machineApiService.GetBuildsAsync( /// /// Gets all Serval builds created after the specified date, across all projects. /// - /// The beginning date to filter build results. + /// (Optional) The beginning date to filter build results. + /// (Optional) If specified, only builds relating to this SF project are returned. /// The cancellation token. /// The builds are returned. /// You do not have permission to retrieve builds. @@ -269,13 +270,20 @@ await _machineApiService.GetBuildsAsync( /// The ML server is temporarily unavailable or unresponsive. [HttpGet(MachineApi.GetBuildsSince)] public async Task>> GetBuildsSinceAsync( - string dateTime, + [FromQuery] string? dateTime, + [FromQuery] string? projectId, CancellationToken cancellationToken ) { - if (!DateTimeOffset.TryParse(dateTime, out DateTimeOffset beginning)) + DateTimeOffset? beginning = null; + if (dateTime is not null) { - return BadRequest($"Could not parse provided datetime: {dateTime}"); + if (!DateTimeOffset.TryParse(dateTime, out DateTimeOffset parsedBeginning)) + { + return BadRequest($"Could not parse provided datetime: {dateTime}"); + } + + beginning = parsedBeginning; } try @@ -284,8 +292,9 @@ CancellationToken cancellationToken return Ok( await _machineApiService.GetBuildsSinceAsync( _userAccessor.UserId, - beginning, isServalAdmin, + beginning, + projectId, cancellationToken ) ); diff --git a/src/SIL.XForge.Scripture/Models/MachineApi.cs b/src/SIL.XForge.Scripture/Models/MachineApi.cs index 34e1eb10c0a..b440ba32fc4 100644 --- a/src/SIL.XForge.Scripture/Models/MachineApi.cs +++ b/src/SIL.XForge.Scripture/Models/MachineApi.cs @@ -11,7 +11,7 @@ public static class MachineApi public const string GetBuild = "translation/builds/id:{sfProjectId}.{buildId?}"; public const string GetRawBuild = "translation/builds/id:{sfProjectId}.{buildId}/raw"; public const string GetBuilds = "translation/builds/project:{sfProjectId}"; - public const string GetBuildsSince = "translation/builds/since:{dateTime}"; + public const string GetBuildsSince = "translation/builds/since"; public const string GetBuildConfidences = "translation/builds/id:{sfProjectId}.{buildId}/confidences"; public const string GetBuildDraftUsfm = "translation/builds/id:{sfProjectId}.{buildId}/usfm"; public const string GetRawEngine = "translation/engines/project:{sfProjectId}/raw"; diff --git a/src/SIL.XForge.Scripture/Services/IMachineApiService.cs b/src/SIL.XForge.Scripture/Services/IMachineApiService.cs index 47425016b9d..75b49035e97 100644 --- a/src/SIL.XForge.Scripture/Services/IMachineApiService.cs +++ b/src/SIL.XForge.Scripture/Services/IMachineApiService.cs @@ -58,8 +58,9 @@ CancellationToken cancellationToken ); Task> GetBuildsSinceAsync( string curUserId, - DateTimeOffset beginning, bool isServalAdmin, + DateTimeOffset? beginning, + string? sfProjectId, CancellationToken cancellationToken ); Task> GetBuildsAsync( diff --git a/src/SIL.XForge.Scripture/Services/MachineApiService.cs b/src/SIL.XForge.Scripture/Services/MachineApiService.cs index d58a190e87f..8cc1cf88b34 100644 --- a/src/SIL.XForge.Scripture/Services/MachineApiService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineApiService.cs @@ -1093,10 +1093,16 @@ .. translationBuilds.Select(translationBuild => MapDto(sfProjectId, translationB /// Retrieves build reports for Serval builds created after the specified time, including /// events-only records for SF draft generation events that don't match any known Serval build. /// + /// The current user identifier. + /// If true, the current user is a Serval Administrator. + /// (Optional) If specified, only builds created at or after this time are reported. + /// (Optional) If specified, only builds and events for this project are reported. + /// The cancellation token. public async Task> GetBuildsSinceAsync( string curUserId, - DateTimeOffset beginning, bool isServalAdmin, + DateTimeOffset? beginning, + string? sfProjectId, CancellationToken cancellationToken ) { @@ -1108,10 +1114,17 @@ CancellationToken cancellationToken IList translationBuilds = []; try { - translationBuilds = await translationBuildsClient.GetAllBuildsCreatedAfterAsync( - beginning, - cancellationToken - ); + if (sfProjectId is null) + { + translationBuilds = await translationBuildsClient.GetAllBuildsCreatedAfterAsync( + beginning, + cancellationToken + ); + } + else + { + translationBuilds = await GetProjectBuildsCreatedAfterAsync(sfProjectId, beginning, cancellationToken); + } } catch (ServalApiException e) { @@ -1126,7 +1139,7 @@ CancellationToken cancellationToken // engine that we have a record of. The reason for this is to surface something we may have missed. // Fetch draft generation events for the time range - Dictionary> eventsByProject = await FetchDraftEventsAsync(beginning); + Dictionary> eventsByProject = await FetchDraftEventsAsync(beginning, sfProjectId); // Build a map from SF project IDs to SFProject snapshots, for resolving project display names. // This is seeded from the engine-to-project map and extended as needed by BuildReport. @@ -1326,13 +1339,44 @@ .. secrets.Where(s => s.ServalData?.PreTranslationEngineId == engineId), } /// - /// Fetches draft generation events across all projects in the specified time range. + /// Gets the Serval builds for a project's pre-translation engine that were created after the specified time. + /// Returns an empty list if the project has no pre-translation engine. + /// + private async Task> GetProjectBuildsCreatedAfterAsync( + string sfProjectId, + DateTimeOffset? beginning, + CancellationToken cancellationToken + ) + { + string translationEngineId; + try + { + translationEngineId = await GetTranslationIdAsync(sfProjectId); + } + catch (DataNotFoundException) + { + return []; + } + + IList builds = await translationEnginesClient.GetAllBuildsAsync( + translationEngineId, + cancellationToken + ); + return [.. builds.Where(b => beginning is null || (b.DateCreated is not null && b.DateCreated >= beginning))]; + } + + /// + /// Fetches draft generation events in the specified time range, for all projects + /// or for a single project (). /// Returns a dictionary keyed by SF project ID. /// - private async Task>> FetchDraftEventsAsync(DateTimeOffset since) + private async Task>> FetchDraftEventsAsync( + DateTimeOffset? since, + string? projectId = null + ) { QueryResults events = await eventMetricService.GetEventMetricsAsync( - projectId: null, + projectId: projectId, scopes: [EventScope.Drafting], eventTypes: [ @@ -1341,7 +1385,7 @@ private async Task>> FetchDraftEventsAsync( nameof(CancelPreTranslationBuildAsync), nameof(BuildCompletedAsync), ], - fromDate: since.UtcDateTime + fromDate: since?.UtcDateTime ?? null ); // Group by SF project ID diff --git a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs index 71102473125..830b71a933d 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs @@ -1541,7 +1541,13 @@ public async Task GetBuildsSinceAsync_NoPermission() // SUT await Assert.ThrowsAsync(() => - env.Service.GetBuildsSinceAsync(User02, DateTimeOffset.UtcNow, isServalAdmin: false, CancellationToken.None) + env.Service.GetBuildsSinceAsync( + User02, + isServalAdmin: false, + DateTimeOffset.UtcNow, + sfProjectId: null, + CancellationToken.None + ) ); } @@ -1582,8 +1588,9 @@ public async Task GetBuildsSinceAsync_ReturnsBuildReport() // SUT IReadOnlyList reports = await env.Service.GetBuildsSinceAsync( User01, - dateCreated.AddDays(-3), isServalAdmin: true, + dateCreated.AddDays(-3), + sfProjectId: null, CancellationToken.None ); @@ -1605,6 +1612,83 @@ public async Task GetBuildsSinceAsync_ReturnsBuildReport() Assert.NotZero(report.BuildConfidences!.VerseConfidences.Count); } + [Test] + public async Task GetBuildsSinceAsync_OnlyQueriesProjectBuildsWhenProjectSpecified() + { + var env = new TestEnvironment(); + DateTimeOffset beginning = DateTimeOffset.UtcNow.AddDays(-5); + env.TranslationEnginesClient.GetAllBuildsAsync(TranslationEngine01, CancellationToken.None) + .Returns( + Task.FromResult>([ + new TranslationBuild + { + Id = ServalBuildId01, + Engine = { Id = TranslationEngine01 }, + State = JobState.Completed, + DateCreated = DateTimeOffset.UtcNow.AddDays(-1), + }, + new TranslationBuild + { + Id = "oldBuild", + Engine = { Id = TranslationEngine01 }, + State = JobState.Completed, + DateCreated = beginning.AddDays(-1), + }, + ]) + ); + env.SetEmptyDraftGenerationMetricAssociations(); + + // SUT + IReadOnlyList reports = await env.Service.GetBuildsSinceAsync( + User01, + isServalAdmin: true, + beginning, + Project01, + CancellationToken.None + ); + + Assert.AreEqual(1, reports.Count); + Assert.AreEqual(ServalBuildId01, reports[0].Build!.AdditionalInfo!.BuildId); + await env.TranslationBuildsClient.DidNotReceiveWithAnyArgs().GetAllBuildsCreatedAfterAsync(default, default); + } + + [Test] + public async Task GetBuildsSinceAsync_ReturnsAllProjectBuildsWhenNoDateSpecified() + { + var env = new TestEnvironment(); + env.TranslationEnginesClient.GetAllBuildsAsync(TranslationEngine01, CancellationToken.None) + .Returns( + Task.FromResult>([ + new TranslationBuild + { + Id = ServalBuildId01, + Engine = { Id = TranslationEngine01 }, + State = JobState.Completed, + DateCreated = DateTimeOffset.UtcNow.AddYears(-3), + }, + new TranslationBuild + { + Id = "otherBuild", + Engine = { Id = TranslationEngine01 }, + State = JobState.Completed, + DateCreated = DateTimeOffset.UtcNow.AddDays(-1), + }, + ]) + ); + env.SetEmptyDraftGenerationMetricAssociations(); + + // SUT + IReadOnlyList reports = await env.Service.GetBuildsSinceAsync( + User01, + isServalAdmin: true, + beginning: null, + Project01, + CancellationToken.None + ); + + Assert.AreEqual(2, reports.Count); + } + [Test] public async Task GetBuildsSinceAsync_IncludesDeploymentVersion() { @@ -1628,8 +1712,9 @@ public async Task GetBuildsSinceAsync_IncludesDeploymentVersion() // SUT IReadOnlyList reports = await env.Service.GetBuildsSinceAsync( User01, - beginning, isServalAdmin: true, + beginning, + sfProjectId: null, CancellationToken.None ); @@ -1705,8 +1790,9 @@ public async Task GetBuildsSinceAsync_IncludesExecutionData() IReadOnlyList reports = await env.Service.GetBuildsSinceAsync( User01, - beginning, isServalAdmin: true, + beginning, + sfProjectId: null, CancellationToken.None ); @@ -1782,8 +1868,9 @@ public async Task GetBuildsSinceAsync_DoesNotIncludeWarningsWhenDiagnosticMessag IReadOnlyList reports = await env.Service.GetBuildsSinceAsync( User01, - beginning, isServalAdmin: true, + beginning, + sfProjectId: null, CancellationToken.None ); @@ -1822,8 +1909,9 @@ public async Task GetBuildsSinceAsync_IncludesWarningsWhenNoDiagnosticMessages() IReadOnlyList reports = await env.Service.GetBuildsSinceAsync( User01, - beginning, isServalAdmin: true, + beginning, + sfProjectId: null, CancellationToken.None ); @@ -1855,8 +1943,9 @@ public async Task GetBuildsSinceAsync_NullExecutionData_MapsExecutionDataAsNull( IReadOnlyList reports = await env.Service.GetBuildsSinceAsync( User01, - beginning, isServalAdmin: true, + beginning, + sfProjectId: null, CancellationToken.None ); @@ -1899,8 +1988,9 @@ public async Task GetBuildsSinceAsync_DuplicateEngineIdsAsync() // SUT IReadOnlyList result = await env.Service.GetBuildsSinceAsync( User01, - beginning, isServalAdmin: true, + beginning, + sfProjectId: null, CancellationToken.None ); @@ -1933,8 +2023,9 @@ public async Task GetBuildsSinceAsync_UnknownProject() // SUT IReadOnlyList reports = await env.Service.GetBuildsSinceAsync( User01, - beginning, isServalAdmin: true, + beginning, + sfProjectId: null, CancellationToken.None ); @@ -2042,8 +2133,9 @@ public async Task GetBuildsSinceAsync_ScriptureRangesIncludeProjectNames() // SUT IReadOnlyList reports = await env.Service.GetBuildsSinceAsync( User01, - dateCreated.AddDays(-3), isServalAdmin: true, + dateCreated.AddDays(-3), + sfProjectId: null, CancellationToken.None ); @@ -2112,8 +2204,9 @@ public async Task GetBuildsSinceAsync_IncludesEventsOnlyReports() // SUT IReadOnlyList reports = await env.Service.GetBuildsSinceAsync( User01, - beginning, isServalAdmin: true, + beginning, + sfProjectId: null, CancellationToken.None ); @@ -2169,8 +2262,9 @@ public async Task GetBuildsSinceAsync_SkipsEventsWithNullProjectId() // SUT IReadOnlyList reports = await env.Service.GetBuildsSinceAsync( User01, - beginning, isServalAdmin: true, + beginning, + sfProjectId: null, CancellationToken.None ); From 27c5ed73e64dc647a5d9374b315867a21fdaace2 Mon Sep 17 00:00:00 2001 From: Raymond Luong Date: Wed, 14 Oct 2026 18:29:00 -0600 Subject: [PATCH 3/4] Take the first value for use unspecified date range --- .../serval-builds.component.spec.ts | 2 +- .../serval-administration/serval-builds.component.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.spec.ts index 8d6f367f659..81905eec150 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.spec.ts @@ -400,7 +400,7 @@ describe('ServalBuildsComponent', () => { verify(mockDraftGenerationService.getBuildsSince(anything(), 'sf-project-123')).once(); let useUnspecifiedDateRange: boolean | undefined; - env.component.useUnspecifiedDateRange$.subscribe(v => (useUnspecifiedDateRange = v)); + env.component.useUnspecifiedDateRange$.pipe(take(1)).subscribe(v => (useUnspecifiedDateRange = v)); expect(useUnspecifiedDateRange).toBe(true); })); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.ts index 4ebe23878ff..94e2d2d1c51 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.ts @@ -39,7 +39,8 @@ import { ReplaySubject, shareReplay, startWith, - switchMap + switchMap, + take } from 'rxjs'; import { CopyComponent } from 'xforge-common/copy/copy.component'; import { DataLoadingComponent } from 'xforge-common/data-loading-component'; @@ -261,7 +262,12 @@ export class ServalBuildsComponent extends DataLoadingComponent implements OnIni } ngOnInit(): void { - combineLatest([this.onlineStatusService.onlineStatus$, this.dateRange$, this.useUnspecifiedDateRange$]) + combineLatest([ + this.onlineStatusService.onlineStatus$, + this.dateRange$, + // only take the first value since we don't want the search filter to trigger a new query + this.useUnspecifiedDateRange$.pipe(take(1)) + ]) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(([isOnline, range, noDateRange]) => { if (range != null || noDateRange) { From 7307e1b402de983b1e7aebee472fd6bdc95e6a3a Mon Sep 17 00:00:00 2001 From: Raymond Luong Date: Tue, 20 Oct 2026 18:11:37 -0600 Subject: [PATCH 4/4] Show project name when serval builds list is empty --- .../serval-builds.component.html | 2 +- .../serval-builds.component.spec.ts | 22 ++++++++++ .../serval-builds.component.ts | 40 ++++++++++++------- .../Services/MachineApiService.cs | 19 ++++++--- .../Services/MachineApiServiceTests.cs | 20 ++++++++++ 5 files changed, 82 insertions(+), 21 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.html index 3f167fc0ff9..9ecd8675f6e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-builds.component.html @@ -56,7 +56,7 @@
- Showing draft jobs for project {{ filteredProjectName || currentProjectFilter }} + Showing Serval builds for project {{ filteredProjectName }}