Support containers in task execution views

This commit is contained in:
Lucio Lelii 2026-03-16 16:50:44 +01:00
parent 1c1121d423
commit 2a42817ef3
8 changed files with 297 additions and 258 deletions

View File

@ -1,4 +1,4 @@
import { FlowBlock, FlowBlockConnection, FlowPort } from './flow';
import { FlowBlock, FlowBlockConnection, FlowContainer, FlowNode, FlowPort } from './flow';
export type TaskExecutionStatus = 'CREATED' | 'READY' | 'RUNNING' | 'WAITING' | 'SUCCESS' | 'ERROR';
export type TaskExecutionStatusGroup = 'INIT' | 'RUNNING' | 'FINAL';
@ -38,7 +38,9 @@ export type TaskExecutionContext = {
};
export type TaskExecutionStep = {
block: FlowBlock;
node?: FlowNode;
block?: FlowBlock;
container?: FlowContainer;
id: string;
inputs: TaskExecutionStepInput[];
outputs: TaskExecutionStepOutput[];
@ -89,3 +91,19 @@ export function normalizeExecutionStatus(status: string | null | undefined): Tas
if (normalized === 'ERROR' || normalized === 'FAILED') return 'ERROR';
return 'CREATED';
}
export function getTaskExecutionStepNode(step: TaskExecutionStep | null | undefined): FlowNode | null {
if (!step) return null;
if (step.node) {
return step.node.nodeFamily === 'container'
? { ...step.node, nodeFamily: 'container' }
: { ...step.node, nodeFamily: 'block' };
}
if (step.container) {
return { ...step.container, nodeFamily: 'container' };
}
if (step.block) {
return { ...step.block, nodeFamily: 'block' };
}
return null;
}

View File

