diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts index 4e7835d51d6..57a977b9d70 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts @@ -357,19 +357,15 @@ export class SFProjectService extends ProjectService { return await this.onlineInvoke>('eventMetrics', { projectId, pageIndex, pageSize }); } - async onlineAllEventMetricsForConstructionDraftJobs( + async onlineAllEventMetricsForConstructingDraftJobs( + eventTypes: string[], projectId?: string, daysBack?: number ): Promise | undefined> { const params: any = { projectId: projectId ?? null, scopes: [3], // Drafting scope - eventTypes: [ - 'StartPreTranslationBuildAsync', - 'BuildProjectAsync', - 'RetrievePreTranslationStatusAsync', - 'CancelPreTranslationBuildAsync' - ] + eventTypes }; if (daysBack != null) { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/_draft-jobs-theme.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/_draft-jobs-theme.scss index ef4d084d249..b570a9ab70e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/_draft-jobs-theme.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/_draft-jobs-theme.scss @@ -6,6 +6,7 @@ --sf-draft-jobs-table-row-background: #{mat.get-theme-color($theme, neutral, if($is-dark, 10, 100))}; --sf-draft-jobs-project-link-color: #{mat.get-theme-color($theme, primary, if($is-dark, 70, 40))}; --sf-draft-jobs-book-count-color: #{mat.get-theme-color($theme, neutral, if($is-dark, 70, 40))}; + --sf-draft-jobs-disabled-link-color: #{mat.get-theme-color($theme, neutral, if($is-dark, 50, 50))}; } @mixin theme($theme) { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/_job-details-dialog-theme.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/_job-details-dialog-theme.scss new file mode 100644 index 00000000000..17115c115bd --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/_job-details-dialog-theme.scss @@ -0,0 +1,41 @@ +@use '@angular/material' as mat; + +@mixin color($theme) { + $is-dark: mat.get-theme-type($theme) == dark; + + // Summary section colors + --job-details-summary-background: #{mat.get-theme-color($theme, neutral, if($is-dark, 17, 98))}; + --job-details-summary-item-background: #{mat.get-theme-color($theme, neutral, if($is-dark, 22, 100))}; + --job-details-label-color: #{mat.get-theme-color($theme, neutral, if($is-dark, 80, 50))}; + --job-details-value-color: #{mat.get-theme-color($theme, neutral, if($is-dark, 90, 10))}; + + // Language pair maybe indicator + --job-details-maybe-color: #{mat.get-theme-color($theme, tertiary, if($is-dark, 70, 50))}; + + // ClearML link colors + --job-details-link-color: #{mat.get-theme-color($theme, primary, if($is-dark, 70, 40))}; + --job-details-link-hover-color: #{mat.get-theme-color($theme, primary, if($is-dark, 80, 30))}; + + // Loading and error states + --job-details-loading-text-color: #{mat.get-theme-color($theme, neutral, if($is-dark, 80, 50))}; + --job-details-error-background: #{mat.get-theme-color($theme, error, if($is-dark, 20, 95))}; + --job-details-error-color: #{mat.get-theme-color($theme, error, if($is-dark, 80, 40))}; + --job-details-no-data-color: #{mat.get-theme-color($theme, neutral, if($is-dark, 60, 60))}; + + // Event timeline colors + --job-details-event-background: #{mat.get-theme-color($theme, neutral, if($is-dark, 17, 98))}; + --job-details-event-timestamp-color: #{mat.get-theme-color($theme, neutral, if($is-dark, 70, 50))}; + --job-details-event-content-background: #{mat.get-theme-color($theme, surface)}; + --job-details-event-content-text: #{mat.get-theme-color($theme, neutral, if($is-dark, 90, 10))}; + --job-details-event-code-background: #{mat.get-theme-color($theme, neutral, if($is-dark, 20, 95))}; + + // Exception styling + --job-details-exception-border: #{mat.get-theme-color($theme, error, if($is-dark, 60, 50))}; + --job-details-exception-background: #{mat.get-theme-color($theme, error, if($is-dark, 20, 95))}; +} + +@mixin theme($theme) { + @if mat.theme-has($theme, color) { + @include color($theme); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/_job-events-dialog-theme.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/_job-events-dialog-theme.scss deleted file mode 100644 index 60d8b73cbaa..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/_job-events-dialog-theme.scss +++ /dev/null @@ -1,18 +0,0 @@ -@use '@angular/material' as mat; - -@mixin color($theme) { - $is-dark: mat.get-theme-type($theme) == dark; - - --sf-job-events-summary-background: #{mat.get-theme-color($theme, neutral, if($is-dark, 20, 95))}; - --sf-job-events-item-border-color: #{mat.get-theme-color($theme, neutral, if($is-dark, 40, 90))}; - --sf-job-events-exception-background: #{mat.get-theme-color($theme, error, if($is-dark, 20, 95))}; - --sf-job-events-exception-border-color: #{mat.get-theme-color($theme, error, if($is-dark, 40, 80))}; - --sf-job-events-payload-background: #{mat.get-theme-color($theme, neutral, if($is-dark, 17, 98))}; - --sf-job-events-payload-border-color: #{mat.get-theme-color($theme, neutral, if($is-dark, 40, 90))}; -} - -@mixin theme($theme) { - @if mat.theme-has($theme, color) { - @include color($theme); - } -} 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 5440fa08e5f..cd1fd051282 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 @@ -73,7 +73,7 @@ Training Books @for (projectBook of row.trainingBooks; track projectBook.projectId) { -
+
{{ getProjectShortName(projectBook.projectId) }}Translation Books @for (projectBook of row.translationBooks; track projectBook.projectId) { -
+
{{ getProjectShortName(projectBook.projectId) }} - - Build ID & ClearML link + + Build Details - - {{ row.job.buildId }} + + {{ row.job.buildId || "N/A" }} - - Events - - - -
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.scss index 644d413725f..7114fde3fd0 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.scss @@ -9,11 +9,22 @@ background-color: var(--sf-draft-jobs-table-row-background); } -app-owner, -.details-button { +app-owner { white-space: nowrap; } +.build-id-link { + text-decoration: underline; + cursor: pointer; + white-space: nowrap; + + &.disabled { + color: var(--sf-draft-jobs-disabled-link-color); + text-decoration: none; + cursor: default; + } +} + .status-cell { display: flex; align-items: center; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.ts index 18b34a2ae66..513c0985376 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.ts @@ -16,7 +16,7 @@ import { SFProjectService } from '../core/sf-project.service'; import { EventMetric } from '../event-metrics/event-metric'; import { NoticeComponent } from '../shared/notice/notice.component'; import { projectLabel } from '../shared/utils'; -import { JobEventsDialogComponent } from './job-events-dialog.component'; +import { JobDetailsDialogComponent } from './job-details-dialog.component'; import { ServalAdministrationService } from './serval-administration.service'; /** @@ -36,6 +36,7 @@ interface DraftJob { finishEvent?: EventMetric; cancelEvent?: EventMetric; events: EventMetric[]; + additionalEvents: EventMetric[]; // Events with same build ID that weren't included in the main job tracking status: 'running' | 'success' | 'failed' | 'cancelled' | 'incomplete'; startTime: Date | null; finishTime: Date | null; @@ -61,6 +62,14 @@ interface Row { clearmlUrl?: string; } +const DRAFTING_EVENTS = [ + 'StartPreTranslationBuildAsync', + 'BuildProjectAsync', + 'RetrievePreTranslationStatusAsync', + 'ExecuteWebhookAsync', + 'CancelPreTranslationBuildAsync' +]; + @Component({ selector: 'app-draft-jobs', templateUrl: './draft-jobs.component.html', @@ -75,8 +84,7 @@ export class DraftJobsComponent extends DataLoadingComponent implements OnInit { 'translationBooks', 'startTime', 'duration', - 'author', - 'buildId' + 'author' ]; rows: Row[] = []; @@ -102,7 +110,7 @@ export class DraftJobsComponent extends DataLoadingComponent implements OnInit { this.daysBack$.next(value); } - private eventMetrics?: EventMetric[]; + private draftEvents?: EventMetric[]; private draftJobs: DraftJob[] = []; private projectNames = new Map(); // Cache for project names private projectShortNames = new Map(); // Cache for project short names @@ -124,16 +132,16 @@ export class DraftJobsComponent extends DataLoadingComponent implements OnInit { } get isLoading(): boolean { - return this.eventMetrics == null; + return this.draftEvents == null; } ngOnInit(): void { if ( - !this.columnsToDisplay.includes('details') && + !this.columnsToDisplay.includes('buildDetails') && (this.authService.currentUserRoles.includes(SystemRole.ServalAdmin) || this.authService.currentUserRoles.includes(SystemRole.SystemAdmin)) ) { - this.columnsToDisplay.push('details'); + this.columnsToDisplay.push('buildDetails'); } this.loadingStarted(); @@ -159,14 +167,15 @@ export class DraftJobsComponent extends DataLoadingComponent implements OnInit { } if (isOnline) { - const queryResults = await this.projectService.onlineAllEventMetricsForConstructionDraftJobs( + const queryResults = await this.projectService.onlineAllEventMetricsForConstructingDraftJobs( + DRAFTING_EVENTS, projectFilterId ?? undefined, daysBack === 'all_time' ? undefined : daysBack ); if (Array.isArray(queryResults?.results)) { - this.eventMetrics = queryResults.results as EventMetric[]; + this.draftEvents = queryResults.results as EventMetric[]; } else { - this.eventMetrics = []; + this.draftEvents = []; } this.processDraftJobs(); await this.loadProjectNames(); @@ -178,20 +187,52 @@ export class DraftJobsComponent extends DataLoadingComponent implements OnInit { .subscribe(); } - openDetailsDialog(job: DraftJob): void { + openJobDetailsDialog(job: DraftJob): void { + if (job.buildId == null) { + void this.noticeService.show('No build ID available for this job'); + return; + } + + // Format event-based duration if available + const eventBasedDuration = job.duration != null ? this.formatDuration(job.duration) : undefined; + + // Format start time if available + const startTime = job.startTime != null ? this.i18n.formatDate(job.startTime, { showTimeZone: true }) : undefined; + // Collect all events that were used to create this job const events = [job.startEvent, job.buildEvent, job.finishEvent, job.cancelEvent] .filter((event): event is EventMetric => event != null) .sort((a, b) => new Date(a.timeStamp).getTime() - new Date(b.timeStamp).getTime()); + // Get ClearML URL + const clearmlUrl = job.buildId + ? `https://app.sil.hosted.allegro.ai/projects?gq=${job.buildId}&tab=tasks` + : undefined; + + // Count how many builds have started on this project since this build started + let buildsStartedSince = 0; + if (job.startTime != null) { + const jobStartTime = job.startTime; + buildsStartedSince = this.draftJobs.filter( + otherJob => + otherJob.projectId === job.projectId && otherJob.startTime != null && otherJob.startTime > jobStartTime + ).length; + } + const dialogData = { + buildId: job.buildId, projectId: job.projectId, jobStatus: this.getStatusDisplay(job.status), - events + events, + additionalEvents: job.additionalEvents, + eventBasedDuration, + startTime, + clearmlUrl, + buildsStartedSince }; - const dialogConfig: MatDialogConfig = { data: dialogData, width: '800px', maxHeight: '80vh' }; - this.dialogService.openMatDialog(JobEventsDialogComponent, dialogConfig); + const dialogConfig: MatDialogConfig = { data: dialogData, width: '1000px', maxHeight: '80vh' }; + this.dialogService.openMatDialog(JobDetailsDialogComponent, dialogConfig); } clearProjectFilter(): void { @@ -203,26 +244,16 @@ export class DraftJobsComponent extends DataLoadingComponent implements OnInit { } private processDraftJobs(): void { - if (this.eventMetrics == null) { + if (this.draftEvents == null) { this.draftJobs = []; this.generateRows(); return; } - // Filter draft-related events - const draftEvents = this.eventMetrics.filter( - event => - event.eventType === 'StartPreTranslationBuildAsync' || - event.eventType === 'BuildProjectAsync' || - event.eventType === 'RetrievePreTranslationStatusAsync' || - event.eventType === 'ExecuteWebhookAsync' || - event.eventType === 'CancelPreTranslationBuildAsync' - ); - const jobs: DraftJob[] = []; // Step 1: Find all build events (BuildProjectAsync) - these are our anchors - const buildEvents = draftEvents.filter(event => event.eventType === 'BuildProjectAsync'); + const buildEvents = this.draftEvents.filter(event => event.eventType === 'BuildProjectAsync'); // Step 2: For each build event, find the nearest preceding start event for (const buildEvent of buildEvents) { @@ -234,7 +265,7 @@ export class DraftJobsComponent extends DataLoadingComponent implements OnInit { const buildTime = new Date(buildEvent.timeStamp); // Find all StartPreTranslationBuildAsync events for this project that precede the build - const candidateStartEvents = draftEvents.filter( + const candidateStartEvents = this.draftEvents.filter( event => event.eventType === 'StartPreTranslationBuildAsync' && event.projectId === buildEvent.projectId && @@ -252,7 +283,7 @@ export class DraftJobsComponent extends DataLoadingComponent implements OnInit { }); // Step 3: Find the first completion event after the build - const candidateCompletionEvents = draftEvents.filter(event => { + const candidateCompletionEvents = this.draftEvents.filter(event => { if (event.projectId !== buildEvent.projectId) return false; if (new Date(event.timeStamp) <= buildTime) return false; @@ -295,6 +326,7 @@ export class DraftJobsComponent extends DataLoadingComponent implements OnInit { finishEvent: undefined, cancelEvent: undefined, events: [], + additionalEvents: [], startTime: new Date(startEvent.timeStamp), userId: startEvent.userId, trainingBooks, @@ -314,6 +346,22 @@ export class DraftJobsComponent extends DataLoadingComponent implements OnInit { } } + // Step 4: Collect additional events (any events with same build ID that weren't already included) + const additionalEvents = this.draftEvents.filter(event => { + // Don't include events we've already tracked + if (event === startEvent || event === buildEvent || event === completionEvent) { + return false; + } + + // Check if this event has the same build ID + const eventBuildId = this.extractBuildIdFromEvent(event); + return eventBuildId === buildId; + }); + + job.additionalEvents = additionalEvents.sort( + (a, b) => new Date(a.timeStamp).getTime() - new Date(b.timeStamp).getTime() + ); + jobs.push(job); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/job-details-dialog.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/job-details-dialog.component.html new file mode 100644 index 00000000000..a9c3fca99ac --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/job-details-dialog.component.html @@ -0,0 +1,226 @@ +

Job Details - {{ data.projectId }}

+ + + + + +
+ @if (isLoadingBuild) { +
+ +

Loading build information...

+
+ } @else if (buildError != null) { +
+ error +

Failed to load build information: {{ buildError }}

+
+ } @else if (rawBuild != null) { +
+

Summary

+
+
+ Build ID: + + @if (data.clearmlUrl != null) { + + {{ data.buildId }} + open_in_new + + } @else { + {{ data.buildId }} + } + +
+
+ Project ID: {{ data.projectId }} +
+
+ Status: {{ buildStatus }} +
+
+ Language Pair: + + {{ languagePair }} + @if (data.buildsStartedSince > 0) { + + (maybe) + + } + +
+
+ Strings Trained On: + {{ stringsTrainedOn | l10nNumber }} +
+
+ Strings Translated: + {{ stringsTranslated | l10nNumber }} +
+ @if (currentProgress != null) { +
+ Current Progress: {{ currentProgress }} +
+ } +
+ Serval Version: {{ servalVersion }} +
+
+
+ +
+

Raw Build Information

+ +
+ } @else { +
+

No build data available for this job.

+
+ } + + @if (isLoadingEngine) { +
+ +

Loading engine information...

+
+ } @else if (engineError != null) { +
+ error +

Failed to load engine information: {{ engineError }}

+
+ } @else if (engine != null) { + + {{ engineInfoNoticeText }} + + +
+

Engine Information

+ +
+ } +
+
+ + + +
+
+

Summary

+
+
+ Job Status: {{ data.jobStatus }} +
+
+ Events Found: {{ data.events.length }} +
+ @if (data.startTime != null) { +
+ Start Time: {{ data.startTime }} +
+ } + @if (data.eventBasedDuration != null) { +
+ Duration: {{ data.eventBasedDuration }} +
+ } +
+
+ +
+ @for (event of data.events; track event.id) { +
+
+ + {{ getEventStatusIcon(event.eventType, event.exception != null) }} + +
+

{{ getEventTypeLabel(event.eventType) }}

+

{{ formatDate(event.timeStamp) }}

+
+
+ + @if (event.exception != null) { +
+ Exception: +
{{ event.exception }}
+
+ } + + @if (hasPayload(event.payload)) { +
+ Parameters: + +
+ } + + @if (event.result != null) { +
+ Result: + +
+ } +
+ } +
+ + @if (data.additionalEvents.length > 0) { +
+

Additional Events

+ + These events occurred after the job completed but have the same build ID. They are shown for diagnostic + purposes and do not affect the job status or duration calculations. + +
+ @for (event of data.additionalEvents; track event.id) { +
+
+
+

{{ event.eventType }}

+

{{ formatDate(event.timeStamp) }}

+
+
+ + @if (event.exception != null) { +
+ Exception: +
{{ event.exception }}
+
+ } + + @if (hasPayload(event.payload)) { +
+ Parameters: + +
+ } + + @if (event.result != null) { +
+ Result: + +
+ } +
+ } +
+
+ } +
+
+
+
+ + + + diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/job-details-dialog.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/job-details-dialog.component.scss new file mode 100644 index 00000000000..8561c781f3f --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/job-details-dialog.component.scss @@ -0,0 +1,237 @@ +mat-dialog-content { + max-height: 70vh; + overflow-y: auto; +} + +.tab-content { + padding: 16px 0; +} + +// Build Information Tab Styles +.summary-section { + margin-bottom: 24px; + padding: 16px; + background-color: var(--job-details-summary-background); + border-radius: 4px; + + h3 { + margin-top: 0; + margin-bottom: 16px; + font-size: 18px; + font-weight: 500; + } +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 12px; +} + +.summary-item { + display: flex; + gap: 8px; + padding: 8px; + background-color: var(--job-details-summary-item-background); + border-radius: 4px; + + .label { + font-weight: 500; + color: var(--job-details-label-color); + min-width: 160px; + } + + .value { + color: var(--job-details-value-color); + word-break: break-word; + display: flex; + align-items: center; + gap: 4px; + } +} + +.language-pair-maybe { + color: var(--job-details-maybe-color); + font-style: italic; + cursor: help; + white-space: nowrap; + display: inline-block; + padding: 2px 4px; + margin-left: 2px; +} + +.build-id-clearml-link { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--job-details-link-color); + text-decoration: none; + transition: color 0.2s; + + &:hover { + color: var(--job-details-link-hover-color); + text-decoration: underline; + } + + .external-link-icon { + font-size: 16px; + width: 16px; + height: 16px; + vertical-align: middle; + } +} + +.engine-info-notice { + margin-top: 16px; + margin-bottom: 16px; +} + +.engine-section, +.raw-build-section { + margin-top: 16px; + padding: 16px; + border-radius: 8px; + background-color: var(--job-details-event-background); + + h3 { + margin-top: 0; + margin-bottom: 12px; + font-size: 16px; + font-weight: 500; + } +} + +.no-data-message { + padding: 24px; + text-align: center; + color: var(--job-details-no-data-color); +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + gap: 16px; + + p { + margin: 0; + color: var(--job-details-loading-text-color); + } +} + +.error-message { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + background-color: var(--job-details-error-background); + border-radius: 4px; + color: var(--job-details-error-color); + margin: 16px 0; + + mat-icon { + flex-shrink: 0; + } + + p { + margin: 0; + } +} + +// Events Tab Styles +.events-timeline { + display: flex; + flex-direction: column; + gap: 16px; + margin-top: 20px; +} + +.additional-events-section { + margin-top: 32px; + padding-top: 24px; + border-top: 2px solid var(--job-details-event-timestamp-color); + + h3 { + margin-top: 0; + margin-bottom: 12px; + font-size: 18px; + font-weight: 500; + } + + .additional-events-notice { + margin-bottom: 16px; + } + + .events-timeline { + margin-top: 16px; + } +} + +.event-item { + padding: 16px; + border-radius: 8px; + background-color: var(--job-details-event-background); +} + +.event-header { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 8px; + + .event-icon { + margin-top: 2px; + } +} + +.event-info { + flex: 1; + + h4 { + margin: 0 0 4px 0; + font-size: 16px; + font-weight: 500; + } + + .event-timestamp { + margin: 0; + font-size: 14px; + color: var(--job-details-event-timestamp-color); + } +} + +.event-exception, +.event-payload, +.event-result { + margin-top: 12px; + padding: 12px; + background-color: var(--job-details-event-content-background); + border-radius: 4px; + + strong { + display: block; + margin-bottom: 8px; + color: var(--job-details-event-content-text); + } + + pre { + margin: 0; + padding: 8px; + background-color: var(--job-details-event-code-background); + border-radius: 4px; + overflow-x: auto; + font-size: 12px; + line-height: 1.5; + } +} + +.event-exception { + border-left: 3px solid var(--job-details-exception-border); + background-color: var(--job-details-exception-background); + + pre { + background-color: var(--job-details-event-content-background); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/job-details-dialog.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/job-details-dialog.component.ts new file mode 100644 index 00000000000..a32f69469eb --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/job-details-dialog.component.ts @@ -0,0 +1,224 @@ +import { CommonModule } from '@angular/common'; +import { Component, Inject, OnInit } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { I18nService } from 'xforge-common/i18n.service'; +import { L10nNumberPipe } from 'xforge-common/l10n-number.pipe'; +import { EventMetric } from '../event-metrics/event-metric'; +import { JsonViewerComponent } from '../shared/json-viewer/json-viewer.component'; +import { NoticeComponent } from '../shared/notice/notice.component'; +import { DraftGenerationService } from '../translate/draft-generation/draft-generation.service'; + +interface JobDetailsDialogData { + buildId: string; + projectId: string; + jobStatus: string; + events: EventMetric[]; + additionalEvents: EventMetric[]; + eventBasedDuration?: string; + startTime?: string; + clearmlUrl?: string; + buildsStartedSince: number; +} + +const EVENT_TYPE_LABELS: { + [key: string]: { label: string; icon: string; color: string }; +} = { + StartPreTranslationBuildAsync: { label: 'Job Started', icon: 'play_arrow', color: 'primary' }, + BuildProjectAsync: { label: 'Project Build', icon: 'build', color: 'accent' }, + RetrievePreTranslationStatusAsync: { label: 'Job Completed', icon: 'check_circle', color: 'primary' }, + ExecuteWebhookAsync: { label: 'Webhook Executed', icon: 'webhook', color: 'accent' }, + CancelPreTranslationBuildAsync: { label: 'Job Cancelled', icon: 'cancel', color: '' } +}; + +/** + * Dialog component to display comprehensive job details including build information, engine data, and event timeline. + * Provides a tabbed interface for administrators to view all aspects of a draft generation job. + */ +@Component({ + selector: 'app-job-details-dialog', + standalone: true, + templateUrl: './job-details-dialog.component.html', + styleUrls: ['./job-details-dialog.component.scss'], + imports: [ + CommonModule, + MatDialogModule, + MatIconModule, + MatProgressSpinnerModule, + MatTabsModule, + MatTooltipModule, + JsonViewerComponent, + NoticeComponent, + MatButtonModule, + L10nNumberPipe + ] +}) +export class JobDetailsDialogComponent implements OnInit { + rawBuild: any = null; + engine: any = null; + isLoadingBuild = true; + isLoadingEngine = true; + buildError: string | null = null; + engineError: string | null = null; + + constructor( + readonly i18n: I18nService, + private readonly draftGenerationService: DraftGenerationService, + @Inject(MAT_DIALOG_DATA) public data: JobDetailsDialogData + ) {} + + ngOnInit(): void { + // Load build and engine data in parallel + const fullBuildId = `${this.data.projectId}.${this.data.buildId}`; + + // Fetch raw build data + this.draftGenerationService.getRawBuild(fullBuildId).subscribe({ + next: data => { + this.rawBuild = data; + this.isLoadingBuild = false; + }, + error: () => { + this.buildError = 'Failed to load build information'; + this.isLoadingBuild = false; + } + }); + + // Fetch raw engine data (pre-translate is true for draft jobs) + this.draftGenerationService.getRawEngine(this.data.projectId, true).subscribe({ + next: data => { + this.engine = data; + this.isLoadingEngine = false; + }, + error: () => { + this.engineError = 'Failed to load engine information'; + this.isLoadingEngine = false; + } + }); + } + + get languagePair(): string { + if (this.engine?.sourceLanguage != null && this.engine?.targetLanguage != null) { + const sourceLang = this.engine.sourceLanguage; + const targetLang = this.engine.targetLanguage; + + const sourceWithName = this.formatLanguageWithName(sourceLang); + const targetWithName = this.formatLanguageWithName(targetLang); + + return `${sourceWithName} → ${targetWithName}`; + } + return 'Unknown'; + } + + get stringsTrainedOn(): number { + return this.rawBuild?.executionData?.trainCount ?? NaN; + } + + get stringsTranslated(): number { + return this.rawBuild?.executionData?.pretranslateCount ?? NaN; + } + + get currentProgress(): string | null { + if (this.rawBuild?.state !== 'Active' && this.rawBuild?.state !== 'Pending') { + return null; + } + + const percentCompleted = this.rawBuild?.percentCompleted; + if (percentCompleted != null) { + return `${Math.round(percentCompleted * 100)}%`; + } + + const step = this.rawBuild?.step; + const message = this.rawBuild?.message; + if (step != null || message != null) { + return `${message ?? 'Processing'} (step ${step ?? '?'})`; + } + + return 'In progress'; + } + + get servalVersion(): string { + return this.rawBuild?.deploymentVersion ?? 'Unknown'; + } + + get buildStatus(): string { + const state = this.rawBuild?.state; + if (state == null) { + return 'Unknown'; + } + return state; + } + + get languagePairTooltip(): string { + return ( + 'This language pair comes from the current engine configuration. ' + + (this.data.buildsStartedSince === 1 ? '1 build has' : `${this.data.buildsStartedSince} builds have`) + + ' started since this build, so the language pair may have changed and may not reflect what was used for this build.' + ); + } + + get engineInfoNoticeType(): 'warning' | 'primary' { + return this.data.buildsStartedSince > 0 ? 'warning' : 'primary'; + } + + get engineInfoNoticeText(): string { + const baseText = + 'Each project has one translation engine in Serval that persists across multiple builds. ' + + 'The engine information shown here reflects the current state of the engine, not necessarily what it was when this build ran.'; + + if (this.data.buildsStartedSince === 0) { + return `${baseText} This is the most recent build for this project, so the engine has not changed since this build started.`; + } else if (this.data.buildsStartedSince === 1) { + return ( + `${baseText} 1 build has started on this project since this build began, ` + + `so the engine may have been modified and may not reflect the configuration used for this build.` + ); + } else { + return ( + `${baseText} ${this.data.buildsStartedSince} builds have started on this project since this build began, ` + + `so the engine may have been modified and may not reflect the configuration used for this build.` + ); + } + } + + formatDate(timestamp: string): string { + return this.i18n.formatDate(new Date(timestamp), { showTimeZone: true }); + } + + hasPayload(payload: any): boolean { + return payload != null && Object.keys(payload).length > 0; + } + + getEventTypeLabel(eventType: string): string { + const label = EVENT_TYPE_LABELS[eventType]?.label ?? eventType; + return `${label} (${eventType})`; + } + + getEventStatusIcon(eventType: string, hasException: boolean): string { + if (hasException) return 'error'; + return EVENT_TYPE_LABELS[eventType]?.icon ?? 'info'; + } + + getEventStatusColor(eventType: string, hasException: boolean): string { + if (hasException) return 'warn'; + return EVENT_TYPE_LABELS[eventType]?.color ?? ''; + } + + private formatLanguageWithName(langCode: string): string { + try { + const displayNames = new Intl.DisplayNames(['en'], { type: 'language' }); + const languageName = displayNames.of(langCode); + + if (languageName != null && languageName !== langCode) { + return `${langCode} (${languageName})`; + } + } catch { + // If Intl.DisplayNames fails or doesn't recognize the code, just return the code + } + + return langCode; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/job-events-dialog.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/job-events-dialog.component.html deleted file mode 100644 index d2c70ddfc66..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/job-events-dialog.component.html +++ /dev/null @@ -1,49 +0,0 @@ -
-

Draft Job Events - {{ data.projectId }}

- -
-

Job Status: {{ data.jobStatus }}

-

Events Found: {{ data.events.length }}

-
- -
- @for (event of data.events; track event.id) { -
-
- - {{ getEventStatusIcon(event.eventType, event.exception != null) }} - -
-

{{ getEventTypeLabel(event.eventType) }}

-

{{ formatDate(event.timeStamp) }}

-
-
- - @if (event.exception != null) { -
- Exception: -
{{ event.exception }}
-
- } - - @if (hasPayload(event.payload)) { -
- Parameters: - -
- } - - @if (event.result != null) { -
- Result: - -
- } -
- } -
-
- - - -
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/job-events-dialog.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/job-events-dialog.component.scss deleted file mode 100644 index a7d5f44566b..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/job-events-dialog.component.scss +++ /dev/null @@ -1,111 +0,0 @@ -@use 'src/variables' as sfColors; - -.job-events-dialog { - min-width: 600px; - max-width: 800px; -} - -.job-summary { - background: var(--sf-job-events-summary-background); - padding: 16px; - border-radius: 4px; - margin-bottom: 20px; - - p { - margin: 4px 0; - } -} - -.events-timeline { - .event-item { - border: 1px solid var(--sf-job-events-item-border-color); - border-radius: 8px; - margin-bottom: 16px; - padding: 16px; - - &.event-StartPreTranslationBuildAsync { - border-left: 4px solid sfColors.$greenDark; - } - - &.event-BuildProjectAsync { - border-left: 4px solid sfColors.$orange; - } - - &.event-RetrievePreTranslationStatusAsync { - border-left: 4px solid sfColors.$greenDark; - } - - &.event-CancelPreTranslationBuildAsync { - border-left: 4px solid sfColors.$greyLight; - } - } - - .event-header { - display: flex; - align-items: flex-start; - margin-bottom: 12px; - - .event-icon { - margin-right: 12px; - margin-top: 2px; - } - - .event-info { - flex: 1; - - h4 { - margin: 0 0 4px 0; - font-size: 16px; - font-weight: 500; - } - - .event-timestamp { - margin: 0; - color: sfColors.$lighterTextColor; - font-size: 14px; - } - } - } - - .event-exception { - background: var(--sf-job-events-exception-background); - border: 1px solid var(--sf-job-events-exception-border-color); - border-radius: 4px; - padding: 12px; - margin-bottom: 8px; - - strong { - color: sfColors.$red; - } - - pre { - margin: 8px 0 0 0; - white-space: pre-wrap; - word-wrap: break-word; - font-size: 12px; - } - } - - .event-payload, - .event-result { - background: var(--sf-job-events-payload-background); - border: 1px solid var(--sf-job-events-payload-border-color); - border-radius: 4px; - padding: 12px; - margin-bottom: 8px; - - strong { - display: block; - margin-bottom: 8px; - } - - pre { - margin: 0; - white-space: pre-wrap; - word-wrap: break-word; - font-size: 12px; - max-height: 200px; - overflow-y: auto; - } - } -} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/job-events-dialog.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/job-events-dialog.component.ts deleted file mode 100644 index ba2ede90c79..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/job-events-dialog.component.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; -import { I18nService } from 'xforge-common/i18n.service'; -import { UICommonModule } from 'xforge-common/ui-common.module'; -import { EventMetric } from '../event-metrics/event-metric'; -import { JsonViewerComponent } from '../shared/json-viewer/json-viewer.component'; - -interface JobEventsDialogData { - projectId: string; - jobStatus: string; - events: EventMetric[]; -} - -const EVENT_TYPE_LABELS: { - [key: string]: { label: string; icon: string; color: string }; -} = { - StartPreTranslationBuildAsync: { label: 'Job Started', icon: 'play_arrow', color: 'primary' }, - BuildProjectAsync: { label: 'Project Build', icon: 'build', color: 'accent' }, - RetrievePreTranslationStatusAsync: { label: 'Job Completed', icon: 'check_circle', color: 'primary' }, - CancelPreTranslationBuildAsync: { label: 'Job Cancelled', icon: 'cancel', color: '' } -}; - -/** - * Dialog component to show all events that were grouped together to create a draft job. - * This helps administrators understand how jobs are derived from event metrics. - */ -@Component({ - selector: 'app-job-events-dialog', - templateUrl: './job-events-dialog.component.html', - styleUrls: ['./job-events-dialog.component.scss'], - imports: [CommonModule, MatDialogModule, UICommonModule, JsonViewerComponent] -}) -export class JobEventsDialogComponent { - constructor( - readonly i18n: I18nService, - @Inject(MAT_DIALOG_DATA) public data: JobEventsDialogData - ) {} - - formatDate(timestamp: string): string { - return this.i18n.formatDate(new Date(timestamp), { showTimeZone: true }); - } - - hasPayload(payload: any): boolean { - return payload != null && Object.keys(payload).length > 0; - } - - getEventTypeLabel(eventType: string): string { - return EVENT_TYPE_LABELS[eventType]?.label ?? eventType; - } - - getEventStatusIcon(eventType: string, hasException: boolean): string { - if (hasException) return 'error'; - else return EVENT_TYPE_LABELS[eventType]?.icon ?? 'info'; - } - - getEventStatusColor(eventType: string, hasException: boolean): string { - if (hasException) return 'warn'; - else return EVENT_TYPE_LABELS[eventType]?.color ?? ''; - } -} 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 ff43bb832d5..236b00f711d 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 @@ -117,7 +117,7 @@ describe('DraftGenerationService', () => { // Setup the HTTP request const req = httpTestingController.expectOne( - `${MACHINE_API_BASE_URL}translation/builds/id:${projectId}?pretranslate=true` + `${MACHINE_API_BASE_URL}translation/builds/id:${projectId}?preTranslate=true` ); expect(req.request.method).toEqual('GET'); req.flush(buildDto); @@ -192,7 +192,7 @@ describe('DraftGenerationService', () => { // Setup the HTTP request const req = httpTestingController.expectOne( - `${MACHINE_API_BASE_URL}translation/builds/project:${projectId}?pretranslate=true` + `${MACHINE_API_BASE_URL}translation/builds/project:${projectId}?preTranslate=true` ); expect(req.request.method).toEqual('GET'); req.flush([buildDto]); @@ -209,7 +209,7 @@ describe('DraftGenerationService', () => { // Setup the HTTP request const req = httpTestingController.expectOne( - `${MACHINE_API_BASE_URL}translation/builds/project:${projectId}?pretranslate=true` + `${MACHINE_API_BASE_URL}translation/builds/project:${projectId}?preTranslate=true` ); expect(req.request.method).toEqual('GET'); req.flush(null, { status: HttpStatusCode.Unauthorized, statusText: 'Unauthorized' }); @@ -226,7 +226,7 @@ describe('DraftGenerationService', () => { // Setup the HTTP request const req = httpTestingController.expectOne( - `${MACHINE_API_BASE_URL}translation/builds/project:${projectId}?pretranslate=true` + `${MACHINE_API_BASE_URL}translation/builds/project:${projectId}?preTranslate=true` ); expect(req.request.method).toEqual('GET'); req.flush(null, { status: HttpStatusCode.NotFound, statusText: 'Not Found' }); @@ -254,7 +254,7 @@ describe('DraftGenerationService', () => { // Setup the HTTP request const req = httpTestingController.expectOne( - `${MACHINE_API_BASE_URL}translation/builds/id:${projectId}?pretranslate=true` + `${MACHINE_API_BASE_URL}translation/builds/id:${projectId}?preTranslate=true` ); expect(req.request.method).toEqual('GET'); req.flush(buildDto); @@ -271,7 +271,7 @@ describe('DraftGenerationService', () => { // Setup the HTTP request const req = httpTestingController.expectOne( - `${MACHINE_API_BASE_URL}translation/builds/id:${projectId}?pretranslate=true` + `${MACHINE_API_BASE_URL}translation/builds/id:${projectId}?preTranslate=true` ); expect(req.request.method).toEqual('GET'); req.flush(faultedBuild); 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 141d1d866d7..032d1f60a14 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 @@ -67,7 +67,7 @@ export class DraftGenerationService { if (!this.onlineStatusService.isOnline) { return of(undefined); } - return this.httpClient.get(`translation/builds/id:${projectId}?pretranslate=true`).pipe( + return this.httpClient.get(`translation/builds/id:${projectId}?preTranslate=true`).pipe( map(res => res.data), catchError(err => { // If no build has ever been started, return undefined @@ -91,7 +91,7 @@ export class DraftGenerationService { if (!this.onlineStatusService.isOnline) { return of(undefined); } - return this.httpClient.get(`translation/builds/project:${projectId}?pretranslate=true`).pipe( + return this.httpClient.get(`translation/builds/project:${projectId}?preTranslate=true`).pipe( map(res => res.data), catchError(err => { // If no build has ever been started, return undefined @@ -131,16 +131,30 @@ export class DraftGenerationService { ); } + /** Gets the build exactly as Serval returns it */ getRawBuild(buildId: string): Observable { if (!this.onlineStatusService.isOnline) { return of(undefined); } - return this.httpClient.get(`translation/builds/id:${buildId}/raw?pretranslate=true`).pipe( + return this.httpClient.get(`translation/builds/id:${buildId}/raw?preTranslate=true`).pipe( map(res => res.data), catchError(() => of(undefined)) ); } + /** Gets the engine exactly as Serval returns it */ + getRawEngine(projectId: string, preTranslate: boolean): Observable { + if (!this.onlineStatusService.isOnline) { + return of(undefined); + } + return this.httpClient + .get(`translation/engines/project:${projectId}/raw?pretranslate=${preTranslate}`) + .pipe( + map(res => res.data), + catchError(() => of(undefined)) + ); + } + /** * Starts a pre-translation build job if one is not already active. * @param buildConfig The build configuration. diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/mock-pretranslation-machine-api.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/mock-pretranslation-machine-api.ts index a493822c93e..057a9507b60 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/mock-pretranslation-machine-api.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/mock-pretranslation-machine-api.ts @@ -69,7 +69,7 @@ export class MockPreTranslationHttpClient { get(url: string): Observable> { const GET_DRAFT_URL_REGEX: RegExp = /^translation\/engines\/project:[^\/]+\/actions\/pretranslate\/(\d+)_(\d+)$/i; - const GET_BUILD_PROGRESS_URL_REGEX: RegExp = /^translation\/builds\/id:[^\/?]+\?pretranslate=true$/i; + const GET_BUILD_PROGRESS_URL_REGEX: RegExp = /^translation\/builds\/id:[^\/?]+\?preTranslate=true$/i; const GET_LAST_COMPLETED_BUILD_URL_REGEX: RegExp = /^translation\/engines\/project:[^\/]+\/actions\/getLastCompletedPreTranslationBuild$/i; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/material-styles.scss b/src/SIL.XForge.Scripture/ClientApp/src/material-styles.scss index 1c857dfabe6..20d5622f4c4 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/material-styles.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/material-styles.scss @@ -24,7 +24,7 @@ @use 'src/app/translate/translate-overview/translate-overview.component' as sf-translate-overview; @use 'src/app/permissions-viewer/permissions-viewer-theme' as sf-permissions-viewer; @use 'src/app/serval-administration/draft-jobs-theme' as sf-draft-jobs; -@use 'src/app/serval-administration/job-events-dialog-theme' as sf-job-events-dialog; +@use 'src/app/serval-administration/job-details-dialog-theme' as sf-job-details-dialog; @use 'text' as sf-text; @@ -58,7 +58,7 @@ @include sf-translate-overview.theme($theme); @include sf-permissions-viewer.theme($theme); @include sf-draft-jobs.theme($theme); - @include sf-job-events-dialog.theme($theme); + @include sf-job-details-dialog.theme($theme); // Custom variables --sf-disabled-foreground: #{mat.get-theme-color($theme, neutral, 70)}; diff --git a/src/SIL.XForge.Scripture/Controllers/MachineApiController.cs b/src/SIL.XForge.Scripture/Controllers/MachineApiController.cs index efdeafcb651..e31b8637544 100644 --- a/src/SIL.XForge.Scripture/Controllers/MachineApiController.cs +++ b/src/SIL.XForge.Scripture/Controllers/MachineApiController.cs @@ -309,6 +309,51 @@ CancellationToken cancellationToken } } + /// + /// Gets a translation engine exactly as Serval provides it. + /// + /// The Scripture Forge project identifier. + /// true if the engine is a pre-translation engine. + /// The cancellation token. + /// The engine is returned. + /// You do not have permission to retrieve the engine for this project. + /// The project does not exist or is not configured on the ML server. + /// The ML server is temporarily unavailable or unresponsive. + [HttpGet(MachineApi.GetRawEngine)] + public async Task> GetRawEngineAsync( + string sfProjectId, + [FromQuery] bool preTranslate, + CancellationToken cancellationToken + ) + { + try + { + bool isServalAdmin = _userAccessor.SystemRoles.Contains(SystemRole.ServalAdmin); + TranslationEngine engine = await _machineApiService.GetRawEngineAsync( + _userAccessor.UserId, + sfProjectId, + preTranslate, + isServalAdmin, + cancellationToken + ); + + return Ok(engine); + } + catch (BrokenCircuitException e) + { + _exceptionHandler.ReportException(e); + return StatusCode(StatusCodes.Status503ServiceUnavailable, MachineApiUnavailable); + } + catch (DataNotFoundException) + { + return NotFound(); + } + catch (ForbiddenException) + { + return Forbid(); + } + } + /// /// Gets the last completed pre-translation build. /// diff --git a/src/SIL.XForge.Scripture/Models/MachineApi.cs b/src/SIL.XForge.Scripture/Models/MachineApi.cs index 94fef6a6581..b6328282770 100644 --- a/src/SIL.XForge.Scripture/Models/MachineApi.cs +++ b/src/SIL.XForge.Scripture/Models/MachineApi.cs @@ -13,6 +13,7 @@ public static class MachineApi public const string GetRawBuild = "translation/builds/id:{sfProjectId}.{buildId}/raw"; public const string GetBuilds = "translation/builds/project:{sfProjectId}"; public const string GetEngine = "translation/engines/project:{sfProjectId}"; + public const string GetRawEngine = "translation/engines/project:{sfProjectId}/raw"; public const string GetWordGraph = "translation/engines/project:{sfProjectId}/actions/getWordGraph"; public const string IsLanguageSupported = "translation/languages/{languageCode}"; public const string TrainSegment = "translation/engines/project:{sfProjectId}/actions/trainSegment"; diff --git a/src/SIL.XForge.Scripture/Services/IMachineApiService.cs b/src/SIL.XForge.Scripture/Services/IMachineApiService.cs index bb87d5f9704..bf81b319f12 100644 --- a/src/SIL.XForge.Scripture/Services/IMachineApiService.cs +++ b/src/SIL.XForge.Scripture/Services/IMachineApiService.cs @@ -69,6 +69,13 @@ CancellationToken cancellationToken CancellationToken cancellationToken ); Task GetEngineAsync(string curUserId, string sfProjectId, CancellationToken cancellationToken); + Task GetRawEngineAsync( + string curUserId, + string sfProjectId, + bool preTranslate, + bool isServalAdmin, + CancellationToken cancellationToken + ); Task GetLastCompletedPreTranslationBuildAsync( string curUserId, string sfProjectId, diff --git a/src/SIL.XForge.Scripture/Services/MachineApiService.cs b/src/SIL.XForge.Scripture/Services/MachineApiService.cs index 1a0d5fe97b3..16fb435ef38 100644 --- a/src/SIL.XForge.Scripture/Services/MachineApiService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineApiService.cs @@ -1255,6 +1255,34 @@ CancellationToken cancellationToken } } + public async Task GetRawEngineAsync( + string curUserId, + string sfProjectId, + bool preTranslate, + bool isServalAdmin, + CancellationToken cancellationToken + ) + { + // Ensure that the user has permission + await EnsureProjectPermissionAsync(curUserId, sfProjectId, isServalAdmin, cancellationToken); + + TranslationEngine? translationEngine = null; + + // Execute on Serval, if it is enabled + string translationEngineId = await GetTranslationIdAsync(sfProjectId, preTranslate); + + try + { + translationEngine = await translationEnginesClient.GetAsync(translationEngineId, cancellationToken); + } + catch (ServalApiException e) + { + ProcessServalApiException(e); + } + + return translationEngine; + } + public async Task GetPreTranslationAsync( string curUserId, string sfProjectId,