Add suspended execution resume support and production Docker setup

This commit is contained in:
Lucio Lelii 2026-03-22 10:35:30 +01:00
parent 447b01786d
commit fad93a3eca
17 changed files with 1106 additions and 1090 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
node_modules
dist
.angular
.git
.gitignore
Dockerfile
README.md
npm-debug.log

18
Dockerfile Normal file
View File

@ -0,0 +1,18 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install --legacy-peer-deps
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist/humainFlow-gui-a21/browser /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -43,13 +43,13 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
"maximumWarning": "1.75MB",
"maximumError": "2MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
"maximumWarning": "12kB",
"maximumError": "16kB"
}
],
"outputHashing": "all"

11
nginx.conf Normal file
View File

@ -0,0 +1,11 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

2030
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -23,17 +23,17 @@
"private": true,
"packageManager": "npm@10.8.2",
"dependencies": {
"@angular/animations": "^21.0.4",
"@angular/cdk": "^21.0.5",
"@angular/common": "^21.0.4",
"@angular/compiler": "^21.0.4",
"@angular/core": "^21.0.4",
"@angular/elements": "^21.0.8",
"@angular/forms": "^21.0.4",
"@angular/material": "^21.0.5",
"@angular/platform-browser": "^21.0.4",
"@angular/platform-browser-dynamic": "^21.0.4",
"@angular/router": "^21.0.0",
"@angular/animations": "21.0.5",
"@angular/cdk": "21.0.5",
"@angular/common": "21.0.5",
"@angular/compiler": "21.0.5",
"@angular/core": "21.0.5",
"@angular/elements": "21.0.5",
"@angular/forms": "21.0.5",
"@angular/material": "21.0.5",
"@angular/platform-browser": "21.0.5",
"@angular/platform-browser-dynamic": "21.0.5",
"@angular/router": "21.0.5",
"@fontsource/material-icons-outlined": "^5.2.6",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
@ -50,9 +50,9 @@
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.0.3",
"@angular/cli": "^21.0.3",
"@angular/compiler-cli": "^21.0.0",
"@angular/build": "21.0.3",
"@angular/cli": "21.0.3",
"@angular/compiler-cli": "21.0.5",
"@tailwindcss/postcss": "^4.1.18",
"jsdom": "^27.1.0",
"postcss": "^8.5.6",

View File

@ -1,7 +1,7 @@
import { FlowBlock, FlowBlockConnection, FlowContainer, FlowNode, FlowPort } from './flow';
export type TaskExecutionStatus = 'CREATED' | 'READY' | 'RUNNING' | 'WAITING' | 'SUCCESS' | 'ERROR' | 'CANCELLED';
export type TaskExecutionStatusGroup = 'INIT' | 'RUNNING' | 'FINAL';
export type TaskExecutionStatus = 'CREATED' | 'READY' | 'RUNNING' | 'WAITING' | 'SUSPENDED' | 'SUCCESS' | 'ERROR' | 'CANCELLED';
export type TaskExecutionStatusGroup = 'INIT' | 'RUNNING' | 'PAUSED' | 'FINAL';
export type StepStatus = 'WAITING_FOR_INPUT' | 'FAILED' | 'COMPLETED' | 'RUNNING' | string;
@ -73,6 +73,9 @@ export function getExecutionStatusGroup(status: string | null | undefined): Task
) {
return 'RUNNING';
}
if (normalized === 'SUSPENDED') {
return 'PAUSED';
}
if (normalized === 'SUCCESS' || normalized === 'ERROR' || normalized === 'CANCELLED') {
return 'FINAL';
}
@ -88,6 +91,7 @@ export function normalizeExecutionStatus(status: string | null | undefined): Tas
if (normalized === 'WAITING' || normalized === 'WAITING_FOR_INPUT' || normalized === 'WAITING_FOR_INTERACTION') {
return 'WAITING';
}
if (normalized === 'SUSPENDED') return 'SUSPENDED';
if (normalized === 'CANCELLED') return 'CANCELLED';
if (normalized === 'SUCCESS' || normalized === 'COMPLETED') return 'SUCCESS';
if (normalized === 'ERROR' || normalized === 'FAILED') return 'ERROR';

View File