@ -213,13 +213,29 @@
display: inline-flex;
flex-direction: column;
align-items: flex-start;
flex: 1 1 0;
min-width: 0;
min-height: 36px;
padding: 4px 10px;
border-radius: 14px;
background: #e2e8f0;
color: #0f172a;
border: 1px solid transparent;
font-size: 12px;
font-weight: 600;
box-sizing: border-box;
}
.container-node__port-label--input {
background: #ecfdf3;
color: #166534;
border-color: #bbf7d0;
}
.container-node__port-label--output {
background: #fff1f2;
color: #9f1239;
border-color: #fecdd3;
align-items: flex-end;
text-align: right;
}
.container-node__port-context {
@ -243,7 +259,7 @@
.container-node__dropzone {
margin: 0 14px 14px;
min-height: 152px;
min-height: 128px;
border: 2px dashed #94a3b8;
border-radius: 18px;
padding: 18px 14px;
@ -256,41 +272,6 @@
text-align: center;
}
.container-node__snapshot {
position: relative;
width: 100%;
height: 74px;
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.3);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.88) 0%, rgba(226, 232, 240, 0.56) 100%);
overflow: hidden;
}
.container-node__snapshot::before {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(148, 163, 184, 0.12) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.12) 1px, transparent 1px);
background-size: 18px 18px;
}
.container-node__snapshot-node {
position: absolute;
z-index: 1;
display: block;
border-radius: 5px;
background: linear-gradient(135deg, #0f766e 0%, #14b8a6 100%);
box-shadow: 0 2px 4px rgba(15, 23, 42, 0.16);
}
.container-node__snapshot-node--container {
background: linear-gradient(135deg, #0284c7 0%, #38bdf8 100%);
border-radius: 6px;
}
.container-node__dropzone--active {
border-color: #0f766e;
background:
@ -308,6 +289,7 @@
font-size: 14px;
font-weight: 700;
color: #0f172a;
flex-wrap: wrap;
}
.container-node__replace-confirm {
@ -372,9 +354,12 @@
display: flex;
justify-content: center;
margin-top: 2px;
gap: 10px;
flex-wrap: wrap;
}
.container-node__import {
.container-node__import,
.container-node__subflow-view {
display: inline-flex;
align-items: center;
justify-content: center;
@ -387,85 +372,16 @@
color: #f8fafc;
font-size: 11px;
font-weight: 700;
}
.container-node__import {
box-shadow: 0 10px 18px rgba(15, 118, 110, 0.18);
}
.container-node__subflow-preview {
display: grid;
gap: 8px;
margin-top: 4px;
text-align: left;
}
.container-node__subflow-chip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border: 1px solid rgba(148, 163, 184, 0.38);
border-radius: 12px;
background: rgba(255, 255, 255, 0.86);
}
.container-node__subflow-chip--container {
border-color: rgba(14, 165, 233, 0.34);
background: rgba(224, 242, 254, 0.72);
}
.container-node__subflow-chip-name {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
font-weight: 700;
color: #0f172a;
}
.container-node__subflow-chip-type {
font-size: 11px;
font-weight: 600;
color: #475569;
white-space: nowrap;
}
.container-node__subflow-more {
font-size: 11px;
font-weight: 700;
color: #0f766e;
text-align: center;
}
.container-node__dropzone-empty {
font-size: 16px;
}
.container-node__subflow-section {
margin: 0 14px 14px;
border: 1px solid #dbe2ea;
border-radius: 16px;
padding: 12px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.96));
}
.container-node__subflow-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.container-node__subflow-title {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #475569;
}
.container-node__subflow-view {
border: 1px solid #bae6fd;
border-radius: 999px;

View File

@ -57,7 +57,7 @@
}"
[emit]="emit">
</div>
<span class="container-node__port-label">
<span class="container-node__port-label container-node__port-label--input">
@if (inputDisplayLabelParts(input.key).context; as context) {
<span class="container-node__port-context">{{ context }}</span>
}
@ -72,7 +72,7 @@
<div class="container-node__port-title">Outputs</div>
@for (output of outputs; track output.key) {
<div class="container-node__port-row container-node__port-row--right">
<span class="container-node__port-label">
<span class="container-node__port-label container-node__port-label--output">
@if (outputDisplayLabelParts(output.key).context; as context) {
<span class="container-node__port-context">{{ context }}</span>
}
@ -118,34 +118,21 @@
<span class="container-node__spinner" aria-hidden="true"></span>
<span>Updating container...</span>
</div>
} @else if (subFlowBlockCount > 0) {
<div class="container-node__snapshot" aria-hidden="true">
@for (node of subFlowSnapshotNodes; track node.id) {
<span
class="container-node__snapshot-node"
[class.container-node__snapshot-node--container]="node.family === 'container'"
[style.left.%]="node.left"
[style.top.%]="node.top"
[style.width.px]="node.width"
[style.height.px]="node.height">
</span>
}
</div>
<div class="container-node__dropzone-main">
<span>{{ subFlowBlockCount }} nodes</span>
<span>{{ subFlowConnectionCount }} connections</span>
</div>
<div class="container-node__dropzone-note">
Drop a new selection here to replace the embedded subflow.
</div>
} @else {
<div class="container-node__dropzone-main">
<span class="container-node__dropzone-empty">Drop selected nodes here</span>
<span class="container-node__dropzone-empty">Drag a flow here or import from flow</span>
</div>
<div class="container-node__dropzone-note">
Use the selection box in the editor, then drag the floating selection badge into this area.
</div>
}
@if (subFlowBlockCount > 0 && !replaceConfirmOpen && !isAssigning) {
<div class="container-node__dropzone-actions">
<button type="button" class="container-node__subflow-view" (pointerdown)="$event.stopPropagation()" (click)="openSubflowPreview($event)">
View Flow
</button>
</div>
}
@if (!isReadonly && !replaceConfirmOpen && !isAssigning) {
<div class="container-node__dropzone-actions">
<button type="button" class="container-node__import" (pointerdown)="$event.stopPropagation()" (click)="importSubflow($event)">
@ -160,30 +147,6 @@
}
</div>
@if (subFlowBlockCount > 0) {
<div class="container-node__subflow-section">
<div class="container-node__subflow-head">
<div class="container-node__subflow-title">Subflow Content</div>
<button type="button" class="container-node__subflow-view" (pointerdown)="$event.stopPropagation()" (click)="openSubflowPreview($event)">
View Flow
</button>
</div>
<div class="container-node__subflow-preview">
@for (node of subFlowPreviewNodes; track node.id) {
<div class="container-node__subflow-chip" [class.container-node__subflow-chip--container]="node.family === 'container'">
<span class="container-node__subflow-chip-name">{{ node.name }}</span>
<span class="container-node__subflow-chip-type">{{ node.type }}</span>
</div>
}
@if (hasMoreSubFlowNodes) {
<div class="container-node__subflow-more">
+{{ subFlowBlockCount - subFlowPreviewNodes.length }} more
</div>
}
</div>
</div>
}
@if (assignmentErrorMessage) {
<div class="container-node__message container-node__message--error">
{{ assignmentErrorMessage }}

View File

@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common';
import { Component, HostBinding, Input, inject } from '@angular/core';
import { currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowContainer, FlowData, FlowNode } from '@models/flow';
import { currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowContainer, FlowData } from '@models/flow';
import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { ContainersService } from '@services/containers/containers';
import { FieldRetriever } from '@services/retriever/field-retriever';
@ -8,7 +8,6 @@ import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-dialog';
import { EditorStateHolder } from '@stores/flow-editor';
import { pathToLabel } from '../node-utility';
import { CONTAINER_SUBFLOW_DRAG_MIME } from './container-node-drag';
import { firstValueFrom } from 'rxjs';
@ -111,55 +110,6 @@ export class ContainerNodeComponent {
return (this.subFlow?.blocks?.length ?? 0) + (this.subFlow?.containers?.length ?? 0);
}
get subFlowConnectionCount() {
return this.subFlow?.connections?.length ?? 0;
}
get subFlowPreviewNodes() {
const subFlow = this.subFlow;
if (!subFlow) return [];
return [...(subFlow.blocks ?? []), ...(subFlow.containers ?? [])]
.slice(0, 6)
.map((node) => this.toPreviewNode(node));
}
get hasMoreSubFlowNodes() {
return this.subFlowBlockCount > this.subFlowPreviewNodes.length;
}
get subFlowSnapshotNodes() {
const subFlow = this.subFlow;
if (!subFlow) return [];
const allNodes = [...(subFlow.blocks ?? []), ...(subFlow.containers ?? [])];
if (!allNodes.length) return [];
const positioned = allNodes.map((node) => ({
id: node.id,
family: node.nodeFamily === 'container' ? 'container' : 'block',
x: typeof node.position?.x === 'number' ? node.position.x : 0,
y: typeof node.position?.y === 'number' ? node.position.y : 0,
width: node.nodeFamily === 'container' ? 24 : 18,
height: node.nodeFamily === 'container' ? 14 : 12
}));
const minX = Math.min(...positioned.map((node) => node.x));
const minY = Math.min(...positioned.map((node) => node.y));
const maxX = Math.max(...positioned.map((node) => node.x + node.width));
const maxY = Math.max(...positioned.map((node) => node.y + node.height));
const spanX = Math.max(1, maxX - minX);
const spanY = Math.max(1, maxY - minY);
return positioned.map((node) => ({
id: node.id,
family: node.family,
left: 6 + ((node.x - minX) / spanX) * 100,
top: 6 + ((node.y - minY) / spanY) * 52,
width: node.width,
height: node.height
}));
}
get replaceConfirmOpen() {
return Array.isArray(this.replaceConfirmSelection) && this.replaceConfirmSelection.length > 0;
}
@ -432,15 +382,6 @@ export class ContainerNodeComponent {
return ports.find((item: any) => item?.name === key) ?? null;
}
private toPreviewNode(node: FlowNode) {
return {
id: node.id,
name: String(node.name ?? node.typeName ?? 'Node'),
type: this.nodeTypeLabel(String(node.typeName ?? 'Node')),
family: node.nodeFamily === 'container' ? 'container' : 'block'
};
}
private normalizeSubFlowBlocks(raw: unknown): FlowBlock[] {
if (!Array.isArray(raw)) return [];
@ -502,8 +443,4 @@ export class ContainerNodeComponent {
};
}
private nodeTypeLabel(typeName: string) {
if (typeName === 'HumanInteractionBlock') return 'Human Task';
return pathToLabel(typeName.replace(/Block$/, ''));
}
}

