feat: add execution groups and schema-driven flow fields
This commit is contained in:
parent
4c4b57781f
commit
a258064eb5
|
|
@ -2,7 +2,7 @@
|
|||
<div class="flex flex-row flex-1 overflow-hidden gap-2">
|
||||
<app-tasks-executions-list
|
||||
class="w-[360px] shrink-0"
|
||||
[executions]="executions()"
|
||||
[groups]="groups()"
|
||||
[selectedExecutionId]="selectedExecutionId()"
|
||||
(executionSelected)="selectExecution($event)"
|
||||
(executionDeleteRequested)="removeExecution($event)">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { normalizeExecutionStatus, TaskExecution } from '@models/task-execution';
|
||||
import { normalizeExecutionStatus, TaskExecution, TaskExecutionGroup } from '@models/task-execution';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
TaskExecutionListItem,
|
||||
TaskExecutionGroupListItem,
|
||||
TasksExecutionsListComponent
|
||||
} from '@shared/tasks-executions-list/tasks-executions-list';
|
||||
import { TaskExecutionViewerComponent } from '@shared/task-execution-viewer/task-execution-viewer';
|
||||
|
|
@ -33,18 +34,11 @@ export class TasksExecutor {
|
|||
);
|
||||
|
||||
readonly executionDetails = this.taskExecutionsService.taskExecutions;
|
||||
readonly executionGroups = this.taskExecutionsService.taskExecutionGroups;
|
||||
readonly pendingExecutionCreation = this.taskExecutionsService.pendingExecutionCreation;
|
||||
|
||||
readonly executions = computed<TaskExecutionListItem[]>(() =>
|
||||
this.executionDetails().map((execution) => ({
|
||||
id: execution.id,
|
||||
title: execution.name,
|
||||
flowName: execution.name,
|
||||
status: normalizeExecutionStatus(execution.context.status),
|
||||
startedAt: this.formatDateTime(execution.creationTime),
|
||||
duration: this.formatDuration(execution.context.startTime ?? null, execution.context.endTime ?? null),
|
||||
simulated: execution.interactionSimulationEnabled === true
|
||||
}))
|
||||
readonly groups = computed<TaskExecutionGroupListItem[]>(() =>
|
||||
this.executionGroups().map((group) => this.toGroupListItem(group))
|
||||
);
|
||||
|
||||
readonly selectedExecutionId = signal<string | null>(null);
|
||||
|
|
@ -88,8 +82,8 @@ export class TasksExecutor {
|
|||
effect(() => {
|
||||
if (this.requestedExecutionId()) return;
|
||||
if (this.selectedExecutionId()) return;
|
||||
const first = this.executions()[0];
|
||||
if (first) this.selectedExecutionId.set(first.id);
|
||||
const first = this.groups()[0];
|
||||
if (first?.latestExecutionId) this.selectedExecutionId.set(first.latestExecutionId);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -118,6 +112,50 @@ export class TasksExecutor {
|
|||
});
|
||||
}
|
||||
|
||||
rerunExecution(id: string) {
|
||||
this.taskExecutionsService.rerunExecution(id).subscribe({
|
||||
next: (execution) => this.selectExecution(execution.id),
|
||||
error: (err) => console.error('Error rerunning execution:', err)
|
||||
});
|
||||
}
|
||||
|
||||
private toGroupListItem(group: TaskExecutionGroup): TaskExecutionGroupListItem {
|
||||
const executions = (group.executions ?? []).map((execution, index) =>
|
||||
this.toExecutionListItem(execution, index + 1)
|
||||
);
|
||||
const latestExecution = executions.find((execution) => execution.id === group.latestExecutionId)
|
||||
?? executions[executions.length - 1]
|
||||
?? null;
|
||||
|
||||
return {
|
||||
id: group.id,
|
||||
sourceFlowId: group.sourceFlowId,
|
||||
name: group.name,
|
||||
executionCount: group.executionCount,
|
||||
lastExecutionTime: group.lastExecutionTime,
|
||||
lastExecutionTimeLabel: this.formatDateTime(group.lastExecutionTime),
|
||||
latestExecutionId: group.latestExecutionId || latestExecution?.id || '',
|
||||
latestStatus: latestExecution?.status ?? 'CREATED',
|
||||
latestRunNumber: latestExecution?.runNumber ?? null,
|
||||
executions
|
||||
};
|
||||
}
|
||||
|
||||
private toExecutionListItem(execution: TaskExecution, fallbackRunNumber: number): TaskExecutionListItem {
|
||||
return {
|
||||
id: execution.id,
|
||||
title: execution.name,
|
||||
flowName: String(execution.sourceFlowId ?? execution.flowId ?? execution.name ?? ''),
|
||||
status: normalizeExecutionStatus(execution.context.status),
|
||||
startedAt: this.formatDateTime(execution.creationTime),
|
||||
creationTime: execution.creationTime,
|
||||
runNumber: typeof execution.runNumber === 'number' ? execution.runNumber : fallbackRunNumber,
|
||||
rerunOfExecutionId: execution.rerunOfExecutionId ?? null,
|
||||
duration: this.formatDuration(execution.context.startTime ?? null, execution.context.endTime ?? null),
|
||||
simulated: execution.interactionSimulationEnabled === true
|
||||
};
|
||||
}
|
||||
|
||||
private formatDateTime(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const yyyy = date.getFullYear();
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import { FlowData, FlowNode } from "./flow";
|
|||
export type HFNodeData = FlowNode & {
|
||||
deleteNode?: () => Promise<void>;
|
||||
replaceWithCreatedNode?: (block: FlowNode) => Promise<void>;
|
||||
assignSelectedBlocksToContainer?: (blockIds?: string[]) => Promise<void>;
|
||||
assignImportedSubflow?: (subFlow: FlowData, validationUrl?: string | null) => Promise<void>;
|
||||
clearContainerSubflow?: () => Promise<void>;
|
||||
assignSelectedBlocksToContainer?: (blockIds?: string[], targetPath?: string, validationUrl?: string | null) => Promise<void>;
|
||||
assignImportedSubflow?: (subFlow: FlowData, targetPath?: string, validationUrl?: string | null) => Promise<void>;
|
||||
clearContainerSubflow?: (targetPath?: string) => Promise<void>;
|
||||
cloneNode?: () => Promise<void>;
|
||||
__readonly?: boolean;
|
||||
__needsServerCreate?: boolean;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ export type TaskExecution = {
|
|||
id: string;
|
||||
name: string;
|
||||
creationTime: number;
|
||||
flowId?: string | null;
|
||||
sourceFlowId?: string | null;
|
||||
runNumber?: number | null;
|
||||
rerunOfExecutionId?: string | null;
|
||||
context: TaskExecutionContext;
|
||||
interactionSimulationEnabled?: boolean;
|
||||
simulationAvailable?: boolean;
|
||||
|
|
@ -33,6 +37,18 @@ export type TaskExecution = {
|
|||
missingGlobalInputKeys?: string[];
|
||||
};
|
||||
|
||||
export type TaskExecutionGroup = {
|
||||
id: string;
|
||||
sourceFlowId: string;
|
||||
name: string;
|
||||
firstExecutionId: string;
|
||||
latestExecutionId: string;
|
||||
creationTime: number;
|
||||
lastExecutionTime: number;
|
||||
executionCount: number;
|
||||
executions: TaskExecution[];
|
||||
};
|
||||
|
||||
export type TaskExecutionAuthorizationRequirement = {
|
||||
key: string;
|
||||
provider: string;
|
||||
|
|
|
|||
|
|
@ -144,6 +144,39 @@ describe('ContainersCallService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('posts subflow validation to the field-specific validation url including query params', async () => {
|
||||
const request = firstValueFrom(service.validateContainerSubflow({
|
||||
blocks: [],
|
||||
containers: [],
|
||||
connections: [],
|
||||
dependencies: []
|
||||
}, '/containers/validate-subflow?type=LOOP_GUARD'));
|
||||
|
||||
const validationRequest = httpMock.expectOne(`${environment.apiUrl}/containers/validate-subflow?type=LOOP_GUARD`);
|
||||
expect(validationRequest.request.method).toBe('POST');
|
||||
expect(validationRequest.request.body).toEqual({
|
||||
subFlow: {
|
||||
blocks: [],
|
||||
containers: [],
|
||||
connections: [],
|
||||
dependencies: []
|
||||
}
|
||||
});
|
||||
validationRequest.flush({
|
||||
valid: true,
|
||||
errors: [],
|
||||
openInputs: [],
|
||||
openOutputs: []
|
||||
});
|
||||
|
||||
await expect(request).resolves.toEqual({
|
||||
valid: true,
|
||||
errors: [],
|
||||
openInputs: [],
|
||||
openOutputs: []
|
||||
});
|
||||
});
|
||||
|
||||
it('fills missing required boolean fields using descriptor schema defaults', async () => {
|
||||
const typesRequest = firstValueFrom(service.retrieveAllContainerTypes());
|
||||
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ export class FieldRetrieverCallServiceFake extends FieldRetrieverCallServiceBase
|
|||
context?: Record<string, string>,
|
||||
retrieverUrl?: string | null
|
||||
): Observable<RetrieverStructuredItem<T>[]> {
|
||||
if (key === 'subFlow') {
|
||||
if (key === 'subFlow' || retrieverUrl?.includes('/Flows/subFlow/items')) {
|
||||
return of(this.subFlowItems as unknown as RetrieverStructuredItem<T>[]);
|
||||
}
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { LLMDescriptor } from '@models/flow';
|
||||
import { ExecutionEventLogEntry, TaskExecution } from '@models/task-execution';
|
||||
import { ExecutionEventLogEntry, TaskExecution, TaskExecutionGroup } from '@models/task-execution';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export abstract class TaskExecutionsCallServiceBase {
|
||||
abstract retrieveAllTaskExecutions(): Observable<TaskExecution[]>;
|
||||
abstract retrieveTaskExecutionGroups(): Observable<TaskExecutionGroup[]>;
|
||||
abstract retrieveExecutionEvents(executionId: string): Observable<ExecutionEventLogEntry[]>;
|
||||
abstract createTaskExecution(flowId: string): Observable<TaskExecution>;
|
||||
abstract rerunTaskExecution(executionId: string): Observable<TaskExecution>;
|
||||
abstract deleteTaskExecution(executionId: string): Observable<void>;
|
||||
abstract startTaskExecution(executionId: string): Observable<TaskExecution>;
|
||||
abstract simulateTaskExecution(executionId: string, simulator: LLMDescriptor): Observable<TaskExecution>;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { LLMDescriptor } from '@models/flow';
|
||||
import { ExecutionEventLogEntry, TaskExecution } from '@models/task-execution';
|
||||
import { ExecutionEventLogEntry, TaskExecution, TaskExecutionGroup } from '@models/task-execution';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { TaskExecutionsCallServiceBase } from './task-executions-call.base';
|
||||
|
||||
|
|
@ -451,16 +451,24 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
|
|||
return of(this.data.map((execution) => this.withSimulationAvailability(execution)));
|
||||
}
|
||||
|
||||
override retrieveTaskExecutionGroups(): Observable<TaskExecutionGroup[]> {
|
||||
return of(this.buildExecutionGroups());
|
||||
}
|
||||
|
||||
override retrieveExecutionEvents(executionId: string): Observable<ExecutionEventLogEntry[]> {
|
||||
const execution = this.findExecution(executionId);
|
||||
return of(this.buildExecutionEvents(execution));
|
||||
}
|
||||
|
||||
override createTaskExecution(flowId: string): Observable<TaskExecution> {
|
||||
const sourceFlowId = flowId || 'Execution';
|
||||
const execution: TaskExecution = {
|
||||
id: crypto.randomUUID(),
|
||||
name: flowId || 'Execution',
|
||||
name: sourceFlowId,
|
||||
creationTime: Date.now(),
|
||||
flowId: sourceFlowId,
|
||||
sourceFlowId,
|
||||
runNumber: this.nextRunNumber(sourceFlowId),
|
||||
simulationAvailable: false,
|
||||
context: {
|
||||
inputs: {},
|
||||
|
|
@ -478,6 +486,38 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
|
|||
return of(this.withSimulationAvailability(execution));
|
||||
}
|
||||
|
||||
override rerunTaskExecution(executionId: string): Observable<TaskExecution> {
|
||||
const source = this.findExecution(executionId);
|
||||
const sourceFlowId = this.executionSourceFlowId(source);
|
||||
const now = Date.now();
|
||||
const execution: TaskExecution = {
|
||||
...this.cloneExecution(source),
|
||||
id: crypto.randomUUID(),
|
||||
name: source.name,
|
||||
creationTime: now,
|
||||
flowId: source.flowId ?? sourceFlowId,
|
||||
sourceFlowId,
|
||||
runNumber: this.nextRunNumber(sourceFlowId),
|
||||
rerunOfExecutionId: source.id,
|
||||
interactionSimulationEnabled: false,
|
||||
interactionSimulationDescriptor: undefined,
|
||||
context: {
|
||||
...this.cloneExecution(source).context,
|
||||
inputs: {},
|
||||
result: {},
|
||||
partialResult: {},
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
errors: {},
|
||||
warnings: {},
|
||||
status: 'CREATED',
|
||||
waitingSteps: []
|
||||
}
|
||||
};
|
||||
this.data.unshift(execution);
|
||||
return of(this.withSimulationAvailability(execution));
|
||||
}
|
||||
|
||||
override deleteTaskExecution(executionId: string): Observable<void> {
|
||||
const index = this.data.findIndex((item) => item.id === executionId);
|
||||
if (index >= 0) {
|
||||
|
|
@ -695,6 +735,64 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
|
|||
return execution;
|
||||
}
|
||||
|
||||
private buildExecutionGroups(): TaskExecutionGroup[] {
|
||||
const grouped = new Map<string, TaskExecution[]>();
|
||||
for (const execution of this.data.map((item) => this.withSimulationAvailability(item))) {
|
||||
const sourceFlowId = this.executionSourceFlowId(execution);
|
||||
if (!grouped.has(sourceFlowId)) grouped.set(sourceFlowId, []);
|
||||
grouped.get(sourceFlowId)!.push(execution);
|
||||
}
|
||||
|
||||
return Array.from(grouped.entries())
|
||||
.map(([sourceFlowId, executions]) => {
|
||||
const ordered = [...executions].sort((left, right) =>
|
||||
(this.executionRunNumber(left) - this.executionRunNumber(right))
|
||||
|| ((left.creationTime ?? 0) - (right.creationTime ?? 0))
|
||||
);
|
||||
const firstExecution = ordered[0];
|
||||
const latestExecution = ordered[ordered.length - 1];
|
||||
return {
|
||||
id: sourceFlowId,
|
||||
sourceFlowId,
|
||||
name: latestExecution?.name ?? sourceFlowId,
|
||||
firstExecutionId: firstExecution?.id ?? '',
|
||||
latestExecutionId: latestExecution?.id ?? '',
|
||||
creationTime: firstExecution?.creationTime ?? 0,
|
||||
lastExecutionTime: latestExecution?.creationTime ?? 0,
|
||||
executionCount: ordered.length,
|
||||
executions: ordered
|
||||
};
|
||||
})
|
||||
.sort((left, right) => right.lastExecutionTime - left.lastExecutionTime);
|
||||
}
|
||||
|
||||
private executionSourceFlowId(execution: TaskExecution): string {
|
||||
return String(execution.sourceFlowId ?? execution.flowId ?? execution.name ?? execution.id).trim() || execution.id;
|
||||
}
|
||||
|
||||
private executionRunNumber(execution: TaskExecution): number {
|
||||
const value = Number(execution.runNumber);
|
||||
return Number.isFinite(value) && value > 0 ? value : 1;
|
||||
}
|
||||
|
||||
private nextRunNumber(sourceFlowId: string): number {
|
||||
const runs = this.data
|
||||
.filter((execution) => this.executionSourceFlowId(execution) === sourceFlowId)
|
||||
.map((execution) => this.executionRunNumber(execution));
|
||||
return runs.length ? Math.max(...runs) + 1 : 1;
|
||||
}
|
||||
|
||||
private cloneExecution(execution: TaskExecution): TaskExecution {
|
||||
if (typeof globalThis.structuredClone === 'function') {
|
||||
try {
|
||||
return globalThis.structuredClone(execution);
|
||||
} catch {
|
||||
// Fall back to JSON clone for plain fake execution data.
|
||||
}
|
||||
}
|
||||
return JSON.parse(JSON.stringify(execution)) as TaskExecution;
|
||||
}
|
||||
|
||||
private withSimulationAvailability(execution: TaskExecution): TaskExecution {
|
||||
execution.simulationAvailable = Object.values(execution.context.steps ?? {}).some((step) => {
|
||||
const typeName = String(step.node?.typeName ?? '').trim();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http';
|
|||
import { inject } from '@angular/core';
|
||||
import { environment } from '@environment';
|
||||
import { LLMDescriptor } from '@models/flow';
|
||||
import { ExecutionEventLogEntry, TaskExecution } from '@models/task-execution';
|
||||
import { ExecutionEventLogEntry, TaskExecution, TaskExecutionGroup } from '@models/task-execution';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { TaskExecutionsCallServiceBase } from './task-executions-call.base';
|
||||
|
||||
|
|
@ -15,6 +15,12 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
|
|||
);
|
||||
}
|
||||
|
||||
override retrieveTaskExecutionGroups(): Observable<TaskExecutionGroup[]> {
|
||||
return this.http.get<unknown>(`${environment.apiUrl}/executions/groups`).pipe(
|
||||
map((raw) => Array.isArray(raw) ? raw.map((item) => this.mapExecutionGroup(item)) : [])
|
||||
);
|
||||
}
|
||||
|
||||
override retrieveExecutionEvents(executionId: string): Observable<ExecutionEventLogEntry[]> {
|
||||
return this.http.get<ExecutionEventLogEntry[]>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/events`);
|
||||
}
|
||||
|
|
@ -25,6 +31,12 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
|
|||
);
|
||||
}
|
||||
|
||||
override rerunTaskExecution(executionId: string): Observable<TaskExecution> {
|
||||
return this.http.post<unknown>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/rerun`, null).pipe(
|
||||
map((raw) => this.mapExecution(raw))
|
||||
);
|
||||
}
|
||||
|
||||
override deleteTaskExecution(executionId: string): Observable<void> {
|
||||
return this.http.delete<void>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}`);
|
||||
}
|
||||
|
|
@ -191,6 +203,36 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
|
|||
};
|
||||
}
|
||||
|
||||
private mapExecutionGroup(raw: unknown): TaskExecutionGroup {
|
||||
const group = (raw ?? {}) as Partial<TaskExecutionGroup> & Record<string, unknown>;
|
||||
const executions = Array.isArray(group['executions'])
|
||||
? group['executions'].map((item) => this.mapExecution(item))
|
||||
: [];
|
||||
const latestExecution = executions.find((execution) => execution.id === group['latestExecutionId'])
|
||||
?? executions[executions.length - 1]
|
||||
?? null;
|
||||
const firstExecution = executions.find((execution) => execution.id === group['firstExecutionId'])
|
||||
?? executions[0]
|
||||
?? null;
|
||||
const sourceFlowId = this.toNonEmptyString(group['sourceFlowId'])
|
||||
?? this.toNonEmptyString(latestExecution?.sourceFlowId)
|
||||
?? this.toNonEmptyString(latestExecution?.flowId)
|
||||
?? this.toNonEmptyString(group['id'])
|
||||
?? '';
|
||||
|
||||
return {
|
||||
id: this.toNonEmptyString(group['id']) ?? sourceFlowId,
|
||||
sourceFlowId,
|
||||
name: this.toNonEmptyString(group['name']) ?? latestExecution?.name ?? sourceFlowId,
|
||||
firstExecutionId: this.toNonEmptyString(group['firstExecutionId']) ?? firstExecution?.id ?? '',
|
||||
latestExecutionId: this.toNonEmptyString(group['latestExecutionId']) ?? latestExecution?.id ?? '',
|
||||
creationTime: this.toTimestamp(group['creationTime'], firstExecution?.creationTime ?? 0),
|
||||
lastExecutionTime: this.toTimestamp(group['lastExecutionTime'], latestExecution?.creationTime ?? 0),
|
||||
executionCount: this.toNumber(group['executionCount'], executions.length),
|
||||
executions
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeGlobalInputValues(raw: unknown): Record<string, unknown> {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||
return { ...(raw as Record<string, unknown>) };
|
||||
|
|
@ -217,4 +259,18 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
|
|||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
private toNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
private toTimestamp(value: unknown, fallback: number): number {
|
||||
const timestamp = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(timestamp) ? timestamp : fallback;
|
||||
}
|
||||
|
||||
private toNumber(value: unknown, fallback: number): number {
|
||||
const number = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(number) ? number : fallback;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { DestroyRef, inject, Injectable, signal } from '@angular/core';
|
||||
import { environment } from '@environment';
|
||||
import { LLMDescriptor } from '@models/flow';
|
||||
import { ExecutionEventLogEntry, getExecutionStatusGroup, TaskExecution } from '@models/task-execution';
|
||||
import { ExecutionEventLogEntry, getExecutionStatusGroup, TaskExecution, TaskExecutionGroup } from '@models/task-execution';
|
||||
import { catchError, finalize, Observable, tap, throwError } from 'rxjs';
|
||||
import { TaskExecutionsCallServiceBase } from './task-executions-call.base';
|
||||
|
||||
|
|
@ -16,9 +16,11 @@ export class TaskExecutionsService {
|
|||
private refreshInFlight = false;
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private _taskExecutions = signal<TaskExecution[]>([]);
|
||||
private _taskExecutionGroups = signal<TaskExecutionGroup[]>([]);
|
||||
private _pendingExecutionCreation = signal(false);
|
||||
|
||||
taskExecutions = this._taskExecutions.asReadonly();
|
||||
taskExecutionGroups = this._taskExecutionGroups.asReadonly();
|
||||
pendingExecutionCreation = this._pendingExecutionCreation.asReadonly();
|
||||
|
||||
init() {
|
||||
|
|
@ -32,11 +34,13 @@ export class TaskExecutionsService {
|
|||
if (this.refreshInFlight) return;
|
||||
this.refreshInFlight = true;
|
||||
|
||||
this.taskExecutionsCallService.retrieveAllTaskExecutions().pipe(
|
||||
this.taskExecutionsCallService.retrieveTaskExecutionGroups().pipe(
|
||||
finalize(() => {
|
||||
this.refreshInFlight = false;
|
||||
})
|
||||
).subscribe((taskExecutions) => {
|
||||
).subscribe((groups) => {
|
||||
const taskExecutions = this.flattenGroups(groups);
|
||||
this._taskExecutionGroups.set([...groups]);
|
||||
this._taskExecutions.set([...taskExecutions]);
|
||||
this.updatePollingState(taskExecutions);
|
||||
});
|
||||
|
|
@ -62,6 +66,18 @@ export class TaskExecutionsService {
|
|||
);
|
||||
}
|
||||
|
||||
rerunExecution(executionId: string) {
|
||||
this._pendingExecutionCreation.set(true);
|
||||
return this.taskExecutionsCallService.rerunTaskExecution(executionId).pipe(
|
||||
finalize(() => this._pendingExecutionCreation.set(false)),
|
||||
tap(() => this.refresh()),
|
||||
catchError((err) => {
|
||||
console.error('Rerun execution failed', err);
|
||||
return throwError(() => err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
deleteExecution(executionId: string) {
|
||||
return this.withRefreshAndErrorHandling(
|
||||
this.taskExecutionsCallService.deleteTaskExecution(executionId),
|
||||
|
|
@ -199,6 +215,12 @@ export class TaskExecutionsService {
|
|||
this.stopPolling();
|
||||
}
|
||||
|
||||
private flattenGroups(groups: TaskExecutionGroup[]): TaskExecution[] {
|
||||
return groups
|
||||
.flatMap((group) => group.executions ?? [])
|
||||
.sort((left, right) => (right.creationTime ?? 0) - (left.creationTime ?? 0));
|
||||
}
|
||||
|
||||
private startPolling() {
|
||||
if (this.pollTimer) return;
|
||||
this.pollTimer = setInterval(() => {
|
||||
|
|
|
|||
|
|
@ -608,6 +608,27 @@
|
|||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.container-node__dropzone-title {
|
||||
align-self: center;
|
||||
max-width: 100%;
|
||||
font-size: 17px;
|
||||
line-height: 1.2;
|
||||
font-weight: 900;
|
||||
color: #db2777;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.container-node__dropzone-description {
|
||||
max-width: 320px;
|
||||
align-self: center;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.container-node__replace-confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -695,7 +716,7 @@
|
|||
}
|
||||
|
||||
.container-node__dropzone-empty {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.container-node__subflow-view {
|
||||
|
|
|
|||
|
|
@ -353,20 +353,21 @@
|
|||
</div>
|
||||
}
|
||||
|
||||
@for (flowField of flowFields; track flowField.path) {
|
||||
<div
|
||||
class="container-node__dropzone"
|
||||
[class.container-node__dropzone--active]="!isReadonly && selectedCount > 0"
|
||||
[class.container-node__dropzone--filled]="subFlowBlockCount > 0"
|
||||
(dragover)="onDropZoneDragOver($event)"
|
||||
[class.container-node__dropzone--filled]="flowField.blockCount > 0"
|
||||
(dragover)="onDropZoneDragOver($event, flowField)"
|
||||
(dragleave)="onDropZoneDragLeave($event)"
|
||||
(drop)="onDropZoneDrop($event)">
|
||||
@if (replaceConfirmOpen) {
|
||||
(drop)="onDropZoneDrop($event, flowField)">
|
||||
@if (flowField.replaceConfirmOpen) {
|
||||
<div class="container-node__replace-confirm" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
|
||||
<div class="container-node__replace-confirm-text">Replace current subflow?</div>
|
||||
<div class="container-node__replace-confirm-text">Replace current {{ flowField.label }}?</div>
|
||||
<div class="container-node__replace-confirm-note">The existing embedded flow will be removed and replaced by the dropped selection.</div>
|
||||
<div class="container-node__replace-confirm-actions">
|
||||
<button type="button" class="container-node__replace-confirm-cancel" (click)="cancelReplaceSubflow($event)">Cancel</button>
|
||||
<button type="button" class="container-node__replace-confirm-action" (click)="confirmReplaceSubflow($event)">Replace</button>
|
||||
<button type="button" class="container-node__replace-confirm-action" (click)="confirmReplaceSubflow(flowField, $event)">Replace</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (isAssigning) {
|
||||
|
|
@ -375,6 +376,12 @@
|
|||
<span>Updating container...</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="container-node__dropzone-title">{{ flowField.label }}</div>
|
||||
@if (flowField.ui.tip) {
|
||||
<div class="container-node__dropzone-description">
|
||||
{{ flowField.ui.tip }}
|
||||
</div>
|
||||
}
|
||||
<div class="container-node__dropzone-main">
|
||||
<span class="container-node__dropzone-empty">Drag a flow here or import from flow</span>
|
||||
</div>
|
||||
|
|
@ -382,17 +389,17 @@
|
|||
Use the selection box in the editor, then drag the floating selection badge into this area.
|
||||
</div>
|
||||
}
|
||||
@if (subFlowBlockCount > 0 && !replaceConfirmOpen && !isAssigning) {
|
||||
@if (flowField.blockCount > 0 && !flowField.replaceConfirmOpen && !isAssigning) {
|
||||
<div class="container-node__dropzone-actions">
|
||||
<button type="button" class="container-node__subflow-view" (pointerdown)="$event.stopPropagation()" (click)="openSubflowPreview($event)">
|
||||
<button type="button" class="container-node__subflow-view" (pointerdown)="$event.stopPropagation()" (click)="openSubflowPreview(flowField, $event)">
|
||||
View Flow
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (!isReadonly && !replaceConfirmOpen && !isAssigning) {
|
||||
@if (!isReadonly && !flowField.replaceConfirmOpen && !isAssigning) {
|
||||
<div class="container-node__dropzone-actions">
|
||||
<button type="button" class="container-node__import" (pointerdown)="$event.stopPropagation()" (click)="importSubflow($event)">
|
||||
@if (importLoading) {
|
||||
<button type="button" class="container-node__import" (pointerdown)="$event.stopPropagation()" (click)="importSubflow(flowField, $event)">
|
||||
@if (flowField.importLoading) {
|
||||
<span class="container-node__spinner" aria-hidden="true"></span>
|
||||
<span>Loading flows...</span>
|
||||
} @else {
|
||||
|
|
@ -402,6 +409,7 @@
|
|||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (assignmentErrorMessage) {
|
||||
<div class="container-node__message container-node__message--error">
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
|
|||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostBinding, HostListener, Input, OnDestroy, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowContainer, FlowData, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow';
|
||||
import { currentFlowPortValueKind, flowValueKindLabel, FlowData, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow';
|
||||
import { NodeSettingField, NodeSettingOption, NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
|
||||
import { ContainersService } from '@services/containers/containers';
|
||||
import { FieldRetriever } from '@services/retriever/field-retriever';
|
||||
|
|
@ -14,6 +14,13 @@ import { CONTAINER_SUBFLOW_DRAG_MIME } from './container-node-drag';
|
|||
import { firstValueFrom } from 'rxjs';
|
||||
import { extractSchemaRequirements, SchemaRequirements } from '../schema-requirements';
|
||||
import { evaluateUiConditionRule, getValueByPath, parentPath, pathToLabel, resolveNodeIcon, resolveSchemaPath, splitTemplatedTextParts, valueToDisplayString } from '../node-utility';
|
||||
import {
|
||||
collectSchemaFlowDataFields,
|
||||
flowDataNodeCount,
|
||||
isFlowDataFieldPath,
|
||||
normalizeFlowDataValue,
|
||||
type SchemaFlowDataFieldDefinition
|
||||
} from '../flow-data-schema-fields';
|
||||
import {
|
||||
buildSchemaEditableFieldDefinitions,
|
||||
buildSchemaFieldViewModel,
|
||||
|
|
@ -56,12 +63,11 @@ type ContainerFieldGroupView = SchemaDisplayGroup<ContainerDisplayItem>;
|
|||
|
||||
type ContainerDisplaySection = SchemaDisplaySection<ContainerDisplayItem>;
|
||||
|
||||
type StructuredRetrieverConfig = {
|
||||
retrieverName: string;
|
||||
retrieverUrl: string;
|
||||
validationUrl: string | null;
|
||||
structuredData: boolean;
|
||||
requiresAuth: boolean;
|
||||
type ContainerFlowFieldView = SchemaFlowDataFieldDefinition & {
|
||||
flow: FlowData | null;
|
||||
blockCount: number;
|
||||
replaceConfirmOpen: boolean;
|
||||
importLoading: boolean;
|
||||
};
|
||||
|
||||
@Component({
|
||||
|
|
@ -87,9 +93,10 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
private containerSchema: Record<string, any> | null = null;
|
||||
private schemaRequirements: SchemaRequirements = { required: [], requiredObjects: [], conditional: [] };
|
||||
private containerFieldDefinitions: ContainerFieldDefinition[] = [];
|
||||
private containerFlowFieldDefinitions: SchemaFlowDataFieldDefinition[] = [];
|
||||
deleteConfirmOpen = false;
|
||||
replaceConfirmSelection: string[] | null = null;
|
||||
importLoading = false;
|
||||
replaceConfirmSelection: { path: string; selection: string[] } | null = null;
|
||||
importLoadingPath: string | null = null;
|
||||
private importErrorMessage: string | null = null;
|
||||
parameterFields: ContainerFieldView[] = [];
|
||||
parameterFieldGroups: ContainerFieldGroupView[] = [];
|
||||
|
|
@ -313,37 +320,25 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
return this.editorState.selectedBlockIds().filter((id) => id !== this.blockId).length;
|
||||
}
|
||||
|
||||
get subFlow(): FlowData | null {
|
||||
const value = this.configuration?.['subFlow'];
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const candidate = value 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')
|
||||
: [];
|
||||
const dependencies = Array.isArray(candidate['dependencies'])
|
||||
? candidate['dependencies'].filter((item): item is FlowData['dependencies'][number] => !!item && typeof item === 'object')
|
||||
: [];
|
||||
|
||||
if (!blocks.length && !containers.length && !connections.length && !dependencies.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
blocks,
|
||||
containers,
|
||||
connections,
|
||||
dependencies
|
||||
};
|
||||
}
|
||||
|
||||
get subFlowBlockCount() {
|
||||
return (this.subFlow?.blocks?.length ?? 0) + (this.subFlow?.containers?.length ?? 0);
|
||||
get flowFields(): ContainerFlowFieldView[] {
|
||||
return this.resolveFlowFieldDefinitions().map((definition) => {
|
||||
const flow = this.flowAtPath(definition.path);
|
||||
return {
|
||||
...definition,
|
||||
flow,
|
||||
blockCount: flowDataNodeCount(flow),
|
||||
replaceConfirmOpen: this.replaceConfirmSelection?.path === definition.path,
|
||||
importLoading: this.importLoadingPath === definition.path
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get replaceConfirmOpen() {
|
||||
return Array.isArray(this.replaceConfirmSelection) && this.replaceConfirmSelection.length > 0;
|
||||
return !!this.replaceConfirmSelection?.selection.length;
|
||||
}
|
||||
|
||||
get hasFlowDropzones() {
|
||||
return this.flowFields.length > 0;
|
||||
}
|
||||
|
||||
get assignmentErrorMessage() {
|
||||
|
|
@ -358,6 +353,8 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
|
||||
get missingRequiredParams() {
|
||||
const config = this.configuration ?? {};
|
||||
const flowFieldDefinitions = this.resolveFlowFieldDefinitions();
|
||||
const flowFieldPaths = new Set(flowFieldDefinitions.map((field) => field.path));
|
||||
const requiredFields = [
|
||||
...this.schemaRequirements.required,
|
||||
...this.schemaRequirements.conditional.filter((field) =>
|
||||
|
|
@ -370,21 +367,25 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
return requiredFields
|
||||
.filter((field, index, fields) => fields.findIndex((candidate) => candidate.path === field.path) === index)
|
||||
.filter((field) => field.path !== 'name')
|
||||
.filter((field) => field.path !== 'subFlow')
|
||||
.filter((field) => !field.path.startsWith('subFlow.'))
|
||||
.filter((field) => !isFlowDataFieldPath(field.path, flowFieldDefinitions))
|
||||
.filter((field) => this.isFieldEnabled(field.path, config))
|
||||
.filter((field) => this.isMissingValue(getValueByPath(config, field.path)))
|
||||
.map((field) => field.label)
|
||||
.concat(
|
||||
this.schemaRequirements.requiredObjects
|
||||
.filter((field) => field.path !== 'name')
|
||||
.filter((field) => field.path !== 'subFlow')
|
||||
.filter((field) => !field.path.startsWith('subFlow.'))
|
||||
.filter((field) => !isFlowDataFieldPath(field.path, flowFieldDefinitions))
|
||||
.filter((field) => this.isFieldEnabled(field.path, config))
|
||||
.filter((field) => this.isMissingValue(getValueByPath(config, field.path)))
|
||||
.map((field) => field.label)
|
||||
)
|
||||
.concat(this.subFlow ? [] : ['Subflow'])
|
||||
.concat(
|
||||
this.schemaRequirements.requiredObjects
|
||||
.filter((field) => flowFieldPaths.has(field.path))
|
||||
.filter((field) => this.isFieldEnabled(field.path, config))
|
||||
.filter((field) => !this.flowAtPath(field.path))
|
||||
.map((field) => field.label)
|
||||
)
|
||||
.filter((field, index, fields) => fields.indexOf(field) === index);
|
||||
}
|
||||
|
||||
|
|
@ -427,25 +428,25 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
return this.toPortLabelParts(this.outputDisplayLabel(outputKey));
|
||||
}
|
||||
|
||||
async importSubflow(event?: Event) {
|
||||
async importSubflow(flowField: ContainerFlowFieldView, event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
if (this.isReadonly || this.importLoading || this.replaceConfirmOpen) return;
|
||||
if (this.isReadonly || this.importLoadingPath || this.replaceConfirmOpen) return;
|
||||
|
||||
this.importLoading = true;
|
||||
this.importLoadingPath = flowField.path;
|
||||
this.importErrorMessage = null;
|
||||
|
||||
try {
|
||||
const retriever = await this.resolveStructuredRetrieverConfig();
|
||||
const retriever = this.resolveStructuredRetrieverConfig(flowField);
|
||||
if (!retriever || !retriever.structuredData) {
|
||||
this.importErrorMessage = 'Subflow import is not available for this container.';
|
||||
this.importErrorMessage = `${flowField.label} import is not available for this container.`;
|
||||
return;
|
||||
}
|
||||
|
||||
const items = await firstValueFrom(
|
||||
this.fieldRetriever.retrieveItems<FlowData>(
|
||||
retriever.retrieverName || this.typeName,
|
||||
'subFlow',
|
||||
retriever.blockType,
|
||||
retriever.key,
|
||||
{
|
||||
context: 'CONTAINER',
|
||||
validOnly: 'true',
|
||||
|
|
@ -495,12 +496,12 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
return;
|
||||
}
|
||||
|
||||
await assignImportedSubflow(selectedItem.data, retriever.validationUrl);
|
||||
await assignImportedSubflow(selectedItem.data, flowField.path, retriever.validationUrl);
|
||||
this.refreshParameterFields();
|
||||
} catch {
|
||||
this.importErrorMessage = 'Failed to load importable flows.';
|
||||
} finally {
|
||||
this.importLoading = false;
|
||||
this.importLoadingPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -591,16 +592,16 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
await this.applyFieldValue(definition, currentValue !== true);
|
||||
}
|
||||
|
||||
onDropZoneDragOver(event: DragEvent) {
|
||||
onDropZoneDragOver(event: DragEvent, flowField: ContainerFlowFieldView) {
|
||||
if (this.isReadonly) return;
|
||||
if (!this.canAcceptSelectionDrop()) return;
|
||||
if (!this.canAcceptSelectionDrop(flowField.path)) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
}
|
||||
|
||||
onDropZoneDrop(event: DragEvent) {
|
||||
onDropZoneDrop(event: DragEvent, flowField: ContainerFlowFieldView) {
|
||||
if (this.isReadonly) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
|
@ -609,13 +610,13 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
const payload = this.parseDraggedSelection(raw);
|
||||
if (!payload.length) return;
|
||||
|
||||
if (this.subFlowBlockCount > 0) {
|
||||
this.replaceConfirmSelection = payload;
|
||||
if (flowField.blockCount > 0) {
|
||||
this.replaceConfirmSelection = { path: flowField.path, selection: payload };
|
||||
this.editorState.stopDraggingSelectedBlocks();
|
||||
return;
|
||||
}
|
||||
|
||||
this.assignSelectionToContainer(payload);
|
||||
this.assignSelectionToContainer(flowField, payload);
|
||||
}
|
||||
|
||||
onDropZoneDragLeave(_: DragEvent) {
|
||||
|
|
@ -623,16 +624,18 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
this.editorState.stopDraggingSelectedBlocks();
|
||||
}
|
||||
|
||||
confirmReplaceSubflow(event?: Event) {
|
||||
confirmReplaceSubflow(flowField: ContainerFlowFieldView, event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
if (this.isReadonly) return;
|
||||
|
||||
const payload = this.replaceConfirmSelection;
|
||||
const payload = this.replaceConfirmSelection?.path === flowField.path
|
||||
? this.replaceConfirmSelection.selection
|
||||
: null;
|
||||
this.replaceConfirmSelection = null;
|
||||
if (!payload?.length) return;
|
||||
|
||||
this.assignSelectionToContainer(payload);
|
||||
this.assignSelectionToContainer(flowField, payload);
|
||||
}
|
||||
|
||||
cancelReplaceSubflow(event?: Event) {
|
||||
|
|
@ -673,11 +676,11 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
openSubflowPreview(event?: Event) {
|
||||
openSubflowPreview(flowField: ContainerFlowFieldView, event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
if (!this.subFlow) return;
|
||||
this.subflowPreview.open(this.subFlow, `${this.name} subflow`, this.name);
|
||||
if (!flowField.flow) return;
|
||||
this.subflowPreview.open(flowField.flow, `${this.name} ${flowField.label}`, this.name);
|
||||
}
|
||||
|
||||
async openFieldPreview(field: ContainerFieldView, event?: Event) {
|
||||
|
|
@ -708,8 +711,49 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
return String(this.data?.data?.typeName ?? 'GenericContainer');
|
||||
}
|
||||
|
||||
private canAcceptSelectionDrop() {
|
||||
return !this.isAssigning && !this.replaceConfirmOpen && this.selectedCount > 0;
|
||||
private resolveFlowFieldDefinitions(): SchemaFlowDataFieldDefinition[] {
|
||||
if (this.containerFlowFieldDefinitions.length) return this.containerFlowFieldDefinitions;
|
||||
|
||||
const config = this.configuration ?? {};
|
||||
const configuredFields = Object.keys(config)
|
||||
.filter((key) => normalizeFlowDataValue(config[key]))
|
||||
.map((key) => this.fallbackSubflowDefinition(key, pathToLabel(key)));
|
||||
|
||||
if (configuredFields.length) {
|
||||
return configuredFields;
|
||||
}
|
||||
|
||||
if (!this.schemaReady) {
|
||||
return [this.fallbackSubflowDefinition()];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private fallbackSubflowDefinition(path = 'subFlow', label = 'Subflow'): SchemaFlowDataFieldDefinition {
|
||||
return {
|
||||
path,
|
||||
label,
|
||||
retrieverBlockType: 'Flows',
|
||||
retrieverKey: path,
|
||||
retrieverUrl: null,
|
||||
retrieverStructuredData: false,
|
||||
retrieverDependsOn: [],
|
||||
validationUrl: null,
|
||||
validationType: null,
|
||||
requiresAuth: false,
|
||||
ui: getSchemaPathUiMeta(this.containerSchema, path)
|
||||
};
|
||||
}
|
||||
|
||||
private flowAtPath(path: string): FlowData | null {
|
||||
return normalizeFlowDataValue(getValueByPath(this.configuration ?? {}, path));
|
||||
}
|
||||
|
||||
private canAcceptSelectionDrop(path: string) {
|
||||
return !this.isAssigning
|
||||
&& (!this.replaceConfirmOpen || this.replaceConfirmSelection?.path === path)
|
||||
&& this.selectedCount > 0;
|
||||
}
|
||||
|
||||
private async openReadonlyTextDialog(label: string, value: string) {
|
||||
|
|
@ -731,12 +775,12 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
private assignSelectionToContainer(payload: string[]) {
|
||||
private assignSelectionToContainer(flowField: ContainerFlowFieldView, payload: string[]) {
|
||||
this.importErrorMessage = null;
|
||||
const assign = this.data?.data?.assignSelectedBlocksToContainer;
|
||||
if (typeof assign !== 'function' || !payload.length) return;
|
||||
|
||||
void assign(payload);
|
||||
void assign(payload, flowField.path, flowField.validationUrl);
|
||||
this.editorState.stopDraggingSelectedBlocks();
|
||||
}
|
||||
|
||||
|
|
@ -780,64 +824,21 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
return ports.find((item: any) => item?.name === key) ?? null;
|
||||
}
|
||||
|
||||
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,
|
||||
position: this.normalizePosition(item['position']),
|
||||
nodeFamily: 'block'
|
||||
})) 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,
|
||||
position: this.normalizePosition(item['position']),
|
||||
nodeFamily: 'container'
|
||||
})) 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 async resolveStructuredRetrieverConfig(): Promise<StructuredRetrieverConfig | null> {
|
||||
const containerType = await this.containersService.getContainerType(this.typeName);
|
||||
const schema = containerType?.schema;
|
||||
const properties = schema?.['properties'];
|
||||
const propertySchema = properties && typeof properties === 'object' && !Array.isArray(properties)
|
||||
? (properties as Record<string, unknown>)['subFlow']
|
||||
: null;
|
||||
|
||||
if (!propertySchema || typeof propertySchema !== 'object' || Array.isArray(propertySchema)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldSchema = propertySchema as Record<string, unknown>;
|
||||
const retrieverUrl = typeof fieldSchema['x-retriever-url'] === 'string' ? fieldSchema['x-retriever-url'] : null;
|
||||
const retrieverName = typeof fieldSchema['x-retriever-name'] === 'string' ? fieldSchema['x-retriever-name'] : this.typeName;
|
||||
if (!retrieverUrl) return null;
|
||||
private resolveStructuredRetrieverConfig(flowField: SchemaFlowDataFieldDefinition): {
|
||||
blockType: string;
|
||||
key: string;
|
||||
retrieverUrl: string;
|
||||
validationUrl: string | null;
|
||||
structuredData: boolean;
|
||||
} | null {
|
||||
if (!flowField.retrieverUrl) return null;
|
||||
|
||||
return {
|
||||
retrieverName,
|
||||
retrieverUrl,
|
||||
validationUrl: typeof fieldSchema['x-retriever-validation-url'] === 'string'
|
||||
? fieldSchema['x-retriever-validation-url']
|
||||
: null,
|
||||
structuredData: fieldSchema['x-retriever-structured-data'] === true,
|
||||
requiresAuth: fieldSchema['x-retriever-requires-auth'] === true
|
||||
blockType: flowField.retrieverBlockType ?? this.typeName,
|
||||
key: flowField.retrieverKey ?? flowField.path,
|
||||
retrieverUrl: flowField.retrieverUrl,
|
||||
validationUrl: flowField.validationUrl,
|
||||
structuredData: flowField.retrieverStructuredData
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -849,6 +850,7 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
const containerType = this.containersService.peekContainerType(this.typeName) ?? await this.containersService.getContainerType(this.typeName);
|
||||
this.containerSchema = (containerType?.schema ?? null) as Record<string, any> | null;
|
||||
this.schemaRequirements = extractSchemaRequirements(this.containerSchema);
|
||||
this.containerFlowFieldDefinitions = collectSchemaFlowDataFields(this.containerSchema);
|
||||
this.containerFieldDefinitions = this.buildContainerFieldDefinitions(this.containerSchema);
|
||||
this.refreshParameterFields();
|
||||
} finally {
|
||||
|
|
@ -919,7 +921,7 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
private buildContainerFieldDefinitions(schema: Record<string, any> | null): ContainerFieldDefinition[] {
|
||||
return buildSchemaEditableFieldDefinitions(schema, {
|
||||
shouldSkip: ({ key, path }) =>
|
||||
key.startsWith('__') || path === 'name' || path === 'subFlow' || this.isContainerTypeField(path)
|
||||
key.startsWith('__') || path === 'name' || isFlowDataFieldPath(path, this.resolveFlowFieldDefinitions()) || this.isContainerTypeField(path)
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
import { FlowBlock, FlowContainer, FlowData, FlowGlobalInput } from '@models/flow';
|
||||
import { orderedSchemaPropertyEntries, parentPath, resolveSchemaRef, schemaFieldLabel } from './node-utility';
|
||||
import { schemaRetrieverMeta, toSchemaFieldUiMeta, type SchemaFieldUiMeta, type SchemaRetrieverDependency } from './schema-driven-fields';
|
||||
|
||||
export type SchemaFlowDataFieldDefinition = {
|
||||
path: string;
|
||||
label: string;
|
||||
retrieverBlockType: string | null;
|
||||
retrieverKey: string | null;
|
||||
retrieverUrl: string | null;
|
||||
retrieverStructuredData: boolean;
|
||||
retrieverDependsOn: SchemaRetrieverDependency[];
|
||||
validationUrl: string | null;
|
||||
validationType: string | null;
|
||||
requiresAuth: boolean;
|
||||
ui: SchemaFieldUiMeta;
|
||||
};
|
||||
|
||||
export function collectSchemaFlowDataFields(root: Record<string, any> | null | undefined): SchemaFlowDataFieldDefinition[] {
|
||||
if (!root) return [];
|
||||
|
||||
const fields: SchemaFlowDataFieldDefinition[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const walk = (node: Record<string, any>, pathPrefix: string) => {
|
||||
const resolved = resolveSchemaRef(node, root);
|
||||
if (!resolved || typeof resolved !== 'object') return;
|
||||
|
||||
for (const { key, schema } of orderedSchemaPropertyEntries(resolved, root)) {
|
||||
if (!schema) continue;
|
||||
|
||||
const path = pathPrefix ? `${pathPrefix}.${key}` : key;
|
||||
const fieldSchema = resolveSchemaRef(schema, root);
|
||||
if (!fieldSchema || typeof fieldSchema !== 'object') continue;
|
||||
|
||||
if (isFlowDataSchema(fieldSchema, root)) {
|
||||
if (!seen.has(path)) {
|
||||
seen.add(path);
|
||||
const retriever = schemaRetrieverMeta(fieldSchema, parentPath(path) ?? '');
|
||||
fields.push({
|
||||
path,
|
||||
label: schemaFieldLabel(path, fieldSchema),
|
||||
...retriever,
|
||||
validationUrl: resolveSubflowValidationUrl(fieldSchema),
|
||||
validationType: toNonEmptyString(fieldSchema['x-subflow-validation-type']),
|
||||
requiresAuth: fieldSchema['x-retriever-requires-auth'] === true,
|
||||
ui: toSchemaFieldUiMeta(fieldSchema)
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasChildren = !!fieldSchema['properties'] || fieldSchema['type'] === 'object';
|
||||
if (hasChildren) {
|
||||
walk(fieldSchema, path);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(root, '');
|
||||
return fields;
|
||||
}
|
||||
|
||||
function resolveSubflowValidationUrl(schema: Record<string, any>): string | null {
|
||||
const validationUrl = toNonEmptyString(schema['x-retriever-validation-url']);
|
||||
const validationType = toNonEmptyString(schema['x-subflow-validation-type']);
|
||||
if (!validationUrl) {
|
||||
return validationType
|
||||
? `/containers/validate-subflow?type=${encodeURIComponent(validationType)}`
|
||||
: null;
|
||||
}
|
||||
|
||||
if (!validationType || hasQueryParam(validationUrl, 'type')) return validationUrl;
|
||||
|
||||
return `${validationUrl}${validationUrl.includes('?') ? '&' : '?'}type=${encodeURIComponent(validationType)}`;
|
||||
}
|
||||
|
||||
function hasQueryParam(rawUrl: string, key: string): boolean {
|
||||
const queryString = rawUrl.split('?', 2)[1];
|
||||
if (!queryString) return false;
|
||||
return new URLSearchParams(queryString).has(key);
|
||||
}
|
||||
|
||||
function toNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function isFlowDataFieldPath(path: string, fields: Array<{ path: string }>): boolean {
|
||||
return fields.some((field) => path === field.path || path.startsWith(`${field.path}.`));
|
||||
}
|
||||
|
||||
export function normalizeFlowDataValue(raw: unknown): FlowData | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
|
||||
const candidate = raw as Record<string, unknown>;
|
||||
const blocks = normalizeSubFlowBlocks(candidate['blocks']);
|
||||
const containers = normalizeSubFlowContainers(candidate['containers']);
|
||||
const connections = Array.isArray(candidate['connections'])
|
||||
? candidate['connections'].filter((item): item is FlowData['connections'][number] => !!item && typeof item === 'object')
|
||||
: [];
|
||||
const dependencies = Array.isArray(candidate['dependencies'])
|
||||
? candidate['dependencies'].filter((item): item is FlowData['dependencies'][number] => !!item && typeof item === 'object')
|
||||
: [];
|
||||
const globalInputs = Array.isArray(candidate['globalInputs'])
|
||||
? candidate['globalInputs'].filter((item): item is FlowGlobalInput => !!item && typeof item === 'object')
|
||||
: undefined;
|
||||
|
||||
if (!blocks.length && !containers.length && !connections.length && !dependencies.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
blocks,
|
||||
containers,
|
||||
connections,
|
||||
dependencies,
|
||||
...(globalInputs ? { globalInputs } : {})
|
||||
};
|
||||
}
|
||||
|
||||
export function flowDataNodeCount(flowData: FlowData | null): number {
|
||||
return (flowData?.blocks?.length ?? 0) + (flowData?.containers?.length ?? 0);
|
||||
}
|
||||
|
||||
function isFlowDataSchema(schema: Record<string, any>, root: Record<string, any>): boolean {
|
||||
const ref = schema['$ref'];
|
||||
if (typeof ref === 'string' && ref.split('/').at(-1) === 'FlowData') return true;
|
||||
|
||||
const resolved = resolveSchemaRef(schema, root);
|
||||
const properties = resolved?.['properties'];
|
||||
if (!properties || typeof properties !== 'object' || Array.isArray(properties)) return false;
|
||||
|
||||
return ['blocks', 'containers', 'connections'].every((key) =>
|
||||
Object.prototype.hasOwnProperty.call(properties, key)
|
||||
);
|
||||
}
|
||||
|
||||
function 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,
|
||||
position: normalizePosition(item['position']),
|
||||
nodeFamily: 'block'
|
||||
})) as FlowBlock[];
|
||||
}
|
||||
|
||||
function 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,
|
||||
position: normalizePosition(item['position']),
|
||||
nodeFamily: 'container'
|
||||
})) as FlowContainer[];
|
||||
}
|
||||
|
||||
function 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 };
|
||||
}
|
||||
|
|
@ -1,3 +1,7 @@
|
|||
import {
|
||||
collectSchemaFlowDataFields,
|
||||
isFlowDataFieldPath
|
||||
} from './flow-data-schema-fields';
|
||||
import {
|
||||
buildOrderedSchemaDisplay,
|
||||
buildSchemaEditableFieldDefinitions,
|
||||
|
|
@ -14,6 +18,69 @@ import {
|
|||
} from './schema-driven-fields';
|
||||
|
||||
describe('schema-driven-fields', () => {
|
||||
it('collects every schema-driven FlowData field', () => {
|
||||
const fields = collectSchemaFlowDataFields({
|
||||
type: 'object',
|
||||
sharedDefinitions: {
|
||||
FlowData: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
blocks: { type: 'array' },
|
||||
containers: { type: 'array' },
|
||||
connections: { type: 'array' },
|
||||
dependencies: { type: 'array' }
|
||||
}
|
||||
}
|
||||
},
|
||||
properties: {
|
||||
subFlow: {
|
||||
$ref: '#/sharedDefinitions/FlowData',
|
||||
'x-ui-label': 'Internal Flow',
|
||||
'x-retriever-url': '/secure-retriever/Flows/subFlow/items',
|
||||
'x-retriever-structured-data': true,
|
||||
'x-retriever-validation-url': '/containers/validate-subflow',
|
||||
'x-subflow-validation-type': 'LOOP_BODY'
|
||||
},
|
||||
guardSubFlow: {
|
||||
$ref: '#/sharedDefinitions/FlowData',
|
||||
'x-ui-label': 'Guard Flow',
|
||||
'x-retriever-url': '/secure-retriever/Flows/subFlow/items',
|
||||
'x-retriever-structured-data': true,
|
||||
'x-retriever-validation-url': '/containers/validate-subflow?type=LOOP_GUARD',
|
||||
'x-subflow-validation-type': 'LOOP_GUARD'
|
||||
},
|
||||
maxIterations: {
|
||||
type: 'integer'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(fields.map((field) => ({
|
||||
path: field.path,
|
||||
label: field.label,
|
||||
key: field.retrieverKey,
|
||||
validationUrl: field.validationUrl,
|
||||
validationType: field.validationType
|
||||
}))).toEqual([
|
||||
{
|
||||
path: 'subFlow',
|
||||
label: 'Internal Flow',
|
||||
key: 'subFlow',
|
||||
validationUrl: '/containers/validate-subflow?type=LOOP_BODY',
|
||||
validationType: 'LOOP_BODY'
|
||||
},
|
||||
{
|
||||
path: 'guardSubFlow',
|
||||
label: 'Guard Flow',
|
||||
key: 'subFlow',
|
||||
validationUrl: '/containers/validate-subflow?type=LOOP_GUARD',
|
||||
validationType: 'LOOP_GUARD'
|
||||
}
|
||||
]);
|
||||
expect(isFlowDataFieldPath('guardSubFlow.blocks', fields)).toBe(true);
|
||||
expect(isFlowDataFieldPath('maxIterations', fields)).toBe(false);
|
||||
});
|
||||
|
||||
it('parses retriever urls including required suffix', () => {
|
||||
expect(parseSchemaRetrieverUrl('/retriever/LLM/providers')).toEqual({
|
||||
blockType: 'LLM',
|
||||
|
|
|
|||
|
|
@ -330,9 +330,11 @@
|
|||
|
||||
@if (hasViewableSubflow()) {
|
||||
<div class="llm-subflow-section">
|
||||
<button type="button" class="llm-subflow-view" (pointerdown)="$event.stopPropagation()" (click)="openSubflowPreview($event)">
|
||||
View Subflow
|
||||
@for (subflow of viewableSubflows(); track subflow.path) {
|
||||
<button type="button" class="llm-subflow-view" (pointerdown)="$event.stopPropagation()" (click)="openSubflowPreview(subflow.path, $event)">
|
||||
View {{ subflow.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,19 @@ import { CommonModule } from '@angular/common';
|
|||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angular/core';
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { ReteModule } from 'rete-angular-plugin/21';
|
||||
import { BlockInteractionContract, BlockType, FlowBlock, FlowContainer, FlowData, FlowPort, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow';
|
||||
import { BlockInteractionContract, BlockType, FlowData, FlowPort, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow';
|
||||
import { BlocksService } from '@services/blocks/blocks';
|
||||
import { ContainersService } from '@services/containers/containers';
|
||||
import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
|
||||
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 {
|
||||
collectSchemaFlowDataFields,
|
||||
isFlowDataFieldPath,
|
||||
normalizeFlowDataValue,
|
||||
type SchemaFlowDataFieldDefinition
|
||||
} from '../flow-data-schema-fields';
|
||||
import {
|
||||
type UiConditionRule,
|
||||
evaluateUiConditionRule,
|
||||
|
|
@ -135,6 +141,7 @@ export class TaskStepNodeComponent {
|
|||
private blockSchema: Record<string, any> | null = null;
|
||||
private blockDescriptor: BlockType | null = null;
|
||||
private arrayFieldDefinitions: ArrayFieldDefinition[] = [];
|
||||
private flowFieldDefinitions: SchemaFlowDataFieldDefinition[] = [];
|
||||
|
||||
ngOnInit() {
|
||||
this.outputs = [];
|
||||
|
|
@ -305,13 +312,19 @@ export class TaskStepNodeComponent {
|
|||
}
|
||||
|
||||
hasViewableSubflow(): boolean {
|
||||
const subFlow = this.subFlow();
|
||||
return !!subFlow && (
|
||||
(subFlow.blocks?.length ?? 0) > 0 ||
|
||||
(subFlow.containers?.length ?? 0) > 0 ||
|
||||
(subFlow.connections?.length ?? 0) > 0 ||
|
||||
(subFlow.dependencies?.length ?? 0) > 0
|
||||
);
|
||||
return this.viewableSubflows().length > 0;
|
||||
}
|
||||
|
||||
viewableSubflows(): Array<{ path: string; label: string; flow: FlowData }> {
|
||||
if (!this.isContainerNode()) return [];
|
||||
|
||||
return this.resolveFlowFieldDefinitions()
|
||||
.map((field) => ({
|
||||
path: field.path,
|
||||
label: field.label,
|
||||
flow: this.flowAtPath(field.path)
|
||||
}))
|
||||
.filter((item): item is { path: string; label: string; flow: FlowData } => item.flow != null);
|
||||
}
|
||||
|
||||
hasExecutionDependencyPorts(): boolean {
|
||||
|
|
@ -443,13 +456,15 @@ export class TaskStepNodeComponent {
|
|||
});
|
||||
}
|
||||
|
||||
openSubflowPreview(event?: Event) {
|
||||
openSubflowPreview(path?: string, event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
const subFlow = this.subFlow();
|
||||
const candidatePath = path ?? this.viewableSubflows()[0]?.path;
|
||||
const subFlow = candidatePath ? this.flowAtPath(candidatePath) : null;
|
||||
if (!subFlow) return;
|
||||
const sourceName = this.name || this.nodeTitle();
|
||||
this.subflowPreview.open(subFlow, `${sourceName} subflow`, sourceName);
|
||||
const label = this.resolveFlowFieldDefinitions().find((field) => field.path === candidatePath)?.label ?? 'Subflow';
|
||||
this.subflowPreview.open(subFlow, `${sourceName} ${label}`, sourceName);
|
||||
}
|
||||
|
||||
openFieldPreview(field: DisplayField, event?: Event) {
|
||||
|
|
@ -589,6 +604,7 @@ export class TaskStepNodeComponent {
|
|||
TaskStepNodeComponent.globalFieldUiMetaCache.delete(typeKey);
|
||||
TaskStepNodeComponent.globalFieldLabelCache.delete(typeKey);
|
||||
}
|
||||
this.flowFieldDefinitions = collectSchemaFlowDataFields(this.blockSchema);
|
||||
this.arrayFieldDefinitions = this.extractArrayFieldDefinitions(this.blockSchema);
|
||||
this.rebuildDisplayState();
|
||||
this.schemaReady = true;
|
||||
|
|
@ -821,7 +837,7 @@ export class TaskStepNodeComponent {
|
|||
const path = pathPrefix ? `${pathPrefix}.${key}` : key;
|
||||
|
||||
if (childResolved?.['type'] === 'array') {
|
||||
if (shouldSkipSchemaField(key, childResolved) || key === 'name' || seen.has(path)) {
|
||||
if (shouldSkipSchemaField(key, childResolved) || key === 'name' || isFlowDataFieldPath(path, this.resolveFlowFieldDefinitions()) || seen.has(path)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(path);
|
||||
|
|
@ -842,61 +858,44 @@ export class TaskStepNodeComponent {
|
|||
|
||||
walk(schema, '');
|
||||
return this.isContainerNode()
|
||||
? definitions.filter((definition) => !definition.path.startsWith('subFlow.'))
|
||||
? definitions.filter((definition) => !isFlowDataFieldPath(definition.path, this.resolveFlowFieldDefinitions()))
|
||||
: definitions;
|
||||
}
|
||||
|
||||
private subFlow(): FlowData | null {
|
||||
if (!this.isContainerNode()) return null;
|
||||
private resolveFlowFieldDefinitions(): SchemaFlowDataFieldDefinition[] {
|
||||
if (this.flowFieldDefinitions.length) return this.flowFieldDefinitions;
|
||||
|
||||
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')
|
||||
: [];
|
||||
const dependencies = Array.isArray(candidate['dependencies'])
|
||||
? candidate['dependencies'].filter((item): item is FlowData['dependencies'][number] => !!item && typeof item === 'object')
|
||||
: [];
|
||||
|
||||
if (!blocks.length && !containers.length && !connections.length && !dependencies.length) return null;
|
||||
return { blocks, containers, connections, dependencies };
|
||||
const config = this.blockConfiguration ?? {};
|
||||
return Object.keys(config)
|
||||
.filter((key) => normalizeFlowDataValue(config[key]))
|
||||
.map((key) => ({
|
||||
path: key,
|
||||
label: key === 'subFlow' ? 'Subflow' : pathToLabel(key),
|
||||
retrieverBlockType: null,
|
||||
retrieverKey: null,
|
||||
retrieverUrl: null,
|
||||
retrieverStructuredData: false,
|
||||
retrieverDependsOn: [],
|
||||
validationUrl: null,
|
||||
validationType: null,
|
||||
requiresAuth: false,
|
||||
ui: {
|
||||
widget: null,
|
||||
acceptVariableAsPlaceholder: false,
|
||||
structural: true,
|
||||
bindableAsInput: false,
|
||||
inputName: null,
|
||||
inputType: null,
|
||||
inputMultiple: null,
|
||||
visibleWhen: [],
|
||||
enabledWhen: [],
|
||||
group: null
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
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 flowAtPath(path: string): FlowData | null {
|
||||
return normalizeFlowDataValue(this.getByPath(this.blockConfiguration ?? {}, path));
|
||||
}
|
||||
|
||||
private shouldHideConfigPath(path: string): boolean {
|
||||
|
|
@ -911,8 +910,7 @@ export class TaskStepNodeComponent {
|
|||
return this.isContainerNode()
|
||||
&& (
|
||||
isContainerTypePath
|
||||
|| path === 'subFlow'
|
||||
|| path.startsWith('subFlow.')
|
||||
|| isFlowDataFieldPath(path, this.resolveFlowFieldDefinitions())
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1098,8 +1096,7 @@ export class TaskStepNodeComponent {
|
|||
}
|
||||
|
||||
const config = this.blockConfiguration;
|
||||
const subFlow = config?.['subFlow'];
|
||||
if (subFlow && typeof subFlow === 'object' && !Array.isArray(subFlow)) {
|
||||
if (config && Object.values(config).some((value) => normalizeFlowDataValue(value))) {
|
||||
return 'container';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,11 +64,11 @@
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.tasks-list-item {
|
||||
.tasks-list-group {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 14px;
|
||||
border-radius: 8px;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(16, 185, 129, 0.1), transparent 32%),
|
||||
linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||
|
|
@ -76,11 +76,12 @@
|
|||
0 10px 24px rgba(15, 23, 42, 0.07),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.72);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
cursor: default;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.tasks-list-item:hover {
|
||||
.tasks-list-group:hover,
|
||||
.tasks-list-group-expanded {
|
||||
transform: translateY(-3px);
|
||||
border-color: #60a5fa;
|
||||
background:
|
||||
|
|
@ -89,11 +90,28 @@
|
|||
box-shadow: 0 18px 28px rgba(37, 99, 235, 0.14);
|
||||
}
|
||||
|
||||
.tasks-list-item-meta {
|
||||
.tasks-list-group-header {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tasks-list-group-chevron {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.tasks-list-group-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tasks-list-item-title {
|
||||
.tasks-list-group-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
|
@ -102,7 +120,7 @@
|
|||
color: #1e293b;
|
||||
}
|
||||
|
||||
.tasks-list-item-subtitle {
|
||||
.tasks-list-group-subtitle {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
|
@ -110,6 +128,28 @@
|
|||
color: #64748b;
|
||||
}
|
||||
|
||||
.tasks-list-group-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tasks-list-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 20px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 999px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tasks-list-simulated-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -125,6 +165,9 @@
|
|||
}
|
||||
|
||||
.tasks-list-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 20px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
border-width: 1px;
|
||||
|
|
@ -133,7 +176,7 @@
|
|||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.52);
|
||||
}
|
||||
|
||||
.tasks-list-item-footer {
|
||||
.tasks-list-group-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
|
@ -145,8 +188,98 @@
|
|||
border-top: 1px solid rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
.tasks-list-item-actions {
|
||||
.tasks-list-executions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.tasks-list-execution {
|
||||
position: relative;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.tasks-list-execution:hover {
|
||||
border-color: #93c5fd;
|
||||
background: #f8fbff;
|
||||
box-shadow: 0 10px 18px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.tasks-list-execution-selected {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.tasks-list-execution-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tasks-list-execution-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tasks-list-run {
|
||||
flex: 0 0 auto;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.tasks-list-execution-id {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.tasks-list-execution-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.tasks-list-execution-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.tasks-list-latest {
|
||||
width: 100%;
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
background: #f8fafc;
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tasks-list-latest:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
<div class="tasks-list-search">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Search executions</mat-label>
|
||||
<mat-label>Search history</mat-label>
|
||||
<mat-icon matPrefix fontIcon="search"></mat-icon>
|
||||
<input matInput placeholder="Search executions..." [(ngModel)]="searchTerm" />
|
||||
<input matInput placeholder="Search history..." [(ngModel)]="searchTerm" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
|
|
@ -27,50 +27,88 @@
|
|||
</div>
|
||||
|
||||
<div class="tasks-list-items">
|
||||
@if (!filteredExecutions().length) {
|
||||
@if (!filteredGroups().length) {
|
||||
<div class="tasks-list-empty">No executions available</div>
|
||||
} @else {
|
||||
<mat-list class="tasks-list-list">
|
||||
@for (execution of filteredExecutions(); track execution.id) {
|
||||
@for (group of filteredGroups(); track group.id) {
|
||||
<mat-list-item class="tasks-list-list-item">
|
||||
<mat-card
|
||||
class="tasks-list-item"
|
||||
[class.border-blue-300]="execution.id === selectedExecutionId()"
|
||||
[class.bg-blue-50]="execution.id === selectedExecutionId()"
|
||||
[class.border-slate-200]="execution.id !== selectedExecutionId()"
|
||||
[class.bg-white]="execution.id !== selectedExecutionId()"
|
||||
(click)="selectExecution(execution.id)">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="tasks-list-item-meta">
|
||||
<div class="tasks-list-item-title">{{ execution.title }}</div>
|
||||
<div class="tasks-list-item-subtitle">{{ execution.flowName }}</div>
|
||||
@if (execution.simulated) {
|
||||
<div class="mt-1">
|
||||
<span class="tasks-list-simulated-badge">Simulated</span>
|
||||
<mat-card class="tasks-list-group" [class.tasks-list-group-expanded]="isGroupExpanded(group.id)">
|
||||
<button type="button" class="tasks-list-group-header" (click)="toggleGroup(group.id, $event)">
|
||||
<mat-icon class="tasks-list-group-chevron" [fontIcon]="isGroupExpanded(group.id) ? 'expand_less' : 'expand_more'"></mat-icon>
|
||||
<div class="tasks-list-group-main">
|
||||
<div class="tasks-list-group-title">{{ group.name }}</div>
|
||||
<div class="tasks-list-group-subtitle">{{ group.sourceFlowId }}</div>
|
||||
</div>
|
||||
<div class="tasks-list-group-summary">
|
||||
<span class="tasks-list-count">{{ group.executionCount }} runs</span>
|
||||
<span class="tasks-list-status" [ngClass]="statusBadgeClass(group.latestStatus)">
|
||||
{{ group.latestStatus }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="tasks-list-group-meta">
|
||||
<span>Latest {{ group.lastExecutionTimeLabel }}</span>
|
||||
<span>Run {{ runNumberLabel(group.latestRunNumber) }}</span>
|
||||
</div>
|
||||
|
||||
@if (isGroupExpanded(group.id)) {
|
||||
<div class="tasks-list-executions">
|
||||
@for (execution of group.executions; track execution.id) {
|
||||
<div
|
||||
class="tasks-list-execution"
|
||||
[class.tasks-list-execution-selected]="execution.id === selectedExecutionId()"
|
||||
(click)="selectExecution(execution.id)">
|
||||
<div class="tasks-list-execution-head">
|
||||
<div class="tasks-list-execution-title">
|
||||
<span class="tasks-list-run">{{ runNumberLabel(execution.runNumber) }}</span>
|
||||
<span class="tasks-list-execution-id">{{ execution.id }}</span>
|
||||
</div>
|
||||
<span class="tasks-list-status" [ngClass]="statusBadgeClass(execution.status)">
|
||||
{{ execution.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tasks-list-execution-details">
|
||||
<span>{{ execution.startedAt }}</span>
|
||||
@if (execution.rerunOfExecutionId) {
|
||||
<span>Rerun of {{ execution.rerunOfExecutionId }}</span>
|
||||
}
|
||||
@if (execution.simulated) {
|
||||
<span class="tasks-list-simulated-badge">Simulated</span>
|
||||
}
|
||||
</div>
|
||||
<div class="tasks-list-execution-actions">
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="!h-8 !w-8 enabled:!text-emerald-700 disabled:!text-slate-300"
|
||||
matTooltip="Rerun execution"
|
||||
aria-label="Rerun execution"
|
||||
[disabled]="!canRerun(execution.status)"
|
||||
(click)="requestRerunExecution(execution.id, $event)">
|
||||
<mat-icon fontIcon="replay"></mat-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="!h-8 !w-8 enabled:!text-red-600 disabled:!text-red-200"
|
||||
matTooltip="Delete execution"
|
||||
aria-label="Delete execution"
|
||||
[disabled]="isDeleteDisabled(execution.status)"
|
||||
(click)="requestDeleteExecution(execution.id, $event)">
|
||||
<mat-icon fontIcon="delete"></mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<span class="tasks-list-status"
|
||||
[ngClass]="statusBadgeClass(execution.status)">
|
||||
{{ execution.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tasks-list-item-footer">
|
||||
<span>{{ execution.startedAt }}</span>
|
||||
<div class="tasks-list-item-actions">
|
||||
<span>{{ execution.duration || '-' }}</span>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="!h-8 !w-8 enabled:!text-red-600 disabled:!text-red-200"
|
||||
matTooltip="Delete execution"
|
||||
aria-label="Delete execution"
|
||||
[disabled]="isDeleteDisabled(execution.status)"
|
||||
(click)="requestDeleteExecution(execution.id, $event)">
|
||||
<mat-icon fontIcon="delete"></mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<button type="button" class="tasks-list-latest" (click)="selectExecution(group.latestExecutionId)">
|
||||
<span>Open latest execution</span>
|
||||
<mat-icon fontIcon="arrow_forward"></mat-icon>
|
||||
</button>
|
||||
}
|
||||
</mat-card>
|
||||
</mat-list-item>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,10 +21,26 @@ export type TaskExecutionListItem = {
|
|||
flowName: string;
|
||||
status: TaskExecutionStatus;
|
||||
startedAt: string;
|
||||
creationTime: number;
|
||||
runNumber: number | null;
|
||||
rerunOfExecutionId?: string | null;
|
||||
duration?: string;
|
||||
simulated?: boolean;
|
||||
};
|
||||
|
||||
export type TaskExecutionGroupListItem = {
|
||||
id: string;
|
||||
sourceFlowId: string;
|
||||
name: string;
|
||||
executionCount: number;
|
||||
lastExecutionTime: number;
|
||||
lastExecutionTimeLabel: string;
|
||||
latestExecutionId: string;
|
||||
latestStatus: TaskExecutionStatus;
|
||||
latestRunNumber: number | null;
|
||||
executions: TaskExecutionListItem[];
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-tasks-executions-list',
|
||||
imports: [CommonModule, FormsModule, Ordering, MatButtonModule, MatButtonToggleModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatListModule, MatTooltipModule],
|
||||
|
|
@ -33,14 +49,16 @@ export type TaskExecutionListItem = {
|
|||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class TasksExecutionsListComponent {
|
||||
readonly executions = input<TaskExecutionListItem[]>([]);
|
||||
readonly groups = input<TaskExecutionGroupListItem[]>([]);
|
||||
readonly selectedExecutionId = input<string | null>(null);
|
||||
readonly executionSelected = output<string>();
|
||||
readonly executionDeleteRequested = output<string>();
|
||||
readonly executionRerunRequested = output<string>();
|
||||
readonly searchTerm = model<string>('');
|
||||
readonly filter = signal<TaskExecutionFilter>('all');
|
||||
readonly orderBy = signal<string | null>('startedAt');
|
||||
readonly orderBy = signal<string | null>('lastExecutionTime');
|
||||
readonly orderDir = signal<orderDirType>('desc');
|
||||
readonly expandedGroupIds = signal<Set<string>>(new Set<string>());
|
||||
|
||||
readonly orderView: OrderViewState = {
|
||||
orderBy: this.orderBy(),
|
||||
|
|
@ -48,26 +66,32 @@ export class TasksExecutionsListComponent {
|
|||
};
|
||||
|
||||
readonly orderFields: OrderField[] = [
|
||||
{ field: 'title', label: 'Title' },
|
||||
{ field: 'flowName', label: 'Flow Name' },
|
||||
{ field: 'startedAt', label: 'Started At' },
|
||||
{ field: 'duration', label: 'Duration' }
|
||||
{ field: 'name', label: 'Name' },
|
||||
{ field: 'executionCount', label: 'Executions' },
|
||||
{ field: 'lastExecutionTime', label: 'Latest Run' },
|
||||
{ field: 'latestStatus', label: 'Status' }
|
||||
];
|
||||
|
||||
readonly filteredExecutions = computed(() => {
|
||||
readonly filteredGroups = computed(() => {
|
||||
const term = this.searchTerm().trim().toLowerCase();
|
||||
const filter = this.filter();
|
||||
const orderBy = this.orderBy();
|
||||
const orderDir = this.orderDir();
|
||||
|
||||
const filtered = this.executions().filter((execution) => {
|
||||
if (!this.matchesFilter(execution.status, filter)) return false;
|
||||
const filtered = this.groups().filter((group) => {
|
||||
if (!this.groupMatchesFilter(group, filter)) return false;
|
||||
if (!term) return true;
|
||||
|
||||
return (
|
||||
execution.title.toLowerCase().includes(term) ||
|
||||
execution.flowName.toLowerCase().includes(term) ||
|
||||
execution.id.toLowerCase().includes(term)
|
||||
group.name.toLowerCase().includes(term) ||
|
||||
group.sourceFlowId.toLowerCase().includes(term) ||
|
||||
group.id.toLowerCase().includes(term) ||
|
||||
group.executions.some((execution) =>
|
||||
execution.title.toLowerCase().includes(term) ||
|
||||
execution.flowName.toLowerCase().includes(term) ||
|
||||
execution.id.toLowerCase().includes(term) ||
|
||||
String(execution.rerunOfExecutionId ?? '').toLowerCase().includes(term)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -99,6 +123,28 @@ export class TasksExecutionsListComponent {
|
|||
this.executionDeleteRequested.emit(executionId);
|
||||
}
|
||||
|
||||
requestRerunExecution(executionId: string, event?: Event) {
|
||||
event?.stopPropagation();
|
||||
this.executionRerunRequested.emit(executionId);
|
||||
}
|
||||
|
||||
toggleGroup(groupId: string, event?: Event) {
|
||||
event?.stopPropagation();
|
||||
this.expandedGroupIds.update((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(groupId)) {
|
||||
next.delete(groupId);
|
||||
} else {
|
||||
next.add(groupId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
isGroupExpanded(groupId: string): boolean {
|
||||
return this.expandedGroupIds().has(groupId);
|
||||
}
|
||||
|
||||
onOrderChanged(event: OrderEvent) {
|
||||
this.orderBy.set(event.orderBy);
|
||||
this.orderDir.set(event.orderDir);
|
||||
|
|
@ -122,6 +168,19 @@ export class TasksExecutionsListComponent {
|
|||
return getExecutionStatusGroup(status) === 'RUNNING';
|
||||
}
|
||||
|
||||
canRerun(status: TaskExecutionStatus): boolean {
|
||||
return getExecutionStatusGroup(status) === 'FINAL';
|
||||
}
|
||||
|
||||
runNumberLabel(runNumber: number | null | undefined): string {
|
||||
return runNumber == null ? '-' : `#${runNumber}`;
|
||||
}
|
||||
|
||||
private groupMatchesFilter(group: TaskExecutionGroupListItem, filter: TaskExecutionFilter): boolean {
|
||||
if (filter === 'all') return true;
|
||||
return group.executions.some((execution) => this.matchesFilter(execution.status, filter));
|
||||
}
|
||||
|
||||
private matchesFilter(status: TaskExecutionStatus, filter: TaskExecutionFilter): boolean {
|
||||
if (filter === 'all') return true;
|
||||
return getExecutionStatusGroup(status) === filter;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { GenericNodeComponent } from "@shared/nodes/generic-node/generic-node";
|
|||
import { TaskStepNodeComponent } from "@shared/nodes/task-step-node/task-step-node";
|
||||
import { CustomSocket } from "@shared/custom-socket/custom-socket";
|
||||
import { CustomConnectionComponent } from "@shared/custom-connection/custom-connection";
|
||||
import { deleteSchemaValueByPath, setSchemaValueByPath } from "@shared/nodes/schema-driven-fields";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
type AreaExtra = AngularArea2D<HFSchemes>;
|
||||
|
|
@ -218,13 +219,13 @@ export async function addBlockToEditor(
|
|||
|
||||
await editor.removeNode(node.id);
|
||||
};
|
||||
const clearContainerSubflow = async () => {
|
||||
const clearContainerSubflow = async (targetPath = "subFlow") => {
|
||||
const currentNode = editor.getNode(node.id) as HFNode | undefined;
|
||||
if (!currentNode?.data) return;
|
||||
const nextConfiguration = {
|
||||
...cloneValue(currentNode.data.specificConfiguration ?? {})
|
||||
};
|
||||
delete (nextConfiguration as Record<string, unknown>)["subFlow"];
|
||||
deleteSchemaValueByPath(nextConfiguration as Record<string, unknown>, targetPath);
|
||||
const replacement = {
|
||||
...cloneValue(currentNode.data),
|
||||
inputs: [],
|
||||
|
|
@ -252,7 +253,7 @@ export async function addBlockToEditor(
|
|||
};
|
||||
const applyContainerSubflow = async (
|
||||
candidateSubFlow: FlowData,
|
||||
options?: { selectedIds?: Set<string>; validationUrl?: string | null; preValidate?: boolean; source?: 'drag' | 'import' }
|
||||
options?: { selectedIds?: Set<string>; targetPath?: string; validationUrl?: string | null; preValidate?: boolean; source?: 'drag' | 'import' }
|
||||
) => {
|
||||
if (!resolvedRuntime) return;
|
||||
|
||||
|
|
@ -288,11 +289,12 @@ export async function addBlockToEditor(
|
|||
}
|
||||
|
||||
const currentConfiguration = cloneValue(currentLiveNode.data.specificConfiguration ?? {}) as Record<string, unknown>;
|
||||
const targetPath = options?.targetPath ?? 'subFlow';
|
||||
const nextConfiguration: Record<string, unknown> = {
|
||||
...currentConfiguration,
|
||||
name: String(currentConfiguration['name'] ?? currentLiveNode.data['name'] ?? 'Container'),
|
||||
subFlow: candidateSubFlow
|
||||
name: String(currentConfiguration['name'] ?? currentLiveNode.data['name'] ?? 'Container')
|
||||
};
|
||||
setSchemaValueByPath(nextConfiguration, targetPath, candidateSubFlow);
|
||||
const nextPosition = cloneValue(currentLiveNode.data['position'] ?? null);
|
||||
|
||||
const containerId = String(currentLiveNode.data['id'] ?? '');
|
||||
|
|
@ -304,7 +306,7 @@ export async function addBlockToEditor(
|
|||
})
|
||||
);
|
||||
if (options?.source === 'import') {
|
||||
console.log('Container create response after subFlow import:', replacementFromServer);
|
||||
console.log(`Container create response after ${targetPath} import:`, replacementFromServer);
|
||||
}
|
||||
|
||||
const selectedIds = options?.selectedIds ?? new Set<string>();
|
||||
|
|
@ -361,7 +363,11 @@ export async function addBlockToEditor(
|
|||
await area.update("node", node.id);
|
||||
}
|
||||
};
|
||||
const assignSelectedBlocksToContainer = async (selectedBlockIds?: string[]) => {
|
||||
const assignSelectedBlocksToContainer = async (
|
||||
selectedBlockIds?: string[],
|
||||
targetPath = 'subFlow',
|
||||
validationUrl?: string | null
|
||||
) => {
|
||||
if (!resolvedRuntime) return;
|
||||
|
||||
const currentNode = editor.getNode(node.id) as HFNode | undefined;
|
||||
|
|
@ -403,10 +409,10 @@ export async function addBlockToEditor(
|
|||
)
|
||||
)
|
||||
};
|
||||
await applyContainerSubflow(candidateSubFlow, { selectedIds, preValidate: true });
|
||||
await applyContainerSubflow(candidateSubFlow, { selectedIds, targetPath, validationUrl, preValidate: true });
|
||||
};
|
||||
const assignImportedSubflow = async (subFlow: FlowData, validationUrl?: string | null) => {
|
||||
await applyContainerSubflow(cloneValue(subFlow), { validationUrl, source: 'import' });
|
||||
const assignImportedSubflow = async (subFlow: FlowData, targetPath = 'subFlow', validationUrl?: string | null) => {
|
||||
await applyContainerSubflow(cloneValue(subFlow), { targetPath, validationUrl, preValidate: true, source: 'import' });
|
||||
};
|
||||
const replaceWithCreatedNode = async (createdBlock: FlowNode) => {
|
||||
if (!editor.getNode(node.id)) return;
|
||||
|
|
|
|||
Loading…
Reference in New Issue