added task layout

This commit is contained in:
Lucio Lelii 2026-03-03 20:01:03 +01:00
parent e3446e7aac
commit 4504ec0087
32 changed files with 578 additions and 62 deletions

View File

@ -4,7 +4,7 @@ import { Signup } from '@pages/auth/signup/signup';
import { AppLayout } from '@layouts/app-layout/app-layout';
import { authGuard } from '@guards/auth-guard';
import { FlowEditor } from '@layouts/flow-editor/flow-editor';
import { Tasks } from '@pages/main/tasks/tasks';
import { TasksExecutor } from '@layouts/tasks-executor/tasks-executor';
export const routes: Routes = [
{
@ -22,7 +22,7 @@ export const routes: Routes = [
},
{
path: 'tasks',
component: Tasks
component: TasksExecutor
}
],
canActivate: [authGuard]

View File

@ -0,0 +1,4 @@
:host {
display: block;
height: 100%;
}

View File

@ -0,0 +1,14 @@
<div class="flex flex-col flex-1 h-full">
<div class="flex flex-row flex-1 overflow-hidden gap-2">
<app-tasks-executions-list
class="w-[320px] shrink-0"
[executions]="executions()"
[selectedExecutionId]="selectedExecutionId()"
(executionSelected)="selectExecution($event)">
</app-tasks-executions-list>
<main class="flex flex-col w-full bg-gray-100 overflow-hidden rounded-md">
<app-task-execution-viewer [execution]="selectedExecution()"></app-task-execution-viewer>
</main>
</div>
</div>

View File

@ -1,18 +1,18 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Tasks } from './tasks';
import { TasksExecutor } from './tasks-executor';
describe('Tasks', () => {
let component: Tasks;
let fixture: ComponentFixture<Tasks>;
describe('TasksExecutor', () => {
let component: TasksExecutor;
let fixture: ComponentFixture<TasksExecutor>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Tasks]
imports: [TasksExecutor]
})
.compileComponents();
fixture = TestBed.createComponent(Tasks);
fixture = TestBed.createComponent(TasksExecutor);
component = fixture.componentInstance;
await fixture.whenStable();
});

View File

@ -0,0 +1,47 @@
import { Component, signal } from '@angular/core';
import { TasksExecutionsListComponent, TaskExecutionListItem } from '@shared/tasks-executions-list/tasks-executions-list';
import { TaskExecutionViewerComponent } from '@shared/task-execution-viewer/task-execution-viewer';
@Component({
selector: 'app-tasks-executor',
imports: [TasksExecutionsListComponent, TaskExecutionViewerComponent],
templateUrl: './tasks-executor.html',
styleUrl: './tasks-executor.css',
})
export class TasksExecutor {
readonly executions = signal<TaskExecutionListItem[]>([
{
id: 'run-001',
title: 'Customer onboarding flow',
flowName: 'Onboarding',
status: 'RUNNING',
startedAt: '2026-03-03 10:21',
duration: '00:02:13'
},
{
id: 'run-002',
title: 'Weekly report generation',
flowName: 'Report Builder',
status: 'COMPLETED',
startedAt: '2026-03-03 09:15',
duration: '00:06:48'
},
{
id: 'run-003',
title: 'Data enrichment pipeline',
flowName: 'Enricher',
status: 'FAILED',
startedAt: '2026-03-03 08:42',
duration: '00:01:59'
}
]);
readonly selectedExecutionId = signal<string | null>(this.executions()[0]?.id ?? null);
readonly selectedExecution = () =>
this.executions().find((execution) => execution.id === this.selectedExecutionId()) ?? null;
selectExecution(id: string) {
this.selectedExecutionId.set(id);
}
}

View File

@ -1,8 +1,12 @@
import { GetSchemes, ClassicPreset } from "rete";
import { FlowBlock } from "./flow";
export type HFNodeData = FlowBlock & {
deleteNode?: () => Promise<void>;
};
export type HFNode = ClassicPreset.Node & {
data?: FlowBlock;
data?: HFNodeData;
};
export type HFConnection = ClassicPreset.Connection<HFNode, HFNode>;

View File

@ -1 +0,0 @@
<p>tasks works!</p>

View File

@ -1,11 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-tasks',
imports: [],
templateUrl: './tasks.html',
styleUrl: './tasks.css',
})
export class Tasks {
}

View File