View File

@ -349,6 +349,17 @@
white-space: nowrap;
}
.llm-pill-context {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 9px;
line-height: 1;
font-weight: 700;
opacity: 0.7;
}
.llm-pill-kind {
font-size: 10px;
line-height: 1;
@ -663,3 +674,30 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.llm-subflow-section {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border: 1px solid #dbe7f5;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.llm-subflow-view {
align-self: flex-start;
border: 1px solid #bfdbfe;
border-radius: 999px;
background: #eff6ff;
color: #1d4ed8;
font-size: 11px;
font-weight: 700;
line-height: 1;
padding: 7px 10px;
cursor: pointer;
}
.llm-subflow-view:hover {
background: #dbeafe;
}

View File

@ -100,7 +100,10 @@
}
<span class="llm-pill llm-pill-input">
<span class="llm-pill-meta">
<span class="llm-pill-name">{{ inputDisplayLabel(input.key) }}</span>
@if (inputDisplayLabelParts(input.key).context; as context) {
<span class="llm-pill-context">{{ context }}</span>
}
<span class="llm-pill-name">{{ inputDisplayLabelParts(input.key).name }}</span>
<span class="llm-pill-kind">{{ inputKindLabel(input.key) }}</span>
</span>
</span>
@ -114,7 +117,10 @@
<div class="llm-row llm-row-output">
<span class="llm-pill llm-pill-output" [ngClass]="outputPillClass(output.key)">
<span class="llm-pill-meta llm-pill-meta-output">
<span class="llm-pill-name">{{ outputDisplayLabel(output.key) }}</span>
@if (outputDisplayLabelParts(output.key).context; as context) {
<span class="llm-pill-context">{{ context }}</span>
}
<span class="llm-pill-name">{{ outputDisplayLabelParts(output.key).name }}</span>
<span class="llm-pill-kind">{{ outputKindLabel(output.key) }}</span>
</span>
</span>
@ -228,6 +234,17 @@
}
</div>
}
@if (hasViewableSubflow()) {
<div class="llm-subflow-section">
<div class="llm-param-row-head">
<div class="llm-param-key">Subflow</div>
</div>
<button type="button" class="llm-subflow-view" (pointerdown)="$event.stopPropagation()" (click)="openSubflowPreview($event)">
View Flow
</button>
</div>
}
</div>
</div>