@ -4,7 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { form, Field, minLength, email, required, validate, maxLength, disabled } from '@angular/forms/signals'
import { Field, form, minLength, email, required, validate, maxLength, disabled } from '@angular/forms/signals'
import { Router, RouterLink } from '@angular/router';
import { UserRegistration } from '@models/user';
import { Authorization } from '@services/authorization/authorization';
@ -13,7 +13,7 @@ import { FormUtility } from '@utilities/form-utility';
@Component({
selector: 'app-signup',
imports: [FormsModule, Field, RouterLink, MatButtonModule, MatCardModule, MatFormFieldModule, MatInputModule],
imports: [FormsModule, RouterLink, Field, MatButtonModule, MatCardModule, MatFormFieldModule, MatInputModule],
templateUrl: './signup.html',
styleUrl: './signup.css',
})

View File

@ -7,6 +7,7 @@ export abstract class TaskExecutionsCallServiceBase {
abstract deleteTaskExecution(executionId: string): Observable<void>;
abstract startTaskExecution(executionId: string): Observable<TaskExecution>;
abstract cancelTaskExecution(executionId: string): Observable<TaskExecution>;
abstract resumeTaskExecution(executionId: string): Observable<TaskExecution>;
abstract prepareStringInput(
executionId: string,
nodeId: string,

View File

@ -516,6 +516,24 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
return of(execution);
}
override resumeTaskExecution(executionId: string): Observable<TaskExecution> {
const execution = this.findExecution(executionId);
const hasWaitingSteps = (execution.context.waitingSteps?.length ?? 0) > 0;
execution.context.status = hasWaitingSteps ? 'WAITING' : 'RUNNING';
execution.context.endTime = null;
for (const stepId of execution.context.waitingSteps ?? []) {
const step = execution.context.steps?.[stepId];
if (!step) continue;
const status = String(step.status ?? '').toUpperCase();
if (status === 'SUSPENDED' || status === 'WAITING' || status === 'WAITING_FOR_INPUT' || status === 'WAITING_FOR_INTERACTION') {
step.status = 'WAITING_FOR_INTERACTION';
}
}
return of(execution);
}
override prepareStringInput(
executionId: string,
nodeId: string,

View File

@ -28,6 +28,10 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
return this.http.put<TaskExecution>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/cancel`, null);
}
override resumeTaskExecution(executionId: string): Observable<TaskExecution> {
return this.http.put<TaskExecution>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/resume`, null);
}
override prepareStringInput(
executionId: string,
nodeId: string,

View File

@ -77,6 +77,22 @@ export class TaskExecutionsService {
);
}
resumeExecution(executionId: string) {
const execution = this._taskExecutions().find((item) => item.id === executionId);
const status = String(execution?.context.status ?? '').toUpperCase();
if (status !== 'SUSPENDED') {
return throwError(() => new Error('Resume is only supported for suspended executions.'));
}
return this.taskExecutionsCallService.resumeTaskExecution(executionId).pipe(
tap(() => this.refresh()),
catchError((err) => {
console.error('Resume execution failed', err);
return throwError(() => err);
})
);
}
prepareStringInput(executionId: string, nodeId: string, inputName: string, value: string) {
return this.taskExecutionsCallService.prepareStringInput(executionId, nodeId, inputName, value).pipe(
tap(() => this.refresh()),

View File

@ -366,6 +366,7 @@ export class TaskStepNodeComponent {
event?.preventDefault();
event?.stopPropagation();
if (this.interactionSubmitting) return;
if (this.executionStatus() === 'SUSPENDED') return;
const executionId = this.executionId();
const executionNodeId = this.executionNodeId();
@ -418,6 +419,11 @@ export class TaskStepNodeComponent {
return typeof value === 'string' && value.length > 0 ? value : null;
}
private executionStatus(): string {
const value = this.blockConfiguration?.['__executionStatus'];
return typeof value === 'string' ? value.toUpperCase() : '';
}
private getExecutionMessages(key: '__executionErrors' | '__executionWarnings'): string[] {
const values = this.blockConfiguration?.[key];
if (!Array.isArray(values)) return [];

View File

@ -8,6 +8,15 @@
<p class="text-sm text-slate-500">Execution ID: {{ execution()!.id }}</p>
</div>
<div class="flex items-stretch gap-2">
<button
type="button"
class="self-stretch h-full rounded-md border border-violet-300 bg-violet-50 px-3 text-violet-700 hover:bg-violet-100 disabled:opacity-40 disabled:cursor-not-allowed"
matTooltip="Resume execution"
aria-label="Resume execution"
[disabled]="!canResumeExecution()"
(click)="resumeExecution()">
Resume
</button>
<button
type="button"
class="self-stretch h-full w-10 rounded-md border border-rose-300 bg-rose-50 text-rose-600 hover:bg-rose-100 disabled:opacity-40 disabled:cursor-not-allowed"
@ -58,6 +67,11 @@
Execution cancelled. This execution is closed. Start a new execution to continue.
</div>
}
@if (execution()!.context.status === 'SUSPENDED') {
<div class="mx-1 mt-1 rounded-md border border-violet-200 bg-violet-50 px-4 py-3 text-sm text-violet-900">
Execution suspended after service restart. Resume the execution to continue.
</div>
}
<div class="flex-1 min-h-0 flex gap-2 p-1">
<div class="flex-1 border border-slate-200 rounded-md bg-slate-50 min-h-0 overflow-hidden">

View File

@ -57,6 +57,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
readonly activeAsideTab = signal<'inputs' | 'output'>('inputs');
readonly startInProgress = signal(false);
readonly cancelInProgress = signal(false);
readonly resumeInProgress = signal(false);
readonly savingInputs = signal<Record<string, boolean>>({});
readonly savingErrors = signal<Record<string, string>>({});
readonly pendingTextInputs = signal<Record<string, string | string[]>>({});
@ -108,7 +109,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
if (dialogState.executionId !== execution.id || !dialogState.nodeId) return;
const executionStatus = String(execution.context.status ?? '').toUpperCase();
if (executionStatus === 'CANCELLED') {
if (executionStatus === 'CANCELLED' || executionStatus === 'SUSPENDED') {
this.humanInteractionDialog.close(null);
return;
}
@ -212,6 +213,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
...(stepNode.specificConfiguration ?? {}),
__executionId: this.execution()?.id ?? null,
__executionNodeId: step.id,
__executionStatus: this.execution()?.context.status ?? null,
__stepStatus: step.status,
__executionStatusGroup: executionStatusGroup,
__isWaitingStep: waitingSteps.includes(step.id),
@ -287,6 +289,11 @@ export class TaskExecutionViewerComponent implements OnDestroy {
return !this.cancelInProgress() && (status === 'RUNNING' || status === 'WAITING');
});
readonly canResumeExecution = computed(() => {
const status = String(this.execution()?.context.status ?? '').toUpperCase();
return !this.resumeInProgress() && status === 'SUSPENDED';
});
readonly canStartExecution = computed(() => {
const execution = this.execution();
if (!execution) return false;
@ -400,6 +407,17 @@ export class TaskExecutionViewerComponent implements OnDestroy {
});
}
resumeExecution() {
const executionId = this.execution()?.id;
if (!executionId || !this.canResumeExecution()) return;
this.resumeInProgress.set(true);
this.taskExecutionsService.resumeExecution(executionId).subscribe({
next: () => this.resumeInProgress.set(false),
error: () => this.resumeInProgress.set(false)
});
}
onTextInputChange(input: EditableExecutionInput, value: string | string[]) {
if (this.inputsReadOnly()) return;
const executionId = this.execution()?.id;

View File

@ -13,6 +13,7 @@
<mat-button-toggle value="all">All</mat-button-toggle>
<mat-button-toggle value="INIT">Init</mat-button-toggle>
<mat-button-toggle value="RUNNING">Running</mat-button-toggle>
<mat-button-toggle value="PAUSED">Paused</mat-button-toggle>
<mat-button-toggle value="FINAL">Final</mat-button-toggle>
</mat-button-toggle-group>
</div>

View File

@ -109,6 +109,7 @@ export class TasksExecutionsListComponent {
if (normalized === 'SUCCESS') return 'bg-emerald-100 text-emerald-700 border-emerald-200';
if (normalized === 'ERROR') return 'bg-rose-100 text-rose-700 border-rose-200';
if (normalized === 'CANCELLED') return 'bg-slate-200 text-slate-700 border-slate-300';
if (normalized === 'SUSPENDED') return 'bg-violet-100 text-violet-700 border-violet-200';
if (normalized === 'WAITING') return 'bg-amber-100 text-amber-700 border-amber-200';
if (normalized === 'RUNNING') return 'bg-blue-100 text-blue-700 border-blue-200';
if (normalized === 'CREATED' || normalized === 'READY') return 'bg-slate-100 text-slate-700 border-slate-200';