@ -51,6 +51,10 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
"LLMDescriptor": {
"type": "object",
"additionalProperties": false,
"required": [
"provider",
"model"
],
"properties": {
"provider": {
"type": "string",
@ -111,6 +115,10 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
"LLMDescriptor": {
"type": "object",
"additionalProperties": false,
"required": [
"provider",
"model"
],
"properties": {
"provider": {
"type": "string",

View File

@ -15,20 +15,26 @@ export class CustomSocket {
@HostBinding("style.width") w = "15px";
@HostBinding("style.height") h = "15px";
@HostBinding("style.display") d = "block";
@HostBinding("style.borderRadius") br = "9999px";
@HostBinding("style.border") border = "2px solid white";
@HostBinding("style.borderRadius") br = "4px";
@HostBinding("style.border") border = "1px solid rgba(255,255,255,0.9)";
@HostBinding("style.cursor") cursor = "crosshair";
@HostBinding("style.background")
get bg() {
return this.data?.side === "input" ? "rgb(34,197,94)" : "rgb(99,102,241)";
return this.socketSide === "input"
? "linear-gradient(145deg, #4ade80 0%, #16a34a 100%)"
: "linear-gradient(145deg, #fb7185 0%, #dc2626 100%)";
}
@HostBinding("style.boxShadow")
get sh() {
console.log("CustomSocket sh data", this.data);
const c = this.data?.side === "input" ? "rgb(34,197,94)" : "rgb(99,102,241)";
return `0 0 0 1px ${c}`;
const c = this.socketSide === "input" ? "rgba(22,163,74,0.45)" : "rgba(220,38,38,0.45)";
return `0 2px 6px ${c}, 0 0 0 1px ${c}`;
}
private get socketSide(): "input" | "output" {
const side = this.data?.__hfSide ?? this.data?.side;
return side === "output" ? "output" : "input";
}

View File

@ -47,6 +47,13 @@
min-width: 0;
}
.hi-header-actions {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
}
.hi-warning {
width: 24px;
height: 24px;
@ -97,7 +104,25 @@
.hi-warning-wrap {
position: relative;
margin-left: auto;
margin-left: 0;
}
.hi-delete-btn {
width: 24px;
height: 24px;
border-radius: 999px;
border: 1px solid rgba(127, 29, 29, 0.45);
background: rgba(127, 29, 29, 0.2);
color: #fff1f2;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.15s ease;
}
.hi-delete-btn:hover {
background: rgba(127, 29, 29, 0.35);
}
.hi-warning-tooltip {

View File

@ -12,19 +12,24 @@
</button>
</div>
</div>
@if (missingRequiredParams.length) {
<div class="hi-warning-wrap">
<div class="hi-warning">
<i class="bi bi-exclamation-triangle-fill"></i>
</div>
<div class="hi-warning-tooltip">
<div class="hi-warning-title">Missing required fields</div>
@for (missing of missingRequiredParams; track missing) {
<div class="hi-warning-item">{{ missing }}</div>
}
<div class="hi-header-actions">
@if (missingRequiredParams.length) {
<div class="hi-warning-wrap">
<div class="hi-warning">
<i class="bi bi-exclamation-triangle-fill"></i>
</div>
<div class="hi-warning-tooltip">
<div class="hi-warning-title">Missing required fields</div>
@for (missing of missingRequiredParams; track missing) {
<div class="hi-warning-item">{{ missing }}</div>
}
</div>
</div>
}
<button type="button" class="hi-delete-btn" title="Delete node" (pointerdown)="$event.stopPropagation()" (click)="confirmDelete($event)">
<i class="bi bi-trash"></i>
</button>
</div>
}
</div>
<div class="hi-body">

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, HostBinding, inject, Input } from '@angular/core';
import { ChangeDetectorRef, Component, HostBinding, inject, Input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
@ -24,6 +24,7 @@ export class HumanInteractionNodeComponent {
private editorState = inject(EditorStateHolder);
private fieldRetreiver = inject(FieldRetreiver);
private blocksService = inject(BlocksService);
private cdr = inject(ChangeDetectorRef);
@Input() data!: any;
@Input() emit!: (data: any) => void;
@ -181,6 +182,19 @@ export class HumanInteractionNodeComponent {
this.openSimpleParamEditor('name', event);
}
async confirmDelete(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const confirmed = window.confirm('Do you want to delete this node from the flow?');
if (!confirmed) return;
const deleteNode = this.data?.data?.deleteNode;
if (typeof deleteNode === 'function') {
await deleteNode();
}
}
private ensureBlockConfiguration(): Record<string, any> {
if (!this.data?.data) {
this.data.data = {};
@ -241,6 +255,8 @@ export class HumanInteractionNodeComponent {
if (!this.refreshingConditionalRequirements) {
void this.refreshConditionalRequirements();
}
this.refreshView();
}
private getByPath(source: Record<string, any>, path: string): unknown {
@ -309,4 +325,14 @@ export class HumanInteractionNodeComponent {
isConditionallyRequired(path: string) {
return !!this.conditionalRequiredByPath.get(path);
}
private refreshView() {
queueMicrotask(() => {
try {
this.cdr.detectChanges();
} catch {
// Node may have been removed while async validation was running.
}
});
}
}

View File

@ -1,6 +1,14 @@
<div class="cursor-move user-select-none rounded-xl bg-emerald-100 shadow-lg border border-gray-200 overflow-visible">
<!-- header/row relativa -->
<div class="relative w-full p-3 pt-0 flex items-center justify-between">
<button
type="button"
class="node-delete-btn"
title="Delete node"
(pointerdown)="$event.stopPropagation()"
(click)="confirmDelete($event)">
<i class="bi bi-trash"></i>
</button>
<div class="grid grid-cols-4 h-full">
<div class="col-span-4 text-ls mb-2 p-1 rounded-md text-green-700 font-bold">
{{ data.label }}
@ -20,4 +28,4 @@
</div>
</div>
</div>
</div>

View File

@ -35,4 +35,25 @@
.input-socket {
text-align: left; margin-left: -1px; display: inline-block;
}
}
.node-delete-btn {
position: absolute;
right: 4px;
top: 4px;
width: 22px;
height: 22px;
border: none;
border-radius: 999px;
background: rgba(185, 28, 28, 0.12);
color: #991b1b;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 20;
}
.node-delete-btn:hover {
background: rgba(185, 28, 28, 0.2);
}

View File

@ -42,4 +42,17 @@ export class InputNodeComponent {
this.rendered();
}
async confirmDelete(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const confirmed = window.confirm('Do you want to delete this node from the flow?');
if (!confirmed) return;
const deleteNode = this.data?.data?.deleteNode;
if (typeof deleteNode === 'function') {
await deleteNode();
}
}
}

View File

@ -52,6 +52,13 @@
min-width: 0;
}
.llm-header-actions {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
}
.llm-warning {
width: 24px;
height: 24px;
@ -102,7 +109,25 @@
.llm-warning-wrap {
position: relative;
margin-left: auto;
margin-left: 0;
}
.llm-delete-btn {
width: 24px;
height: 24px;
border-radius: 999px;
border: 1px solid rgba(185, 28, 28, 0.45);
background: rgba(127, 29, 29, 0.2);
color: #fff1f2;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.15s ease;
}
.llm-delete-btn:hover {
background: rgba(127, 29, 29, 0.35);
}
.llm-warning-tooltip {

View File

@ -12,19 +12,24 @@
</button>
</div>
</div>
@if (missingRequiredParams.length) {
<div class="llm-warning-wrap">
<div class="llm-warning">
<i class="bi bi-exclamation-triangle-fill"></i>
</div>
<div class="llm-warning-tooltip">
<div class="llm-warning-title">Missing required fields</div>
@for (missing of missingRequiredParams; track missing) {
<div class="llm-warning-item">{{ missing }}</div>
}
<div class="llm-header-actions">
@if (missingRequiredParams.length) {
<div class="llm-warning-wrap">
<div class="llm-warning">
<i class="bi bi-exclamation-triangle-fill"></i>
</div>
<div class="llm-warning-tooltip">
<div class="llm-warning-title">Missing required fields</div>
@for (missing of missingRequiredParams; track missing) {
<div class="llm-warning-item">{{ missing }}</div>
}
</div>
</div>
}
<button type="button" class="llm-delete-btn" title="Delete node" (pointerdown)="$event.stopPropagation()" (click)="confirmDelete($event)">
<i class="bi bi-trash"></i>
</button>
</div>
}
</div>
<div class="llm-body">

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, HostBinding, inject, Input } from '@angular/core';
import { ChangeDetectorRef, Component, HostBinding, inject, Input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
@ -25,6 +25,7 @@ export class LLMNodeComponent {
private editorState = inject(EditorStateHolder);
private fieldRetreiver = inject(FieldRetreiver);
private blocksService = inject(BlocksService);
private cdr = inject(ChangeDetectorRef);
@Input() data!: any;
@Input() emit!: (data: any) => void;
@ -79,8 +80,7 @@ export class LLMNodeComponent {
this.inputs.push({ key: inKey, socket: (input as any).socket });
});
const config = this.blockConfiguration;
if (!config) return;
const config = this.ensureBlockConfiguration();
this.name = this.toStringOrNull(config['name']) || this.name;
@ -211,6 +211,19 @@ export class LLMNodeComponent {
this.openSimpleParamEditor('name', event);
}
async confirmDelete(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const confirmed = window.confirm('Do you want to delete this node from the flow?');
if (!confirmed) return;
const deleteNode = this.data?.data?.deleteNode;
if (typeof deleteNode === 'function') {
await deleteNode();
}
}
private get blockConfiguration(): Record<string, any> | null {
return this.data?.data?.specificConfiguration ?? null;
}
@ -280,6 +293,8 @@ export class LLMNodeComponent {
if (!this.refreshingConditionalRequirements) {
void this.refreshConditionalRequirements();
}
this.refreshView();
}
private getByPath(source: Record<string, any>, path: string): unknown {
@ -387,4 +402,14 @@ export class LLMNodeComponent {
return match ? match[1] : token;
}
private refreshView() {
queueMicrotask(() => {
try {
this.cdr.detectChanges();
} catch {
// Node may have been removed while async validation was running.
}
});
}
}

View File

@ -1,6 +1,14 @@
<div class="cursor-move user-select-none rounded-xl bg-red-100 shadow-lg border border-gray-200 overflow-visible">
<!-- header/row relativa -->
<div class="relative w-full p-3 pt-0 flex items-center justify-between">
<button
type="button"
class="node-delete-btn"
title="Delete node"
(pointerdown)="$event.stopPropagation()"
(click)="confirmDelete($event)">
<i class="bi bi-trash"></i>
</button>
<div class="grid grid-cols-4 h-full">
<div class="col-span-4 text-ls mb-2 p-1 rounded-md text-red-700 font-bold">
{{ data.label }}
@ -23,4 +31,3 @@
</div>
</div>

View File

@ -20,3 +20,24 @@
color:white;
font-weight: bolder;
}
.node-delete-btn {
position: absolute;
right: 4px;
top: 4px;
width: 22px;
height: 22px;
border: none;
border-radius: 999px;
background: rgba(185, 28, 28, 0.12);
color: #991b1b;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 20;
}
.node-delete-btn:hover {
background: rgba(185, 28, 28, 0.2);
}

View File

@ -41,4 +41,17 @@ export class OutputNodeComponent {
this.inputSocket = (input as any).socket;
}
async confirmDelete(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const confirmed = window.confirm('Do you want to delete this node from the flow?');
if (!confirmed) return;
const deleteNode = (this.data as any)?.data?.deleteNode;
if (typeof deleteNode === 'function') {
await deleteNode();
}
}
}

View File

@ -0,0 +1,4 @@
:host {
display: block;
width: 100%;
}

View File

@ -1,7 +1,7 @@
<div class="flex items-center w-full justify-between gap-2">
<div class="form-floating">
<div class="form-floating flex-1">
<select id="orderSelect"
class="form-select form-select-sm text-sm w-auto"
class="form-select form-select-sm text-sm w-full"
[(ngModel)]="orderBy"
placeholder="Order By"
>

View File

@ -0,0 +1,4 @@
:host {
display: block;
height: 100%;
}

View File

@ -0,0 +1,35 @@
<section class="h-full p-4">
@if (execution()) {
<div class="h-full bg-white border border-slate-200 rounded-md flex flex-col overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200">
<h2 class="text-base font-semibold text-slate-900">{{ execution()!.title }}</h2>
<p class="text-sm text-slate-500">Execution ID: {{ execution()!.id }}</p>
</div>
<div class="p-5 grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="bg-slate-50 border border-slate-200 rounded-md p-3">
<div class="text-xs text-slate-500">Flow</div>
<div class="text-sm font-medium text-slate-800">{{ execution()!.flowName }}</div>
</div>
<div class="bg-slate-50 border border-slate-200 rounded-md p-3">
<div class="text-xs text-slate-500">Status</div>
<div class="text-sm font-medium text-slate-800">{{ execution()!.status }}</div>
</div>
<div class="bg-slate-50 border border-slate-200 rounded-md p-3">
<div class="text-xs text-slate-500">Duration</div>
<div class="text-sm font-medium text-slate-800">{{ execution()!.duration || '-' }}</div>
</div>
</div>
<div class="flex-1 p-5 pt-0 overflow-auto">
<div class="h-full rounded-md border border-dashed border-slate-300 bg-slate-50 text-slate-500 text-sm flex items-center justify-center">
Execution details viewer placeholder
</div>
</div>
</div>
} @else {
<div class="h-full bg-white border border-slate-200 rounded-md text-slate-400 text-center flex items-center justify-center">
Select an execution from the left panel
</div>
}
</section>

View File

@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { Component, input } from '@angular/core';
import { TaskExecutionListItem } from '@shared/tasks-executions-list/tasks-executions-list';
@Component({
selector: 'app-task-execution-viewer',
imports: [CommonModule],
templateUrl: './task-execution-viewer.html',
styleUrl: './task-execution-viewer.css',
})
export class TaskExecutionViewerComponent {
readonly execution = input<TaskExecutionListItem | null>(null);
}

View File

@ -0,0 +1,4 @@
:host {
display: block;
height: 100%;
}

View File

@ -0,0 +1,82 @@
<aside class="h-full bg-white border border-slate-200 rounded-md flex flex-col overflow-hidden">
<div class="px-4 py-3 border-b border-slate-200">
<h2 class="text-sm font-semibold text-slate-800">Task Executions</h2>
<p class="text-xs text-slate-500">Running and completed runs</p>
</div>
<div class="px-3 pt-2">
<div class="relative flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="absolute w-5 h-5 top-2.5 left-2.5 text-slate-600">
<path fill-rule="evenodd"
d="M10.5 3.75a6.75 6.75 0 1 0 0 13.5 6.75 6.75 0 0 0 0-13.5ZM2.25 10.5a8.25 8.25 0 1 1 14.59 5.28l4.69 4.69a.75.75 0 1 1-1.06 1.06l-4.69-4.69A8.25 8.25 0 0 1 2.25 10.5Z"
clip-rule="evenodd" />
</svg>
<input
class="w-full bg-transparent placeholder:text-slate-400 text-slate-700 text-sm border border-slate-200 rounded-md pl-10 pr-3 py-2 transition duration-300 ease focus:outline-none focus:border-slate-400 hover:border-slate-300 shadow-sm focus:shadow"
placeholder="Search executions..."
[(ngModel)]="searchTerm" />
</div>
</div>
<div class="px-3 pt-2">
<div class="btn-group w-full h-8" role="group">
<button type="button" class="btn btn-sm w-full transition"
[class.btn-primary]="filter() === 'all'"
[class.btn-outline-primary]="filter() !== 'all'"
(click)="filter.set('all')">All</button>
<button type="button" class="btn btn-sm w-full transition"
[class.btn-primary]="filter() === 'RUNNING'"
[class.btn-outline-primary]="filter() !== 'RUNNING'"
(click)="filter.set('RUNNING')">Running</button>
<button type="button" class="btn btn-sm w-full transition"
[class.btn-primary]="filter() === 'COMPLETED'"
[class.btn-outline-primary]="filter() !== 'COMPLETED'"
(click)="filter.set('COMPLETED')">Completed</button>
<button type="button" class="btn btn-sm w-full transition"
[class.btn-primary]="filter() === 'FAILED'"
[class.btn-outline-primary]="filter() !== 'FAILED'"
(click)="filter.set('FAILED')">Failed</button>
</div>
</div>
<div class="px-3 py-2">
<app-ordering
[orderFields]="orderFields"
[orderView]="orderView"
(orderChanged)="onOrderChanged($event)">
</app-ordering>
</div>
<div class="flex-1 overflow-y-auto p-2 space-y-2">
@if (!filteredExecutions().length) {
<div class="text-xs text-slate-500 px-2 py-4 text-center">No executions available</div>
} @else {
@for (execution of filteredExecutions(); track execution.id) {
<button
type="button"
class="w-full text-left p-3 rounded-md border transition-colors"
[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="min-w-0">
<div class="text-sm font-medium text-slate-800 truncate">{{ execution.title }}</div>
<div class="text-xs text-slate-500 truncate">{{ execution.flowName }}</div>
</div>
<span class="text-[10px] px-2 py-0.5 rounded-full border font-semibold"
[ngClass]="statusBadgeClass(execution.status)">
{{ execution.status }}
</span>
</div>
<div class="mt-2 text-[11px] text-slate-500 flex justify-between gap-2">
<span>{{ execution.startedAt }}</span>
<span>{{ execution.duration || '-' }}</span>
</div>
</button>
}
}
</div>
</aside>

View File

@ -0,0 +1,98 @@
import { CommonModule } from '@angular/common';
import { Component, computed, input, model, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { OrderEvent, OrderField, Ordering, orderDirType } from '@shared/ordering/ordering';
import { OrderViewState } from '@utilities/list-state-holder';
export type TaskExecutionStatus = 'RUNNING' | 'COMPLETED' | 'FAILED' | 'QUEUED';
export type TaskExecutionListItem = {
id: string;
title: string;
flowName: string;
status: TaskExecutionStatus;
startedAt: string;
duration?: string;
};
@Component({
selector: 'app-tasks-executions-list',
imports: [CommonModule, FormsModule, Ordering],
templateUrl: './tasks-executions-list.html',
styleUrl: './tasks-executions-list.css',
})
export class TasksExecutionsListComponent {
readonly executions = input<TaskExecutionListItem[]>([]);
readonly selectedExecutionId = input<string | null>(null);
readonly executionSelected = output<string>();
readonly searchTerm = model<string>('');
readonly filter = signal<TaskExecutionStatus | 'all'>('all');
readonly orderBy = signal<string | null>('startedAt');
readonly orderDir = signal<orderDirType>('desc');
readonly orderView: OrderViewState = {
orderBy: this.orderBy(),
orderDir: this.orderDir()
};
readonly orderFields: OrderField[] = [
{ field: 'title', label: 'Title' },
{ field: 'flowName', label: 'Flow Name' },
{ field: 'startedAt', label: 'Started At' },
{ field: 'duration', label: 'Duration' }
];
readonly filteredExecutions = computed(() => {
const term = this.searchTerm().trim().toLowerCase();
const status = this.filter();
const orderBy = this.orderBy();
const orderDir = this.orderDir();
const filtered = this.executions().filter((execution) => {
if (status !== 'all' && execution.status !== status) return false;
if (!term) return true;
return (
execution.title.toLowerCase().includes(term) ||
execution.flowName.toLowerCase().includes(term) ||
execution.id.toLowerCase().includes(term)
);
});
if (!orderBy) return filtered;
return [...filtered].sort((a, b) => {
const aValue = (a as any)[orderBy];
const bValue = (b as any)[orderBy];
if (aValue == null && bValue == null) return 0;
if (aValue == null) return orderDir === 'asc' ? -1 : 1;
if (bValue == null) return orderDir === 'asc' ? 1 : -1;
const aComparable = typeof aValue === 'string' ? aValue.toLowerCase() : aValue;
const bComparable = typeof bValue === 'string' ? bValue.toLowerCase() : bValue;
if (aComparable < bComparable) return orderDir === 'asc' ? -1 : 1;
if (aComparable > bComparable) return orderDir === 'asc' ? 1 : -1;
return 0;
});
});
selectExecution(executionId: string) {
this.executionSelected.emit(executionId);
}
onOrderChanged(event: OrderEvent) {
this.orderBy.set(event.orderBy);
this.orderDir.set(event.orderDir);
this.orderView.orderBy = event.orderBy;
this.orderView.orderDir = event.orderDir;
}
statusBadgeClass(status: TaskExecutionStatus) {
if (status === 'RUNNING') return 'bg-blue-100 text-blue-700 border-blue-200';
if (status === 'COMPLETED') return 'bg-emerald-100 text-emerald-700 border-emerald-200';
if (status === 'FAILED') return 'bg-rose-100 text-rose-700 border-rose-200';
return 'bg-slate-100 text-slate-700 border-slate-200';
}
}

View File

@ -50,7 +50,14 @@ export async function createEditor(
}
return LLMNodeComponent;
},
socket() {
socket(context: any) {
// rete-angular passes only `payload` to socket component props,
// so we persist side on the payload to style input/output sockets.
const socketPayload = context?.payload;
const socketSide = context?.side;
if (socketPayload && (socketSide === "input" || socketSide === "output")) {
(socketPayload as any).__hfSide = socketSide;
}
return CustomSocket;
}
},
@ -131,7 +138,11 @@ export async function addBlockToEditor(
position?: { x: number; y: number }
) {
const node = new ClassicPreset.Node(toNodeLabel(block.typeName)) as HFNode;
node.data = { ...block, position: position ?? block.position };
const removeNode = async () => {
if (!editor.getNode(node.id)) return;
await editor.removeNode(node.id);
};
node.data = { ...block, position: position ?? block.position, deleteNode: removeNode };
for (const output of block.outputs ?? []) {
node.addOutput(output.name, new ClassicPreset.Output(getSocket(editor, output.type ?? "ANY")));