diff --git a/src/app/layouts/tasks-executor/tasks-executor.css b/src/app/layouts/tasks-executor/tasks-executor.css index bbd44d3..510d046 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.css +++ b/src/app/layouts/tasks-executor/tasks-executor.css @@ -11,6 +11,20 @@ position: relative; } +.tasks-executor-rail { + min-height: 0; +} + +.tasks-executor-rail-list { + flex: 1 1 auto; + min-height: 0; +} + +.tasks-executor-rail-tree { + flex: 0 0 auto; + max-height: 45%; +} + .interactive-subflow-switcher { align-items: center; display: flex; diff --git a/src/app/layouts/tasks-executor/tasks-executor.html b/src/app/layouts/tasks-executor/tasks-executor.html index c7696bc..c315233 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.html +++ b/src/app/layouts/tasks-executor/tasks-executor.html @@ -1,12 +1,23 @@
- - +
+ + + + @if (selectedExecution(); as run) { + + + } +
@if (showExecutionCreationLoader()) { @@ -48,8 +59,8 @@ } + [parentExecution]="displayedParentExecution()" + [parentContainerStep]="displayedParentContainerStep()">
diff --git a/src/app/layouts/tasks-executor/tasks-executor.ts b/src/app/layouts/tasks-executor/tasks-executor.ts index be04fa6..3236b0c 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.ts +++ b/src/app/layouts/tasks-executor/tasks-executor.ts @@ -22,6 +22,7 @@ import { TasksExecutionsListComponent } from '@shared/tasks-executions-list/tasks-executions-list'; import { TaskExecutionViewerComponent } from '@shared/task-execution-viewer/task-execution-viewer'; +import { ExecutionTreeComponent, ExecutionTreeSelection } from '@shared/execution-tree/execution-tree'; import { formatDuration } from '@shared/task-execution-viewer/execution-viewer.utils'; import { BlocksService } from '@services/blocks/blocks'; import { ContainersService } from '@services/containers/containers'; @@ -62,7 +63,7 @@ export function findInteractiveSubflowTargets( @Component({ selector: 'app-tasks-executor', - imports: [TasksExecutionsListComponent, TaskExecutionViewerComponent, MatCardModule], + imports: [TasksExecutionsListComponent, TaskExecutionViewerComponent, ExecutionTreeComponent, MatCardModule], templateUrl: './tasks-executor.html', styleUrl: './tasks-executor.css', changeDetection: ChangeDetectionStrategy.OnPush @@ -116,9 +117,30 @@ export class TasksExecutor { const childId = this.activeSubflowTarget()?.childExecutionId; return childId ? this.taskExecutionsService.followedExecutions()[childId] ?? null : null; }); - readonly displayedExecution = computed(() => - this.childExecution() ?? this.selectedExecution() - ); + + /** Execution the user navigated to via the hierarchy tree (any depth). */ + readonly manualTreeSelectionId = signal(null); + + readonly displayedExecution = computed(() => { + const manual = this.resolveExecutionById(this.manualTreeSelectionId()); + if (manual) return manual; + return this.childExecution() ?? this.selectedExecution(); + }); + + readonly displayedParentExecution = computed(() => { + const displayed = this.displayedExecution(); + const parentId = displayed?.parentExecutionId ?? null; + if (!parentId || parentId === displayed?.id) return null; + return this.resolveExecutionById(parentId); + }); + + readonly displayedParentContainerStep = computed(() => { + const displayed = this.displayedExecution(); + const parent = this.displayedParentExecution(); + const stepId = displayed?.parentStepId ?? null; + if (!parent || !stepId) return null; + return parent.context.steps?.[stepId] ?? null; + }); readonly showExecutionCreationLoader = computed(() => this.pendingExecutionCreation() && !this.requestedExecutionId() @@ -153,6 +175,11 @@ export class TasksExecutor { const first = this.groups()[0]; if (first?.latestExecutionId) this.selectedExecutionId.set(first.latestExecutionId); }); + effect(() => { + // Reset tree navigation whenever the selected top-level run changes. + this.selectedExecutionId(); + untracked(() => this.manualTreeSelectionId.set(null)); + }); effect(() => { const targets = this.interactiveSubflowTargets(); const selectedId = this.selectedChildExecutionId(); @@ -205,9 +232,21 @@ export class TasksExecutor { if (!this.interactiveSubflowTargets().some( (target) => target.childExecutionId === childExecutionId )) return; + this.manualTreeSelectionId.set(null); this.selectedChildExecutionId.set(childExecutionId); } + selectTreeExecution(selection: ExecutionTreeSelection) { + this.manualTreeSelectionId.set(selection.executionId); + } + + private resolveExecutionById(executionId: string | null): TaskExecution | null { + if (!executionId) return null; + return this.executionDetails().find((execution) => execution.id === executionId) + ?? this.taskExecutionsService.followedExecutions()[executionId] + ?? null; + } + async removeExecution(id: string) { const confirmed = await this.confirm.open('Are you sure you want to delete this execution?'); if (!confirmed) return; diff --git a/src/app/services/task-executions/task-executions-call.base.ts b/src/app/services/task-executions/task-executions-call.base.ts index 4d5b34e..39076f0 100644 --- a/src/app/services/task-executions/task-executions-call.base.ts +++ b/src/app/services/task-executions/task-executions-call.base.ts @@ -12,6 +12,7 @@ export abstract class TaskExecutionsCallServiceBase { abstract retrieveAllTaskExecutions(): Observable; abstract retrieveTaskExecutionGroups(): Observable; abstract retrieveTaskExecution(executionId: string): Observable; + abstract retrieveStepIterations(executionId: string, stepId: string): Observable; abstract retrieveExecutionEvents(executionId: string): Observable; abstract createTaskExecution(flowId: string): Observable; abstract rerunTaskExecution(executionId: string): Observable; diff --git a/src/app/services/task-executions/task-executions-call.fake.ts b/src/app/services/task-executions/task-executions-call.fake.ts index e8cbac5..6ea7970 100644 --- a/src/app/services/task-executions/task-executions-call.fake.ts +++ b/src/app/services/task-executions/task-executions-call.fake.ts @@ -6,7 +6,7 @@ import { BiasRerunRequest } from '@models/bias-impact'; import { ExecutionEventLogEntry, TaskExecution, TaskExecutionGroup } from '@models/task-execution'; -import { map, Observable, of } from 'rxjs'; +import { map, Observable, of, throwError } from 'rxjs'; import { TaskExecutionsCallServiceBase } from './task-executions-call.base'; export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase { @@ -467,6 +467,27 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase return of(this.cloneExecution(this.findExecution(executionId))); } + override retrieveStepIterations(executionId: string, stepId: string): Observable { + // Ensures the step exists and is a container, mirroring the backend's 404/400 contract. + const execution = this.findExecution(executionId); + const step = execution.context.steps?.[stepId]; + if (!step) { + return throwError(() => new Error(`Step ${stepId} not found in execution ${executionId}`)); + } + + const iterations = this.data + .filter((candidate) => + candidate.parentExecutionId === executionId && candidate.parentStepId === stepId + ) + .sort((left, right) => + (left.parentIterationIndex ?? 0) - (right.parentIterationIndex ?? 0) + || ((left.creationTime ?? 0) - (right.creationTime ?? 0)) + ) + .map((candidate) => this.cloneExecution(candidate)); + + return of(iterations); + } + override retrieveExecutionEvents(executionId: string): Observable { const execution = this.findExecution(executionId); return of(this.buildExecutionEvents(execution)); diff --git a/src/app/services/task-executions/task-executions-call.spec.ts b/src/app/services/task-executions/task-executions-call.spec.ts index e3ea8c7..4a31bb4 100644 --- a/src/app/services/task-executions/task-executions-call.spec.ts +++ b/src/app/services/task-executions/task-executions-call.spec.ts @@ -247,6 +247,40 @@ describe('TaskExecutionsCallService bias APIs', () => { expect(decision.node?.biasAnnotations?.[0].id).toBe('selection-risk'); }); + it('retrieves the ordered iterations for a looping container step', async () => { + const result = firstValueFrom(service.retrieveStepIterations('parent-1', 'container-1')); + const request = httpMock.expectOne( + `${environment.apiUrl}/executions/parent-1/node/container-1/iterations` + ); + expect(request.request.method).toBe('GET'); + request.flush([ + { + id: 'child-main-1', + executionKind: 'SUBFLOW', + parentExecutionId: 'parent-1', + parentStepId: 'container-1', + parentIterationIndex: 1, + subflowRole: 'MAIN', + context: { status: 'SUCCESS', inputs: {}, result: {}, errors: {}, warnings: {}, waitingSteps: [], steps: {} } + }, + { + id: 'child-guard-1', + executionKind: 'SUBFLOW', + parentExecutionId: 'parent-1', + parentStepId: 'container-1', + parentIterationIndex: 1, + subflowRole: 'GUARD', + context: { status: 'SUCCESS', inputs: {}, result: {}, errors: {}, warnings: {}, waitingSteps: [], steps: {} } + } + ]); + + const iterations = await result; + expect(iterations.map((iteration) => iteration.id)).toEqual(['child-main-1', 'child-guard-1']); + expect(iterations[0].parentIterationIndex).toBe(1); + expect(iterations[0].subflowRole).toBe('MAIN'); + expect(iterations[1].subflowRole).toBe('GUARD'); + }); + it('starts an asynchronous impact experiment and maps the job response', async () => { const result = firstValueFrom(service.runBiasImpactExperiment('execution-1', 'step-1', { annotationIds: ['annotation-1'], diff --git a/src/app/services/task-executions/task-executions-call.ts b/src/app/services/task-executions/task-executions-call.ts index bc05b87..ea7e5dc 100644 --- a/src/app/services/task-executions/task-executions-call.ts +++ b/src/app/services/task-executions/task-executions-call.ts @@ -38,6 +38,13 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase { .pipe(map((raw) => this.mapExecution(raw))); } + override retrieveStepIterations(executionId: string, stepId: string): Observable { + const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(stepId)}/iterations`; + return this.http.get(url).pipe( + map((raw) => Array.isArray(raw) ? raw.map((item) => this.mapExecution(item)) : []) + ); + } + override retrieveExecutionEvents(executionId: string): Observable { return this.http.get(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/events`); } diff --git a/src/app/services/task-executions/task-executions.spec.ts b/src/app/services/task-executions/task-executions.spec.ts index 07b93f3..173e3df 100644 --- a/src/app/services/task-executions/task-executions.spec.ts +++ b/src/app/services/task-executions/task-executions.spec.ts @@ -160,6 +160,34 @@ describe('TaskExecutionsService bias operations', () => { expect(service.taskExecutions()).toEqual([]); }); + it('caches every iteration returned for a looping container step', async () => { + const iterationOne = { + id: 'iteration-1', + name: 'Iteration 1', + creationTime: 1, + executionKind: 'SUBFLOW', + parentExecutionId: 'parent-1', + parentStepId: 'container-1', + parentIterationIndex: 1, + subflowRole: 'MAIN', + context: { inputs: {}, result: {}, errors: {}, warnings: {}, steps: {}, status: 'SUCCESS', waitingSteps: [] } + } satisfies TaskExecution; + const iterationTwo = { + ...iterationOne, + id: 'iteration-2', + parentIterationIndex: 2 + } satisfies TaskExecution; + service.taskExecutionsCallService = { + retrieveStepIterations: vi.fn().mockReturnValue(of([iterationOne, iterationTwo])) + } as unknown as typeof service.taskExecutionsCallService; + + const iterations = await lastValueFrom(service.retrieveStepIterations('parent-1', 'container-1')); + + expect(iterations).toEqual([iterationOne, iterationTwo]); + expect(service.followedExecutions()['iteration-1']).toEqual(iterationOne); + expect(service.followedExecutions()['iteration-2']).toEqual(iterationTwo); + }); + it('retrieves and caches a child execution directly', async () => { const child = { id: 'child-1', diff --git a/src/app/services/task-executions/task-executions.ts b/src/app/services/task-executions/task-executions.ts index 14db834..aa2a14b 100644 --- a/src/app/services/task-executions/task-executions.ts +++ b/src/app/services/task-executions/task-executions.ts @@ -92,6 +92,20 @@ export class TaskExecutionsService { ); } + retrieveStepIterations(executionId: string, stepId: string) { + return this.withRefreshAndErrorHandling( + this.taskExecutionsCallService.retrieveStepIterations(executionId, stepId).pipe( + tap((iterations) => { + for (const iteration of iterations) { + this.cacheFollowedExecution(iteration); + } + }) + ), + 'Retrieve step iterations failed', + false + ); + } + createExecution(flowId: string) { this._pendingExecutionCreation.set(true); return this.taskExecutionsCallService.createTaskExecution(flowId).pipe( diff --git a/src/app/shared/execution-tree/execution-tree.css b/src/app/shared/execution-tree/execution-tree.css new file mode 100644 index 0000000..9892f20 --- /dev/null +++ b/src/app/shared/execution-tree/execution-tree.css @@ -0,0 +1,171 @@ +:host { + display: block; +} + +:host(.execution-tree-empty) { + display: none; +} + +.execution-tree-shell { + display: flex; + flex-direction: column; + max-height: 100%; + overflow: hidden; + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 8px; +} + +.execution-tree-title { + padding: 10px 12px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #64748b; + border-bottom: 1px solid #e2e8f0; +} + +.execution-tree-body { + flex: 1; + overflow-y: auto; + padding: 6px 4px; +} + +.tree-row { + display: flex; + align-items: center; + gap: 6px; + min-height: 28px; + padding-right: 8px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + color: #334155; + user-select: none; +} + +.tree-row:hover { + background: #f1f5f9; +} + +.tree-row-selected { + background: #e0edff; + color: #1e3a8a; +} + +.tree-row-selected:hover { + background: #d4e5ff; +} + +.tree-step-row { + color: #475569; + font-size: 12.5px; +} + +.tree-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex: 0 0 auto; + padding: 0; + border: none; + background: transparent; + color: #94a3b8; + cursor: pointer; +} + +.tree-chevron-inline { + cursor: inherit; +} + +.tree-chevron-icon { + display: inline-block; + font-size: 14px; + line-height: 1; + transition: transform 0.12s ease; +} + +.tree-chevron-open .tree-chevron-icon { + transform: rotate(90deg); +} + +.tree-chevron-spacer { + display: inline-block; + width: 16px; + flex: 0 0 auto; +} + +.tree-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: 0 0 auto; + background: #cbd5e1; +} + +.tree-dot-success { background: #10b981; } +.tree-dot-error { background: #f43f5e; } +.tree-dot-cancelled { background: #94a3b8; } +.tree-dot-suspended { background: #8b5cf6; } +.tree-dot-waiting { background: #f59e0b; } +.tree-dot-running { + background: #3b82f6; + animation: tree-pulse 1.2s ease-in-out infinite; +} + +@keyframes tree-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +.tree-icon { + flex: 0 0 auto; + font-size: 12px; + color: #94a3b8; +} + +.tree-label { + flex: 1 1 auto; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.tree-status { + flex: 0 0 auto; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #94a3b8; +} + +.tree-waiting-badge { + flex: 0 0 auto; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #b45309; + background: #fef3c7; + border-radius: 999px; + padding: 1px 6px; +} + +.tree-hint, +.tree-error { + min-height: 24px; + display: flex; + align-items: center; + padding-right: 8px; + font-size: 12px; + font-style: italic; + color: #94a3b8; +} + +.tree-error { + color: #e11d48; + font-style: normal; +} diff --git a/src/app/shared/execution-tree/execution-tree.html b/src/app/shared/execution-tree/execution-tree.html new file mode 100644 index 0000000..c83c470 --- /dev/null +++ b/src/app/shared/execution-tree/execution-tree.html @@ -0,0 +1,68 @@ +@if (rootExecution(); as root) { +@if (hasContainerContent()) { +
+
Execution tree
+
+ +
+
+} +} + + +
+ @if (containerSteps(execution).length) { + + } @else { + + } + + {{ executionLabel(execution) }} + {{ execution.context.status }} +
+ + @if (isExecutionExpanded(execution.id)) { + @for (step of containerSteps(execution); track step.stepId) { +
+ + › + + ▤ + {{ step.name }} + @if (step.waitingForSubflow) { + waiting + } +
+ + @if (isStepExpanded(execution.id, step.stepId)) { + @if (isStepLoading(execution.id, step.stepId)) { +
Loading iterations…
+ } @else if (stepError(execution.id, step.stepId); as err) { +
{{ err }}
+ } @else if (!iterationsFor(execution.id, step.stepId).length) { +
No iterations yet
+ } @else { + @for (iteration of iterationsFor(execution.id, step.stepId); track iteration.id) { + + } + } + } + } + } +
diff --git a/src/app/shared/execution-tree/execution-tree.spec.ts b/src/app/shared/execution-tree/execution-tree.spec.ts new file mode 100644 index 0000000..6b6af3e --- /dev/null +++ b/src/app/shared/execution-tree/execution-tree.spec.ts @@ -0,0 +1,189 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TaskExecution } from '@models/task-execution'; +import { BlockType } from '@models/flow'; +import { ContainersService } from '@services/containers/containers'; +import { TaskExecutionsService } from '@services/task-executions/task-executions'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; + +import { ExecutionTreeComponent } from './execution-tree'; + +function containerStep(overrides: Partial = {}) { + return { + id: 'container-1', + status: 'SUCCESS', + simulated: false, + node: { id: 'container-1', name: 'Loop container', nodeFamily: 'container' as const, typeName: 'LoopContainer', inputs: [], outputs: [], specificConfiguration: {} }, + activeInnerExecutionId: 'iteration-3', + containerIterationIndex: 3, + ...overrides + }; +} + +function rootExecution(steps: TaskExecution['context']['steps'] = {}): TaskExecution { + return { + id: 'root-1', + name: 'Root run', + creationTime: 1, + context: { + inputs: {}, + result: {}, + errors: {}, + warnings: {}, + waitingSteps: [], + status: 'RUNNING', + steps + } + }; +} + +function iteration(id: string, index: number, subflowRole: string | null = null): TaskExecution { + return { + id, + name: `Iteration ${index}`, + creationTime: index, + executionKind: 'SUBFLOW', + parentExecutionId: 'root-1', + parentStepId: 'container-1', + parentIterationIndex: index, + subflowRole: subflowRole ?? undefined, + context: { + inputs: {}, + result: {}, + errors: {}, + warnings: {}, + waitingSteps: [], + status: 'SUCCESS', + steps: {} + } + }; +} + +describe('ExecutionTreeComponent', () => { + let fixture: ComponentFixture; + let component: ExecutionTreeComponent; + const retrieveStepIterations = vi.fn(); + + beforeEach(async () => { + retrieveStepIterations.mockReset(); + await TestBed.configureTestingModule({ + imports: [ExecutionTreeComponent], + providers: [ + { provide: ContainersService, useValue: { peekContainerType: () => null as BlockType | null } }, + { provide: TaskExecutionsService, useValue: { retrieveStepIterations } } + ] + }).compileComponents(); + fixture = TestBed.createComponent(ExecutionTreeComponent); + component = fixture.componentInstance; + }); + + it('reports no container content for a flat execution with only block steps', () => { + const execution = rootExecution({ + 'block-1': { id: 'block-1', status: 'SUCCESS', simulated: false, node: { id: 'block-1', name: 'LLM', nodeFamily: 'block', typeName: 'LLMBlock', inputs: [], outputs: [], specificConfiguration: {} } } + }); + fixture.componentRef.setInput('rootExecution', execution); + fixture.detectChanges(); + + expect(component.hasContainerContent()).toBe(false); + expect(component.containerSteps(execution)).toEqual([]); + }); + + it('lists container steps and flags WAITING_FOR_SUBFLOW ones', () => { + const execution = rootExecution({ 'container-1': containerStep({ status: 'WAITING_FOR_SUBFLOW' }) }); + fixture.componentRef.setInput('rootExecution', execution); + fixture.detectChanges(); + + const steps = component.containerSteps(execution); + expect(steps).toEqual([{ + stepId: 'container-1', + name: 'Loop container', + status: 'WAITING_FOR_SUBFLOW', + waitingForSubflow: true + }]); + expect(component.hasContainerContent()).toBe(true); + }); + + it('lazily loads iterations only once, in order, on expand', () => { + retrieveStepIterations.mockReturnValue(of([iteration('it-1', 1, 'MAIN'), iteration('it-2', 2, 'MAIN')])); + const execution = rootExecution({ 'container-1': containerStep() }); + fixture.componentRef.setInput('rootExecution', execution); + fixture.detectChanges(); + + expect(component.isStepLoaded('root-1', 'container-1')).toBe(false); + + component.toggleStep('root-1', 'container-1'); + fixture.detectChanges(); + + expect(retrieveStepIterations).toHaveBeenCalledWith('root-1', 'container-1'); + expect(retrieveStepIterations).toHaveBeenCalledTimes(1); + expect(component.isStepExpanded('root-1', 'container-1')).toBe(true); + expect(component.iterationsFor('root-1', 'container-1').map((it) => it.id)).toEqual(['it-1', 'it-2']); + + component.toggleStep('root-1', 'container-1'); + component.toggleStep('root-1', 'container-1'); + fixture.detectChanges(); + + expect(retrieveStepIterations).toHaveBeenCalledTimes(1); + }); + + it('filters out GUARD subflows from a LoopContainer iteration list', () => { + retrieveStepIterations.mockReturnValue(of([ + iteration('main-1', 1, 'MAIN'), + iteration('guard-1', 1, 'GUARD'), + iteration('main-2', 2, 'MAIN'), + iteration('guard-2', 2, 'GUARD') + ])); + const execution = rootExecution({ 'container-1': containerStep() }); + fixture.componentRef.setInput('rootExecution', execution); + fixture.detectChanges(); + + component.toggleStep('root-1', 'container-1'); + fixture.detectChanges(); + + expect(component.iterationsFor('root-1', 'container-1').map((it) => it.id)).toEqual(['main-1', 'main-2']); + }); + + it('surfaces a friendly message when the step is not a container', () => { + retrieveStepIterations.mockReturnValue(throwError(() => ({ status: 400 }))); + const execution = rootExecution({ 'container-1': containerStep() }); + fixture.componentRef.setInput('rootExecution', execution); + fixture.detectChanges(); + + component.toggleStep('root-1', 'container-1'); + fixture.detectChanges(); + + expect(component.stepError('root-1', 'container-1')).toBe('This step is not an iterating container.'); + expect(component.isStepLoading('root-1', 'container-1')).toBe(false); + }); + + it('emits the selected execution id on click', () => { + const execution = rootExecution({ 'container-1': containerStep() }); + fixture.componentRef.setInput('rootExecution', execution); + fixture.detectChanges(); + + const selected = vi.fn(); + component.executionSelected.subscribe(selected); + component.selectExecution(execution); + + expect(selected).toHaveBeenCalledWith({ executionId: 'root-1' }); + }); + + it('resets expansion and cached iterations when the root execution id changes', () => { + retrieveStepIterations.mockReturnValue(of([iteration('it-1', 1, 'MAIN')])); + const execution = rootExecution({ 'container-1': containerStep() }); + fixture.componentRef.setInput('rootExecution', execution); + fixture.detectChanges(); + + component.toggleStep('root-1', 'container-1'); + fixture.detectChanges(); + expect(component.isStepLoaded('root-1', 'container-1')).toBe(true); + + const otherExecution = rootExecution({ 'container-2': containerStep({ id: 'container-2' }) }); + otherExecution.id = 'root-2'; + fixture.componentRef.setInput('rootExecution', otherExecution); + fixture.detectChanges(); + + expect(component.isStepLoaded('root-1', 'container-1')).toBe(false); + expect(component.isExecutionExpanded('root-2')).toBe(true); + }); +}); diff --git a/src/app/shared/execution-tree/execution-tree.ts b/src/app/shared/execution-tree/execution-tree.ts new file mode 100644 index 0000000..469e959 --- /dev/null +++ b/src/app/shared/execution-tree/execution-tree.ts @@ -0,0 +1,232 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + output, + signal, + untracked +} from '@angular/core'; +import { TaskExecution, TaskExecutionStep } from '@models/task-execution'; +import { ContainersService } from '@services/containers/containers'; +import { TaskExecutionsService } from '@services/task-executions/task-executions'; +import { take } from 'rxjs'; + +export type ExecutionTreeSelection = { + executionId: string; +}; + +export type ContainerStepView = { + stepId: string; + name: string; + status: string; + waitingForSubflow: boolean; +}; + +/** + * A container step spawns a *separate* TaskExecution per iteration, linked back + * via `parentExecutionId`/`parentStepId`. The step itself only exposes the + * currently active child (`activeInnerExecutionId`); the full set of iterations + * is fetched lazily from `GET /executions/{id}/node/{stepId}/iterations` (see + * TaskExecutionsService.retrieveStepIterations). Each iteration is itself a full + * execution that may contain further container steps, so the tree recurses. + */ +@Component({ + selector: 'app-execution-tree', + imports: [CommonModule], + templateUrl: './execution-tree.html', + styleUrl: './execution-tree.css', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '[class.execution-tree-empty]': '!hasContainerContent()' + } +}) +export class ExecutionTreeComponent { + private containersService = inject(ContainersService); + private taskExecutionsService = inject(TaskExecutionsService); + + readonly rootExecution = input(null); + readonly selectedExecutionId = input(null); + readonly executionSelected = output(); + + private readonly expandedKeys = signal>(new Set()); + private readonly loadingStepKeys = signal>(new Set()); + private readonly stepErrors = signal>({}); + private readonly iterationsByStep = signal>({}); + private lastRootId: string | null = null; + + readonly hasContainerContent = computed(() => { + const root = this.rootExecution(); + return !!root && this.containerSteps(root).length > 0; + }); + + constructor() { + // Reset the tree only when the top-level run actually changes — polling + // hands us a fresh TaskExecution object on every tick with the same id, and + // we must not wipe expansion/loaded state each time. + effect(() => { + const rootId = this.rootExecution()?.id ?? null; + if (rootId === this.lastRootId) return; + this.lastRootId = rootId; + untracked(() => { + this.expandedKeys.set(rootId ? new Set([this.execKey(rootId)]) : new Set()); + this.iterationsByStep.set({}); + this.loadingStepKeys.set(new Set()); + this.stepErrors.set({}); + }); + }); + } + + containerSteps(execution: TaskExecution): ContainerStepView[] { + return Object.values(execution.context.steps ?? {}) + .filter((step) => this.isContainerStep(step)) + .map((step) => ({ + stepId: step.id, + name: step.node?.name?.trim() || step.id, + status: String(step.status ?? ''), + waitingForSubflow: String(step.status ?? '').toUpperCase() === 'WAITING_FOR_SUBFLOW' + })); + } + + private isContainerStep(step: TaskExecutionStep): boolean { + if (step.node?.nodeFamily === 'container') return true; + if (step.activeInnerExecutionId) return true; + if (step.containerContinuationPhase) return true; + if (typeof step.containerIterationIndex === 'number') return true; + const typeName = step.node?.typeName; + return !!typeName && !!this.containersService.peekContainerType(typeName); + } + + /** Iterations for a step, minus GUARD subflows (debug-only, see backend doc). */ + iterationsFor(executionId: string, stepId: string): TaskExecution[] { + const all = this.iterationsByStep()[this.stepKey(executionId, stepId)] ?? []; + return all.filter((iteration) => String(iteration.subflowRole ?? '').toUpperCase() !== 'GUARD'); + } + + isExecutionExpanded(executionId: string): boolean { + return this.expandedKeys().has(this.execKey(executionId)); + } + + toggleExecution(executionId: string, event?: Event) { + event?.stopPropagation(); + this.toggleKey(this.execKey(executionId)); + } + + isStepExpanded(executionId: string, stepId: string): boolean { + return this.expandedKeys().has(this.stepExpandKey(executionId, stepId)); + } + + toggleStep(executionId: string, stepId: string, event?: Event) { + event?.stopPropagation(); + const key = this.stepExpandKey(executionId, stepId); + const willExpand = !this.expandedKeys().has(key); + this.toggleKey(key); + if (willExpand) { + this.ensureIterationsLoaded(executionId, stepId); + } + } + + isStepLoading(executionId: string, stepId: string): boolean { + return this.loadingStepKeys().has(this.stepKey(executionId, stepId)); + } + + stepError(executionId: string, stepId: string): string | null { + return this.stepErrors()[this.stepKey(executionId, stepId)] ?? null; + } + + isStepLoaded(executionId: string, stepId: string): boolean { + return this.stepKey(executionId, stepId) in this.iterationsByStep(); + } + + selectExecution(execution: TaskExecution) { + this.executionSelected.emit({ executionId: execution.id }); + } + + executionLabel(execution: TaskExecution): string { + if (execution.id === this.rootExecution()?.id) return execution.name || 'Execution'; + if (typeof execution.parentIterationIndex === 'number') { + return `Iteration ${execution.parentIterationIndex}`; + } + return execution.name || 'Subflow'; + } + + statusDotClass(status: string | null | undefined): string { + const normalized = String(status ?? '').toUpperCase(); + if (normalized === 'SUCCESS' || normalized === 'COMPLETED') return 'tree-dot-success'; + if (normalized === 'ERROR' || normalized === 'FAILED') return 'tree-dot-error'; + if (normalized === 'CANCELLED') return 'tree-dot-cancelled'; + if (normalized === 'SUSPENDED') return 'tree-dot-suspended'; + if (normalized === 'WAITING' || normalized.startsWith('WAITING_')) return 'tree-dot-waiting'; + if (normalized === 'RUNNING') return 'tree-dot-running'; + return 'tree-dot-idle'; + } + + private ensureIterationsLoaded(executionId: string, stepId: string) { + const key = this.stepKey(executionId, stepId); + if (key in this.iterationsByStep()) return; + if (this.loadingStepKeys().has(key)) return; + + this.loadingStepKeys.update((current) => new Set(current).add(key)); + this.stepErrors.update((current) => { + const next = { ...current }; + delete next[key]; + return next; + }); + + this.taskExecutionsService.retrieveStepIterations(executionId, stepId).pipe(take(1)).subscribe({ + next: (iterations) => { + this.iterationsByStep.update((current) => ({ ...current, [key]: iterations })); + this.clearLoading(key); + }, + error: (err) => { + this.stepErrors.update((current) => ({ ...current, [key]: this.toErrorMessage(err) })); + this.clearLoading(key); + } + }); + } + + private clearLoading(key: string) { + this.loadingStepKeys.update((current) => { + const next = new Set(current); + next.delete(key); + return next; + }); + } + + private toggleKey(key: string) { + this.expandedKeys.update((current) => { + const next = new Set(current); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + } + + private toErrorMessage(err: unknown): string { + if (err && typeof err === 'object' && 'status' in err) { + const status = (err as { status?: number }).status; + if (status === 400) return 'This step is not an iterating container.'; + if (status === 404) return 'Step no longer exists.'; + if (status === 403) return 'Not authorized to load iterations.'; + } + return 'Unable to load iterations.'; + } + + private execKey(executionId: string): string { + return `exec:${executionId}`; + } + + private stepExpandKey(executionId: string, stepId: string): string { + return `step:${this.stepKey(executionId, stepId)}`; + } + + private stepKey(executionId: string, stepId: string): string { + return `${executionId}::${stepId}`; + } +}