View File

@ -2,8 +2,10 @@ import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angular/core';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
import { FlowPort } from '@models/flow';
import { FlowBlock, FlowContainer, FlowData, FlowPort } from '@models/flow';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-dialog';
import { HumanInteractionDialogService } from '@services/dialogs/human-interaction-dialog';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import {
@ -56,6 +58,11 @@ type MainContentView = {
parts: { text: string; isDynamicInput: boolean }[];
};
type PortLabelParts = {
context: string | null;
name: string;
};
@Component({
selector: 'app-task-step-node',
imports: [CommonModule, ReteModule],
@ -67,6 +74,8 @@ type MainContentView = {
})
export class TaskStepNodeComponent {
private blocksService = inject(BlocksService);
private containersService = inject(ContainersService);
private subflowPreview = inject(SubflowPreviewDialogService);
private cdr = inject(ChangeDetectorRef);
private humanInteractionDialog = inject(HumanInteractionDialogService);
private taskExecutionsService = inject(TaskExecutionsService);
@ -120,6 +129,7 @@ export class TaskStepNodeComponent {
this.name = toStringOrNull(config['name']) ?? this.name;
const arrayFieldPaths = new Set(this.arrayFieldDefinitions.map((definition) => definition.path));
const primitiveEntries = flattenPrimitiveValues(config)
.filter((entry) => !this.shouldHideConfigPath(entry.path))
.filter((entry) => !arrayFieldPaths.has(entry.path));
const visibleEntries = primitiveEntries.filter((entry) => this.isPathVisible(entry.path));
const mainContentEntries = visibleEntries
@ -225,10 +235,27 @@ export class TaskStepNodeComponent {
return this.portKindLabel('output', outputKey);
}
inputDisplayLabelParts(inputKey: string): PortLabelParts {
return this.toPortLabelParts(this.inputDisplayLabel(inputKey));
}
outputDisplayLabelParts(outputKey: string): PortLabelParts {
return this.toPortLabelParts(this.outputDisplayLabel(outputKey));
}
hasMainContent(): boolean {
return this.mainContentFields.length > 0;
}
isContainerNode(): boolean {
return this.data?.data?.nodeFamily === 'container' || this.blockType === 'GenericContainer';
}
hasViewableSubflow(): boolean {
const subFlow = this.subFlow();
return !!subFlow && ((subFlow.blocks?.length ?? 0) > 0 || (subFlow.containers?.length ?? 0) > 0 || (subFlow.connections?.length ?? 0) > 0);
}
formatDynamicInputToken(token: string): string {
const match = token.match(/^\$\{\{\s*([^}]+?)\s*\}\}$/);
return match ? match[1] : token;
@ -367,6 +394,14 @@ export class TaskStepNodeComponent {
});
}
openSubflowPreview(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const subFlow = this.subFlow();
if (!subFlow) return;
this.subflowPreview.open(subFlow, `${this.name || this.nodeTitle()} subflow`);
}
private get blockConfiguration(): Record<string, any> | null {
return this.data?.data?.specificConfiguration ?? null;
}
@ -414,6 +449,29 @@ export class TaskStepNodeComponent {
return port?.name ?? key;
}
private toPortLabelParts(label: string): PortLabelParts {
if (!this.isContainerNode()) {
return {
context: null,
name: label
};
}
const trimmed = String(label ?? '').trim();
const separatorIndex = trimmed.lastIndexOf('.');
if (separatorIndex <= 0 || separatorIndex >= trimmed.length - 1) {
return {
context: null,
name: trimmed
};
}
return {
context: trimmed.slice(0, separatorIndex),
name: trimmed.slice(separatorIndex + 1)
};
}
private portTypeLabel(port: FlowPort): string {
const type = String(port.type ?? 'TEXT').toUpperCase();
return port.multiple ? `${type}[]` : type;
@ -441,8 +499,13 @@ export class TaskStepNodeComponent {
const type = this.blockType;
if (!type) return;
const blockType = await this.blocksService.getBlockType(type);
this.blockSchema = (blockType?.schema ?? null) as Record<string, any> | null;
const nodeFamily = this.data?.data?.nodeFamily === 'container' || type === 'GenericContainer'
? 'container'
: 'block';
const typeDescriptor = nodeFamily === 'container'
? await this.containersService.getContainerType(type)
: await this.blocksService.getBlockType(type);
this.blockSchema = (typeDescriptor?.schema ?? null) as Record<string, any> | null;
this.variablePlaceholderPaths = this.extractVariablePlaceholderPaths(this.blockSchema);
this.arrayFieldDefinitions = this.extractArrayFieldDefinitions(this.blockSchema);
this.mainContentPaths = this.extractMainContentPaths(this.blockSchema);
@ -522,7 +585,63 @@ export class TaskStepNodeComponent {
};
walk(schema, '');
return definitions;
return this.isContainerNode()
? definitions.filter((definition) => !definition.path.startsWith('subFlow.'))
: definitions;
}
private subFlow(): FlowData | null {
if (!this.isContainerNode()) return null;
const raw = this.blockConfiguration?.['subFlow'];
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const candidate = raw as Record<string, unknown>;
const blocks = this.normalizeSubFlowBlocks(candidate['blocks']);
const containers = this.normalizeSubFlowContainers(candidate['containers']);
const connections = Array.isArray(candidate['connections'])
? candidate['connections'].filter((item): item is FlowData['connections'][number] => !!item && typeof item === 'object')
: [];
if (!blocks.length && !containers.length && !connections.length) return null;
return { blocks, containers, connections };
}
private normalizeSubFlowBlocks(raw: unknown): FlowBlock[] {
if (!Array.isArray(raw)) return [];
return raw
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object' && !Array.isArray(item))
.map((item) => ({
...item,
nodeFamily: 'block',
position: this.normalizePosition(item['position'])
})) as FlowBlock[];
}
private normalizeSubFlowContainers(raw: unknown): FlowContainer[] {
if (!Array.isArray(raw)) return [];
return raw
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object' && !Array.isArray(item))
.map((item) => ({
...item,
nodeFamily: 'container',
position: this.normalizePosition(item['position'])
})) as FlowContainer[];
}
private normalizePosition(raw: unknown): { x: number; y: number } | undefined {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
const value = raw as Record<string, unknown>;
const x = typeof value['x'] === 'number' ? value['x'] : Number(value['x']);
const y = typeof value['y'] === 'number' ? value['y'] : Number(value['y']);
if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined;
return { x, y };
}
private shouldHideConfigPath(path: string): boolean {
return this.isContainerNode() && (path === 'subFlow' || path.startsWith('subFlow.'));
}
private extractMainContentPaths(schema: Record<string, any> | null): Set<string> {

View File

@ -3,8 +3,17 @@ import { Component, computed, effect, inject, input, OnDestroy, signal } from '@
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { areFlowValueKindsCompatible, FlowBlockConnection, FlowData, normalizeFlowPortValueKinds } from '@models/flow';
import {
areFlowValueKindsCompatible,
FlowBlock,
FlowBlockConnection,
FlowContainer,
FlowData,
FlowNode,
normalizeFlowPortValueKinds
} from '@models/flow';
import {
getTaskExecutionStepNode,
getExecutionStatusGroup,
TaskExecution,
TaskExecutionAuthorizationRequirement,
@ -118,31 +127,45 @@ export class TaskExecutionViewerComponent implements OnDestroy {
const contextWarnings = this.execution()?.context.warnings ?? {};
const waitingSteps = this.execution()?.context.waitingSteps ?? [];
const steps = this.stepsArray();
const blocks = steps.map((step, index) => ({
...step.block,
nodeFamily: 'block' as const,
specificConfiguration: {
...(step.block.specificConfiguration ?? {}),
__executionId: this.execution()?.id ?? null,
__executionNodeId: step.id,
__stepStatus: step.status,
__executionStatusGroup: executionStatusGroup,
__isWaitingStep: waitingSteps.includes(step.id),
__executionInputs: this.getExecutionInputValues(step, contextInputs),
__connectedInputs: this.getConnectedInputs(step),
__executionOutputs: this.getExecutionOutputValues(step, contextResults),
__connectedOutputs: this.getConnectedOutputs(step),
__executionErrors: this.getExecutionErrors(step.id, contextErrors),
__executionWarnings: this.getExecutionWarnings(step.id, contextWarnings)
},
position: step.block.position ?? {
x: 120 + (index % 3) * 340,
y: 100 + Math.floor(index / 3) * 220
const blocks: FlowBlock[] = [];
const containers: FlowContainer[] = [];
for (const [index, step] of steps.entries()) {
const stepNode = getTaskExecutionStepNode(step);
if (!stepNode) continue;
const executionNode: FlowNode = {
...stepNode,
nodeFamily: stepNode.nodeFamily === 'container' ? 'container' : 'block',
specificConfiguration: {
...(stepNode.specificConfiguration ?? {}),
__executionId: this.execution()?.id ?? null,
__executionNodeId: step.id,
__stepStatus: step.status,
__executionStatusGroup: executionStatusGroup,
__isWaitingStep: waitingSteps.includes(step.id),
__executionInputs: this.getExecutionInputValues(step, contextInputs),
__connectedInputs: this.getConnectedInputs(step),
__executionOutputs: this.getExecutionOutputValues(step, contextResults),
__connectedOutputs: this.getConnectedOutputs(step),
__executionErrors: this.getExecutionErrors(step.id, contextErrors),
__executionWarnings: this.getExecutionWarnings(step.id, contextWarnings)
},
position: stepNode.position ?? {
x: 120 + (index % 3) * 340,
y: 100 + Math.floor(index / 3) * 220
}
};
if (executionNode.nodeFamily === 'container') {
containers.push(executionNode);
} else {
blocks.push(executionNode);
}
}));
}
const connections = this.getExecutionConnections(steps);
return { blocks, containers: [], connections };
return { blocks, containers, connections };
});
readonly formattedDuration = computed(() => {
@ -239,7 +262,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
key,
nodeId: step.id,
inputName,
title: step.block.name,
title: this.stepTitle(step),
subtitle: inputName,
type: String(input.descriptor?.type ?? 'TEXT').toUpperCase(),
multiple: Boolean(input.descriptor?.multiple),
@ -460,10 +483,10 @@ export class TaskExecutionViewerComponent implements OnDestroy {
const nodeId = key.slice(0, separatorIndex);
const outputName = key.slice(separatorIndex + 1);
const step = steps[nodeId];
const blockName = step?.block?.name?.trim();
const nodeName = this.stepTitle(step).trim();
return {
title: blockName || key,
title: nodeName || key,
subtitle: outputName || 'Execution output'
};
}
@ -535,9 +558,9 @@ export class TaskExecutionViewerComponent implements OnDestroy {
connections.push({
id,
sourceId: source.sourceStep.block.id,
sourceId: this.stepNodeId(source.sourceStep),
sourceName: source.sourceOutputName,
targetId: targetStep.block.id,
targetId: this.stepNodeId(targetStep),
targetName: input.descriptor.name
});
}
@ -687,4 +710,12 @@ export class TaskExecutionViewerComponent implements OnDestroy {
return String(value);
}
}
private stepTitle(step: TaskExecutionStep | null | undefined): string {
return getTaskExecutionStepNode(step)?.name?.trim() || step?.id || 'Step';
}
private stepNodeId(step: TaskExecutionStep | null | undefined): string {
return getTaskExecutionStepNode(step)?.id || step?.id || '';
}
}