diff --git a/angular.json b/angular.json index 43844f06..7dd505ad 100644 --- a/angular.json +++ b/angular.json @@ -51,7 +51,8 @@ "node_modules/plyr/dist/plyr.css", "node_modules/@ali-hm/angular-tree-component/css/angular-tree-component.css", "node_modules/katex/dist/katex.min.css", - "node_modules/prismjs/themes/prism-okaidia.css" + "node_modules/prismjs/themes/prism-okaidia.css", + "src/scss/angular2-multiselect-dropdown.scss" ], "stylePreprocessorOptions": { "includePaths": [ @@ -61,6 +62,7 @@ "scripts": [ "node_modules/chart.js/dist/chart.min.js", "node_modules/katex/dist/katex.min.js", + "node_modules/katex/dist/contrib/auto-render.min.js", "node_modules/prismjs/prism.js", "node_modules/prismjs/components/prism-css.min.js", "node_modules/prismjs/components/prism-cypher.min.js", diff --git a/package-lock.json b/package-lock.json index 2a646992..aa5ef8b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "ace-builds": "^1.31.1", "ajv": "^8.12.0", "angular-ng-autocomplete": "^2.0.12", + "angular2-multiselect-dropdown": "^9.0.0", "ansi_up": "^5.2.1", "bootstrap": "^5.3.1", "core-js": "^3.26.1", @@ -45,7 +46,8 @@ "highlight.js": "^11.8.0", "jquery": "3.7.1", "jquery-ui": "^1.13.0", - "katex": "^0.16.0", + "json-pointer": "^0.6.2", + "katex": "0.16.10", "lodash": "^4.17.20", "marked": "^9.1.6", "moment": "^2.30.1", @@ -87,6 +89,8 @@ "@types/hammerjs": "^2.0.36", "@types/jasmine": "~3.6.0", "@types/jasminewd2": "^2.0.8", + "@types/json-pointer": "^1.0.34", + "@types/lodash": "^4.17.13", "@types/marked": "^4.3.0", "@types/node": "^12.11.1", "codelyzer": "^6.0.2", @@ -5310,6 +5314,11 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@tweenjs/tween.js": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-17.6.0.tgz", + "integrity": "sha512-utSXj0WHi4qr/iyfFHGMJBaL+ixQ2N7BAmx1R5g8jBqykJfjBUQ0hKWwXf767hbALC3zOoOIofKYSDWu5n04JQ==" + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -5500,12 +5509,24 @@ "@types/jasmine": "*" } }, + "node_modules/@types/json-pointer": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@types/json-pointer/-/json-pointer-1.0.34.tgz", + "integrity": "sha512-JRnWcxzXSaLei98xgw1B7vAeBVOrkyw0+Rt9j1QoJrczE78OpHsyQC8GNbuhw+/2vxxDe58QvWnngS86CoIbRg==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.12", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", + "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==", + "dev": true + }, "node_modules/@types/marked": { "version": "4.3.0", "dev": true, @@ -5973,6 +5994,20 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, + "node_modules/angular2-multiselect-dropdown": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/angular2-multiselect-dropdown/-/angular2-multiselect-dropdown-9.0.0.tgz", + "integrity": "sha512-ZNgh+sWUcAnY7x9umGk3qMO/YQoZE2zxZzRc43EBxLM8GyacpOW6onwidXzgimAfuvcmq0CH9JSHaAmF6PHErA==", + "dependencies": { + "@tweenjs/tween.js": "^17.4.0", + "tslib": "^2.3.0" + } + }, + "node_modules/angular2-multiselect-dropdown/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "node_modules/ansi_up": { "version": "5.2.1", "license": "MIT", @@ -9292,6 +9327,11 @@ "node": ">=0.10.3" } }, + "node_modules/foreach": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz", + "integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==" + }, "node_modules/foreground-child": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", @@ -10697,6 +10737,14 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, + "node_modules/json-pointer": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/json-pointer/-/json-pointer-0.6.2.tgz", + "integrity": "sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw==", + "dependencies": { + "foreach": "^2.0.4" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -10958,14 +11006,15 @@ } }, "node_modules/katex": { - "version": "0.16.0", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.10.tgz", + "integrity": "sha512-ZiqaC04tp2O5utMsl2TEZTXxa6WSC4yo0fv5ML++D3QZv/vx2Mct0mTlRx3O+uUkjfuAgOkzsCmq5MiUEsDDdA==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" ], - "license": "MIT", "dependencies": { - "commander": "^8.0.0" + "commander": "^8.3.0" }, "bin": { "katex": "cli.js" diff --git a/package.json b/package.json index dfc71054..c6319f0c 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "ace-builds": "^1.31.1", "ajv": "^8.12.0", "angular-ng-autocomplete": "^2.0.12", + "angular2-multiselect-dropdown": "^9.0.0", "ansi_up": "^5.2.1", "bootstrap": "^5.3.1", "core-js": "^3.26.1", @@ -48,7 +49,8 @@ "highlight.js": "^11.8.0", "jquery": "3.7.1", "jquery-ui": "^1.13.0", - "katex": "^0.16.0", + "json-pointer": "^0.6.2", + "katex": "0.16.10", "lodash": "^4.17.20", "marked": "^9.1.6", "moment": "^2.30.1", @@ -89,6 +91,8 @@ "@types/hammerjs": "^2.0.36", "@types/jasmine": "~3.6.0", "@types/jasminewd2": "^2.0.8", + "@types/json-pointer": "^1.0.34", + "@types/lodash": "^4.17.13", "@types/marked": "^4.3.0", "@types/node": "^12.11.1", "codelyzer": "^6.0.2", diff --git a/src/app/app.module.ts b/src/app/app.module.ts index f6daff46..18cb815d 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -77,6 +77,8 @@ import {ModalModule} from 'ngx-bootstrap/modal'; import {NgxJsonViewerModule} from 'ngx-json-viewer'; import {NotebooksModule} from './plugins/notebooks/notebooks.module'; import {IconDirective} from '@coreui/icons-angular'; +import {WorkflowsModule} from './plugins/workflows/workflows.module'; +import {MarkdownModule} from 'ngx-markdown'; @NgModule({ @@ -90,6 +92,7 @@ import {IconDirective} from '@coreui/icons-angular'; NgChartsModule, // plugins NotebooksModule, + WorkflowsModule, ToastComponent, NgChartsModule, ToasterComponent, @@ -151,7 +154,8 @@ import {IconDirective} from '@coreui/icons-angular'; NavbarComponent, CollapseDirective, NavbarBrandDirective, - NavbarNavComponent + NavbarNavComponent, + MarkdownModule.forRoot() ], declarations: [ AppComponent, diff --git a/src/app/components/autocomplete/autocomplete.component.html b/src/app/components/autocomplete/autocomplete.component.html new file mode 100644 index 00000000..e0e7b840 --- /dev/null +++ b/src/app/components/autocomplete/autocomplete.component.html @@ -0,0 +1,25 @@ + + + + + + + +
+
\ No newline at end of file diff --git a/src/app/components/autocomplete/autocomplete.component.scss b/src/app/components/autocomplete/autocomplete.component.scss new file mode 100644 index 00000000..8ba73f89 --- /dev/null +++ b/src/app/components/autocomplete/autocomplete.component.scss @@ -0,0 +1,51 @@ +.ng-autocomplete { + width: 100% !important; + + + .autocomplete-container { + box-shadow: none !important; + border: 1px solid #d8dbe0; + border-radius: 0.25rem; + line-height: 2; + height: auto !important; + } + + .not-found { + margin-bottom: -36px; + } + + .input-container { + height: initial; + max-height: 31px !important; // based on small coreUI input + line-height: 2; + background-color: white !important; + border-radius: 0.25rem; + + input { + background-color: transparent !important; + height: auto !important; + line-height: 1 !important; + padding: 0.25rem 0.5rem !important; + } + } + + .autocomplete-container .suggestions-container ul li a { + padding: 0 0.5em !important; + } + + .input-container input::placeholder { + font-size: 0.875rem; // based on small coreUI input + } + + /* bootstrap styles */ + .autocomplete-container { + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + } + + .autocomplete-container:focus-within { + border: 1px #8ad4ee solid !important; + border-radius: 0.25rem; + outline: 0 !important; + box-shadow: 0 0 0 0.2rem rgba(32, 168, 216, 0.25) !important; + } +} \ No newline at end of file diff --git a/src/app/components/autocomplete/autocomplete.component.ts b/src/app/components/autocomplete/autocomplete.component.ts new file mode 100644 index 00000000..d7376e6a --- /dev/null +++ b/src/app/components/autocomplete/autocomplete.component.ts @@ -0,0 +1,64 @@ +import {Component, EventEmitter, Input, model, Output, ViewEncapsulation} from '@angular/core'; + +/** + * A light-weight wrapper around the ng-autocomplete component. + * Its purpose is to introduce the CoreUI style. + * If more inputs are required, they can be added in the future. + * Also see https://www.npmjs.com/package/angular-ng-autocomplete + */ +@Component({ + selector: 'app-autocomplete', + templateUrl: './autocomplete.component.html', + styleUrl: './autocomplete.component.scss', + encapsulation: ViewEncapsulation.None +}) +export class AutocompleteComponent { + value = model.required(); + + @Input({required: true}) data: any[]; + //@Input({ required: true }) searchKeyword: string; removed, instead we use value() + @Input() placeholder: string; + @Input() notFoundText = 'Not Found'; + @Input() disabled = false; + + @Output() changed = new EventEmitter(); // new output, combines selected, inputChanged and inputCleared + @Output() selected = new EventEmitter(); + @Output() inputChanged = new EventEmitter(); + @Output() inputFocused = new EventEmitter(); + @Output() inputCleared = new EventEmitter(); + @Output() opened = new EventEmitter(); + @Output() closed = new EventEmitter(); + @Output() scrolledToEnd = new EventEmitter(); + + onItemSelected(event: any): void { + this.selected.emit(event); + this.changed.emit(); + } + + onInputChanged(event: any): void { + this.inputChanged.emit(event); + this.changed.emit(); + } + + onInputFocused(): void { + this.inputFocused.emit(); + } + + onInputCleared(): void { + this.inputCleared.emit(); + this.changed.emit(); + } + + onOpened(): void { + this.opened.emit(); + } + + onClosed(): void { + this.closed.emit(); + } + + onScrolledToEnd(): void { + this.scrolledToEnd.emit(); + } + +} diff --git a/src/app/components/components.module.ts b/src/app/components/components.module.ts index 2b80036b..485a994f 100644 --- a/src/app/components/components.module.ts +++ b/src/app/components/components.module.ts @@ -27,7 +27,7 @@ import { DropdownItemDirective, DropdownMenuDirective, DropdownToggleDirective, - FormCheckInputDirective, + FormCheckComponent, FormCheckLabelDirective, FormControlDirective, FormDirective, @@ -131,6 +131,7 @@ import {PopoverModule} from 'ngx-bootstrap/popover'; import {AlgMetadataComponent} from './polyalg/algnode/alg-metadata/alg-metadata.component'; import {DoubleArgComponent} from './polyalg/controls/double-arg/double-arg.component'; import {WindowArgComponent} from './polyalg/controls/window-arg/window-arg.component'; +import {AutocompleteComponent} from './autocomplete/autocomplete.component'; //import 'hammerjs'; @@ -218,7 +219,8 @@ import {WindowArgComponent} from './polyalg/controls/window-arg/window-arg.compo PopoverDirective, PopoverModule, ButtonToolbarComponent, - BadgeComponent + BadgeComponent, + FormCheckComponent ], declarations: [ BreadcrumbComponent, @@ -267,7 +269,8 @@ import {WindowArgComponent} from './polyalg/controls/window-arg/window-arg.compo MagneticConnectionComponent, AlgMetadataComponent, DoubleArgComponent, - WindowArgComponent + WindowArgComponent, + AutocompleteComponent ], exports: [ BreadcrumbComponent, @@ -291,7 +294,9 @@ import {WindowArgComponent} from './polyalg/controls/window-arg/window-arg.compo Toast, ReloadButtonComponent, DockerInstanceComponent, - AlgViewerComponent + AlgViewerComponent, + JsonTextComponent, + AutocompleteComponent ] }) export class ComponentsModule { diff --git a/src/app/components/data-view/data-card/data-card.component.html b/src/app/components/data-view/data-card/data-card.component.html index 6ea69b1c..b52cc33d 100644 --- a/src/app/components/data-view/data-card/data-card.component.html +++ b/src/app/components/data-view/data-card/data-card.component.html @@ -2,14 +2,14 @@ (no data to show) -
+
- - d.index).distance(160)); + .force('link', d3.forceLink().id(d => d.id).distance(160)); // disable charge after initial setup diff --git a/src/app/components/data-view/data-table/data-table.component.html b/src/app/components/data-view/data-table/data-table.component.html index cca77d42..10e77bff 100644 --- a/src/app/components/data-view/data-table/data-table.component.html +++ b/src/app/components/data-view/data-table/data-table.component.html @@ -64,7 +64,7 @@ (enter)="updateTuple()" [attr.data-before]="d2" [attr.data-col]="$result().header[j].name"> - + @@ -123,7 +123,7 @@ (valueChange)="inputChange(h.name, $event)" (enter)="insertTuple()"> - + diff --git a/src/app/components/data-view/data-table/entity-config.ts b/src/app/components/data-view/data-table/entity-config.ts index 9eab288a..d3835301 100644 --- a/src/app/components/data-view/data-table/entity-config.ts +++ b/src/app/components/data-view/data-table/entity-config.ts @@ -6,4 +6,5 @@ export interface EntityConfig { search: boolean; exploring: boolean; hideCreateView?: boolean; + cardRelWidth?: boolean; } diff --git a/src/app/components/data-view/data-template/data-template.component.ts b/src/app/components/data-view/data-template/data-template.component.ts index c522fe49..558fe50d 100644 --- a/src/app/components/data-view/data-template/data-template.component.ts +++ b/src/app/components/data-view/data-template/data-template.component.ts @@ -1,17 +1,4 @@ -import { - Component, - computed, - effect, - EventEmitter, - inject, - Input, - OnDestroy, - OnInit, - Signal, - signal, - untracked, - WritableSignal -} from '@angular/core'; +import {Component, computed, effect, EventEmitter, inject, Input, OnDestroy, OnInit, Signal, signal, untracked, WritableSignal} from '@angular/core'; import {RelationalResult, Result, UiColumnDefinition} from '../models/result-set.model'; import {WebuiSettingsService} from '../../../services/webui-settings.service'; import {CatalogService} from '../../../services/catalog.service'; @@ -65,7 +52,9 @@ export abstract class DataTemplateComponent implements OnInit, OnDestroy { sort: true, update: true, delete: true, - exploring: false + exploring: false, + hideCreateView: false, + cardRelWidth: false }); protected readonly currentRoute: WritableSignal = signal(this._route.snapshot.paramMap.get('id')); protected readonly routeParams = toSignal(this._route.params); @@ -132,7 +121,7 @@ export abstract class DataTemplateComponent implements OnInit, OnDestroy { } ngOnInit() { - this._sidebar.open(); + //this._sidebar.open(); //listen to results this.initWebsocket(); @@ -371,7 +360,7 @@ export abstract class DataTemplateComponent implements OnInit, OnDestroy { //when double-clicking the delete btn return; } - if (this.entityConfig.update) { + if (this.entityConfig().update) { this.updateValues.clear(); this.$result().data[i].forEach((v, k) => { if (this.$result().header[k].dataType === 'bool') { diff --git a/src/app/components/data-view/json-text/json-text.component.html b/src/app/components/data-view/json-text/json-text.component.html index e565e719..9e2cce34 100644 --- a/src/app/components/data-view/json-text/json-text.component.html +++ b/src/app/components/data-view/json-text/json-text.component.html @@ -1,3 +1,3 @@
- +
diff --git a/src/app/components/data-view/json-text/json-text.component.ts b/src/app/components/data-view/json-text/json-text.component.ts index 6c918437..dc2e91e1 100644 --- a/src/app/components/data-view/json-text/json-text.component.ts +++ b/src/app/components/data-view/json-text/json-text.component.ts @@ -1,4 +1,4 @@ -import {Component, Input, OnInit} from '@angular/core'; +import {Component, computed, input, OnInit, Signal} from '@angular/core'; @Component({ selector: 'app-json-text', @@ -7,18 +7,22 @@ import {Component, Input, OnInit} from '@angular/core'; }) export class JsonTextComponent implements OnInit { - @Input() text?: string; - json: {}; + text = input(); + expanded = input(false); + + private readonly regex = new RegExp('/ObjectId(\d{1,24})/g'); + json: Signal; constructor() { } ngOnInit(): void { - const regex = new RegExp('/ObjectId(\d{1,24})/g'); - if (regex.test(this.text)) { - return; - } - this.json = this.parse(this.text); + this.json = computed(() => { + if (this.regex.test(this.text())) { + return {}; + } + return this.parse(this.text()); + }); } parse(text: string): {} { diff --git a/src/app/components/editor/editor.component.ts b/src/app/components/editor/editor.component.ts index 68185d1f..625a0e83 100644 --- a/src/app/components/editor/editor.component.ts +++ b/src/app/components/editor/editor.component.ts @@ -6,6 +6,9 @@ import 'ace-builds/src-noconflict/mode-java'; import 'ace-builds/src-noconflict/mode-python'; import 'ace-builds/src-noconflict/mode-markdown'; import 'ace-builds/src-noconflict/mode-pig'; +import 'ace-builds/src-noconflict/mode-json'; +import 'ace-builds/src-noconflict/mode-plain_text'; +import 'ace-builds/src-noconflict/mode-sh'; import 'ace-builds/src-noconflict/theme-tomorrow'; import 'ace-builds/src-noconflict/ext-language_tools'; import {SidebarNode} from '../../models/sidebar-node.model'; @@ -34,7 +37,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnChanges { @Input() code ?; suggestions: string[] = []; - private readonly supportedLanguages = ['pgsql', 'sql', 'java', 'python', 'markdown', 'pig']; + private readonly supportedLanguages = ['pgsql', 'sql', 'java', 'python', 'markdown', 'pig', 'json', 'plain_text', 'sh']; constructor() { effect(() => { @@ -53,7 +56,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnChanges { } ngOnChanges(changes: SimpleChanges): void { - if (changes.lang && !changes.lang.firstChange) { + if (changes.language && !changes.language.firstChange) { this.updateLanguage(); } if (changes.autocomplete && !changes.autocomplete.firstChange) { @@ -123,6 +126,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnChanges { } } + insertAtCursor(code: string) { + this.codeEditor.session.insert(this.codeEditor.getCursorPosition(), code); + } + // from: https://stackoverflow.com/questions/30041816/ace-editor-autocomplete-custom-strings setAutocomplete() { this.codeEditor.setOptions({enableLiveAutocompletion: true}); @@ -175,7 +182,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnChanges { this.codeEditor.getSession().setMode('ace/mode/' + this.language); } else { this.codeEditor.getSession().setMode('ace/mode/sql'); - } } diff --git a/src/app/components/json/json-editor.component.ts b/src/app/components/json/json-editor.component.ts index 5841289f..4dc6d2f7 100644 --- a/src/app/components/json/json-editor.component.ts +++ b/src/app/components/json/json-editor.component.ts @@ -146,7 +146,7 @@ export class JsonEditorComponent implements OnInit { } } catch (e) { - console.log('could not translate'); + console.log('could not translate', e); } if (this.empty && this.data.length === 0) { diff --git a/src/app/components/json/json-elem/json-elem.component.ts b/src/app/components/json/json-elem/json-elem.component.ts index c79f344b..a6c2ae40 100644 --- a/src/app/components/json/json-elem/json-elem.component.ts +++ b/src/app/components/json/json-elem/json-elem.component.ts @@ -1,13 +1,4 @@ -import { - ChangeDetectionStrategy, - Component, - EventEmitter, - Input, - OnChanges, - OnInit, - Output, - SimpleChanges, -} from '@angular/core'; +import {ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges,} from '@angular/core'; import {Info, Pair, Type} from '../json-editor.component'; @Component({ @@ -44,7 +35,6 @@ export class JsonElemComponent implements OnInit, OnChanges { childDupStatus = []; ngOnChanges(changes: SimpleChanges) { - console.log(changes); } ngOnInit(): void { @@ -176,7 +166,6 @@ export class JsonElemComponent implements OnInit, OnChanges { return; } const keys = (this.el.value as Pair[]).map(e => e.key); - console.log(keys); const temp = []; const copy = keys; @@ -191,14 +180,10 @@ export class JsonElemComponent implements OnInit, OnChanges { temp.push(key); status.push(copy.includes(key)); } - console.log(copy); - console.log(temp); - console.log(status); count++; } this.childDupStatus = status; - console.log(this.childDupStatus); } diff --git a/src/app/components/left-sidebar/left-sidebar.component.ts b/src/app/components/left-sidebar/left-sidebar.component.ts index adf41f47..69abefca 100644 --- a/src/app/components/left-sidebar/left-sidebar.component.ts +++ b/src/app/components/left-sidebar/left-sidebar.component.ts @@ -64,7 +64,7 @@ export class LeftSidebarComponent implements OnInit, AfterViewInit { } static readonly EXPAND_SHOWN_ROUTES: String[] = [ - '/views/monitoring', '/views/config', '/views/uml', '/views/querying/console', '/views/notebooks']; + '/views/monitoring', '/views/config', '/views/uml', '/views/querying/console', '/views/notebooks', '/views/workflows']; private readonly _router = inject(Router); public readonly _sidebar = inject(LeftSidebarService); diff --git a/src/app/components/polyalg/controls/agg-arg/agg-arg.component.ts b/src/app/components/polyalg/controls/agg-arg/agg-arg.component.ts index 150804e7..a1e3bcd8 100644 --- a/src/app/components/polyalg/controls/agg-arg/agg-arg.component.ts +++ b/src/app/components/polyalg/controls/agg-arg/agg-arg.component.ts @@ -17,7 +17,7 @@ export class AggArgComponent implements OnInit { constructor(private _registry: PolyAlgService) { } - @Input() data: AggControl; // TODO: support multiple args, colls + @Input() data: AggControl; // support multiple args, colls in the future fChoices: string[] = []; fChoicesSimple = ['AVG', 'COUNT', 'MAX', 'MIN', 'SUM'].sort(); diff --git a/src/app/components/polyalg/controls/string-arg/string-arg.component.ts b/src/app/components/polyalg/controls/string-arg/string-arg.component.ts index 3c62f795..9c95dd01 100644 --- a/src/app/components/polyalg/controls/string-arg/string-arg.component.ts +++ b/src/app/components/polyalg/controls/string-arg/string-arg.component.ts @@ -24,7 +24,7 @@ export class StringControl extends ArgControl { alias = signal(this.value.alias === this.value.arg ? '' : this.value.alias); isTrivial = computed(() => { const hasTrivialAlias = !this.showAlias || !this.alias() || this.alias() === this.arg(); - const hasTrivialArg = !this.arg() || /^[a-zA-Z0-9_$]+$/.test(this.arg()); // TODO: use better way to determine whether arg is trivial + const hasTrivialArg = !this.arg() || /^[a-zA-Z0-9_$]+$/.test(this.arg()); // use better way to determine whether arg is trivial? return hasTrivialAlias && hasTrivialArg; }); diff --git a/src/app/components/polyalg/polyalg-viewer/alg-editor.ts b/src/app/components/polyalg/polyalg-viewer/alg-editor.ts index cbeefa5f..35a8944f 100644 --- a/src/app/components/polyalg/polyalg-viewer/alg-editor.ts +++ b/src/app/components/polyalg/polyalg-viewer/alg-editor.ts @@ -18,7 +18,14 @@ import {PolyAlgService} from '../polyalg.service'; import {DataflowEngine} from 'rete-engine'; import {Position} from 'rete-angular-plugin/17/types'; import {Subject} from 'rxjs'; -import {canCreateConnection, findRootNodeId, getContextMenuNodes, getMagneticConnectionProps, updateMultiConnAfterCreate, updateMultiConnAfterRemove} from './alg-editor-utils'; +import { + canCreateConnection, + findRootNodeId, + getContextMenuNodes, + getMagneticConnectionProps, + updateMultiConnAfterCreate, + updateMultiConnAfterRemove +} from './alg-editor-utils'; import {setupPanningBoundary} from './panning-boundary'; import {useMagneticConnection} from './magnetic-connection'; import {MagneticConnectionComponent} from './magnetic-connection/magnetic-connection.component'; @@ -26,6 +33,8 @@ import {AlgMetadata} from '../algnode/alg-metadata/alg-metadata.component'; import {Transform} from 'rete-area-plugin/_types/area'; import {PlanType} from '../../../models/information-page.model'; import {OperatorModel} from '../models/polyalg-registry'; +import {CustomZoom} from './custom-zoom'; +import {CustomDrag} from './custom-drag'; export type Schemes = GetSchemes>; type AreaExtra = AngularArea2D | ContextMenuExtra; @@ -126,6 +135,8 @@ export async function createEditor(container: HTMLElement, injector: Injector, r useMagneticConnection(connection, getMagneticConnectionProps(editor)); } + area.area.setZoomHandler(new CustomZoom(0.1, 0.3, true)); + area.area.setDragHandler(new CustomDrag(container)); // horizontal scrolling AreaExtensions.simpleNodesOrder(area); addCustomBackground(area); AreaExtensions.restrictor(area, {scaling: {min: 0.03, max: 5}}); // Restrict Zoom diff --git a/src/app/components/polyalg/polyalg-viewer/alg-viewer.component.html b/src/app/components/polyalg/polyalg-viewer/alg-viewer.component.html index 28e58852..c0e62079 100644 --- a/src/app/components/polyalg/polyalg-viewer/alg-viewer.component.html +++ b/src/app/components/polyalg/polyalg-viewer/alg-viewer.component.html @@ -3,11 +3,12 @@ @@ -23,7 +24,8 @@ [readonly]="isReadOnly">
-

PolyAlgebra can only be edited in advanced mode.

+

PolyAlgebra can only be edited in + advanced mode.

@@ -49,14 +51,18 @@
- @@ -84,16 +90,19 @@ - -
+

Open the context menu to add or remove nodes @@ -122,4 +131,4 @@

Open Plan in Editor
- \ No newline at end of file + diff --git a/src/app/components/polyalg/polyalg-viewer/custom-drag.ts b/src/app/components/polyalg/polyalg-viewer/custom-drag.ts new file mode 100644 index 00000000..91946d68 --- /dev/null +++ b/src/app/components/polyalg/polyalg-viewer/custom-drag.ts @@ -0,0 +1,28 @@ +import {Drag} from 'rete-area-plugin'; + +/** + * Enables to use horizontal scrolling for moving the area horizontally. + */ +export class CustomDrag extends Drag { + constructor(private container: HTMLElement) { + super(); + + this.container.addEventListener('wheel', this.wheel); + } + + private wheel = (e: WheelEvent) => { + if (e.deltaX === 0) { + return; + } + e.preventDefault(); + const startPosition = {...this.config.getCurrentPosition()}; + const zoom = this.config.getZoom(); + const x = startPosition.x - e.deltaX * 0.75 / zoom; + void this.events.translate(x, startPosition.y, null); + } + + public destroy() { + this.container.removeEventListener('wheel', this.wheel); + super.destroy(); + } +} diff --git a/src/app/components/polyalg/polyalg-viewer/custom-zoom.ts b/src/app/components/polyalg/polyalg-viewer/custom-zoom.ts new file mode 100644 index 00000000..0515c044 --- /dev/null +++ b/src/app/components/polyalg/polyalg-viewer/custom-zoom.ts @@ -0,0 +1,97 @@ +import {Zoom} from 'rete-area-plugin'; +import {OnZoom} from 'rete-area-plugin/_types/zoom'; +import {document} from 'ngx-bootstrap/utils'; +import {debounceTime, filter, fromEvent, Subscription} from 'rxjs'; +import {tap} from 'rxjs/operators'; + +/** + * Improves intensity for touchpad zooming and adds the possibility to require CTRL being pressed for the zoom to have an effect. + */ +export class CustomZoom extends Zoom { + private readonly m: number; + private readonly b: number; + private overlay: HTMLDivElement = null; + private wheelSubscription: Subscription; + + constructor( + intensity: number, + trackPadIntensity: number = 0.3, + private requireCtrl: boolean = false) { + super(intensity); + this.b = Math.max(0, Math.min(trackPadIntensity, 1)); + this.m = 1 - this.b; + } + + + initialize(container: HTMLElement, element: HTMLElement, onzoom: OnZoom) { + super.initialize(container, element, onzoom); + if (this.requireCtrl) { + this.initOverlay(); + this.wheelSubscription = fromEvent(container, 'wheel').pipe( + // Only consider the event if Ctrl key is not pressed + filter((event) => !event.ctrlKey), + tap(() => this.overlay.style.display = 'flex'), + debounceTime(1000), + tap(() => this.overlay.style.display = 'none') + ).subscribe(); + } + } + + protected wheel = (e: WheelEvent) => { + if (this.requireCtrl) { + if (e.ctrlKey) { + this.overlay.style.display = 'none'; + } else { + return; + } + } + e.preventDefault(); + + const {left, top} = this.element.getBoundingClientRect(); + + let deltaAbs = Math.abs(e.deltaY) / 100; + if (0.01 < deltaAbs && deltaAbs < 1) { + deltaAbs = this.m * deltaAbs + this.b; // linearly increase intensity of small values that indicate a touchpad gesture is used + } + deltaAbs = deltaAbs * this.intensity; + + const delta = e.deltaY < 0 ? deltaAbs : -deltaAbs; + const ox = (left - e.clientX) * delta; + const oy = (top - e.clientY) * delta; + + this.onzoom(delta, ox, oy, 'wheel'); + } + + + destroy() { + super.destroy(); + this.wheelSubscription?.unsubscribe(); + } + + private initOverlay() { + const overlay = document.createElement('div'); + overlay.id = 'zoom-overlay'; + overlay.style.position = 'absolute'; + overlay.style.top = '0'; + overlay.style.left = '0'; + overlay.style.width = '100%'; + overlay.style.height = '100%'; + overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.6)'; + overlay.style.color = 'white'; + overlay.style.fontSize = '24px'; + overlay.style.display = 'none'; // Hide by default + overlay.style.justifyContent = 'center'; + overlay.style.alignItems = 'center'; + overlay.style.pointerEvents = 'none'; // Allow interaction with editor + + // Add the message text + const message = document.createElement('div'); + message.innerText = 'Use ctrl / ⌘ + scroll to zoom'; + message.style.pointerEvents = 'none'; + overlay.appendChild(message); + + // Append the overlay to the container + this.container.appendChild(overlay); + this.overlay = overlay; + } +} diff --git a/src/app/components/polyalg/polyalg-viewer/panning-boundary/index.ts b/src/app/components/polyalg/polyalg-viewer/panning-boundary/index.ts index d60638a5..85cd9318 100644 --- a/src/app/components/polyalg/polyalg-viewer/panning-boundary/index.ts +++ b/src/app/components/polyalg/polyalg-viewer/panning-boundary/index.ts @@ -1,11 +1,10 @@ -import {NodeEditor} from 'rete'; +import {GetSchemes, NodeEditor} from 'rete'; import {AreaExtensions, AreaPlugin} from 'rete-area-plugin'; import {getFrameWeight} from './frame'; import {animate, watchPointerMove} from './utils'; -import {Schemes} from '../alg-editor'; interface Props { - area: AreaPlugin; + area: AreaPlugin, any>; selector: AreaExtensions.Selector; intensity?: number; padding?: number; diff --git a/src/app/containers/default-layout/default-layout.component.html b/src/app/containers/default-layout/default-layout.component.html index 68064b21..9b4f7cfb 100644 --- a/src/app/containers/default-layout/default-layout.component.html +++ b/src/app/containers/default-layout/default-layout.component.html @@ -75,6 +75,13 @@
+ + + + Workflows + + + @@ -115,7 +122,7 @@
- + @@ -126,8 +133,8 @@
- - + + diff --git a/src/app/containers/default-layout/default-layout.component.ts b/src/app/containers/default-layout/default-layout.component.ts index fb92c94f..26b0f46a 100644 --- a/src/app/containers/default-layout/default-layout.component.ts +++ b/src/app/containers/default-layout/default-layout.component.ts @@ -1,4 +1,4 @@ -import {AfterContentChecked, ChangeDetectorRef, Component, Inject, OnDestroy, signal} from '@angular/core'; +import {AfterContentChecked, ChangeDetectorRef, Component, Inject, OnDestroy, OnInit, signal, ViewChild} from '@angular/core'; import {DOCUMENT} from '@angular/common'; import {navItems} from '../../_nav'; import {LeftSidebarService} from '../../components/left-sidebar/left-sidebar.service'; @@ -7,13 +7,19 @@ import {CrudService} from '../../services/crud.service'; import {PluginService} from '../../services/plugin.service'; import {freeSet} from '@coreui/icons'; import {WebuiSettingsService} from '../../services/webui-settings.service'; +import {filter, Subscription} from 'rxjs'; +import {ActivatedRoute, NavigationEnd, Router} from '@angular/router'; +import {map} from 'rxjs/operators'; +import {SidebarComponent} from '@coreui/angular'; @Component({ selector: 'app-dashboard', templateUrl: './default-layout.component.html', styleUrls: ['./default-layout.component.scss'] }) -export class DefaultLayoutComponent implements OnDestroy, AfterContentChecked { +export class DefaultLayoutComponent implements OnInit, OnDestroy, AfterContentChecked { + @ViewChild('leftSidebar') leftSidebarComponent: SidebarComponent; + public navItems = navItems; public sidebarMinimized = true; private changes: MutationObserver; @@ -22,6 +28,9 @@ export class DefaultLayoutComponent implements OnDestroy, AfterContentChecked { hover = signal(false); modal = signal(false); + isFluid = signal(false); + private routeDataSubscription: Subscription; + constructor( public _sidebar: LeftSidebarService, @@ -30,6 +39,8 @@ export class DefaultLayoutComponent implements OnDestroy, AfterContentChecked { public _crud: CrudService, public _plugin: PluginService, public _left: LeftSidebarService, + private _route: ActivatedRoute, + private _router: Router, private changeDetector: ChangeDetectorRef, @Inject(DOCUMENT) _document?: any, ) { @@ -45,12 +56,24 @@ export class DefaultLayoutComponent implements OnDestroy, AfterContentChecked { } + ngOnInit(): void { + this.isFluid.set(this.getLastChildData().isFullWidth || false); + + this.routeDataSubscription = this._router.events.pipe( + filter(event => event instanceof NavigationEnd), + map(() => this.getLastChildData()) + ).subscribe(data => { + this.isFluid.set(data.isFullWidth || false); + }); + } + ngAfterContentChecked(): void { this.changeDetector.detectChanges(); } ngOnDestroy(): void { this.changes.disconnect(); + this.routeDataSubscription.unsubscribe(); } getConnectedColor() { @@ -90,4 +113,21 @@ export class DefaultLayoutComponent implements OnDestroy, AfterContentChecked { handleModalChange($event: boolean) { this.modal.set($event); } + + private getLastChildData() { + // data of child routes: https://stackoverflow.com/a/50780702 + let route = this._route; + while (route.firstChild) { + route = route.firstChild; + } + return route.snapshot.data; + } + + changedVisible(isVisible: boolean) { + // find a better solution to keep the sidebar component hidden after rescaling the window? + if (isVisible && !this._left.isVisible()) { + this._left.isVisible.set(isVisible); + setTimeout(() => this._left.isVisible.set(!isVisible), 10); + } + } } diff --git a/src/app/models/ui-request.model.ts b/src/app/models/ui-request.model.ts index 1c0717c2..0645e328 100644 --- a/src/app/models/ui-request.model.ts +++ b/src/app/models/ui-request.model.ts @@ -75,9 +75,11 @@ export class QueryRequest extends UIRequest { export class GraphRequest extends QueryRequest { type = 'GraphRequest'; + nodeIds: string[]; constructor(namespace: string, nodeIds: Set, edgeIds: Set) { super('MATCH * RETURN *', false, false, 'CYPHER', namespace); + this.nodeIds = Array.from(nodeIds); } } diff --git a/src/app/plugins/notebooks/components/edit-notebook/edit-notebook.component.html b/src/app/plugins/notebooks/components/edit-notebook/edit-notebook.component.html index b0779414..e2aae92f 100644 --- a/src/app/plugins/notebooks/components/edit-notebook/edit-notebook.component.html +++ b/src/app/plugins/notebooks/components/edit-notebook/edit-notebook.component.html @@ -168,8 +168,8 @@ -
-

{{ name }}

+
+

{{name}}

diff --git a/src/app/plugins/notebooks/components/edit-notebook/edit-notebook.component.scss b/src/app/plugins/notebooks/components/edit-notebook/edit-notebook.component.scss index 350fc9ad..26939731 100644 --- a/src/app/plugins/notebooks/components/edit-notebook/edit-notebook.component.scss +++ b/src/app/plugins/notebooks/components/edit-notebook/edit-notebook.component.scss @@ -13,9 +13,10 @@ .nb-scroll { overflow-y: auto; overflow-x: hidden; - height: calc(100vh - 197px); + height: calc(100vh - 245px); padding-right: 30px; margin-right: -30px; + outline-style: none; } .nb-background { diff --git a/src/app/plugins/notebooks/components/edit-notebook/edit-notebook.component.ts b/src/app/plugins/notebooks/components/edit-notebook/edit-notebook.component.ts index 5e6611fd..ec971a82 100644 --- a/src/app/plugins/notebooks/components/edit-notebook/edit-notebook.component.ts +++ b/src/app/plugins/notebooks/components/edit-notebook/edit-notebook.component.ts @@ -1,18 +1,4 @@ -import { - Component, - EventEmitter, - HostListener, - inject, - Input, - OnChanges, - OnDestroy, - OnInit, - Output, - QueryList, - SimpleChanges, - ViewChild, - ViewChildren -} from '@angular/core'; +import {Component, EventEmitter, HostListener, inject, Input, OnChanges, OnDestroy, OnInit, Output, QueryList, SimpleChanges, ViewChild, ViewChildren} from '@angular/core'; import {KernelSpec, NotebookContent, SessionResponse} from '../../models/notebooks-response.model'; import {NotebooksService} from '../../services/notebooks.service'; import {NotebooksSidebarService} from '../../services/notebooks-sidebar.service'; @@ -30,6 +16,7 @@ import {LoadingScreenService} from '../../../../components/loading-screen/loadin import {FormControl, FormGroup, Validators} from '@angular/forms'; import {ToasterService} from '../../../../components/toast-exposer/toaster.service'; import {WebuiSettingsService} from '../../../../services/webui-settings.service'; +import {MarkdownService, MarkedRenderer} from 'ngx-markdown'; @Component({ selector: 'app-edit-notebook', @@ -47,6 +34,7 @@ export class EditNotebookComponent implements OnInit, OnChanges, OnDestroy { private readonly _loading = inject(LoadingScreenService); private readonly _settings = inject(WebuiSettingsService); + private readonly _markdown = inject(MarkdownService); @Input() sessionId: string; @Output() openChangeSessionModal = new EventEmitter<{ name: string, path: string }>(); @@ -87,9 +75,13 @@ export class EditNotebookComponent implements OnInit, OnChanges, OnDestroy { this._content.onSessionsChange().subscribe(sessions => this.updateSession(sessions)) ); this.subscriptions.add( - this._content.onNamespaceChange().subscribe(namespaces => this.namespaces = namespaces) + this._content.onNamespaceChange().subscribe(namespaces => { + console.log('namespaces:', namespaces); + this.namespaces = namespaces; + }) ); this.initForms(); + this.initMarkdown(); } ngOnChanges(changes: SimpleChanges): void { @@ -756,6 +748,33 @@ export class EditNotebookComponent implements OnInit, OnChanges, OnDestroy { return c.id === this.selectedCell.id; }); } + + private initMarkdown() { + const renderer = new MarkedRenderer(); + + // enforce usage of default renderer, since heading is changed in workflow + renderer.heading = renderer.heading.bind(renderer); + + renderer.blockquote = (text: string) => { + return '

' + text + '

'; + }; + + const defaultLinkRenderer = renderer.link.bind(renderer); + renderer.link = (href, title, text) => { + const link = defaultLinkRenderer(href, title, text); + return link.startsWith(' { + href = `${baseUrl}/${href}`; + return defaultImageRenderer(href, title, text); + }; + + this._markdown.renderer = renderer; + } } export type NbMode = 'edit' | 'command'; diff --git a/src/app/plugins/notebooks/components/edit-notebook/nb-cell/nb-cell.component.html b/src/app/plugins/notebooks/components/edit-notebook/nb-cell/nb-cell.component.html index 132a56c0..d0e67f48 100644 --- a/src/app/plugins/notebooks/components/edit-notebook/nb-cell/nb-cell.component.html +++ b/src/app/plugins/notebooks/components/edit-notebook/nb-cell/nb-cell.component.html @@ -2,7 +2,7 @@ tabindex="-1">
- + + + + + + v + + + + @switch (_creator.type) { + @case ('SCHEDULED') { + + Cron Expression + + + } + } + +
+ + + Advanced + +
+
+
+ + + + + + Maximum Retries + +
+ The maximum number of times execution is restarted on a failure. 0 to disable. +
+
+ + + + + +

Overwrite Workflow Variables

+ +
+ +
+
+
+ + + + + +
+ +
+
+ } + \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-job/job-creator/job-creator.component.scss b/src/app/plugins/workflows/components/workflow-job/job-creator/job-creator.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/plugins/workflows/components/workflow-job/job-creator/job-creator.component.ts b/src/app/plugins/workflows/components/workflow-job/job-creator/job-creator.component.ts new file mode 100644 index 00000000..1fbd8aaa --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-job/job-creator/job-creator.component.ts @@ -0,0 +1,38 @@ +import {Component, inject, ViewChild} from '@angular/core'; +import {JobCreatorService} from '../../../services/job-creator.service'; +import {JsonEditorComponent} from '../../../../../components/json/json-editor.component'; +import {ToasterService} from '../../../../../components/toast-exposer/toaster.service'; + +@Component({ + selector: 'app-job-creator', + templateUrl: './job-creator.component.html', + styleUrl: './job-creator.component.scss' +}) +export class JobCreatorComponent { + @ViewChild('variableEditor') variableEditor: JsonEditorComponent; + + readonly _creator = inject(JobCreatorService); + readonly _toast = inject(ToasterService); + isExpanded = false; + changedVariables: string; + + saveJob() { + if (!this.variableEditor.isValid()) { + this._toast.warn('The specified variables are invalid'); + return; + } + if (this.changedVariables !== undefined) { + this._creator.variables = JSON.parse(this.changedVariables); + } + + if (this._creator.isValid()) { + this._creator.buildAndSave(); + } + } + + resetVariables() { + this._creator.variables = {}; + this.changedVariables = '{}'; + setTimeout(() => this.variableEditor?.addInitialValues(), 10); + } +} diff --git a/src/app/plugins/workflows/components/workflow-job/job-list/job-list.component.html b/src/app/plugins/workflows/components/workflow-job/job-list/job-list.component.html new file mode 100644 index 00000000..b496f2c4 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-job/job-list/job-list.component.html @@ -0,0 +1,66 @@ + + +

Jobs

+ + + + + Create Job + + + + + + + + + + + + + +
    + @for (job of jobs(); track job.jobId) { +
  • +
    +
    +

    + {{ job.name }} +

    + + State: + + @if (job.sessionId) { + Enabled + } @else { + Disabled + } + + + + Type: + + {{ job.type }} + + + + Workflow: + + {{ _creator.workflowDefs()[job.workflowId]?.name }} + v{{ job.version }} + + + + Enabled on Startup: + {{ job.enableOnStartup }} + +
    + +
    +
  • + } +
\ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-job/job-list/job-list.component.scss b/src/app/plugins/workflows/components/workflow-job/job-list/job-list.component.scss new file mode 100644 index 00000000..7bdc09a5 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-job/job-list/job-list.component.scss @@ -0,0 +1,11 @@ +.col-md-6.fixed-width { + max-width: 400px; +} + +.job-name { + word-wrap: break-word; +} + +.limit-name-width { + max-width: min(1000px, 50vw); +} \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-job/job-list/job-list.component.ts b/src/app/plugins/workflows/components/workflow-job/job-list/job-list.component.ts new file mode 100644 index 00000000..e57955ae --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-job/job-list/job-list.component.ts @@ -0,0 +1,46 @@ +import {Component, inject, OnDestroy, OnInit, signal} from '@angular/core'; +import {ToasterService} from '../../../../../components/toast-exposer/toaster.service'; +import {WorkflowsService} from '../../../services/workflows.service'; +import {JobModel, TriggerType} from '../../../models/workflows.model'; +import {Router} from '@angular/router'; +import {JobCreatorService} from '../../../services/job-creator.service'; +import {Subscription} from 'rxjs'; + +@Component({ + selector: 'app-job-list', + templateUrl: './job-list.component.html', + styleUrl: './job-list.component.scss' +}) +export class JobListComponent implements OnInit, OnDestroy { + private readonly _toast = inject(ToasterService); + private readonly _workflows = inject(WorkflowsService); + private readonly _router = inject(Router); + readonly _creator = inject(JobCreatorService); + private readonly subscriptions = new Subscription(); + + jobs = signal([]); + triggerType: TriggerType = TriggerType.SCHEDULED; + + ngOnInit(): void { + this._workflows.getJobs().subscribe(res => this.jobs.set(Object.values(res))); + this.subscriptions.add(this._creator.onSaveJob().subscribe(job => this.createScheduledJob(job))); + } + + createScheduledJob(job: JobModel) { + this._workflows.setJob(job).subscribe({ + next: jobId => { + this._creator.close(); + this.openJob(jobId); + }, + error: err => this._toast.error(err.error) + }); + } + + openJob(jobId: string) { + this._router.navigate([`/views/workflows/jobs/${jobId}`]); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } +} diff --git a/src/app/plugins/workflows/components/workflow-job/workflow-job.component.html b/src/app/plugins/workflows/components/workflow-job/workflow-job.component.html new file mode 100644 index 00000000..acce0247 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-job/workflow-job.component.html @@ -0,0 +1,201 @@ + + + +@if (job()) { +
+

+ {{ job().name }} +

+
+ +
+
+ + + + + + + + +
Job Information
+
+ + + Type: + + {{ job().type }} + + + + Workflow: + + {{ _creator.workflowDefs()[job().workflowId]?.name }} + v{{ job().version }} + + + + Enabled on Startup: + {{ job().enableOnStartup }} + + + Retries: + {{ job().maxRetries }} + + + Optimizations: + {{ job().performance }} + + + Overwrites Variables: + {{ Object.keys( job().variables || {} ).length > 0 }} + + @if (job().type === 'SCHEDULED') { + + Schedule: + {{ job().schedule }} + + } +
+ + + +
+
+
+
+ + + + +
Workflow Session
+
+ + @if (session()) { + + State: + {{ session().state }} + + + Last Interaction: + + {{ session().lastInteraction | date: 'yyyy-MM-dd HH:mm:ss' }} + + + + Connected Users: + {{ session().connectionCount }} + + + } @else { + Enable the job to start the corresponding session. + } + +
+
+
+ + @if (session()) { +

Execution History ({{ session().executionHistory?.length || 0 }})

+ +
    + @for (exec of session().executionHistory; track $index) { +
  • + + +
    + @switch (exec.result) { + @case (JobResult.SUCCESS) { + Success + } + @case (JobResult.FAILED) { + Failed + } + @case (JobResult.SKIPPED) { + Skipped + } + } +
    +
    + +
    + + {{ exec.startTime | date: 'yyyy-MM-dd HH:mm:ss' }} + +
    +
    + +
    {{ exec.message }}
    +
    +
    + +
    + @if (exec.statistics) { +

    Duration: {{ exec.statistics.totalDuration }} ms

    + + } + @if (exec.variables) { + + } +
    +
  • + } @empty { +
  • + Workflow was not yet executed. +
  • + } +
+ } +} + + + +
Workflow Variables
+ +
+ + + + + + +
+ + + +
Disable Job
+ +
+ + Do you really want to disable this Job? The action will reset the execution history. + + + +
+ +
+
+
\ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-job/workflow-job.component.scss b/src/app/plugins/workflows/components/workflow-job/workflow-job.component.scss new file mode 100644 index 00000000..3539a147 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-job/workflow-job.component.scss @@ -0,0 +1,3 @@ +.col-md-6.fixed-width { + max-width: 400px; +} \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-job/workflow-job.component.ts b/src/app/plugins/workflows/components/workflow-job/workflow-job.component.ts new file mode 100644 index 00000000..dda91a47 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-job/workflow-job.component.ts @@ -0,0 +1,167 @@ +import {Component, computed, effect, inject, Injector, OnDestroy, OnInit, signal} from '@angular/core'; +import {ActivatedRoute, Router} from '@angular/router'; +import {ToasterService} from '../../../../components/toast-exposer/toaster.service'; +import {LeftSidebarService} from '../../../../components/left-sidebar/left-sidebar.service'; +import {WorkflowsService} from '../../services/workflows.service'; +import {JobModel, JobResult, SessionModel, Variables} from '../../models/workflows.model'; +import {JobCreatorService} from '../../services/job-creator.service'; +import {Subscription} from 'rxjs'; +import {Workflow} from '../workflow-viewer/workflow'; + +@Component({ + selector: 'app-workflow-job', + templateUrl: './workflow-job.component.html', + styleUrl: './workflow-job.component.scss' +}) +export class WorkflowJobComponent implements OnInit, OnDestroy { + private readonly _route = inject(ActivatedRoute); + private readonly _router = inject(Router); + private readonly _toast = inject(ToasterService); + private readonly _sidebar = inject(LeftSidebarService); + private readonly _workflows = inject(WorkflowsService); + readonly _creator = inject(JobCreatorService); + private readonly subscriptions = new Subscription(); + private readonly REFRESH_INTERVAL_MS = 5000; + + protected readonly JobResult = JobResult; + protected readonly Object = Object; + + jobId = signal(null); + job = signal(null); + sessionId = computed(() => this.job()?.sessionId); + session = signal(null); + isEnabled = computed(() => this.job().sessionId != null); + workflow = signal(null); + deleteConfirm = signal(false); + showVariablesModal = signal(false); + showDisableModal = signal(false); + selectedVariablesStr: string; + private interval: number; + + constructor(private injector: Injector) { + effect(() => { + if (this.sessionId?.()) { + this.updateSession(); + this._workflows.getWorkflow(this.job().workflowId, this.job().version).subscribe({ + next: res => this.workflow.set(new Workflow(res, this._workflows.getRegistry(), this.injector)) + }); + } else { + this.session.set(null); + this.workflow.set(null); + } + }, {allowSignalWrites: true}); + } + + ngOnInit(): void { + this._sidebar.hide(); + this.subscriptions.add(this._creator.onSaveJob().subscribe(job => this.updateModifiedJob(job))); + this._route.paramMap.subscribe(params => { + this.jobId.set(params.get('jobId')); + if (this.jobId()) { + this._workflows.getJobs().subscribe({ + next: res => { + this.job.set(res[this.jobId()]); + if (!this.job()) { + this.backToDashboard(); + } + }, + error: () => this.backToDashboard() + }); + } + }); + this.jobId.set(this._route.snapshot.paramMap.get('jobId')); + + this.interval = setInterval(() => this.updateSession(), this.REFRESH_INTERVAL_MS); + } + + backToDashboard() { + this._router.navigate(['/views/workflows/jobs']); + } + + enableJob() { + if (this.isEnabled()) { + return; + } + this._workflows.enableJob(this.jobId()).subscribe({ + next: sessionId => this.job.update(job => ({...job, sessionId})), + error: err => this._toast.error(err.error) + }); + } + + disableJob() { + if (!this.isEnabled()) { + return; + } + if (!this.showDisableModal() && this.session().executionHistory.length > 0) { + this.showDisableModal.set(true); + return; + } + + this.showDisableModal.set(false); + this._workflows.disableJob(this.jobId()).subscribe({ + next: () => this.job.update(job => ({...job, sessionId: null})), + error: err => this._toast.error(err.error) + }); + } + + executeJob() { + this._workflows.triggerJob(this.jobId()).subscribe({ + next: () => this.updateSession(), + error: err => this._toast.error(err.error) + }); + } + + updateSession() { + if (this.sessionId()) { + this._workflows.getSession(this.sessionId()).subscribe({ + next: res => this.session.set(res) + }); + } + } + + openSession() { + this._router.navigate([`/views/workflows/sessions/${this.sessionId()}`]); + } + + editJob() { + this._creator.openModify(this.job()); + } + + openVariables(variables: Variables) { + this.selectedVariablesStr = JSON.stringify(variables); + this.showVariablesModal.set(true); + } + + onDeleteClick() { + if (!this.deleteConfirm()) { + this.deleteConfirm.set(true); + } else { + this.deleteJob(); + } + } + + deleteJob() { + this._workflows.deleteJob(this.jobId()).subscribe({ + next: () => { + this.backToDashboard(); + this._toast.success('Job was successfully deleted'); + }, + error: err => this._toast.error(err.error) + }); + } + + private updateModifiedJob(job: JobModel) { + this._workflows.setJob(job).subscribe({ + next: () => { + this._creator.close(); + this.job.set(job); + }, + error: err => this._toast.error(err.error) + }); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + clearInterval(this.interval); + } +} diff --git a/src/app/plugins/workflows/components/workflow-session/workflow-session.component.html b/src/app/plugins/workflows/components/workflow-session/workflow-session.component.html new file mode 100644 index 00000000..f22f4459 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-session/workflow-session.component.html @@ -0,0 +1,34 @@ +
+ @if (nestedSessionStack().length === 0) { + @switch (session?.type) { + @case ('USER_SESSION') { + + + } + @case ('API_SESSION') { + + + } + @case ('JOB_SESSION') { + + + } + @default { + + Unsupported Session Type + + } + } + } @else if (!hideNested) { + + + } +
diff --git a/src/app/plugins/workflows/components/workflow-session/workflow-session.component.scss b/src/app/plugins/workflows/components/workflow-session/workflow-session.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/plugins/workflows/components/workflow-session/workflow-session.component.ts b/src/app/plugins/workflows/components/workflow-session/workflow-session.component.ts new file mode 100644 index 00000000..88ba8a0b --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-session/workflow-session.component.ts @@ -0,0 +1,159 @@ +import {Component, computed, inject, OnDestroy, OnInit, signal} from '@angular/core'; +import {ActivatedRoute, Router} from '@angular/router'; +import {ToasterService} from '../../../../components/toast-exposer/toaster.service'; +import {LeftSidebarService} from '../../../../components/left-sidebar/left-sidebar.service'; +import {WorkflowsService} from '../../services/workflows.service'; +import {SessionModel} from '../../models/workflows.model'; +import {MarkdownService, MarkedRenderer} from 'ngx-markdown'; + +@Component({ + selector: 'app-workflow-session', + templateUrl: './workflow-session.component.html', + styleUrl: './workflow-session.component.scss' +}) +export class WorkflowSessionComponent implements OnInit, OnDestroy { + private readonly _route = inject(ActivatedRoute); + private readonly _router = inject(Router); + private readonly _toast = inject(ToasterService); + private readonly _sidebar = inject(LeftSidebarService); + private readonly _workflows = inject(WorkflowsService); + private readonly _markdown = inject(MarkdownService); + + sessionId: string; + session: SessionModel; + + nestedSessionStack = signal([]); // nested sessions are stored in a stack to allow navigating back + nestedSession = computed(() => this.nestedSessionStack()[this.nestedSessionStack().length - 1]); + hideNested = false; + showEditor = true; + + ngOnInit(): void { + this._sidebar.hide(); + this.initMarkdown(); + + this._route.paramMap.subscribe(params => { + this.sessionId = params.get('sessionId'); + if (this.sessionId) { + this._workflows.getSession(this.sessionId).subscribe({ + next: res => this.session = res, + error: () => this.backToDashboard() + }); + } + }); + + this.sessionId = this._route.snapshot.paramMap.get('sessionId'); + } + + ngOnDestroy(): void { + } + + isRegistryLoaded() { + return this._workflows.registryLoaded(); + } + + backToDashboard() { + this._router.navigate(['/views/workflows/dashboard']); + } + + backToJob() { + this._router.navigate([`/views/workflows/jobs/${this.session.jobId}`]); + } + + saveSession(message: string) { + this._workflows.saveSession(this.sessionId, message).subscribe({ + next: version => { + if (this.session.version !== undefined) { + this.session.version = version; + } + this._toast.success('Successfully saved workflow version ' + version, 'Saved Workflow'); + }, + error: e => { + this._toast.error(e.error, 'Unable to Save Workflow'); + } + }); + } + + terminateSession() { + this._workflows.terminateSession(this.sessionId).subscribe({ + next: () => this.backToDashboard(), + error: e => this._toast.error(e.error, 'Unable to Terminate Session'), + }); + } + + openNestedSession(session: SessionModel) { + if (session.type !== 'NESTED_SESSION') { + return; + } + this.nestedSessionStack.update(stack => { + return [...stack, session]; + }); + if (this.nestedSessionStack().length > 1) { + this.hideNested = true; + setTimeout(() => this.hideNested = false, 10); // destroy and instantiate editor again + } + } + + closeNestedSession() { + this.nestedSessionStack.update(stack => { + stack.pop(); + return [...stack]; + }); + if (this.nestedSessionStack().length > 0) { + this.hideNested = true; + setTimeout(() => this.hideNested = false, 10); // destroy and instantiate editor again + } + } + + reloadViewer() { + this.showEditor = false; + setTimeout(() => { + this.showEditor = true; + this._toast.info('Completed workflow synchronization'); + }, 100); + } + + renameWorkflow(data: { name: string; description: string }) { + this._workflows.renameWorkflow(this.session.workflowId, data.name, null, data.description).subscribe({ + next: () => { + this.session.workflowDef.name = data.name; + this.session.workflowDef.description = data.description; + this._toast.success('Successfully modified workflow', 'Modify Workflow'); + }, + error: e => this._toast.error(e.error, 'Unable to modify workflow name / description') + }); + } + + private initMarkdown() { + const renderer = new MarkedRenderer(); + renderer.blockquote = (text: string) => { + return `
+ ${text} +
`; + }; + + renderer.codespan = (code) => { + return `${code}`; + }; + + const defaultHeadingRenderer = renderer.heading.bind(renderer); + renderer.heading = (text: string, level: number, raw: string) => { + return defaultHeadingRenderer(text, level + 3, raw); + }; + + const defaultLinkRenderer = renderer.link.bind(renderer); + renderer.link = (href, title, text) => { + const link = defaultLinkRenderer(href, title, text); + return link.startsWith(' { + href = `${baseUrl}/${href}`; + return defaultImageRenderer(href, title, text); + }; + + this._markdown.renderer = renderer; + } +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/activity-help/activity-help.component.html b/src/app/plugins/workflows/components/workflow-viewer/activity-help/activity-help.component.html new file mode 100644 index 00000000..383c3238 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/activity-help/activity-help.component.html @@ -0,0 +1,97 @@ +
+
+ + + + + + + + + +
+ + @if (requiresMarkdown()) { + + + } @else { +

{{ def().longDescription }}

+ } + +
+ @if (def().inPorts.length > 0) { +

Inputs

+ +
    + @for (inPort of def().inPorts; track $index) { +
  • +
    {{ $index }}: + + Multiple + {{ portTypeToDataModel( inPort.type ) }} + (optional) + +
    + +
  • + } +
+
+ } + + @if (def().outPorts.length > 0) { +

Outputs

+ +
    + @for (outPort of def().outPorts; track $index) { +
  • +
    {{ $index }}: + + {{ portTypeToDataModel( outPort.type ) }} + +
    + +
  • + } +
+
+ } +
+ +
+

Settings

+ + + + @for (subgroup of activeSettingGroup().subgroups; track subgroup.key) { + +
{{ subgroup.displayName }}
+ +
    + @for (setting of subgroup.settings; track setting.key) { +
  • +
    {{ setting.displayName }} + ({{ setting.key }}: {{ setting.type }}) +
    + +
  • + } +
+
+
+ } +
+
diff --git a/src/app/plugins/workflows/components/workflow-viewer/activity-help/activity-help.component.scss b/src/app/plugins/workflows/components/workflow-viewer/activity-help/activity-help.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/plugins/workflows/components/workflow-viewer/activity-help/activity-help.component.ts b/src/app/plugins/workflows/components/workflow-viewer/activity-help/activity-help.component.ts new file mode 100644 index 00000000..a175a726 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/activity-help/activity-help.component.ts @@ -0,0 +1,30 @@ +import {Component, computed, effect, input, OnInit, signal} from '@angular/core'; +import {ActivityDef, GroupDef, portTypeToDataModel} from '../../../models/activity-registry.model'; +import {KatexOptions} from 'ngx-markdown'; + +@Component({ + selector: 'app-activity-help', + templateUrl: './activity-help.component.html', + styleUrl: './activity-help.component.scss', +}) +export class ActivityHelpComponent implements OnInit { + def = input.required(); + + // if longDescription does not exist, it is set to shortDescription by backend, which could result in unescaped .md + readonly requiresMarkdown = computed(() => this.def().shortDescription !== this.def().longDescription); + activeSettingGroup = signal(null); + + protected readonly portTypeToDataModel = portTypeToDataModel; + + readonly options: KatexOptions = { + throwOnError: false, + errorColor: '#f86c6b' + }; + + constructor() { + effect(() => this.activeSettingGroup.set(this.def().getFirstGroup()), {allowSignalWrites: true}); + } + + ngOnInit(): void { + } +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/editor/activity-port/activity-port.component.html b/src/app/plugins/workflows/components/workflow-viewer/editor/activity-port/activity-port.component.html new file mode 100644 index 00000000..0ef38f20 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/editor/activity-port/activity-port.component.html @@ -0,0 +1,27 @@ +
+ @switch (data.controlType) { + @case ('in') { + + } + @case ('success') { + + } + @case ('fail') { + + } + @default { + + } + + } +
diff --git a/src/app/plugins/workflows/components/workflow-viewer/editor/activity-port/activity-port.component.scss b/src/app/plugins/workflows/components/workflow-viewer/editor/activity-port/activity-port.component.scss new file mode 100644 index 00000000..7a094562 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/editor/activity-port/activity-port.component.scss @@ -0,0 +1,103 @@ +@use "sass:math"; +@import "../vars"; + +:host { + cursor: pointer; + vertical-align: middle; + z-index: 2; + box-sizing: border-box; + +} + +.port-wrapper { + display: flex; + justify-content: center; + align-items: center; + + &.control-port { + width: $control-port-size; + height: $control-port-size; + border-radius: 3px; + + &:hover { + filter: brightness(120%); + } + } + + &.data-port { + width: $data-port-size; + height: $data-port-size; + border-radius: calc(#{$data-port-size} / 2); + background: var(--cui-dark); + border: 2px solid var(--cui-dark); + + &.is-multi { + margin-left: calc((#{$data-port-size} - #{$multi-width}) / 2); + height: $multi-height; + width: $multi-width; + } + + &.out-port.state-finished { + background: repeating-linear-gradient( + 135deg, + var(--cui-dark), + var(--cui-dark) 3px, + var(--cui-secondary) 3px, + var(--cui-secondary) 6px + ); + + &:hover { + background: repeating-linear-gradient( + 135deg, + var(--cui-secondary), + var(--cui-secondary) 3px, + var(--cui-light) 3px, + var(--cui-light) 6px + ) + } + } + + &.is-optional { + background: var(--cui-secondary); + + &:hover { + background: var(--cui-light); + } + } + + &:hover { + background: var(--cui-secondary); + } + } + + &.success-control { + background: var(--cui-success); + + .port-icon { + font-size: $control-port-size - 4px; + padding-top: 1px; + } + } + + &.fail-control { + background: var(--cui-danger); + + .port-icon { + font-size: $control-port-size - 4px; + padding-top: 1px; + } + } + + &.in-control { + background: var(--cui-dark); + + .port-icon { + font-size: $control-port-size - 5px; + padding-top: 1px; + } + } + + .port-icon { + color: var(--cui-light) + } +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/editor/activity-port/activity-port.component.ts b/src/app/plugins/workflows/components/workflow-viewer/editor/activity-port/activity-port.component.ts new file mode 100644 index 00000000..29a9e0de --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/editor/activity-port/activity-port.component.ts @@ -0,0 +1,73 @@ +import {ChangeDetectorRef, Component, ElementRef, HostBinding, Input, OnChanges, Signal, signal} from '@angular/core'; +import {ClassicPreset} from 'rete'; +import {InPortDef, OutPortDef, portTypeToDataModel} from '../../../../models/activity-registry.model'; +import {DataModel} from '../../../../../../models/ui-request.model'; +import {ActivityState} from '../../../../models/workflows.model'; +import {NgClass} from '@angular/common'; + +@Component({ + selector: 'app-activity-port', + standalone: true, + imports: [ + NgClass + ], + templateUrl: './activity-port.component.html', + styleUrl: './activity-port.component.scss' +}) +export class ActivityPortComponent implements OnChanges { + @Input() data!: ActivityPort; + @Input() rendered!: any; + + @HostBinding('title') get title() { + if (this.data.isControl) { + return this.data.controlType + ' control'; + } else if (!this.data.isInput && this.data.activityState() === 'FINISHED') { + return this.data.dataModel() + ' (not materialized)'; + } + let title = ''; + if (this.data.isMulti) { + title += 'Multiple Connections: '; + } + title += this.data.dataModel() || '?'; + if (this.data.portDef['isOptional']) { + title += ' (optional)'; + } + return title; + } + + constructor(private cdr: ChangeDetectorRef, private elementRef: ElementRef) { + this.cdr.detach(); + } + + ngOnChanges(): void { + this.cdr.detectChanges(); + requestAnimationFrame(() => this.rendered()); + } + +} + +export class ActivityPort extends ClassicPreset.Socket { + public readonly isControl: boolean; + public readonly isMulti: boolean; + public readonly dataModel = signal(null); + + constructor(public readonly portDef: InPortDef | OutPortDef, public readonly isInput: boolean, + public readonly controlType: 'success' | 'fail' | 'in' | null, + public readonly activityState: Signal) { + super(''); + this.isControl = controlType != null; + if (!this.isControl) { + this.dataModel.set(portTypeToDataModel(portDef.type)); + } + this.isMulti = isInput && !this.isControl && (portDef as InPortDef)?.isMulti; + } + + isCompatibleWith(target: ActivityPort) { + if (this.isControl) { + return target.isControl; + } + + return !target.isControl && (this.dataModel() === target.dataModel() || !this.dataModel() || !target.dataModel()); + + } +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/editor/activity/activity.component.html b/src/app/plugins/workflows/components/workflow-viewer/editor/activity/activity.component.html new file mode 100644 index 00000000..9d9a7d92 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/editor/activity/activity.component.html @@ -0,0 +1,118 @@ +
+ +
+

+ {{ statusText?.() }} +

+
+ +
+ +
+ + +
+
+ {{ data.commonType() }} + (aborted) +
+ + @switch (data.expectedOutcome()) { + @case ('MUST_SUCCEED') { +
+ +
+ } + @case ('MUST_FAIL') { +
+ +
+ } + } + + + + + + +
+ +
+
+
+
+
+ + +
+
+
+ +
+
+ +
+

{{ data.displayName() }}

+ +
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/plugins/workflows/components/workflow-viewer/editor/activity/activity.component.scss b/src/app/plugins/workflows/components/workflow-viewer/editor/activity/activity.component.scss new file mode 100644 index 00000000..a110c589 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/editor/activity/activity.component.scss @@ -0,0 +1,100 @@ +@use "sass:math"; +@import "../vars"; + +:host { + display: block; + border: 3px solid transparent; // when a visible border appears, the node does not move + cursor: pointer; + box-sizing: border-box; + width: $node-width; + height: auto; + position: relative; + user-select: none; + + &:hover { + border-color: var(--cui-light); + } + + &.selected { + border-color: var(--cui-secondary); + } + + .activity { + box-shadow: 0 0 10px 2px rgba(0, 0, 0, 0.5); + background: var(--cui-light); + + &.is-opened { + box-shadow: 0 0 20px 2px var(--cui-primary); + + } + + &.meta-activity .control-bar { + background: var(--cui-dark); + color: var(--cui-light); + } + } + + .status-bar { + height: 18px; + + &.state-executing { + background: #0d6efd; // fallback if linear gradient is not supported + } + + .status-text { + font-size: 12px; + margin-top: -2px; + } + } + + .activity-inputs, .activity-outputs { + min-width: 36px; + } + + .output { + text-align: right; + } + + .input { + text-align: left; + } + + .output-spacer { + height: $control-port-size + 8px; // data in and out-ports should start at same height + } + + .output-socket { + text-align: right; + display: inline-block; + } + + .input-socket { + text-align: left; + display: inline-block; + } + + .control-inport { + margin-left: calc(-#{$control-port-size} / 2); + } + + .data-inport { + margin-left: calc(-#{$data-port-size} / 2); + } + + .control-outport { + margin-right: calc(-#{$control-port-size} / 2); + } + + .data-outport { + margin-right: calc(-#{$data-port-size} / 2); + } + + #activity-notes { + resize: none; + background: var(--cui-light); + + &::placeholder { + color: var(--cui-secondary); + } + } +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/editor/activity/activity.component.ts b/src/app/plugins/workflows/components/workflow-viewer/editor/activity/activity.component.ts new file mode 100644 index 00000000..2c3587fa --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/editor/activity/activity.component.ts @@ -0,0 +1,261 @@ +import {ChangeDetectorRef, Component, computed, HostBinding, Input, OnChanges, OnInit, Signal} from '@angular/core'; +import {ClassicPreset} from 'rete'; +import {KeyValue} from '@angular/common'; +import {ActivityState, WorkflowState} from '../../../../models/workflows.model'; +import {ActivityPort} from '../activity-port/activity-port.component'; +import {Subject} from 'rxjs'; +import {Activity} from '../../workflow'; +import {WorkflowsWebSocketService} from '../../../../services/workflows-websocket.service'; +import {ToasterService} from '../../../../../../components/toast-exposer/toaster.service'; +import {PortType} from '../../../../models/activity-registry.model'; + + +export const stateColors = { + [ActivityState.IDLE]: '#dddddd', + [ActivityState.QUEUED]: '#39f', + [ActivityState.EXECUTING]: '#0d6efd', + [ActivityState.FINISHED]: '#96dbad', // same color as saved + [ActivityState.SAVED]: '#2eb85c', + [ActivityState.SKIPPED]: '#f9b115', + [ActivityState.FAILED]: '#e55353', +}; + +export const IN_CONTROL_KEY = 'c_in'; +export const SUCCESS_CONTROL_KEY = 'c_out_success'; +export const FAIL_CONTROL_KEY = 'c_out_fail'; + +export const portTypeIcons = { + [PortType.REL]: 'fa fa-table', + [PortType.DOC]: 'fa fa-file-text-o', + [PortType.LPG]: 'cil-graph', + [PortType.ANY]: 'fa fa-question', +}; + +@Component({ + selector: 'app-activity', + templateUrl: './activity.component.html', + styleUrl: './activity.component.scss' +}) +export class ActivityComponent implements OnInit, OnChanges { + @Input() data!: ActivityNode; + @Input() emit!: (data: any) => void; + @Input() rendered!: () => void; + seed = 0; + + statusBackground: Signal; + statusText: Signal; + readonly inIcons: Record> = {}; + readonly outIcons: Record> = {}; + + protected readonly IN_CONTROL_KEY = IN_CONTROL_KEY; + protected readonly ActivityState = ActivityState; + + @HostBinding('class.selected') get selected() { + return this.data.selected; + } + + constructor(private cdr: ChangeDetectorRef, private _websocket: WorkflowsWebSocketService, private _toast: ToasterService) { + this.cdr.detach(); + } + + ngOnInit(): void { + const grad1 = '#20c997'; + const grad2 = stateColors[ActivityState.EXECUTING]; + this.statusBackground = computed(() => { + if (this.data.state() === ActivityState.EXECUTING) { + const percent = this.data.progress() * 100; + return `linear-gradient(to right, ${grad1} ${percent - 5}%, ${grad2} ${percent + 5}%)`; + } else if (this.data.state() === ActivityState.FINISHED) { + return `repeating-linear-gradient( 135deg, + ${stateColors[ActivityState.SAVED]}, ${stateColors[ActivityState.SAVED]} 45px, + ${stateColors[ActivityState.FINISHED]} 45px, ${stateColors[ActivityState.FINISHED]} 90px + )`; + } else { + return stateColors[this.data.state()]; + } + }); + this.statusText = computed(() => { + switch (this.data.state()) { + case ActivityState.EXECUTING: + return `executing (${Math.floor(this.data.progress() * 100)} %)`; + case ActivityState.FINISHED: + return 'finished (not materialized)'; + case ActivityState.SAVED: + return 'finished'; + case ActivityState.SKIPPED: + return 'cancelled'; + default: + return this.data.state().toLowerCase(); + } + }); + Object.keys(this.data.dataInputs).forEach(key => { + const idx = ActivityNode.getDataPortIndexFromKey(key); + const inPort = this.data.def.inPorts[idx]; + if (inPort.isMulti && inPort.type === PortType.ANY) { + this.inIcons[key] = computed(() => portTypeIcons[PortType.ANY]); + } else { + this.inIcons[key] = computed(() => portTypeIcons[this.data.inPortTypes()[idx]]); + } + }); + Object.keys(this.data.dataOutputs).forEach(key => { + const idx = ActivityNode.getDataPortIndexFromKey(key); + this.outIcons[key] = computed(() => portTypeIcons[this.data.outPortTypes()[idx]]); + }); + + this.cdr.detectChanges(); + } + + ngOnChanges(): void { + this.cdr.detectChanges(); + requestAnimationFrame(() => this.rendered()); + this.seed++; // force render sockets + } + + saveNotes() { + // we can perform this update locally, instead of from the workflow-viewer + this._websocket.updateActivity(this.data.activityId, null, null, this.data.rendering()); + } + + showProblems() { + if (this.data.hasInvalidSettings()) { + this._toast.warn(this.data.firstInvalidSetting()[1], 'Problem with Setting "' + this.data.firstInvalidSetting()[0] + '"'); + this.data.openSettings(); + } + if (this.data.invalidReason()) { + this._toast.warn(this.data.invalidReason(), 'Problem with Activity'); + } + } + + sortByIndex< + N extends object, + T extends KeyValue + >(a: T, b: T) { + const ai = a.value.index || 0; + const bi = b.value.index || 0; + + return ai - bi; + } +} + +export class ActivityNode extends ClassicPreset.Node { + width = 400; + height = 275; + + readonly activityId = this.activity.id; + readonly def = this.activity.def; + readonly hasNested = this.activity.hasNested; + readonly isMetaActivity = this.activity.isMetaActivity; + readonly displayName = this.activity.displayName; + readonly rendering = this.activity.rendering; + readonly state = this.activity.state.asReadonly(); + readonly progress = this.activity.progress.asReadonly(); + readonly commonType = this.activity.commonType; + readonly expectedOutcome = this.activity.expectedOutcome; + readonly isRolledBack = this.activity.isRolledBack; + readonly invalidReason = this.activity.invalidReason; + readonly hasInvalidSettings = this.activity.hasInvalidSettings; + readonly firstInvalidSetting = computed(() => this.hasInvalidSettings() ? Object.entries(this.activity.invalidSettings())[0] : undefined); + readonly isOpened = this.activity.isOpened; + readonly controlInput: ClassicPreset.Input; + readonly dataInputs: { [key: string]: ClassicPreset.Input } = {}; + readonly controlOutputs: { [key: string]: ClassicPreset.Output }; + readonly dataOutputs: { [key: string]: ClassicPreset.Output } = {}; + + canExecute = computed(() => this.isEditable && this.state() === 'IDLE' && this.workflowState() === 'IDLE'); + canReset = computed(() => this.isEditable && this.state() !== 'IDLE' && this.workflowState() === 'IDLE'); + canOpenCheckpoint = computed(() => this.state() === 'FINISHED' || this.state() === 'SAVED'); + inPortTypes = computed(() => this.activity.inTypePreview().map(p => p.portType)); + outPortTypes = computed(() => this.activity.outTypePreview().map(p => p.portType)); + isExpectedOutcome = computed(() => { + if (this.expectedOutcome() === 'MUST_SUCCEED' && (this.state() === 'FAILED' || this.state() === 'SKIPPED')) { + return false; + } else if (this.expectedOutcome() === 'MUST_FAIL' && (this.state() === 'SAVED' || this.state() === 'FINISHED')) { + return false; + } + return true; + }); + + + constructor( + private readonly activity: Activity, + public readonly workflowState: Signal, + public readonly executeActivitySubject: Subject, + public readonly resetActivitySubject: Subject, + public readonly openSettingsSubject: Subject, + public readonly openNestedSubject: Subject, + public readonly openCheckpointSubject: Subject<[string, boolean, number]>, + public readonly isEditable: boolean, // whether workflow is editable + ) { + super(activity.displayName()); + // control ports + this.controlInput = new ClassicPreset.Input(new ActivityPort(null, true, 'in', this.state), null, true); + this.addInput(IN_CONTROL_KEY, this.controlInput); + this.addOutput(SUCCESS_CONTROL_KEY, new ClassicPreset.Output(new ActivityPort(null, false, 'success', this.state), null, true)); + this.addOutput(FAIL_CONTROL_KEY, new ClassicPreset.Output(new ActivityPort(null, false, 'fail', this.state), null, true)); + + this.controlOutputs = { + [SUCCESS_CONTROL_KEY]: this.outputs[SUCCESS_CONTROL_KEY] as ClassicPreset.Output, + [FAIL_CONTROL_KEY]: this.outputs[FAIL_CONTROL_KEY] as ClassicPreset.Output + }; + + // data ports + this.def.inPorts.forEach((inPort, i) => { + const input = new ClassicPreset.Input(new ActivityPort(inPort, true, null, this.state), null, inPort.isMulti); + this.addInput(ActivityNode.getDataPortKey(i), input); + this.dataInputs[ActivityNode.getDataPortKey(i)] = input; + }); + this.def.outPorts.forEach((outPort, i) => { + const output = new ClassicPreset.Output(new ActivityPort(outPort, false, null, this.state), null, true); + this.addOutput(ActivityNode.getDataPortKey(i), new ClassicPreset.Output(new ActivityPort(outPort, false, null, this.state), null, true)); + this.dataOutputs[ActivityNode.getDataPortKey(i)] = output; + }); + + this.height += Math.max(0, + (this.def.inPorts.length - 3) * 34, + (this.def.outPorts.length - 2) * 34 + ); + } + + public static getDataPortKey(index: number) { + return 'd_' + index; + } + + public static getDataPortIndexFromKey(key: string) { + return parseInt(key.slice(2), 10); + } + + public static isControlPortKey(key: string) { + return key === IN_CONTROL_KEY || key === SUCCESS_CONTROL_KEY || key === FAIL_CONTROL_KEY; + } + + /** + * Takes multi-inputs into account. + */ + public getInDataPortKey(index: number) { + const count = this.activity.def.inPorts.length; + if (index >= count) { + return ActivityNode.getDataPortKey(count - 1); + } + return ActivityNode.getDataPortKey(index); + } + + execute() { + this.executeActivitySubject.next(this.activityId); + } + + reset() { + this.resetActivitySubject.next(this.activityId); + } + + openSettings() { + this.openSettingsSubject.next(this.activityId); + } + + openNested() { + this.openNestedSubject.next(this.activityId); + } + + openCheckpoint(isInput: boolean, key: string) { + this.openCheckpointSubject.next([this.activityId, isInput, ActivityNode.getDataPortIndexFromKey(key)]); + } +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/editor/edge/edge.component.html b/src/app/plugins/workflows/components/workflow-viewer/editor/edge/edge.component.html new file mode 100644 index 00000000..864d1fb7 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/editor/edge/edge.component.html @@ -0,0 +1,13 @@ + + + +
+ +
+ +
+ {{ data.multiIdx }} +
\ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/editor/edge/edge.component.scss b/src/app/plugins/workflows/components/workflow-viewer/editor/edge/edge.component.scss new file mode 100644 index 00000000..e5cfb1f0 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/editor/edge/edge.component.scss @@ -0,0 +1,60 @@ +svg { + overflow: visible; + position: absolute; + pointer-events: none; + + path { + fill: none; + stroke-width: 4px; + pointer-events: auto; + stroke: var(--cui-dark); + + &.state-IDLE { + stroke: var(--cui-dark); + } + + &.state-ACTIVE { + stroke: var(--cui-success); + } + + &.state-INACTIVE { + stroke: var(--cui-danger); + } + + &.is-control { + stroke-dasharray: 16, 8; + stroke-width: 3px; + + &.is-fail-control { + stroke-dasharray: 16, 4, 3, 4; + } + } + + &.is-magnetic { + stroke: rgba(79, 93, 115, 0.3); + } + } +} + +#path-label { + position: absolute; + + i { + color: var(--cui-dark); + } + + i.state-ACTIVE { + color: var(--cui-success); + } + + i.state-INACTIVE { + color: var(--cui-danger); + } +} + +.multi-index { + position: absolute; + transform: translate(-50%, -150%); + z-index: 1; + user-select: none; +} \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/editor/edge/edge.component.ts b/src/app/plugins/workflows/components/workflow-viewer/editor/edge/edge.component.ts new file mode 100644 index 00000000..a31dc44a --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/editor/edge/edge.component.ts @@ -0,0 +1,78 @@ +import {Component, Input, OnInit, signal, Signal} from '@angular/core'; +import {ClassicPreset} from 'rete'; +import {ActivityNode, FAIL_CONTROL_KEY, IN_CONTROL_KEY, SUCCESS_CONTROL_KEY} from '../activity/activity.component'; +import {EdgeModel, EdgeState} from '../../../../models/workflows.model'; + +@Component({ + selector: 'app-edge', + templateUrl: './edge.component.html', + styleUrl: './edge.component.scss' +}) +export class EdgeComponent implements OnInit { + @Input() data!: Edge; + @Input() start: { x: number; y: number }; + @Input() end: { x: number; y: number }; + @Input() path: string; + + isControl = false; + isFailControl = false; + center: Signal<{ x: number, y: number, angle: number }>; + + ngOnInit(): void { + // @ts-ignore + if (this.data.isPseudo || this.data.isMagnetic) { + this.isControl = ActivityNode.isControlPortKey(this.data.sourceOutput) || ActivityNode.isControlPortKey(this.data.targetInput); + } else { + this.isControl = this.data.isControl; + } + this.isFailControl = this.data.sourceOutput === FAIL_CONTROL_KEY; + this.center = this.data.center; + } +} + +export class Edge extends ClassicPreset.Connection { + isMagnetic = false; + readonly sourceActivityId: string; // activityId + readonly targetActivityId: string; // activityId + readonly center = signal({x: 0, y: 0, angle: 0}); + isMulti = false; + multiIdx = 0; + + constructor(source: N, sourceOutput: keyof N['outputs'], target: N, targetInput: keyof N['inputs'], + public readonly toIdx: number, // for multi-port: can differ from socket + public readonly isControl: boolean, public readonly state: Signal) { + super(source, sourceOutput, target, targetInput); + this.sourceActivityId = source.activityId; + this.targetActivityId = target.activityId; + if (!isControl && target.def.inPorts[ActivityNode.getDataPortIndexFromKey(targetInput as string)].isMulti) { + this.isMulti = true; + this.multiIdx = toIdx + 1 - target.def.inPorts.length; + } + } + + public static createDataEdge(from: ActivityNode, fromPort: number, to: ActivityNode, toPort: number, state: Signal) { + return new Edge(from, ActivityNode.getDataPortKey(fromPort), to, to.getInDataPortKey(toPort), toPort, false, state); + } + + public static createControlEdge(from: ActivityNode, to: ActivityNode, fromPort: number, state: Signal) { + const isSuccess = fromPort === 0; + return new Edge(from, isSuccess ? SUCCESS_CONTROL_KEY : FAIL_CONTROL_KEY, to, IN_CONTROL_KEY, 0, true, state); + } + + getFromPort() { + if (this.isControl) { + return this.sourceOutput === SUCCESS_CONTROL_KEY ? 0 : 1; + } else { + // @ts-ignore + return ActivityNode.getDataPortIndexFromKey(this.sourceOutput); + } + } + + toModel(): EdgeModel { + return { + fromId: this.sourceActivityId, fromPort: this.getFromPort(), + toId: this.targetActivityId, toPort: this.toIdx, + isControl: this.isControl + }; + } +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/editor/vars.scss b/src/app/plugins/workflows/components/workflow-viewer/editor/vars.scss new file mode 100644 index 00000000..fd6616c2 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/editor/vars.scss @@ -0,0 +1,5 @@ +$node-width: 400px; +$control-port-size: 16px; +$data-port-size: 20px; +$multi-width: 15px; +$multi-height: 40px; \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/editor/workflow-editor-utils.ts b/src/app/plugins/workflows/components/workflow-viewer/editor/workflow-editor-utils.ts new file mode 100644 index 00000000..267c0c1e --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/editor/workflow-editor-utils.ts @@ -0,0 +1,198 @@ +import {NodeEditor} from 'rete'; +import {SocketData} from 'rete-connection-plugin'; +import {Position} from 'rete-angular-plugin/17/types'; +import {Schemes} from './workflow-editor'; +import {EdgeModel, WorkflowState} from '../../../models/workflows.model'; +import {ActivityNode, IN_CONTROL_KEY, SUCCESS_CONTROL_KEY} from './activity/activity.component'; +import {Subject} from 'rxjs'; +import {Item, Items} from 'rete-context-menu-plugin/_types/types'; +import {ContextMenuPlugin} from 'rete-context-menu-plugin'; +import {BaseAreaPlugin} from 'rete-area-plugin'; +import {ActivityPort} from './activity-port/activity-port.component'; +import {Edge} from './edge/edge.component'; +import {Workflow} from '../workflow'; + + +export function getMagneticConnectionProps(editor: NodeEditor, createEdgeSubject: Subject) { + return { + async createConnection(from: SocketData, to: SocketData) { + if (from.side === to.side) { + return; + } + const [source, target] = from.side === 'output' ? [from, to] : [to, from]; + + if (!canCreateConnection(source, target, editor)) { + return; + } + + if (!ActivityNode.isControlPortKey(target.key)) { + const connectionsToRemove = editor.getConnections().filter(c => + c.target === target.nodeId && c.targetInput === target.key); + if (!connectionsToRemove[0]?.isMulti) { + for (const c of connectionsToRemove) { + await editor.removeConnection(c.id); // the edge add request could be sent before remove, but this is not a problem + } + } + } + createEdgeSubject.next(socketsToEdgeModel(source, target, editor)); + }, + display(from: SocketData, to: SocketData) { + const [source, target] = from.side === 'output' ? [from, to] : [to, from]; + return canCreateConnection(source, target, editor); + }, + offset(socket: SocketData, position: Position) { + + return { + x: position.x, //+ (socket.side === 'input' ? 3 : -3), + y: position.y //+ (socket.side === 'input' ? 12 : -12) + }; + }, + distance: 125 + }; +} + +export function socketsToEdgeModel(source: SocketData, target: SocketData, editor: NodeEditor) { + const isControl = target.key === IN_CONTROL_KEY; + const toNode = editor.getNode(target.nodeId); + let toPort = 0; + if (!isControl) { + toPort = ActivityNode.getDataPortIndexFromKey(target.key); + if (toNode.def.inPorts[toPort].isMulti) { + toPort += editor.getConnections().filter(c => c.target === target.nodeId && c.isMulti).length; + } + } + return { + fromId: editor.getNode(source.nodeId).activityId, + toId: toNode.activityId, + fromPort: isControl ? (source.key === SUCCESS_CONTROL_KEY ? 0 : 1) : ActivityNode.getDataPortIndexFromKey(source.key), + toPort: toPort, + isControl: target.key === IN_CONTROL_KEY + }; +} + +export function getContextMenuItems(removeEdgeSubject: Subject, moveMultiEdgeSubject: Subject<[EdgeModel, number]>, + removeActivitySubject: Subject, cloneActivitySubject: Subject, workflow: Workflow): Items { + const items: Item[] = [ + //{label: 'Print Something', key: '0', handler: () => console.log('something was printed')}, + ]; + return (context: 'root' | Schemes['Node'] | Schemes['Connection'], plugin: ContextMenuPlugin) => { + const area = plugin.parentScope(BaseAreaPlugin); + const editor = area.parentScope(NodeEditor); + + if (context === 'root' || workflow.state() !== WorkflowState.IDLE) { + return { + searchBar: false, + list: items + }; + } + + const isEdge = 'source' in context && 'target' in context; + const deleteItem: Item = { + label: 'Delete', + key: 'delete', + handler: async () => { + if (isEdge) { + // connection + removeEdgeSubject.next(context.toModel()); + } else { + // node + removeActivitySubject.next(context.activityId); + } + } + }; + + const cloneItem: Item = { + label: 'Clone', + key: 'clone', + handler: () => { + if (context instanceof ActivityNode) { + cloneActivitySubject.next(context.activityId); + } + } + }; + + if (isEdge && context.isMulti) { + const items = [deleteItem]; + if (context.multiIdx > 0) { + items.push({ + label: 'Set as First Edge', + key: 'first', + handler: () => moveMultiEdgeSubject.next([context.toModel(), 0]) + }); + } + const multiCount = editor.getConnections().filter( + c => c.target === context.target && (c as Edge).isMulti) + .length; + if (context.multiIdx + 1 < multiCount) { + items.push({ + label: 'Set as Last Edge', + key: 'last', + handler: () => moveMultiEdgeSubject.next([context.toModel(), -1]) + }); + } + return {searchBar: false, list: items}; + } + + return { + searchBar: false, + list: context instanceof ActivityNode ? [deleteItem, cloneItem] : [deleteItem] + }; + }; +} + +export function canCreateConnection(source: SocketData, target: SocketData, editor: NodeEditor): boolean { + if (!source || !target || source.side === target.side || source.nodeId === target.nodeId) { + return false; + } + + const sourceNode = editor.getNode(source.nodeId); + const targetNode = editor.getNode(target.nodeId); + const sourcePort: ActivityPort = sourceNode.outputs[source.key].socket as ActivityPort; + const targetPort: ActivityPort = targetNode.inputs[target.key].socket as ActivityPort; + + if (!sourcePort.isCompatibleWith(targetPort)) { + return false; + } + + const connections = editor.getConnections(); + if (connections.find(c => c.source === source.nodeId && c.target === target.nodeId + && c.sourceOutput === source.key && c.targetInput === target.key)) { + return false; + } + // Detect cycle using BFS + const queue = [targetNode.id]; + const visited = new Set(); + while (queue.length > 0) { + const successor = queue.shift(); + if (visited.has(successor)) { + continue; + } + if (successor === sourceNode.id) { + return false; + } + visited.add(successor); + const successors = getSuccessors(successor, connections); + if (successors) { + queue.push(...successors); + } + } + + return true; +} + +export function computeCenter(points: { x: number; y: number }[]) { + const [start, end] = points; + return { + x: (start.x + end.x) / 2, + y: (start.y + end.y) / 2, + angle: Math.atan2(end.y - start.y, end.x - start.x) * (180 / Math.PI) + }; +} + +function getSuccessors(nodeId: string, connections: Edge[]): string[] | null { + const outgoing = connections.filter(c => c.source === nodeId).map(c => c.target); + if (outgoing.length === 0) { + return null; + } + return outgoing; +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/editor/workflow-editor.ts b/src/app/plugins/workflows/components/workflow-viewer/editor/workflow-editor.ts new file mode 100644 index 00000000..bf37fe66 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/editor/workflow-editor.ts @@ -0,0 +1,404 @@ +import {effect, Injector, Signal} from '@angular/core'; +import {GetSchemes, NodeEditor} from 'rete'; +import {AreaExtensions, AreaPlugin} from 'rete-area-plugin'; +import {ClassicFlow, ConnectionPlugin, getSourceTarget} from 'rete-connection-plugin'; +import {AngularArea2D, AngularPlugin, Presets} from 'rete-angular-plugin/17'; +import {EdgeModel, EdgeState, RenderModel, WorkflowState} from '../../../models/workflows.model'; +import {ActivityComponent, ActivityNode} from './activity/activity.component'; +import {Edge, EdgeComponent} from './edge/edge.component'; +import {ActivityPortComponent} from './activity-port/activity-port.component'; +import {addCustomBackground} from '../../../../../components/polyalg/polyalg-viewer/background'; +import {ReadonlyPlugin} from 'rete-readonly-plugin'; +import {setupPanningBoundary} from '../../../../../components/polyalg/polyalg-viewer/panning-boundary'; +import {AutoArrangePlugin} from 'rete-auto-arrange-plugin'; +import {debounceTime, Subject, Subscription} from 'rxjs'; +import {Position} from 'rete-angular-plugin/17/types'; +import {canCreateConnection, computeCenter, getContextMenuItems, getMagneticConnectionProps, socketsToEdgeModel} from './workflow-editor-utils'; +import {Activity, edgeToString, Workflow} from '../workflow'; +import {ContextMenuExtra, ContextMenuPlugin} from 'rete-context-menu-plugin'; +import {useMagneticConnection} from '../../../../../components/polyalg/polyalg-viewer/magnetic-connection'; +import {CustomZoom} from '../../../../../components/polyalg/polyalg-viewer/custom-zoom'; +import {CustomDrag} from '../../../../../components/polyalg/polyalg-viewer/custom-drag'; + +export type Schemes = GetSchemes>; +type AreaExtra = AngularArea2D | ContextMenuExtra; + +export class WorkflowEditor { + private readonly editor: NodeEditor = new NodeEditor(); + private readonly connection: ConnectionPlugin = new ConnectionPlugin(); + private readonly readonlyPlugin = new ReadonlyPlugin(); + private readonly area: AreaPlugin; + private readonly render: AngularPlugin; + private readonly arrange = new AutoArrangePlugin(); + private readonly nodeMap: Map = new Map(); // uuid to activitynode + private readonly connectionMap: Map> = new Map(); // serialized EdgeModel (without state) to array of out edges + private readonly nodeIdToActivityId: Map = new Map(); + private panningBoundary: { destroy: any; }; + + private workflow: Workflow; + private readonly translateSubjects: { [key: string]: Subject } = {}; // debounce the translation of activities + private readonly debouncedTranslateSubject = new Subject<{ activityId: string, pos: Position }>(); + private readonly DEBOUNCE_TIME_MS = 100; + private readonly removeActivitySubject = new Subject(); // activityId + private readonly cloneActivitySubject = new Subject(); // activityId + private readonly removeEdgeSubject = new Subject(); + private readonly moveMultiEdgeSubject = new Subject<[EdgeModel, number]>(); // edge, targetIndex + private readonly createEdgeSubject = new Subject(); + private readonly executeActivitySubject = new Subject(); // activityId + private readonly resetActivitySubject = new Subject(); // activityId + private readonly openSettingsSubject = new Subject(); // activityId + private readonly openNestedSubject = new Subject(); // activityId + private readonly openCheckpointSubject = new Subject<[string, boolean, number]>(); // activityId, isInput, portIdx + private readonly reloadEditorSubject = new Subject(); // activityId + private readonly subscriptions = new Subscription(); + private checkConnectionsInterval: number; + + constructor(private injector: Injector, container: HTMLElement, private readonly isEditable: boolean) { + this.area = new AreaPlugin(container); + this.render = new AngularPlugin({injector}); + + this.render.addPreset( + Presets.classic.setup({ + customize: { + node() { + return ActivityComponent; + }, + connection() { + return EdgeComponent; + }, + socket() { + return ActivityPortComponent; + }, + }, + }) + ); + this.render.addPreset(Presets.contextMenu.setup({delay: 200})); // time in ms for context menu to close + + this.connection.addPreset(() => new ClassicFlow({ + canMakeConnection: (from, to): boolean => { + const [source, target] = getSourceTarget(from, to) || [null, null]; + return canCreateConnection(source, target, this.editor); + }, + makeConnection: (from, to) => { + const [source, target] = getSourceTarget(from, to) || [null, null]; + if (source && target) { + this.createEdgeSubject.next(socketsToEdgeModel(source, target, this.editor)); + return true; + } + } + })); + this.arrange.addPreset(() => { + return { + port(data) { + let y; + const firstData = 156; + const isControl = ActivityNode.isControlPortKey(data.key); + if (data.side === 'input') { + y = isControl ? 80 : firstData + 34 * (data.index - 1); + } else { + y = isControl ? firstData + 34 * (data.ports - 2) + 24 * (data.index) : firstData + 34 * (data.index - 2); + } + return { + x: 0, + y: y, + width: 15, + height: 15, + side: 'output' === data.side ? 'EAST' : 'WEST' + }; + } + }; + }); + this.area.area.setZoomHandler(new CustomZoom(0.1, 0.3, false)); + this.area.area.setDragHandler(new CustomDrag(container)); // horizontal scrolling + + // Attach plugins + this.editor.use(this.readonlyPlugin.root); + this.editor.use(this.area); + this.area.use(this.render); + this.area.use(this.readonlyPlugin.area); + this.area.use(this.arrange); + + AreaExtensions.restrictor(this.area, {scaling: {min: 0.03, max: 5}}); // Restrict Zoom + AreaExtensions.simpleNodesOrder(this.area); + addCustomBackground(this.area); + + this.editor.addPipe(context => { + if (context.type === 'connectionremove') { + const edgeModel = context.data.toModel(); + if (this.workflow.getEdgeState(edgeModel)) { + this.removeEdgeSubject.next(edgeModel); // additionally, the edge is deleted immediately to not cause any issues + } + } + return context; + }); + + this.area.addPipe(context => { + if (context.type === 'zoom' && context.data.source === 'dblclick') { + return; // https://github.com/retejs/rete/issues/204 + } else if (context.type === 'nodetranslated') { + this.translateSubjects[this.nodeIdToActivityId.get(context.data.id)].next(context.data.position); + }/* else if (!['pointermove', 'render', 'rendered', 'rendered', 'zoom', 'zoomed', 'translate', 'translated', 'nodetranslate', 'unmount'].includes(context.type)) { + console.log(context); + }*/ + return context; + }); + + this.render.addPipe(context => { + if (context.type === 'connectionpath') { + context.data.payload.center?.set(computeCenter(context.data.points)); + } + return context; + }); + } + + async initialize(workflow: Workflow): Promise { + if (this.workflow) { + console.error('Workflow editor has already been initialized'); + return; + } + this.workflow = workflow; + let requiresArranging = true; + for (const activity of this.workflow.getActivities()) { + const isInOrigin = await this.addNode(activity); + if (!isInOrigin) { + requiresArranging = false; + } + } + for (const edgePair of this.workflow.getEdges()) { + await this.addConnection(...edgePair); + } + this.editor.getNodes().forEach(n => this.area.update('node', n.id)); // ensure all node components are rendered + + this.addSubscriptions(); + + const selector = AreaExtensions.selector(); + AreaExtensions.selectableNodes(this.area, selector, { + accumulating: AreaExtensions.accumulateOnCtrl(), + }); + + if (this.isEditable) { + this.area.use(this.connection); // make connections editable + + const contextMenu: ContextMenuPlugin = new ContextMenuPlugin({ + items: getContextMenuItems(this.removeEdgeSubject, this.moveMultiEdgeSubject, + this.removeActivitySubject, this.cloneActivitySubject, this.workflow) + }); + + this.area.use(contextMenu); // add context menu + useMagneticConnection(this.connection, getMagneticConnectionProps(this.editor, this.createEdgeSubject)); + this.panningBoundary = setupPanningBoundary({area: this.area, selector, padding: 40, intensity: 2}); + } + + + setTimeout(async () => { + if (requiresArranging) { + await this.arrangeNodes(); + } + await AreaExtensions.zoomAt(this.area, this.editor.getNodes()); + if (!this.isEditable) { + this.readonlyPlugin.enable(); + } + }, 100); + + } + + onActivityTranslate() { + return this.debouncedTranslateSubject.asObservable(); + } + + onActivityRemove() { + return this.removeActivitySubject.asObservable(); + } + + onActivityClone() { + return this.cloneActivitySubject.asObservable(); + } + + onActivityExecute() { + return this.executeActivitySubject.asObservable(); + } + + onActivityReset() { + return this.resetActivitySubject.asObservable(); + } + + onOpenActivitySettings() { + return this.openSettingsSubject.asObservable(); + } + + onOpenNestedActivity() { + return this.openNestedSubject.asObservable(); + } + + onOpenCheckpoint() { + return this.openCheckpointSubject.asObservable(); + } + + onEdgeRemove() { + return this.removeEdgeSubject.asObservable(); + } + + onMoveMulti() { + return this.moveMultiEdgeSubject.asObservable(); + } + + onEdgeCreate() { + return this.createEdgeSubject.asObservable(); + } + + onReloadEditor() { + return this.reloadEditorSubject.asObservable(); + } + + async arrangeNodes() { + await this.arrange.layout({ + applier: undefined, options: {'elk.layered.spacing.nodeNodeBetweenLayers': '100'} + }); + } + + getCenter(): Position { + const box = this.area.container.getBoundingClientRect(); + return this.clientCoords2EditorCoords({x: box.width / 2, y: box.height / 2}, true); + } + + clientCoords2EditorCoords(clientPos: Position, isRelative = false) { + // https://retejs.org/docs/faq#viewport-center + const box = this.area.container.getBoundingClientRect(); + const relPos = isRelative ? clientPos : {x: clientPos.x - box.x, y: clientPos.y - box.y}; + + if (relPos.x < 0 || relPos.x > box.width || relPos.y < 0 || relPos.y > box.height) { + return null; + } + + const {x, y, k} = this.area.area.transform; + const activityHalfWidth = 200 / 2; // approximation + const activityHalfHeight = 300 / 2; + return {x: (relPos.x - x) / k - activityHalfWidth, y: (relPos.y - y) / k - activityHalfHeight}; + } + + destroy(): void { + this.subscriptions.unsubscribe(); + Object.values(this.translateSubjects).forEach((subject) => subject.complete()); + this.area.destroy(); + this.panningBoundary?.destroy(); + clearInterval(this.checkConnectionsInterval); + } + + private async addNode(activity: Activity) { + const node = new ActivityNode(activity, this.workflow.state, + this.executeActivitySubject, this.resetActivitySubject, + this.openSettingsSubject, this.openNestedSubject, this.openCheckpointSubject, this.isEditable); + this.nodeMap.set(activity.id, node); + this.nodeIdToActivityId.set(node.id, activity.id); + const translateSubject = new Subject(); + translateSubject.pipe( + debounceTime(this.DEBOUNCE_TIME_MS) + ).subscribe(pos => this.debouncedTranslateSubject.next({activityId: activity.id, pos: pos})); + this.translateSubjects[activity.id] = translateSubject; + await this.editor.addNode(node); + await this.area.resize(node.id, node.width, node.height); // ensure specified size is actually reflected visually + if (activity.rendering().posX !== 0 || activity.rendering().posY !== 0) { + await this.area.translate(node.id, {x: activity.rendering().posX, y: activity.rendering().posY}); + return false; + } + return true; + } + + private setActivityPosition(id: string, rendering: RenderModel) { + const node = this.nodeMap.get(id); + if (this.editor.getNode(node?.id)) { + this.area.translate(node.id, {x: rendering.posX, y: rendering.posY}); + } + } + + private async addConnection(edge: EdgeModel, state: Signal) { + const from = this.nodeMap.get(edge.fromId); + const to = this.nodeMap.get(edge.toId); + + const connection = edge.isControl ? Edge.createControlEdge(from, to, edge.fromPort, state) : + Edge.createDataEdge(from, edge.fromPort, to, edge.toPort, state); + + const edgeString = edgeToString(edge); + this.connectionMap.set(edgeString, connection); + + if (connection.isMulti) { + // enforce adding the connections in the correct order + setTimeout(() => this.editor.addConnection(connection), connection.multiIdx); + } else { + await this.editor.addConnection(connection); + } + } + + private removeNode(activityId: string) { + const node = this.nodeMap.get(activityId); + if (this.editor.getNode(node.id)) { + this.editor.removeNode(node.id); + } + this.translateSubjects[activityId].complete(); + this.nodeMap.delete(activityId); + delete this.translateSubjects[activityId]; + } + + private removeConnection(edgeString: string) { + const connection = this.connectionMap.get(edgeString); + if (this.editor.getConnection(connection.id)) { // connection might already have been deleted + if (connection.isMulti) { + // require because of a weird bug with rete.js => enforce ordered removal + setTimeout(() => this.editor.removeConnection(connection.id), connection.multiIdx); + } else { + this.editor.removeConnection(connection.id); + } + } + + this.connectionMap.delete(edgeString); + } + + private addSubscriptions() { + effect(() => { + if (this.isEditable) { + const state = this.workflow.state(); + if (state !== WorkflowState.IDLE) { + setTimeout(() => this.readonlyPlugin.enable(), 20); // time for multi-edges to appear + } else { + setTimeout(() => this.readonlyPlugin.disable(), 20); + } + } + }, {injector: this.injector}); + + this.subscriptions.add(this.workflow.onActivityChange().subscribe(activityId => { + const node = this.nodeMap.get(activityId); + this.area.update('node', node.id); + this.setActivityPosition(activityId, this.workflow.getActivity(activityId).rendering()); + })); + this.subscriptions.add(this.workflow.onActivityRemove().subscribe(activityId => { + this.removeNode(activityId); + })); + this.subscriptions.add(this.workflow.onActivityAdd().subscribe(activity => { + this.addNode(activity); + })); + this.subscriptions.add(this.workflow.onEdgeChange().subscribe(edgeString => { + const connection = this.connectionMap.get(edgeString); + this.area.update('connection', connection.id); + })); + this.subscriptions.add(this.workflow.onEdgeRemove().subscribe(edgeString => { + this.removeConnection(edgeString); + })); + this.subscriptions.add(this.workflow.onEdgeAdd().subscribe(([edgeModel, state]) => { + this.addConnection(edgeModel, state); + })); + + if (this.isEditable) { + this.checkConnectionsInterval = setInterval(() => { + // Issue: sometimes the connection corresponding to a removed edge cannot be removed from the editor. + // Current Solution (not ideal): reload entire editor + if (this.editor.getConnections().length > this.workflow.getEdges().length) { + setTimeout(() => { // wait if problem resolves itself + if (this.editor.getConnections().length > this.workflow.getEdges().length) { + this.reloadEditorSubject.next(); + } + }, 10); + } + }, 1000); + } + } +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/execution-monitor/execution-monitor.component.html b/src/app/plugins/workflows/components/workflow-viewer/execution-monitor/execution-monitor.component.html new file mode 100644 index 00000000..ab30c97f --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/execution-monitor/execution-monitor.component.html @@ -0,0 +1,171 @@ + + +
Execution Statistics
+ +
+ + @if (isLoading()) { +
+ +
+ } @else if (monitor()) { + + + Success: + + + {{ monitor().isSuccess }} + + + + + Start Time: + + + {{ monitor().startTime | date:'medium' }} + + + + + Total Duration: + + + {{ monitor().totalDuration }} ms + + + + + Tuples Written: + + + {{ monitor().tuplesWritten }} ({{ tuplesPerSecond() }} t/s) + + + + + Target Activity: + + + {{ workflow.getActivity( monitor().targetActivity )?.displayName() }} + + + + + Total Activities: + + + {{ monitor().totalCount }} + + + + + Success / cancel / fail: + + + {{ monitor().successCount }} / + {{ monitor().skipCount }} / + {{ monitor().failCount }} + + + +
Activity Count by Executor Type
+ + + + + + + + + @for (entry of monitor().countByExecutorType | keyvalue; track entry.key) { + + + + + } + +
ExecutorActivity Count
{{ entry.key }}{{ entry.value }}
+ +
Submitted Execution Trees
+ + @for (info of monitor().infos; track $index) { + + + + + +
+ + The execution was unsuccessful. + +

+ Tuples written: + {{ info.tuplesWritten }} +

+
Duration
+ + + + + + + + + @for (execState of orderedExecutionStates; track execState) { + + + + + } + +
PhaseTime (ms)
{{ execState }}{{ info.durations[execState] | number:'1.2-2' }}
+ Total Duration: {{ info.totalDuration }} ms + +
Activities in Execution Tree
+
    +
  • + + {{ workflow.getActivity( activity )?.displayName() }} + root + + + + +
  • +
+
+
+
+ } +
+ + + } @else { +

Workflow has not yet been executed.

+ } +
+ + + +
\ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/execution-monitor/execution-monitor.component.scss b/src/app/plugins/workflows/components/workflow-viewer/execution-monitor/execution-monitor.component.scss new file mode 100644 index 00000000..1022c536 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/execution-monitor/execution-monitor.component.scss @@ -0,0 +1,12 @@ +.info-duration { + min-width: 50px !important; + text-align: end; +} + +.remove-padding { + padding: 0 !important; +} + +.accordion-button::after { + margin-left: 30px; +} \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/execution-monitor/execution-monitor.component.ts b/src/app/plugins/workflows/components/workflow-viewer/execution-monitor/execution-monitor.component.ts new file mode 100644 index 00000000..76699875 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/execution-monitor/execution-monitor.component.ts @@ -0,0 +1,55 @@ +import {Component, computed, EventEmitter, Input, Output, signal} from '@angular/core'; +import {ExecutionMonitorModel, ExecutionState} from '../../../models/workflows.model'; +import {WorkflowsService} from '../../../services/workflows.service'; +import {Workflow} from '../workflow'; + +@Component({ + selector: 'app-execution-monitor', + templateUrl: './execution-monitor.component.html', + styleUrl: './execution-monitor.component.scss' +}) +export class ExecutionMonitorComponent { + + + constructor(private _workflows: WorkflowsService) { + } + + @Input() sessionId: string; // does not change + @Input() workflow: Workflow; + @Input() canOpen = true; // does not change + @Output() openActivity = new EventEmitter(); + + readonly monitor = signal(null); + readonly isLoading = signal(false); + readonly showModal = signal(false); + readonly infoDurationsSeconds = computed(() => { + if (this.monitor()) { + return this.monitor().infos.map(info => + Math.round(info.totalDuration / 1000) + ); + } + return null; + }); + readonly tuplesPerSecond = computed(() => { + const tps = this.monitor().tuplesWritten * 1000 / this.monitor().totalDuration; + return Math.round(tps); + }); + readonly orderedExecutionStates = [ExecutionState.SUBMITTED, ExecutionState.EXECUTING, ExecutionState.AWAIT_PROCESSING, ExecutionState.PROCESSING_RESULT]; + + toggleModal() { + this.showModal.update(b => !b); + } + + show(execMonitor?: ExecutionMonitorModel) { + if (execMonitor) { + this.monitor.set(execMonitor); + } else { + this.isLoading.set(true); + this._workflows.getExecutionMonitor(this.sessionId).subscribe(m => { + this.monitor.set(m); + this.isLoading.set(false); + }); + } + this.showModal.set(true); + } +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/left-menu/left-menu.component.html b/src/app/plugins/workflows/components/workflow-viewer/left-menu/left-menu.component.html new file mode 100644 index 00000000..78381837 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/left-menu/left-menu.component.html @@ -0,0 +1,105 @@ +
+ @if (visible()) { +
+ +
+ + + + + + + + {{ item.itemName }} + + + + + + + +
+ + @if (filteredList.length === 0 && selectedCategories.length > 0) { + + Remove filter {{ selectedCategories.length == 1 ? 'category' : 'categories' }} to show more results. + + } +
+ @if (showDescription) { + @for (activity of filteredList; track activity.type) { +
+ + + +
+ } + } @else { + + +
+ + + +
+
+ } +
+ +
+
+ +
+ +
+
+ } + +
+ + + + + +
+
{{ activity.displayName }}
+
+ +
+ +
+ + + + + + +
+ +
+ + + + +
+ {{ activity.shortDescription }} +
+
\ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/left-menu/left-menu.component.scss b/src/app/plugins/workflows/components/workflow-viewer/left-menu/left-menu.component.scss new file mode 100644 index 00000000..cd65a059 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/left-menu/left-menu.component.scss @@ -0,0 +1,60 @@ + +[hidden] { + display: none !important; +} + +.info-visible { + width: 800px; +} + +.info-hidden { + width: 400px; +} + +.add-activity-col { + max-width: 400px; +} + +.info-col { + max-width: 400px; + + .menu-header { + min-height: 51px; // same height as Add Activity + } +} + +.activity-list { + overflow-y: auto; + flex-basis: 0; +} + +.activity-info { + overflow-y: auto; + flex-basis: 0; +} + +.no-select { + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; +} + +.cdk-drag-preview { + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); +} + +.draggable { + cursor: grab; +} + +.draggable:active { + cursor: grabbing !important; +} + +.activity-card:hover { + + .info-button:not(.text-primary) { + color: var(--cui-primary) !important; + } +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/left-menu/left-menu.component.ts b/src/app/plugins/workflows/components/workflow-viewer/left-menu/left-menu.component.ts new file mode 100644 index 00000000..f00aaf96 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/left-menu/left-menu.component.ts @@ -0,0 +1,97 @@ +import {Component, EventEmitter, input, Output, signal} from '@angular/core'; +import {WorkflowsService} from '../../../services/workflows.service'; +import {CdkDragDrop} from '@angular/cdk/drag-drop'; +import {ActivityCategory, ActivityDef} from '../../../models/activity-registry.model'; + +@Component({ + selector: 'app-left-menu', + templateUrl: './left-menu.component.html', + styleUrl: './left-menu.component.scss' +}) +export class LeftMenuComponent { + isEditable = input.required(); + @Output() create = new EventEmitter(); + @Output() createAt = new EventEmitter<[string, { x: number, y: number }]>(); + + visible = signal(true); + + private readonly registry = this._workflows.getRegistry(); + readonly activityTypes = this.registry.getTypes(); + readonly isRelational: Record = {}; + readonly isDocument: Record = {}; + readonly isGraph: Record = {}; + readonly isVariables: Record = {}; + readonly dropdownCats: { id: number; itemName: string; }[] = []; + // https://www.npmjs.com/package/angular2-multiselect-dropdown + readonly dropdownSettings = { + singleSelection: false, + text: 'Filter by category', + noDataLabel: 'No categories found', + enableSearchFilter: true, + enableCheckAll: false, + enableFilterSelectAll: false, + classes: 'categories-multiselect' + }; + filterText: string; + selectedCategories = []; + filteredList: ActivityDef[]; + showDescription = false; + + openedActivityDef: ActivityDef; // for info + + constructor(private readonly _workflows: WorkflowsService) { + const categories = this.registry.categories; + for (const [i, category] of categories.entries()) { + this.dropdownCats.push({ + 'id': i, + 'itemName': category + }); + } + const defaultCat = this.dropdownCats.find(item => item.itemName === 'ESSENTIALS'); + if (defaultCat) { + this.selectedCategories.push(defaultCat); + } + + for (const type of this.activityTypes) { + const cats = this.registry.getDef(type).categories; + this.isRelational[type] = cats.includes(ActivityCategory.RELATIONAL); + this.isDocument[type] = cats.includes(ActivityCategory.DOCUMENT); + this.isGraph[type] = cats.includes(ActivityCategory.GRAPH); + this.isVariables[type] = cats.includes(ActivityCategory.VARIABLES); + } + + this.filterList(); + } + + toggleMenu() { + this.visible.update(b => !b); + } + + onDragDropped($event: CdkDragDrop) { + this.createAt.emit([$event.item.data, $event.dropPoint]); + } + + onItemSelect() { + this.filterList(); + } + + OnItemDeSelect() { + this.filterList(); + } + + filterList() { + this.filteredList = this.activityTypes.filter(type => { + const def = this.registry.getDef(type); + const trimmed = this.filterText?.trim().toLowerCase(); + if (trimmed?.length > 0 && !( + type.toLowerCase().includes(trimmed) || def.displayName.toLowerCase().includes(trimmed) + )) { + return false; + } + return this.selectedCategories.length === 0 || + !this.selectedCategories.some(item => !def.categories.includes(item.itemName)); + + }).map(type => this.registry.getDef(type)) + .sort((a, b) => a.displayName.localeCompare(b.displayName)); + } +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-config-editor/activity-config-editor.component.html b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-config-editor/activity-config-editor.component.html new file mode 100644 index 00000000..64be69b1 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-config-editor/activity-config-editor.component.html @@ -0,0 +1,102 @@ +
+ +
+ @if (hasConfigChanged()) { + Discard + + } + + + + +
+ +
+ + @if (!isMetaActivity()) { + +
Disable Optimization
+ + + + + + + +
Timeout
+
+ +
+ Execution timeout in seconds, or 0 to use workflow default. +
+
+ + +
Expected Outcome
+
+ +
+ Used to determine the overall success of a workflow execution. +
+
+ + +
Preferred Stores
+
+ @for (outputStore of editableConfig().preferredStores; track i; let i = $index) { + + + + + } +
+ } + + +
Common Transaction
+
+ +
+ Common transactions provide atomicity, consistency and isolation guarantees over multiple activities. Guarantees do not hold for activities with + side-effects in external systems. +
+
+ + +
Control State Merger
+
+ +
+ Determines whether the activity gets executed in case of multiple control edges. AND_AND means all control edges must be active. + AND_OR means all success control edges must be active and at least one fail control edge. +
+
+ + +
Log Exception Stacktrace
+ + + + +
+
+ diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-config-editor/activity-config-editor.component.scss b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-config-editor/activity-config-editor.component.scss new file mode 100644 index 00000000..2eb5dc10 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-config-editor/activity-config-editor.component.scss @@ -0,0 +1,4 @@ +.config-inputs { + overflow-y: auto; + flex-basis: 0; +} \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-config-editor/activity-config-editor.component.ts b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-config-editor/activity-config-editor.component.ts new file mode 100644 index 00000000..c688590e --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-config-editor/activity-config-editor.component.ts @@ -0,0 +1,92 @@ +import {Component, computed, effect, EventEmitter, input, OnInit, Output, Signal, signal} from '@angular/core'; +import {ActivityConfigModel, CommonType, ControlStateMerger, ExpectedOutcome} from '../../../../models/workflows.model'; +import {NgForOf, NgIf} from '@angular/common'; +import {FormsModule} from '@angular/forms'; +import {ActivityDef} from '../../../../models/activity-registry.model'; +import {ButtonDirective, FormCheckComponent, FormCheckInputDirective, FormCheckLabelDirective, FormControlDirective, FormDirective, FormSelectDirective, FormTextDirective, InputGroupComponent, InputGroupTextDirective} from '@coreui/angular'; +import {AdapterModel} from '../../../../../../views/adapters/adapter.model'; +import {CatalogService} from '../../../../../../services/catalog.service'; +import {ComponentsModule} from '../../../../../../components/components.module'; +import {META_ACTIVITY_TYPES, NESTED_WF_ACTIVITY_TYPE} from '../../workflow'; + +@Component({ + selector: 'app-activity-config-editor', + standalone: true, + imports: [ + FormsModule, + NgForOf, + FormDirective, + FormCheckComponent, + FormCheckInputDirective, + FormCheckLabelDirective, + FormSelectDirective, + ButtonDirective, + InputGroupComponent, + InputGroupTextDirective, + NgIf, + FormControlDirective, + ComponentsModule, + FormTextDirective + ], + templateUrl: './activity-config-editor.component.html', + styleUrl: './activity-config-editor.component.scss' +}) +export class ActivityConfigEditorComponent implements OnInit { + + config = input.required(); + private readonly resetConfigSignal = signal(true); // manually enforce recomputing config + def = input.required(); + isEditable = input.required(); + @Output() save = new EventEmitter(); + + readonly expectedOutcomes = Object.values(ExpectedOutcome); + readonly commonTypes = Object.values(CommonType); + readonly controlStateMergers = Object.values(ControlStateMerger); + readonly adapters: Signal; + serializedConfig = computed(() => { + this.resetConfigSignal(); + return JSON.stringify(this.config()); + }, + {equal: () => false}); // enforce change when switching to different activity, even if it has the same config value + editableConfig = computed(() => JSON.parse(this.serializedConfig())); // we edit a copy of the actual config + serializedEditedConfig = signal(null); + hasConfigChanged: Signal; + lockCommonTransaction = computed(() => + this.def().type === NESTED_WF_ACTIVITY_TYPE && this.config().commonType === CommonType.NONE + ); + isMetaActivity = computed(() => META_ACTIVITY_TYPES.includes(this.def().type)); + + constructor(private _catalog: CatalogService) { + this.adapters = computed(() => { + this._catalog.listener(); + return [...this._catalog.getStores().filter(store => + // mvcc currently results in deadlocks with concurrent schema changes + store.adapterName !== 'HSQLDB' || store.settings['trxControlMode'] !== 'mvcc' + )]; + }); + + effect(() => this.serializedEditedConfig.set(this.serializedConfig()), {allowSignalWrites: true}); + } + + ngOnInit(): void { + this.hasConfigChanged = computed(() => this.serializedConfig() !== this.serializedEditedConfig()); + } + + saveConfig() { + for (const [i, store] of this.editableConfig().preferredStores.entries()) { + if (store?.length === 0) { + this.editableConfig().preferredStores[i] = null; + } + } + this.save.emit(this.editableConfig()); + } + + checkForChanges() { + this.serializedEditedConfig.set(JSON.stringify(this.editableConfig())); + } + + resetConfig() { + this.resetConfigSignal.set(false); + this.resetConfigSignal.set(true); + } +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-exec-stats/activity-exec-stats.component.html b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-exec-stats/activity-exec-stats.component.html new file mode 100644 index 00000000..8b4930c9 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-exec-stats/activity-exec-stats.component.html @@ -0,0 +1,115 @@ +

Activity ID: {{ activity().id }}

+

Activity Type: {{ activity().def.type }}

+ + +@if (activity().executionInfo()) { +
{{ activity().executionInfo().executorType }} Executor
+ + + + + Submission Time: + + + {{ activity().executionInfo().submissionTime | date:'medium' }} + + + + + Execution State: + + + {{ activity().executionInfo().state }} + + + + + @if (activity().executionInfo().activities.length > 1) { + Tuples written (by root of Execution Tree): + } @else { + Tuples written: + } + + + {{ activity().executionInfo().tuplesWritten }} + + + + @if (activity().executionInfo().activities.length > 1) { +

Activities in Execution Tree:

+
    +
  • + {{ activity }} +
  • +
+ } + + @if (activity().error()) { +

Execution Error:

+
+ {{ activity().error().message }} +
+ } + +
Duration
+ + + + + + + + + @for (duration of orderedDurations(); track duration[0]) { + + + + + } + +
PhaseTime (ms)
{{ duration[0] }}{{ duration[1] | number:'1.2-2' }}
+

Total Duration: {{ activity().executionInfo().totalDuration }} ms

+
+
+ + + + + @if (activity().logMessages().length > 0) { +
Log
+
+ + + + + + + + + + @for (logMsg of activity().logMessages(); track activity().id + $index) { + + + + + @if ($first && settingSplit()) { + + + } @else { + + } + + } + +
LevelTime (ms)Message
{{ logMsg.level }}{{ logMsg.timeMs }} + {{ settingSplit()[0] }} + + {{ logMsg.msg }}
+
+ } +} @else { + + Execute the activity to show execution stats. + +} \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-exec-stats/activity-exec-stats.component.scss b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-exec-stats/activity-exec-stats.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-exec-stats/activity-exec-stats.component.ts b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-exec-stats/activity-exec-stats.component.ts new file mode 100644 index 00000000..c465be0d --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-exec-stats/activity-exec-stats.component.ts @@ -0,0 +1,31 @@ +import {Component, computed, input, Signal} from '@angular/core'; +import {Activity} from '../../workflow'; +import {ExecutionState} from '../../../../models/workflows.model'; + +@Component({ + selector: 'app-activity-exec-stats', + templateUrl: './activity-exec-stats.component.html', + styleUrl: './activity-exec-stats.component.scss' +}) +export class ActivityExecStatsComponent { + activity = input.required(); + + orderedDurations = computed(() => { + const durations = this.activity().executionInfo().durations; + return [ExecutionState.SUBMITTED, ExecutionState.EXECUTING, ExecutionState.AWAIT_PROCESSING, ExecutionState.PROCESSING_RESULT] + .map(state => [state, durations[state] || 0]); + }); + + settingSplit: Signal<[string, string]> = computed(() => { // the first log message should contain the used settings + if (this.activity().logMessages().length > 0) { + const first = this.activity().logMessages()[0]; + if (first.level === 'INFO' && first.msg.includes(' with settings: {')) { + const idx = first.msg.indexOf('{'); + if (idx > 0 && idx < first.msg.length - 2) { + return [first.msg.slice(0, idx), first.msg.slice(idx)]; + } + } + } + return null; + }); +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/activity-settings.component.html b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/activity-settings.component.html new file mode 100644 index 00000000..39ea990b --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/activity-settings.component.html @@ -0,0 +1,212 @@ +@if (activeSettingGroup()) { + +
+ +
+ + + + @if (isEditable()) { +
+ @if (hasSettingsChanged()) { + Discard + + } +
+ +
+ +
+ +
+ +
+ + } @else { +
+ +
+ } + +
+ +
+ @if (activity().error()) { + + Execution Error: +
+ {{ activity().error().message }} +
+
+ } +

{{ activity().def.shortDescription }}

+ @for (subgroup of activeSettingGroup().subgroups; track activity().id + subgroup.key) { + +

{{ subgroup.displayName }}

+
+ @for (setting of subgroup.settings; track setting.key) { +
+
+
+ {{ setting.displayName }} +
+ + +
+ + {{ activity().invalidSettings()[setting.key] }} + +

{{ setting.shortDescription }}

+ + +
+ +
+ + +
Note
+ The static setting values are only used as a fallback when variables are present. +
+ +
+ @switch (setting.type) { + @case (SettingType.INT) { + + + } + @case (SettingType.STRING) { + + + } + @case (SettingType.BOOLEAN) { + + + } + @case (SettingType.DOUBLE) { + + + } + @case (SettingType.ENTITY) { + + + } + @case (SettingType.QUERY) { + + + } + @case (SettingType.FIELD_SELECT) { + + + } + @case (SettingType.ENUM) { + + + } + @case (SettingType.COLLATION) { + + + } + @case (SettingType.FIELD_RENAME) { + + + } + @case (SettingType.CAST) { + + + } + @case (SettingType.FILTER) { + + + } + @case (SettingType.GRAPH_MAP) { + + + } + @case (SettingType.FILE) { + + + } + @case (SettingType.AGGREGATE) { + + + } + @default { +

unknown setting type

+ } + } +
+
+ } +
+ +
+ } +
+
+ +} @else { + + This activity has no settings. + +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/activity-settings.component.scss b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/activity-settings.component.scss new file mode 100644 index 00000000..0e151cd2 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/activity-settings.component.scss @@ -0,0 +1,11 @@ +.setting-group { + overflow-y: auto; + flex-basis: 0; + + margin-right: -16px; + padding-right: 16px; +} + +.show-description-button:hover { + color: var(--cui-dark) !important; +} \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/activity-settings.component.ts b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/activity-settings.component.ts new file mode 100644 index 00000000..e715bf94 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/activity-settings.component.ts @@ -0,0 +1,127 @@ +import {Component, computed, effect, input, OnInit, Signal, signal, WritableSignal} from '@angular/core'; +import {GroupDef, SettingType} from '../../../../models/activity-registry.model'; +import {Activity, Settings, VariableReference} from '../../workflow'; +import {WorkflowsWebSocketService} from '../../../../services/workflows-websocket.service'; +import {ToasterService} from '../../../../../../components/toast-exposer/toaster.service'; +import {Variables} from '../../../../models/workflows.model'; + +@Component({ + selector: 'app-activity-settings', + templateUrl: './activity-settings.component.html', + styleUrl: './activity-settings.component.scss' +}) +export class ActivitySettingsComponent implements OnInit { + protected readonly SettingType = SettingType; + + activity = input.required(); + workflowVars = input.required(); + isEditable = input.required(); + + activeSettingGroup = signal(null); + private readonly resetSettingsSignal = signal(true); // manually enforce recomputing settings + readonly serializedSettings = computed(() => { + this.resetSettingsSignal(); + return this.activity().settings().serialize(); + }, + {equal: () => false}); // enforce change when switching to different activity, even if it has the same settings value + readonly editableSettings = computed(() => new Settings(this.serializedSettings())); // we edit a copy of the actual settings + readonly editableReferences = computed>(() => { + const map = new Map(); + this.editableSettings().settings.forEach((setting, key) => map.set(key, setting.references)); + return map; + }); + readonly serializedEditedSettings = signal(null); + hasSettingsChanged: Signal; + readonly visibleSettings = new Set(); + readonly visibleSubgroups = new Set(); + readonly variablesVisibilityMap = new Map>(); + showDescription: boolean; + + constructor(private readonly _websocket: WorkflowsWebSocketService, private readonly _toast: ToasterService) { + const showDescStr = localStorage.getItem('workflows.showSettingsDescription'); + this.setShowDescription(showDescStr === null ? true : showDescStr === 'true'); + effect(() => this.activeSettingGroup.set(this.activity().def.getFirstGroup()), {allowSignalWrites: true}); + + // if activity or settings (externally) changes, also update serialized edited settings. + effect(() => { + this.serializedEditedSettings.set(this.serializedSettings()); + this.updateVisibility(); + this.resetVariablesVisibility(); + }, {allowSignalWrites: true}); + } + + ngOnInit(): void { + this.hasSettingsChanged = computed(() => this.serializedSettings() !== this.serializedEditedSettings()); + //this.updateVisibility(); + } + + saveSettings() { + this._websocket.updateActivity(this.activity().id, this.editableSettings().toModel(true), null, null); + } + + checkForChanges() { + this.updateVisibility(); + this.serializedEditedSettings.set(this.editableSettings().serialize()); + } + + toggleVariablesVisibility(key: string) { + this.variablesVisibilityMap.get(key).update(v => !v); + } + + private updateVisibility() { + this.visibleSettings.clear(); + this.visibleSubgroups.clear(); + const def = this.activity().def; + const settingsModel = this.editableSettings().toModel(false); + this.activity().settings().keys().forEach(key => { + const settingDef = def.getSettingDef(key); + if (settingDef.isVisible(settingsModel)) { + this.visibleSettings.add(key); + this.visibleSubgroups.add(settingDef.getGroup() + '/' + settingDef.getSubgroup()); + } + }); + + } + + private resetVariablesVisibility() { + this.variablesVisibilityMap.clear(); + this.activity().settings().keys().forEach(key => { + const isVisible = this.activity().settings().get(key).references.length > 0; + this.variablesVisibilityMap.set(key, signal(isVisible)); + }); + } + + addVariableRef(key: string, event: [string, string, string]) { + const [variable, pointer, target] = event; + + const variablePointer = variable + this.prefixWithSlash(pointer); + if (this.editableSettings().get(key).addReference(variablePointer, target)) { + this.checkForChanges(); + } else { + this._toast.warn(`The specified target "${target}" is not a valid json pointer for this object`, 'Invalid Variable Reference'); + } + + } + + deleteReference(key: string, ref: VariableReference) { + this.editableSettings().get(key).deleteReference(ref); + this.checkForChanges(); + } + + resetSettings() { + this.resetSettingsSignal.set(false); + this.resetSettingsSignal.set(true); + } + + setShowDescription(value: boolean) { + this.showDescription = value; + localStorage.setItem('workflows.showSettingsDescription', value.toString()); + } + + private prefixWithSlash(str: string): string { + if (str.length > 0 && !str.startsWith('/')) { + return '/' + str; + } + return str; + } +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/add-variable/add-variable.component.html b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/add-variable/add-variable.component.html new file mode 100644 index 00000000..0079380e --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/add-variable/add-variable.component.html @@ -0,0 +1,67 @@ + + +
Variable Pointers
+
    + @for (ref of references(); track ref.target) { +
  • +
    + {{ ref.varRef }} + + {{ ref.target }} +
    + +
  • + } +
+ +
+
Add Pointer
+ + + + + + + + + + + + + +
+ + + + + + + + + + +
+
+ +
+
+
+
diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/add-variable/add-variable.component.scss b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/add-variable/add-variable.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/add-variable/add-variable.component.ts b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/add-variable/add-variable.component.ts new file mode 100644 index 00000000..831143d3 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/add-variable/add-variable.component.ts @@ -0,0 +1,65 @@ +import {Component, computed, EventEmitter, input, Output, signal} from '@angular/core'; +import {VariableReference} from '../../../workflow'; +import {envVarsKey, Variables, wfVarsKey} from '../../../../../models/workflows.model'; +import JsonPointer from 'json-pointer'; + +@Component({ + selector: 'app-add-variable', + templateUrl: './add-variable.component.html', + styleUrl: './add-variable.component.scss' +}) +export class AddVariableComponent { + references = input.required(); + activityVars = input.required(); + workflowVars = input.required(); + settingValue = input.required(); + isEditable = input.required(); + + @Output() add = new EventEmitter<[string, string, string]>(); + @Output() delete = new EventEmitter(); + + variables = computed(() => { + const localVars = Object.keys(this.activityVars()).filter(key => key !== wfVarsKey && key !== envVarsKey); + const wfVars = Object.keys(this.workflowVars()).map(key => wfVarsKey + '/' + key); + const envVars = Object.keys(this.activityVars()[envVarsKey] || {}).map(key => envVarsKey + '/' + key); + return Array.from(new Set([...localVars, ...wfVars, ...envVars])); + }); + variablePointers = computed(() => { + const aVar = this.activityVars()[this.variableInput()]; + if (aVar !== undefined) { + return Object.keys(JsonPointer.dict(aVar)).sort((a, b) => a.length - b.length); + } + const split = this.variableInput().split('/'); + if (split.length > 1 && split[0] === wfVarsKey) { + const wVar = this.workflowVars()[split[1]]; + try { + return Object.keys(JsonPointer.dict(wVar)).sort((a, b) => a.length - b.length); + } catch (ignored) { + } + } + return []; + }); + targets = computed(() => { + if (typeof this.settingValue() === 'string' || this.settingValue() === null || this.settingValue() === undefined) { + return []; + } + const leaves = Object.keys(JsonPointer.dict(this.settingValue())); + const firstChildren = leaves.length > 0 ? Object.keys(this.settingValue()).map(k => '/' + k) : []; + return Array.from(new Set([...leaves, ...firstChildren])).slice(0, 20) // limit number of suggestions + .sort((a, b) => a.length - b.length); + }); + + variableInput = signal(''); + + showAdvanced = false; + pointerInput = signal(''); + targetInput = signal(''); + isValid = computed(() => this.variableInput().length > 0); + + addVariableRef() { + this.add.emit([this.variableInput(), this.pointerInput(), this.targetInput()]); + this.variableInput.set(''); + this.pointerInput.set(''); + this.targetInput.set(''); + } +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/aggregate-setting/aggregate-setting.component.html b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/aggregate-setting/aggregate-setting.component.html new file mode 100644 index 00000000..be57a676 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/aggregate-setting/aggregate-setting.component.html @@ -0,0 +1,41 @@ + +
    + @for (agg of val(); track $index) { +
  • + + +
    + + +
    + +
    +
    + + + + +
    + + + +
    +
  • + } +
+ +
+ + \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/aggregate-setting/aggregate-setting.component.scss b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/aggregate-setting/aggregate-setting.component.scss new file mode 100644 index 00000000..c1638537 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/aggregate-setting/aggregate-setting.component.scss @@ -0,0 +1,16 @@ +.draggable { + cursor: grab; +} + +.draggable:active { + cursor: grabbing !important; +} + +.cdk-drag-preview { + border: none; + border-radius: 4px; + padding: 8px 16px; + box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), + 0 8px 10px 1px rgba(0, 0, 0, 0.14), + 0 3px 14px 2px rgba(0, 0, 0, 0.12); +} \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/aggregate-setting/aggregate-setting.component.ts b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/aggregate-setting/aggregate-setting.component.ts new file mode 100644 index 00000000..56b6fe13 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/aggregate-setting/aggregate-setting.component.ts @@ -0,0 +1,68 @@ +import {Component, computed, EventEmitter, input, model, Output} from '@angular/core'; +import {SettingDefModel} from '../../../../../models/activity-registry.model'; +import {TypePreviewModel} from '../../../../../models/workflows.model'; +import {CdkDragDrop, moveItemInArray} from '@angular/cdk/drag-drop'; +import {getSuggestions} from '../../../workflow'; + + +@Component({ + selector: 'app-aggregate-setting', + templateUrl: './aggregate-setting.component.html', + styleUrl: './aggregate-setting.component.scss' +}) +export class AggregateSettingComponent { + isEditable = input.required(); + settingDef = input.required(); + inTypePreview = input.required(); + value = model.required(); + @Output() hasChanged = new EventEmitter(); + + val = computed(() => this.value() as AggregateEntry[]); // like value, but with correct type + def = computed(() => this.settingDef() as AggregateSettingDef); + targetPreview = computed(() => this.inTypePreview()[this.def().targetInput]); + fieldType = computed(() => this.targetPreview().portType === 'REL' ? 'column' : 'field'); + suggestions = computed(() => getSuggestions(this.targetPreview(), 'props')); + + readonly displayFunctions = { + 'MAX': 'Max', + 'MIN': 'Min', + 'AVG': 'Average', + 'SUM': 'Sum', + 'SUM0': 'Sum (0 if empty)', + 'COUNT': 'Count', + 'STDDEV': 'Standard Deviation', + 'VARIANCE': 'Variance' + }; + + valueChanged() { + setTimeout(() => this.hasChanged.emit(), 1); + } + + addField() { + this.val().push({target: '', function: this.def().allowedFunctions[0], alias: ''}); + this.valueChanged(); + } + + deleteField(idx: number) { + this.val().splice(idx, 1); + this.valueChanged(); + } + + drop(event: CdkDragDrop) { + if (event.previousIndex !== event.currentIndex) { + moveItemInArray(this.val(), event.previousIndex, event.currentIndex); + this.valueChanged(); + } + } +} + +interface AggregateSettingDef extends SettingDefModel { + targetInput: number; + allowedFunctions: string[]; +} + +interface AggregateEntry { + target: string; + function: string; + alias: string; +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/boolean-setting/boolean-setting.component.html b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/boolean-setting/boolean-setting.component.html new file mode 100644 index 00000000..830bd929 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/boolean-setting/boolean-setting.component.html @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/boolean-setting/boolean-setting.component.scss b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/boolean-setting/boolean-setting.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/boolean-setting/boolean-setting.component.ts b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/boolean-setting/boolean-setting.component.ts new file mode 100644 index 00000000..daa17ec2 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/boolean-setting/boolean-setting.component.ts @@ -0,0 +1,17 @@ +import {Component, EventEmitter, input, model, Output} from '@angular/core'; +import {SettingDefModel} from '../../../../../models/activity-registry.model'; +import {TypePreviewModel} from '../../../../../models/workflows.model'; + +@Component({ + selector: 'app-boolean-setting', + templateUrl: './boolean-setting.component.html', + styleUrl: './boolean-setting.component.scss' +}) +export class BooleanSettingComponent { + isEditable = input.required(); + settingDef = input.required(); + inTypePreview = input.required(); // not required for int + value = model.required(); + @Output() hasChanged = new EventEmitter(); + +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/cast-setting/cast-setting.component.html b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/cast-setting/cast-setting.component.html new file mode 100644 index 00000000..834305a8 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/cast-setting/cast-setting.component.html @@ -0,0 +1,127 @@ + +
    + @for (cast of casts(); track $index) { +
  • + + + +
    + @if (suggestions().length > 0) { + + + } @else { + + } + + +
    + + @if (!cast.asJson) { +
    + + + + + +
    + @if (supportsPrecision()[$index] || supportsScale()[$index]) { + +
    + + + {{ precisionPlaceholder()[$index] }} + + + + + scale + + + +
    + } + +
    + + + @if (cast.collectionsType === 'ARRAY') { + + + dim + + + + + card + + + } +
    + } @else { + + JSON + + } + + +
    + + + +
    +
  • + } +
+
+ +
+ +
+ @if (suggestions().length > 0) { + + + + } @else { + + } + + @if (def().allowJson) { + + + + + } +
+ + + +
\ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/cast-setting/cast-setting.component.scss b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/cast-setting/cast-setting.component.scss new file mode 100644 index 00000000..0e29154b --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/cast-setting/cast-setting.component.scss @@ -0,0 +1,8 @@ + +.source-input { + width: calc(50% - 4px); +} + +.target-input, .collection-type { + max-width: calc(50% - 4px); +} \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/cast-setting/cast-setting.component.ts b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/cast-setting/cast-setting.component.ts new file mode 100644 index 00000000..b3da1dc2 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/cast-setting/cast-setting.component.ts @@ -0,0 +1,144 @@ +import {Component, computed, EventEmitter, inject, input, model, OnInit, Output, signal} from '@angular/core'; +import {SettingDefModel} from '../../../../../models/activity-registry.model'; +import {PolyType} from '../../../../../../../components/data-view/models/result-set.model'; +import {DbmsTypesService} from '../../../../../../../services/dbms-types.service'; +import {TypePreviewModel} from '../../../../../models/workflows.model'; +import {ToasterService} from '../../../../../../../components/toast-exposer/toaster.service'; +import {getSuggestions} from '../../../workflow'; + + +@Component({ + selector: 'app-cast-setting', + templateUrl: './cast-setting.component.html', + styleUrl: './cast-setting.component.scss' +}) +export class CastSettingComponent implements OnInit { + isEditable = input.required(); + settingDef = input.required(); + inTypePreview = input.required(); + value = model.required(); + @Output() hasChanged = new EventEmitter(); + + casts = computed(() => this.value().casts as SingleCast[]); + def = computed(() => this.settingDef() as CastSettingDef); + targetPreview = computed(() => this.inTypePreview()[this.def().targetInput]); + fieldType = computed(() => this.targetPreview()?.portType === 'REL' ? 'column' : 'field'); + suggestions = computed(() => getSuggestions(this.targetPreview(), 'props')); + + private changed = signal(false); // dummy signal to trigger recomputation + supportsPrecision = computed(() => { + this.changed(); + return this.casts().map(c => this._types.supportsPrecision(c.type)); + }); + supportsScale = computed(() => { + this.changed(); + return this.casts().map(c => this._types.supportsScale(c.type)); + }); + precisionPlaceholder = computed(() => { + this.changed(); + return this.casts().map(c => this._types.precisionPlaceholder(c.type)); + }); + + addName = ''; + addAsJson = false; + + readonly _toast = inject(ToasterService); + readonly _types = inject(DbmsTypesService); + types: PolyType[] = []; + + + ngOnInit(): void { + this._types.getTypes().subscribe( + t => this.types = t + ); + } + + valueChanged() { + this.changed.update(v => !v); + setTimeout(() => { + this.hasChanged.emit(); + }, 1); + } + + addCast() { + if (this.casts().some(c => c.source === this.addName)) { + this._toast.warn('Cast for this source is already defined'); + this.addName = ''; + return; + } + + const sourceCol = this.targetPreview()?.columns?.find(col => col.name === this.addName); + let type = this.def().defaultType; + let nullable = true; + let collectionsType = ''; + let dimension = -1; + let cardinality = -1; + let precision = null; + let scale = null; + if (sourceCol) { + nullable = !sourceCol.dataType.includes('NOT NULL'); + + // matches the first word (e.g. VARCHAR) and optionally precision and scale in parentheses + const match = sourceCol.dataType.match(/^(\w+)(?:\((\d+)(?:,\s*(\d+))?\))?/); + if (match) { + type = match[1]; + if (match[2]) { + precision = parseInt(match[2], 10); + } + if (match[3]) { + scale = parseInt(match[3], 10); + } + } + + const collMatch = sourceCol.dataType.match(/ARRAY\((\d+),\s*(\d+)\)/); + if (collMatch) { + collectionsType = 'ARRAY'; + cardinality = parseInt(collMatch[1], 10); + dimension = parseInt(collMatch[2], 10); + } + } + + const cast = { + source: this.addName, target: null, + type, nullable, collectionsType, precision, scale, dimension, cardinality + }; + if (this.addAsJson) { + cast['asJson'] = true; + } + + + this.casts().push(cast); + this.addName = ''; + this.valueChanged(); + } + + deleteCast(idx: number) { + this.casts().splice(idx, 1); + this.valueChanged(); + } +} + +interface CastSettingDef extends SettingDefModel { + targetInput: number; + defaultType: string; + allowDuplicateSource: boolean; + allowTarget: boolean; + allowJson: boolean; + singleCast: boolean; +} + +interface SingleCast { + source: string; + target: string; + + type: string; + nullable: boolean; + collectionsType: string; + precision: number | null; + scale: number | null; + dimension: number | null; + cardinality: number | null; + + asJson?: boolean; // only required if true + +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/collation-setting/collation-setting.component.html b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/collation-setting/collation-setting.component.html new file mode 100644 index 00000000..cad22123 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/collation-setting/collation-setting.component.html @@ -0,0 +1,41 @@ + +
    + @for (field of val(); track field.name) { +
  • + + +
    + + + + + + +
    + + + + +
    + + + +
    +
  • + } +
+ +
+ + \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/collation-setting/collation-setting.component.scss b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/collation-setting/collation-setting.component.scss new file mode 100644 index 00000000..c1638537 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/collation-setting/collation-setting.component.scss @@ -0,0 +1,16 @@ +.draggable { + cursor: grab; +} + +.draggable:active { + cursor: grabbing !important; +} + +.cdk-drag-preview { + border: none; + border-radius: 4px; + padding: 8px 16px; + box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), + 0 8px 10px 1px rgba(0, 0, 0, 0.14), + 0 3px 14px 2px rgba(0, 0, 0, 0.12); +} \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/collation-setting/collation-setting.component.ts b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/collation-setting/collation-setting.component.ts new file mode 100644 index 00000000..6e2b6aef --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/collation-setting/collation-setting.component.ts @@ -0,0 +1,60 @@ +import {Component, computed, EventEmitter, input, model, Output} from '@angular/core'; +import {SettingDefModel} from '../../../../../models/activity-registry.model'; +import {TypePreviewModel} from '../../../../../models/workflows.model'; +import {CdkDragDrop, moveItemInArray} from '@angular/cdk/drag-drop'; +import {getSuggestions} from '../../../workflow'; + +type Directions = 'ASCENDING' | 'STRICTLY_ASCENDING' | 'DESCENDING' | 'STRICTLY_DESCENDING' | 'CLUSTERED'; + +@Component({ + selector: 'app-collation-setting', + templateUrl: './collation-setting.component.html', + styleUrl: './collation-setting.component.scss' +}) +export class CollationSettingComponent { + isEditable = input.required(); + settingDef = input.required(); + inTypePreview = input.required(); + value = model.required(); + @Output() hasChanged = new EventEmitter(); + + val = computed(() => this.value() as FieldCollation[]); // like value, but with correct type + def = computed(() => this.settingDef() as CollationSettingDef); + targetPreview = computed(() => this.inTypePreview()[this.def().targetInput]); + fieldType = computed(() => this.targetPreview().portType === 'REL' ? 'column' : 'field'); + suggestions = computed(() => getSuggestions(this.targetPreview(), 'props')); + + readonly dirChoices: Directions[] = ['ASCENDING', 'STRICTLY_ASCENDING', 'DESCENDING', 'STRICTLY_DESCENDING', 'CLUSTERED']; + + valueChanged() { + setTimeout(() => this.hasChanged.emit(), 1); + } + + addField() { + this.val().push({name: '', direction: 'ASCENDING', regex: false}); + this.valueChanged(); + } + + deleteField(idx: number) { + this.val().splice(idx, 1); + this.valueChanged(); + } + + drop(event: CdkDragDrop) { + if (event.previousIndex !== event.currentIndex) { + moveItemInArray(this.val(), event.previousIndex, event.currentIndex); + this.valueChanged(); + } + } +} + +interface CollationSettingDef extends SettingDefModel { + targetInput: number; + allowRegex: boolean; +} + +interface FieldCollation { + name: string; + direction: Directions; + regex: boolean; +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/double-setting/double-setting.component.html b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/double-setting/double-setting.component.html new file mode 100644 index 00000000..dcdc2adc --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/double-setting/double-setting.component.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/double-setting/double-setting.component.scss b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/double-setting/double-setting.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/double-setting/double-setting.component.ts b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/double-setting/double-setting.component.ts new file mode 100644 index 00000000..e5aaf5d0 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/double-setting/double-setting.component.ts @@ -0,0 +1,23 @@ +import {Component, computed, EventEmitter, input, model, Output} from '@angular/core'; +import {SettingDefModel} from '../../../../../models/activity-registry.model'; +import {TypePreviewModel} from '../../../../../models/workflows.model'; + +@Component({ + selector: 'app-double-setting', + templateUrl: './double-setting.component.html', + styleUrl: './double-setting.component.scss' +}) +export class DoubleSettingComponent { + isEditable = input.required(); + settingDef = input.required(); + inTypePreview = input.required(); // not required for double + value = model.required(); + @Output() hasChanged = new EventEmitter(); + + def = computed(() => this.settingDef() as DoubleSettingDef); +} + +interface DoubleSettingDef extends SettingDefModel { + minValue: number; + maxValue: number; +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/entity-setting/entity-setting.component.html b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/entity-setting/entity-setting.component.html new file mode 100644 index 00000000..958eb337 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/entity-setting/entity-setting.component.html @@ -0,0 +1,19 @@ +
+ + +
+ +@if (def().dataModel !== DataModel.GRAPH) { +
+ + +
+} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/entity-setting/entity-setting.component.scss b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/entity-setting/entity-setting.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/entity-setting/entity-setting.component.ts b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/entity-setting/entity-setting.component.ts new file mode 100644 index 00000000..03983512 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/entity-setting/entity-setting.component.ts @@ -0,0 +1,56 @@ +import {Component, computed, EventEmitter, inject, input, model, OnInit, Output, signal, Signal} from '@angular/core'; +import {SettingDefModel} from '../../../../../models/activity-registry.model'; +import {TypePreviewModel} from '../../../../../models/workflows.model'; +import {DataModel} from '../../../../../../../models/ui-request.model'; +import {CatalogService} from '../../../../../../../services/catalog.service'; + +@Component({ + selector: 'app-entity-setting', + templateUrl: './entity-setting.component.html', + styleUrl: './entity-setting.component.scss' +}) +export class EntitySettingComponent implements OnInit { + isEditable = input.required(); + settingDef = input.required(); + inTypePreview = input.required(); // not required for int + value = model.required(); + @Output() hasChanged = new EventEmitter(); + + def = computed(() => this.settingDef() as EntitySettingDef); + + protected readonly DataModel = DataModel; + private readonly _catalog = inject(CatalogService); + private readonly changed = signal(false); // dummy signal to trigger recomputation + namespaces: Signal; + names: Signal; + + ngOnInit(): void { + this.namespaces = computed(() => + this._catalog.getNamespaces().filter(ns => ns.dataModel === this.def().dataModel).map(ns => ns.name) + ); + this.names = computed(() => { + this.changed(); + const catalog = this._catalog.listener(); + if (this.value().namespace) { + const namespace = catalog.getNamespaceFromName(this.value().namespace); + if (namespace) { + return catalog.getEntities(namespace.id).map(e => e.name); + } + } + return []; + }); + } + + + valueChanged() { + setTimeout(() => { + this.hasChanged.emit(); + }, 10); // short delay to ensure this.value().? updated correctly when selecting an autocomplete entry + this.changed.update(v => !v); + } +} + +interface EntitySettingDef extends SettingDefModel { + dataModel: DataModel; // non-null! + mustExist: boolean; +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/enum-setting/enum-setting.component.html b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/enum-setting/enum-setting.component.html new file mode 100644 index 00000000..9d85e231 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/enum-setting/enum-setting.component.html @@ -0,0 +1,41 @@ +@switch (def().style) { + @case ('DROPDOWN') { +
+ + + + +
+ {{ description() }} +
+
+ } + @case ('RADIO_BUTTON') { +
+

+ {{ def().label }} +

+ + @for (option of options(); track option[0]) { + + } + +
+ {{ description() }} +
+
+ } + @default { + unsupported display style: {{ def().style }} + } +} + + diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/enum-setting/enum-setting.component.scss b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/enum-setting/enum-setting.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/enum-setting/enum-setting.component.ts b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/enum-setting/enum-setting.component.ts new file mode 100644 index 00000000..c33bf68d --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/enum-setting/enum-setting.component.ts @@ -0,0 +1,35 @@ +import {Component, computed, EventEmitter, input, model, Output} from '@angular/core'; +import {SettingDefModel} from '../../../../../models/activity-registry.model'; + +@Component({ + selector: 'app-enum-setting', + templateUrl: './enum-setting.component.html', + styleUrl: './enum-setting.component.scss' +}) +export class EnumSettingComponent { + isEditable = input.required(); + settingDef = input.required(); + value = model.required(); + @Output() hasChanged = new EventEmitter(); + + def = computed(() => this.settingDef() as EnumSettingDef); + options = computed(() => { + const displayOpts = this.def().displayOptions; + return this.def().options.map((value, index) => [value, displayOpts[index]]); + }); + description = computed(() => { + if (!this.def().displayDescriptions) { + return null; + } + const i = this.def().options.indexOf(this.value()); + return this.def().displayDescriptions[i]; + }); +} + +interface EnumSettingDef extends SettingDefModel { + options: string[]; + displayOptions: string[]; + displayDescriptions: string[] | null; + label: string; + style: 'DROPDOWN' | 'RADIO_BUTTON'; +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/field-rename-setting/field-rename-setting.component.html b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/field-rename-setting/field-rename-setting.component.html new file mode 100644 index 00000000..a021778f --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/field-rename-setting/field-rename-setting.component.html @@ -0,0 +1,56 @@ + + + + + + + + + + + + +
    + @for (rule of rules(); track $index) { +
  • + + +
    + @if (this.value().mode === 'EXACT') { +
    + + +
    + } @else { + + } +
    +
    + +
    +
    + + + +
    +
  • + } +
+ +
+ + \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/field-rename-setting/field-rename-setting.component.scss b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/field-rename-setting/field-rename-setting.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/field-rename-setting/field-rename-setting.component.ts b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/field-rename-setting/field-rename-setting.component.ts new file mode 100644 index 00000000..95f7cf28 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/field-rename-setting/field-rename-setting.component.ts @@ -0,0 +1,79 @@ +import {Component, computed, EventEmitter, input, model, Output} from '@angular/core'; +import {SettingDefModel} from '../../../../../models/activity-registry.model'; +import {TypePreviewModel} from '../../../../../models/workflows.model'; +import {getSuggestions} from '../../../workflow'; + + +@Component({ + selector: 'app-field-rename-setting', + templateUrl: './field-rename-setting.component.html', + styleUrl: './field-rename-setting.component.scss' +}) +export class FieldRenameSettingComponent { + isEditable = input.required(); + settingDef = input.required(); + inTypePreview = input.required(); + value = model.required(); + @Output() hasChanged = new EventEmitter(); + + rules = computed(() => this.value().rules as RenameRule[]); // like value, but with correct type + def = computed(() => this.settingDef() as FieldRenameSettingDef); + targetPreview = computed(() => this.inTypePreview()[this.def().targetInput]); + fieldType = computed(() => this.targetPreview()?.portType === 'REL' ? 'column' : 'field'); + suggestions = computed(() => getSuggestions(this.targetPreview(), this.def().forLabels ? 'labels' : 'props')); + modes = computed(() => { + const modes: [RenameMode, String][] = [['EXACT', 'Exact']]; + if (this.def().allowRegex) { + modes.push(['REGEX', 'Regex']); + } + if (this.def().allowIndex) { + modes.push(['INDEX', 'Index']); + } + return modes; + }); + + valueChanged() { + setTimeout(() => this.hasChanged.emit(), 1); + } + + addRule() { + switch (this.value().mode) { + case 'EXACT': + case 'REGEX': + this.rules().push({source: '', replacement: ''}); + break; + case 'INDEX': + let highestIndex = -1; + this.rules().forEach(r => { + const i = parseInt(r.source, 10); + if (i > highestIndex) { + highestIndex = i; + } + }); + this.rules().push({source: '' + (highestIndex + 1), replacement: ''}); + break; + } + this.valueChanged(); + } + + deleteRule(idx: number) { + this.rules().splice(idx, 1); + this.valueChanged(); + } +} + +type RenameMode = 'EXACT' | 'REGEX' | 'INDEX'; + +interface FieldRenameSettingDef extends SettingDefModel { + defaultMode: RenameMode; + allowRegex: boolean; + allowIndex: boolean; + targetInput: number; + forLabels: boolean; +} + +interface RenameRule { + source: string; + replacement: string; + +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/field-select-setting/field-select-setting.component.html b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/field-select-setting/field-select-setting.component.html new file mode 100644 index 00000000..74959420 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/field-select-setting/field-select-setting.component.html @@ -0,0 +1,115 @@ +@if (def().simplified) { + @if (isEditable()) { + + + + {{ item.itemName }} + + + + + } @else { + + + } + +} @else { + + +
Exclude
+ + +
    + @for (field of val().exclude; track field) { +
  • +
    + {{ field }} + +
    +
    +
  • + } +
+
+
+ + + +
+
+ +
+ Include + (ignored) +
+
+ + +
+ +
    + @for (field of val().include; track field) { +
  • +
    + {{ field }} + +
    +
  • + } +
+
+
+ + + +
+
+
+

+ Drag and drop the fields to modify the lists + (the field order is ignored). +

+ + + + + + + @if (val().unspecifiedIndex >= 0 && def().reorder) { + + Insertion Index + + + + } +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/field-select-setting/field-select-setting.component.scss b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/field-select-setting/field-select-setting.component.scss new file mode 100644 index 00000000..f56dd583 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/field-select-setting/field-select-setting.component.scss @@ -0,0 +1,40 @@ +.field-list { + min-height: 230px; // 5 * 46 +} + +li { + min-height: 46px; +} + +.cdk-drag-animating { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} + +.field-list.cdk-drop-list-dragging .field-entry:not(.cdk-drag-placeholder) { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} + +.draggable { + cursor: grab; +} + +.draggable:active { + cursor: grabbing !important; +} + +.cdk-drag-preview { + border: none; + border-radius: 4px; + padding: 8px 16px; + background: #fff; + box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), + 0 8px 10px 1px rgba(0, 0, 0, 0.14), + 0 3px 14px 2px rgba(0, 0, 0, 0.12); +} + +.custom-placeholder { + background: var(--cui-secondary); + //border: dotted 3px var(--cui-light); + min-height: 46px; + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/field-select-setting/field-select-setting.component.ts b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/field-select-setting/field-select-setting.component.ts new file mode 100644 index 00000000..c86ca3ef --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/field-select-setting/field-select-setting.component.ts @@ -0,0 +1,176 @@ +import {Component, computed, effect, EventEmitter, input, model, Output, signal, ViewChild} from '@angular/core'; +import {SettingDefModel} from '../../../../../models/activity-registry.model'; +import {PK_COL, TypePreviewModel} from '../../../../../models/workflows.model'; +import {EditorComponent} from '../../../../../../../components/editor/editor.component'; +import {CdkDragDrop, moveItemInArray, transferArrayItem} from '@angular/cdk/drag-drop'; +import {ToasterService} from '../../../../../../../components/toast-exposer/toaster.service'; +import {getSuggestions} from '../../../workflow'; + +@Component({ + selector: 'app-field-select-setting', + templateUrl: './field-select-setting.component.html', + styleUrl: './field-select-setting.component.scss' +}) +export class FieldSelectSettingComponent { + isEditable = input.required(); + settingDef = input.required(); + inTypePreview = input.required(); + value = model.required(); + @Output() hasChanged = new EventEmitter(); + + @ViewChild('editor') editor: EditorComponent; + + val = computed(() => this.value() as FieldSelectValue); // like value, but with correct type + def = computed(() => this.settingDef() as FieldSelectSettingDef); + targetPreview = computed(() => this.inTypePreview()[this.def().targetInput]); + isRel = computed(() => this.targetPreview()?.portType === 'REL'); + canInitialize = computed(() => { + this.changed(); + return this.val().include.length === 0 && this.val().exclude.length === 0 && this.fields().length > 0; + }); + + addExclude = ''; + addInclude = ''; + + fields = computed(() => getSuggestions(this.targetPreview(), this.def().forLabels ? 'labels' : 'props')); + notExcludedFields = computed(() => { + this.changed(); + return this.fields().filter(f => !this.val().exclude.includes(f)); + }); + notIncludedFields = computed(() => { + this.changed(); + return this.fields().filter(f => !this.val().include.includes(f)); + }); + + + // simple + dropdownData = computed(() => this.fields().map(field => { + return {id: field, itemName: field}; + })); + includeData: { id: string; itemName: string; }[] = []; + private changed = signal(false); // dummy signal to trigger recomputation + + + // https://www.npmjs.com/package/angular2-multiselect-dropdown + readonly dropdownSettings = { + singleSelection: false, + text: 'Select...', + noDataLabel: 'No fields found', + searchPlaceholderText: 'Search or add new', + enableSearchFilter: true, + enableCheckAll: true, + enableFilterSelectAll: false, + addNewItemOnFilter: true, + tagToBody: false, + position: 'bottom', // Top currently has bug: https://github.com/CuppaLabs/angular2-multiselect-dropdown/issues/584, https://github.com/CuppaLabs/angular2-multiselect-dropdown/issues/605 + autoPosition: false + }; + + constructor(private _toast: ToasterService) { + effect(() => { + this.changed(); + + if (this.def().simplified) { + this.includeData = this.val().include.map(fieldName => { + return {id: fieldName, itemName: fieldName}; + }); + } + }); + } + + + valueChanged() { + this.hasChanged.emit(); + this.changed.update(v => !v); + } + + initialize() { + this.val().include = [...this.fields()]; + this.valueChanged(); + } + + drop(event: CdkDragDrop) { + if (event.previousContainer === event.container) { + moveItemInArray(event.container.data, event.previousIndex, event.currentIndex); + } else { + transferArrayItem( + event.previousContainer.data, + event.container.data, + event.previousIndex, + event.currentIndex, + ); + } + this.valueChanged(); + } + + delete(target: 'exclude' | 'include', index: number) { + if (target === 'exclude') { + this.val().exclude.splice(index, 1); + } else { + this.val().include.splice(index, 1); + } + this.valueChanged(); + } + + add(target: 'exclude' | 'include', fieldName: string) { + if (this.isRel() && fieldName === PK_COL) { + this._toast.warn(`Cannot add primary key column ${PK_COL}`); + return; + } + if (fieldName.length === 0) { + this._toast.warn('Field names must not be empty'); + return; + } + if (this.val().include.includes(fieldName) || this.val().exclude.includes(fieldName)) { + this._toast.warn('Duplicate field names are not permitted'); + return; + } + if (target === 'exclude') { + this.val().exclude.push(fieldName); + } else { + this.val().include.push(fieldName); + } + this.valueChanged(); + } + + moveAllTo(target: 'exclude' | 'include') { + const [sourceArr, targetArr] = target === 'exclude' ? + [this.val().include, this.val().exclude] : + [this.val().exclude, this.val().include]; + if (sourceArr.length > 0) { + targetArr.push(...sourceArr); + sourceArr.splice(0, sourceArr.length); + } + this.valueChanged(); + } + + unknownChanged(event: Event) { + if ((event.target as HTMLInputElement).checked) { + this.val().unspecifiedIndex = this.val().include.length; + } else { + this.val().unspecifiedIndex = -1; + } + this.valueChanged(); + } + + // simple + + includeDataChange(event: { id: string; itemName: string; }[]) { + this.val().include = event.map(e => e.id); + this.valueChanged(); + } +} + +interface FieldSelectSettingDef extends SettingDefModel { + simplified: boolean; + reorder: boolean; + defaultAll: boolean; + targetInput: number; + forLabels: boolean; +} + +interface FieldSelectValue { + include: string[]; + exclude: string[]; + unspecifiedIndex: number; // -1 if excluded by default (should always be the case for simplified +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/file-setting/file-setting.component.html b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/file-setting/file-setting.component.html new file mode 100644 index 00000000..07e65b82 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/file-setting/file-setting.component.html @@ -0,0 +1,26 @@ +
+

+ Location +

+ + @for (option of allowedModes(); track option.type) { + + } + +
+ +
+ Path + +
+ + + + + \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/file-setting/file-setting.component.scss b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/file-setting/file-setting.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/file-setting/file-setting.component.ts b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/file-setting/file-setting.component.ts new file mode 100644 index 00000000..5b5ae21c --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/file-setting/file-setting.component.ts @@ -0,0 +1,63 @@ +import {Component, computed, EventEmitter, input, model, OnInit, Output, signal} from '@angular/core'; +import {SettingDefModel} from '../../../../../models/activity-registry.model'; +import {TypePreviewModel} from '../../../../../models/workflows.model'; + +@Component({ + selector: 'app-file-setting', + templateUrl: './file-setting.component.html', + styleUrl: './file-setting.component.scss' +}) +export class FileSettingComponent implements OnInit { + isEditable = input.required(); + settingDef = input.required(); + inTypePreview = input.required(); // not required for int + value = model.required(); + @Output() hasChanged = new EventEmitter(); + + def = computed(() => this.settingDef() as FileSettingDef); + val = computed(() => this.value() as FileValue); // like value, but with correct type + + readonly modes: { type: SourceType, name: string, allowMultiple: boolean }[] = [ + {type: 'ABS_FILE', name: 'File Path', allowMultiple: true}, + {type: 'REL_FILE', name: 'Relative File Path', allowMultiple: true}, + {type: 'URL', name: 'URL', allowMultiple: false} + ]; + allowedModes = computed(() => this.modes.filter(m => this.def().modes.includes(m.type))); + showMulti = computed(() => { + this.changed(); + return this.def().allowMultiple && + this.allowedModes().find(m => m.type === this.val().type)?.allowMultiple; + } + ); + private changed = signal(false); // dummy signal to trigger recomputation + + ngOnInit(): void { + } + + + valueChanged() { + setTimeout(() => this.hasChanged.emit(), 1); + this.changed.update(v => !v); + } + + selectType(option: { type: SourceType; name: string; allowMultiple: boolean }) { + if (!option.allowMultiple && this.val().multi) { + this.val().multi = false; + } + this.val().type = option.type; + this.valueChanged(); + } +} + +type SourceType = 'ABS_FILE' | 'REL_FILE' | 'URL'; + +interface FileSettingDef extends SettingDefModel { + allowMultiple: boolean; + modes: SourceType[]; +} + +interface FileValue { + path: string; + type: SourceType; + multi: boolean; +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/filter-setting/filter-setting.component.html b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/filter-setting/filter-setting.component.html new file mode 100644 index 00000000..996d75e9 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/filter-setting/filter-setting.component.html @@ -0,0 +1,77 @@ + + + + + + + +
    + @for (condition of conditions(); track $index) { +
  • + + +
    + @if (value().targetMode === 'EXACT') { +
    + + +
    + } @else { + + } + + + + +
    + + +
    + + + + + + + + +
    +
    + + + +
    +
  • + } +
+
+ + + + + + + + + \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/filter-setting/filter-setting.component.scss b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/filter-setting/filter-setting.component.scss new file mode 100644 index 00000000..b42f1b15 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/filter-setting/filter-setting.component.scss @@ -0,0 +1,3 @@ +.half-width { + width: calc(50% - 4px); +} \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/filter-setting/filter-setting.component.ts b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/filter-setting/filter-setting.component.ts new file mode 100644 index 00000000..daf3634d --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/filter-setting/filter-setting.component.ts @@ -0,0 +1,81 @@ +import {Component, computed, EventEmitter, input, model, Output} from '@angular/core'; +import {SettingDefModel} from '../../../../../models/activity-registry.model'; +import {TypePreviewModel} from '../../../../../models/workflows.model'; +import {getSuggestions} from '../../../workflow'; + + +@Component({ + selector: 'app-filter-setting', + templateUrl: './filter-setting.component.html', + styleUrl: './filter-setting.component.scss' +}) +export class FilterSettingComponent { + isEditable = input.required(); + settingDef = input.required(); + inTypePreview = input.required(); + value = model.required(); + @Output() hasChanged = new EventEmitter(); + + conditions = computed(() => this.value().conditions as Condition[]); // like value, but with correct type + def = computed(() => this.settingDef() as FilterSettingDef); + targetPreview = computed(() => this.inTypePreview()[this.def().targetInput]); + fieldType = computed(() => this.targetPreview()?.portType === 'REL' ? 'column' : 'field'); + suggestions = computed(() => getSuggestions(this.targetPreview(), 'props')); + modes = computed(() => this.def().modes.map(m => [m, m.toLowerCase()])); + opChoices = computed(() => Object.entries(Operator).filter(([key]) => { + return this.def().operators.includes(key as Operator); + })); + + readonly showIgnoreCase = new Set(['EQUALS', 'NOT_EQUALS', 'REGEX', 'INCLUDED', 'NOT_INCLUDED', 'CONTAINS', 'NOT_CONTAINS', 'HAS_KEY']); + readonly hideValue = new Set(['NULL', 'NON_NULL', 'IS_ARRAY', 'IS_OBJECT']); + + valueChanged() { + setTimeout(() => this.hasChanged.emit(), 1); + } + + addCondition() { + this.conditions().push({field: '', operator: this.def().operators[0], value: '', ignoreCase: false}); + this.valueChanged(); + } + + deleteCondition(idx: number) { + this.conditions().splice(idx, 1); + this.valueChanged(); + } +} + +type SelectMode = 'EXACT' | 'REGEX' | 'INDEX'; + +export enum Operator { + EQUALS = '=', + NOT_EQUALS = '!=', + GREATER_THAN = '>', + LESS_THAN = '<', + GREATER_THAN_EQUALS = '≥', + LESS_THAN_EQUALS = '≤', + REGEX = 'Matches Regex', + REGEX_NOT = 'Does Not Match Regex', + NULL = 'Is Null', + NON_NULL = 'Is Not Null', + INCLUDED = 'In', + NOT_INCLUDED = 'Not In', + IS_ARRAY = 'Is Array', + CONTAINS = 'Contains', + NOT_CONTAINS = 'Does Not Contain', + HAS_KEY = 'Has Key', + IS_OBJECT = 'Is Document' +} + + +interface FilterSettingDef extends SettingDefModel { + modes: SelectMode[]; + operators: Operator[]; + targetInput: number; +} + +interface Condition { + field: string; + operator: Operator; + value: string; + ignoreCase: boolean; +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/graph-map-setting/graph-map-setting.component.html b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/graph-map-setting/graph-map-setting.component.html new file mode 100644 index 00000000..066240fa --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/graph-map-setting/graph-map-setting.component.html @@ -0,0 +1,330 @@ + + @for (mapping of mappings(); track $index) { + + + + + +
+ @if (mapping.edgeOnly) { + + +
+ + @if (mapping.edge.dynamicEdgeLabels) { + + + } @else { + + } + + + + + +
+ + + + + + + + +
Source Node
+
+ + +
+ + + Input Index + + + + @if (mapping.edge.leftTargetIdx === -1) { +
+
+ + +
+
+ + +
+
+ } @else { + + + } + + +
Target Node
+
+ + +
+ + + Input Index + + + + @if (mapping.edge.rightTargetIdx === -1) { +
+
+ + +
+
+ + +
+
+ } @else { + + + } + + + } @else { + +
+ @if (mapping.dynamicNodeLabels) { + + + } @else { + + } + + + + + +
+
Outgoing Edges
+ +
    + @for (edge of mapping.edges; track idx; let idx = $index) { +
  • + + + +
    + @if (edge.dynamicEdgeLabels) { + + + } @else { + + } + + + + + +
    + + + @if (isEditable()) { + + + + {{ item.itemName }} + + + + } @else { + + + } + + + + + + +
    This Node
    + +
    + + +
    + + +
    Other Node
    + + Input Index + + + + @if (edge.rightTargetIdx === -1) { +
    + +
    + + +
    +
    + + +
    +
    + } @else { + + + } + + +
    + + + +
    +
  • + } +
+
+ + } +
+
+
+ } +
+ +
+
+ + Input Index + + + + + + + +
+ + +
\ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/graph-map-setting/graph-map-setting.component.scss b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/graph-map-setting/graph-map-setting.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/graph-map-setting/graph-map-setting.component.ts b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/graph-map-setting/graph-map-setting.component.ts new file mode 100644 index 00000000..d3ad12d8 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/graph-map-setting/graph-map-setting.component.ts @@ -0,0 +1,225 @@ +import {Component, computed, effect, EventEmitter, input, model, Output, signal} from '@angular/core'; +import {SettingDefModel} from '../../../../../models/activity-registry.model'; +import {TypePreviewModel} from '../../../../../models/workflows.model'; +import {ToasterService} from '../../../../../../../components/toast-exposer/toaster.service'; +import {getSuggestions} from '../../../workflow'; + + +@Component({ + selector: 'app-graph-map-setting', + templateUrl: './graph-map-setting.component.html', + styleUrl: './graph-map-setting.component.scss' +}) +export class GraphMapSettingComponent { + isEditable = input.required(); + settingDef = input.required(); + inTypePreview = input.required(); + value = model.required(); + @Output() hasChanged = new EventEmitter(); + + protected readonly parseInt = parseInt; + + private changed = signal(false); // dummy signal to trigger recomputation + mappings = computed(() => this.value().mappings as InputMapping[]); // like value, but with correct type + def = computed(() => this.settingDef() as GraphMapSettingDef); + targetPreviews = computed(() => this.inTypePreview().slice(this.def().targetInput)); + suggestions = computed(() => this.targetPreviews().map(p => getSuggestions(p, 'props'))); // never a graph, so labelsOrProps doesn't matter + graphLabels = computed(() => { + if (!this.def().canExtendGraph || this.def().graphInput < 0) { + return []; + } + return this.inTypePreview()[this.def().graphInput]?.labels || []; + }); + graphProps = computed(() => { + if (!this.def().canExtendGraph || this.def().graphInput < 0) { + return []; + } + return this.inTypePreview()[this.def().graphInput]?.properties || []; + }); + + nInputs = computed(() => this.suggestions().length); + remainingInputs = computed(() => { + this.changed(); + const indexes = []; + for (let i = 0; i < this.nInputs(); i++) { + if (!this.mappings().some(m => m.inputIdx === i)) { + indexes.push(i); + } + } + if (this.addIdx === null) { + this.addIdx = indexes[0]; + } + return indexes; + }); + + addIdx = null; + addEdgeOnly = false; + + // https://www.npmjs.com/package/angular2-multiselect-dropdown + readonly propertiesSettings = { + singleSelection: false, + text: 'Add Edge Properties...', + noDataLabel: 'No fields found', + searchPlaceholderText: 'Search or add new', + enableSearchFilter: true, + enableCheckAll: false, + enableFilterSelectAll: false, + addNewItemOnFilter: true, + tagToBody: false + }; + fieldsData = computed(() => this.suggestions().map(fields => + fields.map(field => ({id: field, itemName: field})) + )); + propertiesData: Map = new Map(); // required since multiselect expects specific structure of items + + constructor(private _toast: ToasterService) { + effect(() => { + this.changed(); + for (const mapping of this.mappings()) { + if (!mapping.edgeOnly) { + for (const [index, edge] of mapping.edges.entries()) { + const items = edge.propertyFields.map(field => ({id: field, itemName: field})); + this.propertiesData.set(mapping.inputIdx + '_' + index, items); + + } + } + } + }); + } + + valueChanged() { + this.changed.update(v => !v); + setTimeout(() => this.hasChanged.emit(), 1); + } + + addMapping() { + const inputIdx = parseInt(String(this.addIdx), 10); + if (!this.remainingInputs().includes(inputIdx)) { + this._toast.warn('A mapping for this input already exists: ' + inputIdx + ', ' + this.remainingInputs()); + return; + } + if (this.addEdgeOnly) { + this.mappings().push({ + edgeOnly: true, inputIdx, + edge: { + dynamicEdgeLabels: false, edgeLabels: [''], + leftField: '', leftTargetIdx: 0, leftTargetField: '', + rightField: '', rightTargetIdx: 0, rightTargetField: '', + invertDirection: false, + } + }); + } else { + this.mappings().push({ + edgeOnly: false, inputIdx, + dynamicNodeLabels: false, nodeLabels: [''], edges: [] + }); + } + if (this.remainingInputs().length > 1) { + this.addIdx = this.remainingInputs().find(i => i !== this.addIdx); + } + + + this.valueChanged(); + } + + deleteMapping(idx: number) { + const removed = this.mappings().splice(idx, 1)[0]; // idx is different to inputIdx + if (!this.remainingInputs().includes(this.addIdx)) { + this.addIdx = removed.inputIdx; + } + this.valueChanged(); + } + + addEdge(mapping: InputMapping) { + mapping.edges.push({ + dynamicEdgeLabels: false, edgeLabels: [''], + rightField: '', rightTargetIdx: 0, rightTargetField: '', + invertDirection: false, propertyFields: [] + }); + this.valueChanged(); + } + + deleteEdge(mapping: InputMapping, idx: number) { + mapping.edges.splice(idx, 1); + this.valueChanged(); + } + + addProperty(edge: EdgeMapping, fieldName: string) { + if (edge.propertyFields.includes(fieldName)) { + this._toast.warn('Duplicate field names are not permitted'); + return; + } + edge.propertyFields.push(fieldName); + this.valueChanged(); + } + + propertyDataChange(edge: EdgeMapping, event: { id: string; itemName: string; }[]) { + edge.propertyFields = event.map(e => e.id); + this.valueChanged(); + } + + getLabel(targetField: string) { + // string is either label.property or property + const i = targetField.indexOf('.'); + if (i < 1) { + return ''; + } + return targetField.substring(0, i); + } + + getProperty(targetField: string) { + const i = targetField.indexOf('.'); + return i !== -1 ? targetField.substring(i + 1) : targetField; + } + + replaceLabel(targetField: string, label: string) { + if (label.trim().length === 0) { + return this.getProperty(targetField); + } + return label + '.' + this.getProperty(targetField); + } + + replaceProperty(targetField: string, property: string) { + if (targetField.indexOf('.') < 1) { + return property; + } + return this.getLabel(targetField) + '.' + property; + } + +} + + +interface GraphMapSettingDef extends SettingDefModel { + canExtendGraph: boolean; + targetInput: number; + graphInput: number; +} + +interface InputMapping { + edgeOnly: boolean; + inputIdx: number; + + // node + dynamicNodeLabels?: boolean; + nodeLabels?: string[]; + edges?: EdgeMapping[]; + + // edge + edge?: EdgeMapping; +} + +interface EdgeMapping { + dynamicEdgeLabels: boolean; + edgeLabels: string[]; + + leftField?: string; + leftTargetIdx?: number; + leftTargetField?: string; + + rightField: string; + rightTargetIdx: number; + rightTargetField: string; + + invertDirection: boolean; + propertyFields?: string[]; +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/int-setting/int-setting.component.html b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/int-setting/int-setting.component.html new file mode 100644 index 00000000..f65597bd --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/int-setting/int-setting.component.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/int-setting/int-setting.component.scss b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/int-setting/int-setting.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/int-setting/int-setting.component.ts b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/int-setting/int-setting.component.ts new file mode 100644 index 00000000..d292a211 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/int-setting/int-setting.component.ts @@ -0,0 +1,23 @@ +import {Component, computed, EventEmitter, input, model, Output} from '@angular/core'; +import {SettingDefModel} from '../../../../../models/activity-registry.model'; +import {TypePreviewModel} from '../../../../../models/workflows.model'; + +@Component({ + selector: 'app-int-setting', + templateUrl: './int-setting.component.html', + styleUrl: './int-setting.component.scss' +}) +export class IntSettingComponent { + isEditable = input.required(); + settingDef = input.required(); + inTypePreview = input.required(); // not required for int + value = model.required(); + @Output() hasChanged = new EventEmitter(); + + def = computed(() => this.settingDef() as IntSettingDef); +} + +interface IntSettingDef extends SettingDefModel { + minValue: number; + maxValue: number; +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/query-setting/query-setting.component.html b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/query-setting/query-setting.component.html new file mode 100644 index 00000000..107f7d4f --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/query-setting/query-setting.component.html @@ -0,0 +1,27 @@ + + + + + +
+ + @for (inType of inTypePreview(); track $index) { + + } + + +
+ +
+ +
\ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/query-setting/query-setting.component.scss b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/query-setting/query-setting.component.scss new file mode 100644 index 00000000..cb0561f8 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/query-setting/query-setting.component.scss @@ -0,0 +1,3 @@ +.editor-container { + border-radius: 4px; +} \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/query-setting/query-setting.component.ts b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/query-setting/query-setting.component.ts new file mode 100644 index 00000000..06fa2ed1 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/query-setting/query-setting.component.ts @@ -0,0 +1,75 @@ +import {AfterViewInit, Component, computed, effect, EventEmitter, input, model, Output, ViewChild} from '@angular/core'; +import {SettingDefModel} from '../../../../../models/activity-registry.model'; +import {TypePreviewModel} from '../../../../../models/workflows.model'; +import {EditorComponent} from '../../../../../../../components/editor/editor.component'; + +@Component({ + selector: 'app-query-setting', + templateUrl: './query-setting.component.html', + styleUrl: './query-setting.component.scss' +}) +export class QuerySettingComponent implements AfterViewInit { + isEditable = input.required(); + settingDef = input.required(); + inTypePreview = input.required(); + value = model.required(); + @Output() hasChanged = new EventEmitter(); + + @ViewChild('editor') editor: EditorComponent; + + val = computed(() => this.value() as QueryValue); // like value, but with correct type + def = computed(() => this.settingDef() as QuerySettingDef); + listenForChanges = true; + + readonly editorOptions = { + minLines: 4, + maxLines: 10, + showLineNumbers: true, + highlightGutterLine: false, + highlightActiveLine: false, + fontSize: '0.875rem' + }; + + constructor() { + effect(() => { + this.val(); // listen for changes in val + this.listenForChanges = false; + this.editor?.setCode(this.val().query); + this.listenForChanges = true; + }); + } + + ngAfterViewInit(): void { + this.editor.onChange(() => { + if (this.listenForChanges) { + const oldQuery = this.val().query; + const newQuery = this.editor.getCode(); + if (oldQuery !== newQuery) { + this.val().query = newQuery; + this.valueChanged(); + } + } + }); + } + + + valueChanged() { + this.hasChanged.emit(); + } + + insertInput(idx: number) { + this.editor.insertAtCursor(this.def().entityL + idx.toString() + this.def().entityR); + this.editor.focus(); + } +} + +interface QuerySettingDef extends SettingDefModel { + queryLanguages: string[]; + entityL: string; + entityR: string; +} + +interface QueryValue { + query: string; + queryLanguage: string; +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/string-setting/string-setting.component.html b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/string-setting/string-setting.component.html new file mode 100644 index 00000000..082981a9 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/string-setting/string-setting.component.html @@ -0,0 +1,28 @@ +@if (def().textEditor) { +
+ +
+} @else if (usesAutocomplete()) { +
+ + +
+ @if (def().autoComplete === 'WORKFLOW_NAMES') { +
+ ID: {{ workflowNamesToId.get( value() ) }} +
+ } +} @else { + +} \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/string-setting/string-setting.component.scss b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/string-setting/string-setting.component.scss new file mode 100644 index 00000000..cb0561f8 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/string-setting/string-setting.component.scss @@ -0,0 +1,3 @@ +.editor-container { + border-radius: 4px; +} \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/string-setting/string-setting.component.ts b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/string-setting/string-setting.component.ts new file mode 100644 index 00000000..baa87e29 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/activity-settings/string-setting/string-setting.component.ts @@ -0,0 +1,122 @@ +import {AfterViewInit, Component, computed, effect, EventEmitter, input, model, OnInit, Output, signal, Signal, ViewChild} from '@angular/core'; +import {SettingDefModel} from '../../../../../models/activity-registry.model'; +import {envVarsKey, TypePreviewModel, Variables, wfVarsKey} from '../../../../../models/workflows.model'; +import {DataModel} from '../../../../../../../models/ui-request.model'; +import {AdapterModel} from '../../../../../../../views/adapters/adapter.model'; +import {CatalogService} from '../../../../../../../services/catalog.service'; +import {EditorComponent} from '../../../../../../../components/editor/editor.component'; +import {WorkflowsService} from '../../../../../services/workflows.service'; +import {getSuggestions} from '../../../workflow'; + +@Component({ + selector: 'app-string-setting', + templateUrl: './string-setting.component.html', + styleUrl: './string-setting.component.scss' +}) +export class StringSettingComponent implements OnInit, AfterViewInit { + isEditable = input.required(); + settingDef = input.required(); + inTypePreview = input.required(); + activityVars = input.required(); + value = model.required(); + @Output() hasChanged = new EventEmitter(); + + def = computed(() => this.settingDef() as StringSettingDef); + usesAutocomplete = computed(() => this.def().autoComplete !== 'NONE'); + targetPreview = computed(() => this.inTypePreview()[this.def().autoCompleteInput]); + suggestions = computed(() => { + if (this.usesAutocomplete()) { + switch (this.def().autoComplete) { + case 'FIELD_NAMES': + return getSuggestions(this.targetPreview(), 'props'); + case 'ADAPTERS': + return this.adapters().map(a => a.name); + case 'VARIABLES': + return Object.keys(this.activityVars()).filter(key => key !== wfVarsKey && key !== envVarsKey); + case 'WORKFLOW_NAMES': + return this.workflowNames(); + } + } + return []; + }); + adapters: Signal; + workflowNames = signal([]); + workflowNamesToId = new Map(); + protected readonly DataModel = DataModel; + + readonly editorOptions = { + minLines: 4, + maxLines: 20, + showLineNumbers: false, + highlightGutterLine: false, + highlightActiveLine: false, + fontSize: '0.875rem' + }; + listenForChanges = true; + @ViewChild('editor') editor: EditorComponent; + + constructor(private _catalog: CatalogService, private _workflows: WorkflowsService) { + this.adapters = computed(() => { + this._catalog.listener(); + return [...this._catalog.getStores()]; // warning, HSQLDB mvcc might result in deadlocks with concurrent schema changes + }); + + + effect(() => { + if (this.def().textEditor) { + if (this.editor.getCode() !== this.value()) { // triggered if value is changed externally + this.listenForChanges = false; + this.editor?.setCode(this.value()); + this.listenForChanges = true; + } + } + }); + } + + ngOnInit(): void { + this.editorOptions.showLineNumbers = this.def().textEditorLineNumbers; + } + + ngAfterViewInit(): void { + if (this.def().textEditor) { + this.editor.onChange(() => { + if (this.listenForChanges) { + const oldVal = this.value(); + const newVal = this.editor.getCode(); + if (oldVal !== newVal) { + this.value.set(newVal); + this.valueChanged(); + } + } + }); + } + if (this.def().autoComplete === 'WORKFLOW_NAMES') { + this._workflows.getWorkflowDefs().subscribe(defs => { + const names = Object.values(defs).map(def => def.name); + names.sort((a, b) => a.localeCompare(b)); + this.workflowNames.set(names); + this.workflowNamesToId.clear(); + Object.entries(defs).forEach(([key, def]) => this.workflowNamesToId.set(def.name, key)); + }); + } + } + + valueChanged() { + // short delay to ensure this.value() has updated when selecting an autocomplete entry + setTimeout(() => this.hasChanged.emit(), 1); + } +} + +type AutoCompleteType = 'NONE' | 'FIELD_NAMES' | 'VALUES' | 'ADAPTERS' | 'VARIABLES' | 'WORKFLOW_NAMES'; + +interface StringSettingDef extends SettingDefModel { + minLength: number; + maxLength: number; + autoComplete: AutoCompleteType; + autoCompleteInput: number; + nonBlank: boolean; + containsRegex: boolean; + textEditor: boolean; + textEditorLanguage: string; + textEditorLineNumbers: boolean; +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/right-menu.component.html b/src/app/plugins/workflows/components/workflow-viewer/right-menu/right-menu.component.html new file mode 100644 index 00000000..aadf88e6 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/right-menu.component.html @@ -0,0 +1,137 @@ + diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/right-menu.component.scss b/src/app/plugins/workflows/components/workflow-viewer/right-menu/right-menu.component.scss new file mode 100644 index 00000000..a1bca84b --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/right-menu.component.scss @@ -0,0 +1,32 @@ + +[hidden] { + display: none !important; +} + +.menu { + width: 500px; +} + +.no-select { + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; +} + +.menu-body { + overflow-x: hidden; + overflow-y: auto; + flex-basis: 0; +} + +.edit-name-button:hover { + color: var(--cui-heading-color) !important; +} + +.editable-name { + font-weight: 500; + line-height: 1; + color: var(--cui-heading-color) !important; + font-size: 1.75rem; +} \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/right-menu/right-menu.component.ts b/src/app/plugins/workflows/components/workflow-viewer/right-menu/right-menu.component.ts new file mode 100644 index 00000000..2dd039fd --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/right-menu/right-menu.component.ts @@ -0,0 +1,105 @@ +import {Component, computed, ElementRef, input, signal, ViewChild} from '@angular/core'; +import {Activity} from '../workflow'; +import {WorkflowsService} from '../../../services/workflows.service'; +import {ActivityConfigModel, envVarsKey, RenderModel, SettingsModel, Variables, wfVarsKey} from '../../../models/workflows.model'; +import {WorkflowsWebSocketService} from '../../../services/workflows-websocket.service'; +import {ActivitySettingsComponent} from './activity-settings/activity-settings.component'; +import {ActivityConfigEditorComponent} from './activity-config-editor/activity-config-editor.component'; +import {ToasterService} from '../../../../../components/toast-exposer/toaster.service'; + + +export type MenuTabs = 'settings' | 'variables' | 'config' | 'execution' | 'help'; + +@Component({ + selector: 'app-right-menu', + templateUrl: './right-menu.component.html', + styleUrl: './right-menu.component.scss' +}) +export class RightMenuComponent { + isEditable = input.required(); + activity = input.required(); + workflowVars = input.required(); + + @ViewChild('settings') settingsComponent: ActivitySettingsComponent; + @ViewChild('config') configComponent: ActivityConfigEditorComponent; + @ViewChild('editableName') editableName: ElementRef; + visible = signal(false); + activeTab = signal('settings'); + + isEditingName = signal(false); + editableDisplayName = ''; + + basicVars = computed(() => { + const allVars = this.activity().variables(); + return Object.keys(this.activity().variables()) + .filter((key) => key !== wfVarsKey && key !== envVarsKey) + .reduce((obj, key) => { + obj[key] = allVars[key]; + return obj; + }, {}); + }); + envVars = computed(() => this.activity().variables()[envVarsKey] || {}); + wfVars = computed(() => this.activity().variables()[wfVarsKey] || {}); + + constructor(private readonly _workflows: WorkflowsService, + private readonly _websocket: WorkflowsWebSocketService, + private readonly _toast: ToasterService + ) { + } + + toggleMenu() { + this.visible.update(b => !b); + } + + save(settings: SettingsModel, config: ActivityConfigModel, rendering: RenderModel) { + this._websocket.updateActivity(this.activity().id, settings, config, rendering); + } + + changeActiveTab(tab: MenuTabs) { + if (tab !== this.activeTab() && this.canSafelyNavigate()) { + this.activeTab.set(tab); + } + } + + canSafelyNavigate(warn = true) { + if (this.activeTab() === 'settings') { + if (this.settingsComponent?.hasSettingsChanged()) { + if (warn) { + this._toast.warn('Please save or discard any unsaved changes to the settings first.', 'Unsaved settings'); + this.visible.set(true); + } + return false; + } + } else if (this.activeTab() === 'execution') { + if (this.configComponent?.hasConfigChanged()) { + if (warn) { + this._toast.warn('Please save or discard the unsaved changes to the config first.', 'Unsaved config'); + this.visible.set(true); + } + return false; + } + } + return true; + } + + editDisplayName() { + this.editableDisplayName = this.activity().staticDisplayName(); + this.isEditingName.set(true); + setTimeout(() => { + this.editableName.nativeElement.focus(); + }, 0); + } + + saveDisplayName() { + this.isEditingName.set(false); + const isDefault = this.editableDisplayName === this.activity().def.displayName; + const name = isDefault ? undefined : this.editableDisplayName; + this.activity().rendering.update(r => ({...r, name})); + this._websocket.updateActivity(this.activity().id, null, null, this.activity().rendering()); + } + + resetDisplayName() { + this.editableDisplayName = this.activity().def.displayName; + this.saveDisplayName(); + } +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/workflow-config-editor/workflow-config-editor.component.html b/src/app/plugins/workflows/components/workflow-viewer/workflow-config-editor/workflow-config-editor.component.html new file mode 100644 index 00000000..f5f32be2 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/workflow-config-editor/workflow-config-editor.component.html @@ -0,0 +1,104 @@ + + +
Workflow Config
+ +
+ +
+ + +
+
Preferred Stores
+ + @for (entry of editableConfig().preferredStores | keyvalue; track entry.key) { + + + + + } +
+ +
+ +
Optimization
+ + + +
+ Fusion can increase performance by reducing the number of materialized checkpoints and more opportunities for query optimization. +
+
+ + + +
+ Pipelining can increase performance by reducing the number of materialized checkpoints and increasing the degree of concurrency. +
+
+ + + + + + +
+ Reduce storage requirements by dropping no longer required checkpoints. + Checkpoints of individual activities can be enforced in their config. +
+
+
+ + + +
+
Worker Threads
+ +
+ The maximum number of activities that can be executed concurrently (pipelined or fused activities count as 1). + An upper limit is given by the globalWorkers Polypheny config value. +
+
+ + +
+
Timeout
+ +
+ Activity execution timeout in seconds, or 0 to disable. +
+
+ + +
+
Pipeline Queue Capacity
+ +
+ + +
+
Log Capacity per Activity
+ +
+ +
+
+ + +
+ +
+

Changes only apply to already executed activities after a reset.

+
+
\ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/workflow-config-editor/workflow-config-editor.component.scss b/src/app/plugins/workflows/components/workflow-viewer/workflow-config-editor/workflow-config-editor.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/plugins/workflows/components/workflow-viewer/workflow-config-editor/workflow-config-editor.component.ts b/src/app/plugins/workflows/components/workflow-viewer/workflow-config-editor/workflow-config-editor.component.ts new file mode 100644 index 00000000..ffb836d2 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/workflow-config-editor/workflow-config-editor.component.ts @@ -0,0 +1,66 @@ +import {Component, computed, effect, input, OnInit, Signal, signal} from '@angular/core'; +import {WorkflowConfigModel} from '../../../models/workflows.model'; +import {CatalogService} from '../../../../../services/catalog.service'; +import {AdapterModel} from '../../../../../views/adapters/adapter.model'; +import {WorkflowsWebSocketService} from '../../../services/workflows-websocket.service'; + +@Component({ + selector: 'app-workflow-config-editor', + templateUrl: './workflow-config-editor.component.html', + styleUrl: './workflow-config-editor.component.scss' +}) +export class WorkflowConfigEditorComponent implements OnInit { + config = input.required(); + isEditable = input.required(); + + readonly adapters: Signal; + readonly serializedConfig = computed(() => JSON.stringify(this.config()), + {equal: () => false}); // enforce change when switching to different activity, even if it has the same config value + readonly editableConfig = computed(() => JSON.parse(this.serializedConfig())); // we edit a copy of the actual config + readonly serializedEditedConfig = signal(null); + hasConfigChanged: Signal; + readonly showModal = signal(false); + + constructor(private _catalog: CatalogService, private readonly _websocket: WorkflowsWebSocketService) { + this.adapters = computed(() => { + this._catalog.listener(); + return [...this._catalog.getStores().filter(store => + // mvcc currently results in deadlocks with concurrent schema changes + store.adapterName !== 'HSQLDB' || store.settings['trxControlMode'] !== 'mvcc' + )]; + }); + + effect(() => this.serializedEditedConfig.set(this.serializedConfig()), {allowSignalWrites: true}); + } + + ngOnInit(): void { + this.hasConfigChanged = computed(() => this.serializedConfig() !== this.serializedEditedConfig()); + } + + saveConfig() { + const config = this.editableConfig(); + if (config.maxWorkers < 1) { + + } + this._websocket.updateConfig(config); + this.showModal.set(false); + } + + toggleModal() { + this.showModal.update(b => !b); + } + + show() { + this.resetEditableConfig(); // reset unsaved changes + this.showModal.set(true); + } + + resetEditableConfig() { + Object.assign(this.editableConfig(), JSON.parse(this.serializedConfig())); + this.checkForChanges(); + } + + checkForChanges() { + this.serializedEditedConfig.set(JSON.stringify(this.editableConfig())); + } +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/workflow-help/workflow-help.component.html b/src/app/plugins/workflows/components/workflow-viewer/workflow-help/workflow-help.component.html new file mode 100644 index 00000000..9a1435a7 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/workflow-help/workflow-help.component.html @@ -0,0 +1,230 @@ + + +
Help
+ +
+ + + + @switch (helpTab()) { + @case ("intro") { +

Each workflow is made up of a set of reusable building blocks called activities. They are connected with + edges to form an acyclic graph that represents the flow of execution.

+ +
Activities
+

Each activity has a status bar whose color indicates the activity state.

+
    +
  • + Idle + the activity has not yet been executed. +
  • +
  • + Queued + the activity is waiting to be executed. +
  • +
  • + Executing + the activity is getting executed. Many activities also report the estimated progress in form of a progress bar. +
  • +
  • + Finished + the activity was successfully executed and its results can be viewed. +
  • +
  • + Failed + the execution was unsuccessful. Open the activity settings to see an error message. +
  • +
  • + Cancelled + the activity was queued for execution, but it was never executed. +
  • +
+ +

+ If a problem with an activity is detected, the execution cannot be started. + A warning sign is shown. + By clicking on it, a detailed problem description can be viewed. +

+ + +
Data Models
+

Data flowing through the workflow can adhere to any of the data models supported by Polypheny:

+
    +
  • + + relational model +
  • +
  • + + document model +
  • +
  • + + property graph model +
  • +
+

Outputs of one model can only be connected to inputs of a compatible data model. + Some activities have inputs that are agnostic to the data model + (). +

+ +
Checkpoints
+ Without any optimizations enabled, each successfully executed + activity stores its outputs in form of a checkpoint. This is useful for iterative development of workflows and recoverability. +

+ Checkpoints can be viewed by clicking on the corresponding output symbol + . +

+ } + @case ("control") { +

+ Apart from defining the flow of data, the data edges also restrict the order in which activities are executed. + Oftentimes, more granular control over the execution order is required. We want to be able to + condition the execution of an activity on the success or failure of another activity. This is solved by control edges. + They enable conditional branching and error handling similar to try-catch structures in programming languages. +

+

Control edges are dashed lines starting at the + success or fail output of an activity and ending at a + control input. + The success output becomes active when the activity was successfully executed, the fail output becomes active when it failed. +

+ +
Variables
+ Just like data edges, control edges also carry dynamic variables (see Variables). +

A fail control edge also carries the error message of the failed activity, which can be useful for logging.

+ +
Multiple Control Edges
+

If multiple edges are connected to a single control input, the logical AND is computed to determine whether the activity can be executed. + If we want to executed an activity if (at least) one of several activities fails, this behavior might not be desired. + The Control State Merger in the activity Configuration menu can be used to change it to the logical OR of all fail edges. +

+ +
Overall Execution Success
+

+ Control edges allow us to properly handle activity failures. A failed activity does not necessarily mean that the entire workflow execution was unsuccessful. + By default, the workflow execution is thus always considered a success. +

+

+ More granular control over whether workflow execution has been successful is important for nested workflows. + The expected outcome can be customized in the activity Configuration. + For instance, MUST_SUCCEED results in the workflow only being considered a success if that specific activity succeeded. +

+ } + @case ("variables") { +

+ The workflow engine comes with a powerful variable system. It allows any Activity-Setting value to be set dynamically. + There are three types of variables: +

+
    +
  • Workflow Variables: They are defined in the Variables toolbar menu and do not change during execution. Every activity can access these variables.
  • +
  • Dynamic Variables: They are set dynamically by specialized activities and are propagated by both data and control edges.
  • +
  • Environment Variables: They work just like dynamic variables, but are intended to be used for secrets. + The actual variable values are only accessible by activities, not by the workflow editor. +
  • +
+ } + @case ("optimization") { +

+ The creation of checkpoints is very resource intensive. Two kinds of optimizations can be enabled + in the workflow configuration to skip unnecessary checkpoints. This is done by executing all activities + in a subtree of compatible activities at the same time. +

+

+ An executed activity whose checkpoint is not materialized can be recognized by a + striped + status bar. +

+ +
Fusion
+

+ Depending on the activity type and setting values, some activities are pushed down to the query execution engine of Polypheny. + When activity fusion is enabled, such adjacent activities are fused into a single query plan and executed in one unit. This + can be a very powerful optimization, as the query optimizer of Polypheny can further optimize the plan. +

+ +
Pipelining
+

+ Most activities work on a tuple-by-tuple basis. The execution of such adjacent activities can be pipelined. + They are executed concurrently and bounded queues are used to transmit tuples between activities. +

+ +
Enforcing Checkpoints
+

+ Checkpoints for specific activities can be enforced from the activity Configuration. +

+ + } + @case ("nested") { +

+ If certain combinations of activities are frequently used across multiple workflows, duplicating them can become inefficient. + Nested workflows solves this challenge by dividing complex workflows into smaller, logical sub-workflows. +

+

+ Any stored workflow version can be executed from a different workflow by using the 'Execute Workflow' activity. + As long as there is no recursive call, workflows can be nested arbitrarily deeply. + Optimization techniques + do not extend across workflow boundaries. Apart from this, the performance impact of using nested workflows is minimal. +

+ + + Always ensure that a sufficient number of global workers is configured. Each level of nesting requires one + additional worker. An insufficient number of workers might result in a deadlock. + + +
Data Inputs & Outputs
+

+ A nested workflow can have an arbitrary number of inputs and up to to outputs. + Use one 'Nested Workflow Input' activity per input and configure its index. + All outputs are configured with a single 'Nested Workflow Outputs' activity. If this activity fails or cannot be executed, + the entire nested workflow is considered to have failed (see Overall Execution Success). +

+ +
Variables
+

+ By default, variables + remain separated between parent and nested workflow. The 'Execute Workflow' activity can + selectively enable the transfer of variables to the nested workflow. While transferred workflow variables become + available at all activities in the nested workflow, dynamic and environment variables are only injected into the input activities. +

+

+ The nested output activity can enable the export of dynamic variables from the nested to the parent workflow. +

+ } + + } + + +
+ + + +
diff --git a/src/app/plugins/workflows/components/workflow-viewer/workflow-help/workflow-help.component.scss b/src/app/plugins/workflows/components/workflow-viewer/workflow-help/workflow-help.component.scss new file mode 100644 index 00000000..4aabb3a8 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/workflow-help/workflow-help.component.scss @@ -0,0 +1,3 @@ +.help-nav a { + cursor: pointer; +} \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/workflow-help/workflow-help.component.ts b/src/app/plugins/workflows/components/workflow-viewer/workflow-help/workflow-help.component.ts new file mode 100644 index 00000000..c573bf05 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/workflow-help/workflow-help.component.ts @@ -0,0 +1,33 @@ +import {Component, signal} from '@angular/core'; +import {portTypeIcons, stateColors} from '../editor/activity/activity.component'; +import {ActivityState} from '../../../models/workflows.model'; +import {PortType} from '../../../models/activity-registry.model'; + +type HelpTabs = 'intro' | 'control' | 'variables' | 'optimization' | 'nested'; + +@Component({ + selector: 'app-workflow-help', + templateUrl: './workflow-help.component.html', + styleUrl: './workflow-help.component.scss' +}) +export class WorkflowHelpComponent { + + protected readonly stateColors = stateColors; + protected readonly portTypeIcons = portTypeIcons; + protected readonly ActivityState = ActivityState; + protected readonly PortType = PortType; + + + readonly helpTab = signal('intro'); + readonly showHelpModal = signal(false); + + readonly stripedColor = `repeating-linear-gradient( 135deg, + ${stateColors[ActivityState.SAVED]}, ${stateColors[ActivityState.SAVED]} 10px, + ${stateColors[ActivityState.FINISHED]} 10px, ${stateColors[ActivityState.FINISHED]} 20px + )`; + + + toggleHelpModal() { + this.showHelpModal.update(b => !b); + } +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/workflow-viewer.component.html b/src/app/plugins/workflows/components/workflow-viewer/workflow-viewer.component.html new file mode 100644 index 00000000..a0b3a1e4 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/workflow-viewer.component.html @@ -0,0 +1,218 @@ +
+ + + + + + + + @if (workflowDef) { +
+

{{ name }}

+
+ + } @else { +

{{ name }}

+ } +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + {{ workflow.state() }} + + +
+ +
+
+ + + + + +
+ +
+ + +
+ + +
+
+ + + + + + + +
Save Workflow
+ +
+ +

You can save this workflow by creating a new version. Please specify a version description:

+ + Description + + +
+ + +
+ +
+
+
+ + + +
Workflow Variables
+ +
+ + @if (isEditable) { + + } @else { + + } + + + +
+ +
+
+
+ + + +
Inspect Workflow Information
+ +
+ + Inspect and modify the full workflow name and description (changes apply to all versions). + +
+ + Name + + +
+
+ + +
+
+ + +
+ +
+
+
+ + + + diff --git a/src/app/plugins/workflows/components/workflow-viewer/workflow-viewer.component.scss b/src/app/plugins/workflows/components/workflow-viewer/workflow-viewer.component.scss new file mode 100644 index 00000000..488741c2 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/workflow-viewer.component.scss @@ -0,0 +1,45 @@ +.rete { + height: 100%; +} + +.editor-container { + border-top: 1px lightgray solid; +} + +.workflow-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.workflow-name-btn:hover { + background-color: var(--cui-light); + border-radius: var(--cui-border-radius); + cursor: pointer; +} + +[rete-context-menu] { + // Also see global style.scss + .block { + background: var(--cui-dark) !important; + color: var(--cui-white) !important; + border-bottom: 1px solid #c8c9cb !important; + } + + .block:hover { + background: var(--cui-secondary) !important; + color: var(--cui-body-color) !important; + } +} + +.no-select { + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; +} + +.description-textarea { + height: 12em !important; + resize: none; +} \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflow-viewer/workflow-viewer.component.ts b/src/app/plugins/workflows/components/workflow-viewer/workflow-viewer.component.ts new file mode 100644 index 00000000..58e2da5e --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/workflow-viewer.component.ts @@ -0,0 +1,403 @@ +import {Component, computed, effect, ElementRef, EventEmitter, Injector, Input, OnDestroy, OnInit, Output, signal, Signal, ViewChild, ViewEncapsulation} from '@angular/core'; +import {WorkflowsService} from '../../services/workflows.service'; +import {ToasterService} from '../../../../components/toast-exposer/toaster.service'; +import {WorkflowEditor} from './editor/workflow-editor'; +import {ActivityUpdateResponse, ErrorResponse, ProgressUpdateResponse, RenderingUpdateResponse, ResponseType, StateUpdateResponse, WsResponse} from '../../models/ws-response.model'; +import {filter, Subscription} from 'rxjs'; +import {Position} from 'rete-angular-plugin/17/types'; +import {Activity, Workflow} from './workflow'; +import {ActivityState, SessionModel, WorkflowDefModel, WorkflowState} from '../../models/workflows.model'; +import {RightMenuComponent} from './right-menu/right-menu.component'; +import {switchMap, tap} from 'rxjs/operators'; +import {WorkflowConfigEditorComponent} from './workflow-config-editor/workflow-config-editor.component'; +import {WorkflowsWebSocketService} from '../../services/workflows-websocket.service'; +import {JsonEditorComponent} from '../../../../components/json/json-editor.component'; +import {CheckpointViewerService} from '../../services/checkpoint-viewer.service'; +import {Router} from '@angular/router'; +import {ExecutionMonitorComponent} from './execution-monitor/execution-monitor.component'; +import {WorkflowHelpComponent} from './workflow-help/workflow-help.component'; +import {WORKFLOW_DESCRIPTION_LENGTH} from '../workflows-dashboard/workflows-dashboard.component'; + +@Component({ + selector: 'app-workflow-viewer', + templateUrl: './workflow-viewer.component.html', + styleUrl: './workflow-viewer.component.scss', + providers: [WorkflowsWebSocketService, CheckpointViewerService], + encapsulation: ViewEncapsulation.None // required to be able to style the rete context menu +}) +export class WorkflowViewerComponent implements OnInit, OnDestroy { + @Input() sessionId: string; + @Input() isEditable: boolean; + @Input() name: string; + @Input() canTerminate = true; + @Input() workflowDef?: WorkflowDefModel; + + @Output() saveWorkflowEvent = new EventEmitter(); + @Output() close = new EventEmitter(); + @Output() terminate = new EventEmitter(); + @Output() openNested = new EventEmitter(); + @Output() reloadViewer = new EventEmitter(); + @Output() rename = new EventEmitter<{ name: string, description: string }>(); + + @ViewChild('rete') container!: ElementRef; + @ViewChild('leftMenu') leftMenu: RightMenuComponent; + @ViewChild('rightMenu') rightMenu: RightMenuComponent; + @ViewChild('workflowConfigEditor') workflowConfigEditor: WorkflowConfigEditorComponent; + @ViewChild('executionMonitor') executionMonitor: ExecutionMonitorComponent; + @ViewChild('variableEditor') variableEditor: JsonEditorComponent; + @ViewChild('workflowHelp') workflowHelp: WorkflowHelpComponent; + + private readonly registry = this._workflows.getRegistry(); + private readonly subscriptions = new Subscription(); + private editor: WorkflowEditor; + workflow: Workflow; + isExecuting: Signal; + canExecute: Signal; + openedActivity: Signal; + + readonly terminateConfirm = signal(false); + private isTerminating = false; + readonly showSaveModal = signal(false); + saveMessage = ''; + readonly showVariableModal = signal(false); + serializedVariables: Signal; + readonly editedVariables = signal(null); + readonly hasVariablesChanged = computed(() => this.serializedVariables?.() !== this.editedVariables()); + + + readonly showRenameModal = signal(false); + readonly renameData = {name: '', description: ''}; + protected readonly WORKFLOW_DESCRIPTION_LENGTH = WORKFLOW_DESCRIPTION_LENGTH; + + + constructor( + private readonly _workflows: WorkflowsService, + private readonly _toast: ToasterService, + private readonly _websocket: WorkflowsWebSocketService, + private readonly _router: Router, + readonly _checkpoint: CheckpointViewerService, + private injector: Injector) { + + effect(() => { + if (!this._websocket.connected() && this.workflow) { + this._toast.error('Lost the connection to the workflow session'); + this._router.navigate(['/views/workflows/dashboard']); + } + }); + } + + ngOnInit(): void { + } + + ngAfterViewInit(): void { + const el = this.container.nativeElement; + + if (el) { + this.editor = new WorkflowEditor(this.injector, el, this.isEditable); + this._workflows.getActiveWorkflow(this.sessionId).subscribe({ + next: res => { + this.workflow = new Workflow(res, this.registry, this.injector); + this.editor.initialize(this.workflow); + this._websocket.initWebSocket(this.sessionId); + this._websocket.onMessage().subscribe(msg => this.handleWsMsg(msg)); + this.addSubscriptions(); + this.isExecuting = computed(() => { + return this.workflow.state() !== WorkflowState.IDLE; + }); + this.canExecute = computed(() => { + return !this.isExecuting() && this.workflow.hasUnfinishedActivities(); + }); + this.serializedVariables = computed(() => JSON.stringify(this.workflow?.variables())); + this.openedActivity = this.workflow.getOpenedActivity(); + } + }); + } + } + + execute() { + if (!this.openedActivity() || [ActivityState.FINISHED, ActivityState.SAVED].includes(this.openedActivity().state()) + || this.rightMenu.canSafelyNavigate()) { + this._websocket.execute(); + } + } + + reset() { + if (!this.openedActivity() || this.openedActivity().state() === ActivityState.IDLE + || this.rightMenu.canSafelyNavigate()) { + this._websocket.reset(); + } + } + + interrupt() { + this._websocket.interrupt(); + } + + arrangeNodes() { + this.editor.arrangeNodes(); + } + + private addSubscriptions() { + this.subscriptions.add(this.editor.onActivityTranslate().subscribe( + ({activityId, pos}) => this.updateActivityPosition(activityId, pos) + )); + this.subscriptions.add(this.editor.onActivityRemove().subscribe( + activityId => this._websocket.deleteActivity(activityId) + )); + this.subscriptions.add(this.editor.onActivityClone().subscribe( + activityId => { + if (this.openedActivity()?.id !== activityId || this.rightMenu.canSafelyNavigate()) { + const rendering = this.workflow.getActivity(activityId).rendering(); + const delta = 50; + this._websocket.cloneActivity(activityId, rendering.posX + delta, rendering.posY + delta); + } + } + )); + this.subscriptions.add(this.editor.onEdgeRemove().subscribe( + edge => this._websocket.deleteEdge(edge) + )); + this.subscriptions.add(this.editor.onMoveMulti().subscribe( + ([edge, targetIndex]) => this._websocket.moveMultiEdge(edge, targetIndex) + )); + this.subscriptions.add(this.editor.onEdgeCreate().subscribe( + edge => this._websocket.createEdge(edge) + )); + this.subscriptions.add(this.editor.onActivityExecute().subscribe( + activityId => { + if (!this.openedActivity() || (activityId !== this.openedActivity().id || this.rightMenu.canSafelyNavigate())) { + this._websocket.execute(activityId); + } + } + )); + this.subscriptions.add(this.editor.onActivityReset().subscribe( + activityId => { + if (!this.openedActivity() || (activityId !== this.openedActivity().id || this.rightMenu.canSafelyNavigate())) { + this._websocket.reset(activityId); + } + } + )); + this.subscriptions.add(this.editor.onOpenActivitySettings().subscribe( + activityId => this.openActivitySettings(activityId) + )); + this.subscriptions.add(this.editor.onOpenNestedActivity().pipe( + switchMap(activityId => this._workflows.getNestedSession(this.sessionId, activityId)), + tap(sessionModel => { + if (sessionModel) { + this.openNested.emit(sessionModel); + } else { + this._toast.warn('Execute the activity to be able to access the nested workflow.'); + } + }) + ).subscribe()); + this.subscriptions.add(this.editor.onOpenCheckpoint().subscribe( + ([activityId, isInput, idx]) => { + if (isInput) { + const edge = this.workflow.getInEdges(activityId, 'data').find(([edge,]) => edge.toPort === idx)?.[0]; + if (edge) { + activityId = edge.fromId; + idx = edge.fromPort; + } else { + return; + } + } + const activity = this.workflow.getActivity(activityId); + this._checkpoint.openCheckpoint(activity, idx, this.isEditable); + } + )); + this.subscriptions.add(this.editor.onReloadEditor().subscribe( + () => this.reloadViewer.emit() + )); + this.subscriptions.add(this.workflow.onActivityDirty().pipe( + filter(activityId => this.openedActivity()?.id === activityId), + switchMap(activityId => this._workflows.getActivity(this.sessionId, activityId)), + tap(activityModel => this.workflow.updateOrCreateActivity(activityModel)) + ).subscribe()); + } + + private handleWsMsg(msg: { response: WsResponse, isDirect: boolean }) { + const {response, isDirect} = msg; + switch (response.type) { + case ResponseType.STATE_UPDATE: + const stateResponse = response as StateUpdateResponse; + this.workflow.state.set(stateResponse.workflowState); + if (!(this.workflow.updateActivityStates(stateResponse.activityStates, + stateResponse.rolledBack, + stateResponse.activityInvalidReasons, + stateResponse.activityInvalidSettings, + stateResponse.inTypePreviews, + stateResponse.outTypePreviews, + stateResponse.dynamicActivityNames) + && this.workflow.updateEdgeStates(stateResponse.edgeStates))) { + this.synchronizeWorkflow(); + } + break; + case ResponseType.PROGRESS_UPDATE: + if (!this.workflow.updateProgress((response as ProgressUpdateResponse).progress)) { + this.synchronizeWorkflow(); + } + break; + case ResponseType.RENDERING_UPDATE: + const renderResponse = response as RenderingUpdateResponse; + if (!this.workflow.updateActivityRendering(renderResponse.activityId, renderResponse.rendering)) { + this.synchronizeWorkflow(); + } + break; + case ResponseType.ACTIVITY_UPDATE: + this.workflow.updateOrCreateActivity((response as ActivityUpdateResponse).activity); + break; + case ResponseType.ERROR: + if (isDirect) { + const errorResponse = response as ErrorResponse; + const cause = errorResponse.cause ? ': ' + errorResponse.cause : ''; + this._toast.error(errorResponse.reason + cause, errorResponse.parentType + ' was unsuccessful'); + } + break; + case ResponseType.CHECKPOINT_DATA: + break; // handled by checkpoint service + default: + console.warn('unhandled websocket response', response); + } + } + + openActivitySettings(activityId: string) { + if (!this.openedActivity() || ((activityId !== this.openedActivity().id || !this.rightMenu.visible()) && this.rightMenu.canSafelyNavigate())) { + this._workflows.getActivity(this.sessionId, activityId).subscribe(activityModel => { + this.workflow.updateOrCreateActivity(activityModel); + this.workflow.setOpenedActivity(activityModel.id); + this.rightMenu.visible.set(true); + } + ); + } + } + + private synchronizeWorkflow() { + // if workflow is not in sync, we get a consistent workflow by fetching and updating the entire workflow + this._workflows.getActiveWorkflow(this.sessionId).subscribe(workflowModel => + this.workflow.update(workflowModel) + ); + } + + private updateActivityPosition(activityId: string, pos: Position) { + const rendering = this.workflow.getActivity(activityId).rendering(); + if (rendering.posX === pos.x && rendering.posY === rendering.posY) { + return; + } + const modifiedRendering = { + ...rendering, + posX: pos.x, + posY: pos.y + }; + // the workflow is getting updated by the websocket broadcast + this._websocket.updateActivity(activityId, null, null, modifiedRendering); + } + + toggleSaveModal() { + this.showSaveModal.update(b => !b); + } + + saveWorkflow() { + this.saveWorkflowEvent.emit(this.saveMessage || 'Manual Save'); + this.toggleSaveModal(); + this.saveMessage = ''; + } + + onTerminateClick() { + if (!this.canTerminate) { + return; + } + if (!this.terminateConfirm()) { + if (this.isTerminating) { + this._toast.warn('Termination is already in progress. Repeat the request regardless?'); + } + this.terminateConfirm.set(true); + } else { + this.isTerminating = true; + this.terminate.emit(); + } + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + this.editor?.destroy(); + this._websocket?.close(); + } + + showConfigModal() { + this._workflows.getWorkflowConfig(this.sessionId).subscribe(config => { + this.workflow.config.set(config); + this.workflowConfigEditor.show(); + }); + } + + showMonitorModal() { + this.executionMonitor.show(); + } + + showHelpModal() { + this.workflowHelp.toggleHelpModal(); + } + + openVariableModal() { + this._workflows.getWorkflowVariables(this.sessionId).subscribe(variables => { + this.workflow.variables.set(variables); + this.editedVariables.set(JSON.stringify(variables)); + setTimeout(() => { // wait for input of editor to change + this.variableEditor?.addInitialValues(); // if not editable, nothing has to be added + this.showVariableModal.set(true); + }, 50); + }); + + } + + toggleVariableModal() { + this.showVariableModal.update(b => !b); + } + + saveVariables() { + if (this.variableEditor.isValid()) { + this._websocket.updateVariables(JSON.parse(this.editedVariables())); + this.showVariableModal.set(false); + this._workflows.getWorkflowVariables(this.sessionId).subscribe(variables => + this.workflow.variables.set(variables) + ); + } else { + this._toast.warn('Specified variables are invalid', 'Unable to save variables'); + } + } + + createActivity(activityType: string) { + const center = this.editor.getCenter(); + this._websocket.createActivity(activityType, { + posX: center.x, + posY: center.y, + name: null, + notes: null + }); + } + + createActivityAt($event: [string, { x: number; y: number }]) { + const [activityType, dropPos] = $event; + const pos = this.editor.clientCoords2EditorCoords(dropPos); + if (pos === null || !activityType) { + return; + } + this._websocket.createActivity(activityType, { + posX: pos.x, + posY: pos.y, + name: null, + notes: null + }); + } + + openRenameModal() { + if (this.workflowDef) { + this.renameData.name = this.workflowDef.name; + this.renameData.description = this.workflowDef.description; + } + this.showRenameModal.set(true); + } + + renameWorkflow() { + this.rename.emit({...this.renameData}); + this.showRenameModal.set(false); + } +} diff --git a/src/app/plugins/workflows/components/workflow-viewer/workflow.ts b/src/app/plugins/workflows/components/workflow-viewer/workflow.ts new file mode 100644 index 00000000..3ce7d383 --- /dev/null +++ b/src/app/plugins/workflows/components/workflow-viewer/workflow.ts @@ -0,0 +1,601 @@ +import {ActivityConfigModel, ActivityModel, ActivityState, CommonType, EdgeModel, EdgeState, errorKey, ErrorVariable, ExecutionInfoModel, ExpectedOutcome, PK_COL, RenderModel, SettingsModel, TypePreviewModel, Variables, WorkflowConfigModel, WorkflowModel, WorkflowState} from '../../models/workflows.model'; +import {computed, Injector, Signal, signal, WritableSignal} from '@angular/core'; +import * as _ from 'lodash'; +import {Subject} from 'rxjs'; +import {ActivityDef, ActivityRegistry} from '../../models/activity-registry.model'; +import JsonPointer from 'json-pointer'; +import {toSignal} from '@angular/core/rxjs-interop'; + +export function edgeToString(edge: EdgeModel) { + return JSON.stringify({ + fromId: edge.fromId, + toId: edge.toId, + fromPort: edge.fromPort, + toPort: edge.toPort, + isControl: edge.isControl + }); +} + +export function stringToEdge(edgeString: string, state?: EdgeState) { + const edge: EdgeModel = JSON.parse(edgeString); + edge.state = state; + return edge; +} + +export function getSuggestions(preview: TypePreviewModel, labelsOrProps: 'labels' | 'props') { + if (preview?.portType === 'REL') { + return preview.columns?.map(c => c.name).filter(n => n !== PK_COL) || []; + } else if (preview?.portType === 'DOC') { + return preview.fields || []; + } else if (preview?.portType === 'LPG') { + return (labelsOrProps === 'labels' ? preview.labels : preview.properties) || []; + } + return []; +} + +export class Workflow { + readonly state: WritableSignal; + private readonly activities: Map = new Map(); + private readonly edgeStates: Map> = new Map(); + readonly config: WritableSignal; + readonly variables: WritableSignal; + readonly hasUnfinishedActivities: Signal; + private readonly openedActivity = signal(undefined); // which activity settings are open + + private readonly activityChangeSubject = new Subject(); + private readonly activityRemoveSubject = new Subject(); + private readonly activityAddSubject = new Subject(); + private readonly activityDirtySubject = new Subject(); // if the activity state changed without updating the activity itself + private readonly edgeChangeSubject = new Subject(); // edgeString + private readonly edgeAddSubject = new Subject<[EdgeModel, WritableSignal]>(); + private readonly edgeRemoveSubject = new Subject(); // edgeString + + constructor(workflowModel: WorkflowModel, private readonly registry: ActivityRegistry, injector: Injector) { + this.state = signal(workflowModel.state); + workflowModel.activities.forEach(model => + this.activities.set(model.id, new Activity( + model, + registry.getDef(model.type), + computed(() => this.openedActivity()?.id === model.id) + ))); + workflowModel.edges.forEach(edge => this.edgeStates.set(edgeToString(edge), signal(edge.state))); + this.config = signal(workflowModel.config, {equal: _.isEqual}); + this.variables = signal(workflowModel.variables, {equal: _.isEqual}); + + const addActivitySignal = toSignal(this.activityAddSubject, {injector: injector}); + const removeActivitySignal = toSignal(this.activityRemoveSubject, {injector: injector}); + + this.hasUnfinishedActivities = computed(() => { + addActivitySignal(); // force recompute when activity is added or removed + removeActivitySignal(); + return [...this.activities.values()].some( + a => a.state() !== ActivityState.FINISHED && a.state() !== ActivityState.SAVED + ); + }); + } + + getActivities() { + return [...this.activities.values()]; + } + + getEdges(): [EdgeModel, WritableSignal][] { + const edges = []; + for (const [edgeString, state] of this.edgeStates.entries()) { + const edgeModel = stringToEdge(edgeString, state()); + edges.push([edgeModel, state]); + } + return edges; + } + + getInEdges(activityId: string, type: 'data' | 'control' | 'both') { + const edges = this.getEdges(); + switch (type) { + case 'data': + return edges.filter(([model,]) => !model.isControl && model.toId === activityId); + case 'control': + return edges.filter(([model,]) => model.isControl && model.toId === activityId); + case 'both': + return edges.filter(([model,]) => model.toId === activityId); + } + } + + getEdgeState(edge: EdgeModel | string): WritableSignal | undefined { + if (typeof edge === 'string') { + return this.edgeStates.get(edge); + } else { + return this.edgeStates.get(edgeToString(edge)); + } + } + + getActivity(activityId: string): Activity | undefined { + return this.activities.get(activityId); + } + + removeActivity(activityId: string) { + this.activities.delete(activityId); + this.activityRemoveSubject.next(activityId); + if (this.openedActivity()?.id === activityId) { + this.openedActivity.set(null); + } + } + + updateOrCreateActivity(activityModel: ActivityModel) { + if (this.applyIfExists(activityModel.id, a => a.update(activityModel))) { + this.activityChangeSubject.next(activityModel.id); + } else { + const activity = new Activity(activityModel, this.registry.getDef(activityModel.type), + computed(() => this.openedActivity()?.id === activityModel.id)); + this.activities.set(activityModel.id, activity); + this.activityAddSubject.next(activity); + } + } + + removeEdge(edge: EdgeModel | string) { + const edgeString = typeof edge === 'string' ? edge : edgeToString(edge); + if (this.edgeStates.delete(edgeString)) { + this.edgeRemoveSubject.next(edgeString); + } + } + + /** + * Attempts to create the specified edge or update its state if it already exists. + * @param edgeModel the ege to create or update + * @return a boolean indicating the success of the operation. False is returned if either the source or target + * activity does not exist yet. + */ + updateOrCreateEdge(edgeModel: EdgeModel): boolean { + if (this.updateEdgeState(edgeModel)) { + // edge changed + this.edgeChangeSubject.next(edgeToString(edgeModel)); + } else { + if (this.getActivity(edgeModel.fromId) === undefined || this.getActivity(edgeModel.toId) === undefined) { + return false; + } + const stateSignal = signal(edgeModel.state); + this.edgeStates.set(edgeToString(edgeModel), stateSignal); + this.edgeAddSubject.next([edgeModel, stateSignal]); + } + return true; + } + + updateActivityStates(activityStates: Record, + rolledBack: string[], + invalidReasons: Record, + invalidSettings: Record>, + inTypePreviews: Record, + outTypePreviews: Record, + dynamicNames: Record): boolean { + const missing = new Set(); + const remaining = new Set(this.activities.keys()); + + for (const [id, state] of Object.entries(activityStates)) { + const oldState = this.activities.get(id)?.state(); + if (this.updateActivityState(id, state, rolledBack.includes(id), invalidReasons[id], invalidSettings[id], + inTypePreviews[id], outTypePreviews[id], dynamicNames[id])) { + this.activityChangeSubject.next(id); + if (state !== oldState) { + this.activityDirtySubject.next(id); + } + remaining.delete(id); + } else { + missing.add(id); + } + } + remaining.forEach(a => this.removeActivity(a)); + return missing.size === 0; + } + + updateProgress(progressMap: Record): boolean { + const missing = new Set(); + + for (const [id, progress] of Object.entries(progressMap)) { + if (this.updateActivityProgress(id, progress)) { + this.activityChangeSubject.next(id); + // no tracking of remaining activities, as only subsets of activities might get updated + } else { + missing.add(id); + } + } + return missing.size === 0; + } + + updateActivityRendering(activityId: string, rendering: RenderModel): boolean { + return this.applyIfExists(activityId, a => { + a.rendering.set(rendering); + this.activityChangeSubject.next(activityId); + }); + } + + updateEdgeStates(edgeStates: EdgeModel[]): boolean { + const missing = new Set(); + const remaining = new Set(this.edgeStates.keys()); + + for (const edgeModel of edgeStates) { + const edgeString = edgeToString(edgeModel); + if (this.updateOrCreateEdge(edgeModel)) { + remaining.delete(edgeString); + } else { + missing.add(edgeString); // missing because activity does not exist + } + } + remaining.forEach(e => this.removeEdge(e)); + + return missing.size === 0; + } + + update(workflowModel: WorkflowModel) { + this.state.set(workflowModel.state); + this.config.set(workflowModel.config); + this.variables.set(workflowModel.variables); + + const remainingActivities = new Set(this.activities.keys()); + const remainingEdges = new Set(this.edgeStates.keys()); + workflowModel.activities.forEach(activity => { + this.updateOrCreateActivity(activity); + remainingActivities.delete(activity.id); + }); + workflowModel.edges.forEach(edge => { + this.updateOrCreateEdge(edge); + remainingEdges.delete(edgeToString(edge)); + }); + remainingEdges.forEach(edge => this.removeEdge(edge)); + remainingActivities.forEach(activityId => this.removeActivity(activityId)); + } + + setOpenedActivity(activityId: string) { + const activity = this.getActivity(activityId); + const old = this.openedActivity(); + this.openedActivity.set(activity); + if (old) { + this.activityChangeSubject.next(old.id); + } + if (activityId) { + this.activityChangeSubject.next(activity.id); + } + } + + getOpenedActivity() { + return this.openedActivity.asReadonly(); + } + + onActivityChange() { + return this.activityChangeSubject.asObservable(); + } + + onActivityAdd() { + return this.activityAddSubject.asObservable(); + } + + onActivityRemove() { + return this.activityRemoveSubject.asObservable(); + } + + onActivityDirty() { + return this.activityDirtySubject.asObservable(); + } + + onEdgeChange() { + return this.edgeChangeSubject.asObservable(); + } + + onEdgeAdd() { + return this.edgeAddSubject.asObservable(); + } + + onEdgeRemove() { + return this.edgeRemoveSubject.asObservable(); + } + + private updateActivityState(activityId: string, state: ActivityState, isRolledBack: boolean, invalidReason: string, + invalidSettings: Record | undefined, + inTypePreview: TypePreviewModel[], outTypePreview: TypePreviewModel[], + dynamicName: string | undefined): boolean { + if (state === 'IDLE') { + this.updateActivityProgress(activityId, 0); // reset progress + } + return this.applyIfExists(activityId, a => { + a.state.set(state); + a.isRolledBack.set(isRolledBack); + a.invalidReason.set(invalidReason); + a.invalidSettings.set(invalidSettings || {}); + a.inTypePreview.set(inTypePreview); + a.outTypePreview.set(outTypePreview); + a.dynamicName.set(dynamicName); + }); + } + + private updateActivityProgress(activityId: string, progress: number): boolean { + return this.applyIfExists(activityId, a => a.progress.set(progress)); + + } + + private updateEdgeState(edge: EdgeModel): boolean { + const edgeState = this.getEdgeState(edge); + if (edgeState === undefined) { + return false; + } + edgeState.set(edge.state); + return true; + } + + private applyIfExists(activityId: string, fct: (activity: Activity) => void): boolean { + const activity = this.getActivity(activityId); + if (activity === undefined) { + return false; + } + fct(activity); + return true; + } +} + + +export const NESTED_WF_ACTIVITY_TYPE = 'nestedWorkflow'; +export const META_ACTIVITY_TYPES = ['nestedInput', 'nestedOutput']; + +export class Activity { + readonly type: string; + readonly id: string; + readonly def: ActivityDef; + readonly hasNested: boolean; + readonly isMetaActivity: boolean; + + readonly state: WritableSignal; + readonly isRolledBack: WritableSignal; + readonly progress = signal(0); + readonly settings: WritableSignal; + readonly config: WritableSignal; + readonly commonType: Signal; + readonly expectedOutcome: Signal; + readonly rendering: WritableSignal; + readonly inTypePreview: WritableSignal; + readonly outTypePreview: WritableSignal; + readonly invalidReason: WritableSignal; + readonly invalidSettings: WritableSignal>; + readonly hasInvalidSettings: Signal; + readonly variables: WritableSignal; + readonly hasVariables: Signal; + readonly dynamicName: WritableSignal; + readonly displayName: Signal; + readonly staticDisplayName: Signal; + readonly executionInfo: WritableSignal; + readonly logMessages: Signal; + readonly error: Signal; + + constructor(activityModel: ActivityModel, def: ActivityDef, readonly isOpened: Signal) { + this.type = activityModel.type; + this.id = activityModel.id; + this.def = def; + this.hasNested = def.type === NESTED_WF_ACTIVITY_TYPE; + this.isMetaActivity = META_ACTIVITY_TYPES.includes(def.type); + this.state = signal(activityModel.state); + this.isRolledBack = signal(activityModel.rolledBack); + this.settings = signal(new Settings(activityModel.settings)); // deep equivalence check + this.config = signal(this.prepareConfig(activityModel.config), {equal: _.isEqual}); + this.commonType = computed(() => this.config().commonType); + this.expectedOutcome = computed(() => this.config().expectedOutcome); + this.rendering = signal(activityModel.rendering, {equal: _.isEqual}); + this.inTypePreview = signal(activityModel.inTypePreview, {equal: _.isEqual}); + this.outTypePreview = signal(activityModel.outTypePreview, {equal: _.isEqual}); + this.invalidReason = signal(activityModel.invalidReason); + this.invalidSettings = signal(activityModel.invalidSettings); + this.hasInvalidSettings = computed(() => Object.keys(this.invalidSettings()).length > 0); + this.variables = signal(activityModel.variables, {equal: _.isEqual}); + this.hasVariables = computed(() => Object.keys(this.variables()).length > 0); + this.dynamicName = signal(activityModel.dynamicName); + this.displayName = computed(() => { + return this.rendering().name || this.dynamicName() || this.def.displayName; + }); + this.staticDisplayName = computed(() => { + return this.rendering().name || this.def.displayName; // does never show the dynamic name + }); + this.executionInfo = signal(activityModel.executionInfo); + this.logMessages = computed(() => + this.executionInfo().log?.map(m => new LogMessage(m)) + .filter(m => m.activityId === this.id) || [] + ); + this.error = computed(() => this.state() === ActivityState.FAILED && this.variables()[errorKey]); + } + + update(activityModel: ActivityModel) { + this.state.set(activityModel.state); + this.isRolledBack.set(activityModel.rolledBack); + this.settings.set(new Settings(activityModel.settings)); + this.config.set(this.prepareConfig(activityModel.config)); + this.rendering.set(activityModel.rendering); + this.inTypePreview.set(activityModel.inTypePreview); + this.outTypePreview.set(activityModel.outTypePreview); + this.invalidReason.set(activityModel.invalidReason); + this.invalidSettings.set(activityModel.invalidSettings); + this.variables.set(activityModel.variables); + this.dynamicName.set(activityModel.dynamicName); + this.executionInfo.set(activityModel.executionInfo); + } + + prepareConfig(config: ActivityConfigModel) { + // the received config might not be of correct length + let prefs: string[] = config.preferredStores; + if (prefs === null) { + prefs = []; + } else { + prefs = prefs.map(store => store === null ? '' : store); + } + + while (prefs.length < this.def.outPorts.length) { + prefs.push(''); + } + config.preferredStores = prefs; + return config; + } + +} + +export class Settings { + readonly settings = new Map(); + + constructor(model: SettingsModel | string) { + if (typeof model === 'string') { + model = JSON.parse(model); + } + Object.entries(model).forEach(([key, value]) => + this.settings.set(key, new Setting(key, value))); + } + + get(key: string): Setting | undefined { + return this.settings.get(key); + } + + toModel(insertRefs: boolean) { + const model = {}; + for (const [key, value] of this.settings) { + model[key] = value.toModel(insertRefs); + } + return model; + } + + serialize() { + return JSON.stringify(this.toModel(true)); + } + + keys() { + return [...this.settings.keys()]; + } +} + +export class Setting { + readonly references: VariableReference[]; + value: any; // static value of this setting, possibly containing values that will get overwritten + + + constructor(public readonly key: string, model: any) { + const tokens = []; + const copy = JSON.parse(JSON.stringify(model)); + [this.references, this.value] = Setting.splitRecursive(tokens, copy); + } + + private static splitRecursive(tokens: string[], obj: any): [VariableReference[], any] { + const references: VariableReference[] = []; + if (obj === null) { + return [references, null]; + } else if (Array.isArray(obj)) { + for (const [i, value] of obj.entries()) { + const [r, o] = Setting.splitRecursive([...tokens, i.toString()], value); + references.push(...r); + obj[i] = o; + } + // undefined in lists means there is a variable reference that does not overwrite an existing value + obj = obj.filter(o => o !== undefined); + } else if (typeof obj === 'object') { + if (VARIABLE_REF_FIELD in obj) { + const ref = new VariableReference(JsonPointer.compile(tokens), obj); + references.push(ref); + return [references, ref.defaultValue]; + } else { + Object.entries(obj).forEach(([key, value]) => { + const [r, o] = Setting.splitRecursive([...tokens, key], value); + references.push(...r); + obj[key] = o; + }); + } + } + + return [references, obj]; + } + + static toModel(references: VariableReference[], value: any): any { + let copy = JSON.parse(JSON.stringify(value)); + for (const ref of references) { + let defaultValue = ref.defaultValue; // fallback + if (ref.target === '/') { + defaultValue = copy; + } else { + try { + defaultValue = JsonPointer.get(copy, ref.target); // overwrite with value set in settings component + } catch (ignored) { + } + } + + const refObject = {[VARIABLE_REF_FIELD]: ref.varRef, [VARIABLE_DEFAULT_FIELD]: defaultValue}; + if (ref.target.length <= 1) { + copy = refObject; // setting the root + } else { + JsonPointer.set(copy, ref.target, refObject); + } + } + return copy; + } + + toModel(insertRefs: boolean): any { + if (insertRefs) { + return Setting.toModel(this.references, this.value); + } + return JSON.parse(JSON.stringify(this.value)); + } + + addReference(variablePointer: string, target: string): boolean { + target = target.startsWith('/') ? target : '/' + target; + if (this.isValidTargetPointer(target)) { + this.references.push(VariableReference.of(target, variablePointer)); + return true; + } + return false; + } + + deleteReference(ref: VariableReference) { + this.references.splice(this.references.indexOf(ref), 1); + + } + + isValidTargetPointer(target: string) { + + if (this.references.find(ref => target.startsWith(ref.target) || ref.target.startsWith(target))) { + return false; // cannot set two variables to same target + } + if (target === '/') { + return true; + } + + const slashCount = (target.match(/\//g) || []).length; + + return JsonPointer.has(this.value, target) || ( + slashCount > 1 && + JsonPointer.has(this.value, target.substring(0, target.lastIndexOf('/'))) + ); + } +} + +const VARIABLE_REF_FIELD = '$ref'; +const VARIABLE_DEFAULT_FIELD = '$default'; + +export class VariableReference { + readonly target: string; // Json-Pointer to target location in setting + readonly varRef: string; // Json-Pointer to a variable + readonly defaultValue: any | null; + + constructor(target: string, ref: { [VARIABLE_REF_FIELD]: string, [VARIABLE_DEFAULT_FIELD]?: any }) { + this.target = target.startsWith('/') ? target : '/' + target; + this.varRef = ref[VARIABLE_REF_FIELD]; + this.defaultValue = ref[VARIABLE_DEFAULT_FIELD] === undefined ? null : ref[VARIABLE_DEFAULT_FIELD]; + } + + static of(target: string, varRef: string): VariableReference { + return new VariableReference(target, {[VARIABLE_REF_FIELD]: varRef}); + } + +} + +export class LogMessage { + activityId: string; + level: 'INFO' | 'WARN' | 'ERROR'; + timeMs: number; + msg: string; + + constructor(rawMessage: string) { + const [activityId, level, timeMs, ...rest] = rawMessage.split('|'); + const msg = rest.join('|'); + this.activityId = activityId; + //@ts-ignore + this.level = level; + this.timeMs = parseInt(timeMs, 10); + this.msg = msg; + } +} diff --git a/src/app/plugins/workflows/components/workflows-dashboard/workflows-dashboard.component.html b/src/app/plugins/workflows/components/workflows-dashboard/workflows-dashboard.component.html new file mode 100644 index 00000000..2b116f6c --- /dev/null +++ b/src/app/plugins/workflows/components/workflows-dashboard/workflows-dashboard.component.html @@ -0,0 +1,440 @@ +@switch (route()) { + @case ('dashboard') { +

Workflow Dashboard

+ + + + + + Create Workflow + + + + Name + + + + Folder + + + + + + + + + + + + Upload Workflow + + + + Name + + + + Folder + + + + + + + + + + + + + + +

Stored Workflows ({{ workflowDefsCount }})

+ + @for (group of sortedGroups; track group.groupName) { +
+ +

+ + {{ group.groupName }} ({{ group.defs.length }}) +

+ +
+ +
+ + @for (def of group.defs; track def.id) { + + + + +
+
+ +
+
+ + + {{ expandedDescriptions.has( def.id ) ? 'Hide Description' : 'Show Description' }} + +
+
+
{{ def.def.description }}
+
+
+ +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @for (version of def.def.versions | keyvalue; track version.key) { + + + + + + + } + +
VersionCreation TimeCommentActions
v{{ version.key }}{{ version.value.creationTime | date:'short' }}{{ version.value.description }} + + + +
+
+
+ + } + +
+ } + } + @case ('sessions') { +

Active Sessions

+
    + @for (session of userSessions; track session.sessionId) { + + } +
+ } + @case ('jobs') { + + } + @case ('api') { +

Workflow API

+

{{ _settings.getConnection( 'workflows.rest' ) }}/api

+ + + + + + + +
+ +
+
+

+ POST +

+ /sessions +
+ Submit a workflow.json to create a new session +
+
+ +
+
+

+ POST +

+ /sessions/{sessionId}/execute +
+ Execute the workflow +
+
+ +
+
+

+ POST +

+ /sessions/{sessionId}/reset +
+ Reset workflow execution +
+
+ +
+
+

+ GET +

+ /sessions/{sessionId}/state +
+ Get the execution state +
+
+ +
+
+

+ GET +

+ /sessions/{sessionId}/workflow/statistics +
+ Get workflow execution statistics +
+
+ +
+
+

+ GET +

+ /sessions/{sessionId}/workflow/{activityId}/statistics +
+ Get execution statistics for the specified activity +
+
+ +
+
+

+ GET +

+ /sessions/{sessionId}/workflow/{activityId}/{outIdx} +
+ Get a result preview for the specified output and activity +
+
+ +
+
+

+ DELETE +

+ /sessions/{sessionId} +
+ Terminate the session +
+
+
+
+
+
+ +

API Sessions

+
    + @for (session of apiSessions; track session.sessionId) { + + } +
+ } +} + + +
  • +
    +
    + @if (session.type === 'USER_SESSION') { +

    + {{ workflowDefs[session.workflowId].name }} + v{{ session.version }} +

    + + Session ID: + {{ session.sessionId }} + + } @else { +

    + {{ session.sessionId }} +

    + } + + State: + {{ session.state }} + + + Last Interaction: + + {{ session.lastInteraction | date: 'yyyy-MM-dd HH:mm:ss' }} + + + + Connected Users: + {{ session.connectionCount }} + + + Activities: + {{ session.activityCount }} + + +
    + +
    +
  • +
    + + + +
    Delete Workflow
    + +
    + + Do you really want to delete all versions of workflow "{{ workflowToDelete.def.name }}"? + This operation cannot be undone. + + + +
    + +
    +
    +
    + + + +
    Copy Workflow
    + +
    + + Create a new Workflow from the selected version. + + Name + + + + Folder + + + + + +
    + +
    +
    +
    + + + + +
    Set Workflow Description
    + +
    + + Give a brief description of the workflow (maximal {{ MAX_DESCRIPTION_LENGTH }} characters). +
    + + +
    +
    + + +
    + +
    +
    +
    \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflows-dashboard/workflows-dashboard.component.scss b/src/app/plugins/workflows/components/workflows-dashboard/workflows-dashboard.component.scss new file mode 100644 index 00000000..9ac3ebc3 --- /dev/null +++ b/src/app/plugins/workflows/components/workflows-dashboard/workflows-dashboard.component.scss @@ -0,0 +1,30 @@ +.col-md-6.fixed-width { + max-width: 400px; +} + +.workflow-name { + word-wrap: break-word; +} + +.limit-name-width { + max-width: min(1000px, 50vw); +} + +.session-name { + word-wrap: break-word; +} + +h4 .badge { + width: 90px; +} + +.expanded-description { + white-space: pre-line; + max-height: 12em; + overflow-y: auto; +} + +.description-textarea { + height: 12em; + resize: none; +} \ No newline at end of file diff --git a/src/app/plugins/workflows/components/workflows-dashboard/workflows-dashboard.component.ts b/src/app/plugins/workflows/components/workflows-dashboard/workflows-dashboard.component.ts new file mode 100644 index 00000000..51f7b2d1 --- /dev/null +++ b/src/app/plugins/workflows/components/workflows-dashboard/workflows-dashboard.component.ts @@ -0,0 +1,331 @@ +import {Component, effect, inject, OnDestroy, OnInit, signal, WritableSignal} from '@angular/core'; +import {ActivatedRoute, Router} from '@angular/router'; +import {ToasterService} from '../../../../components/toast-exposer/toaster.service'; +import {LeftSidebarService} from '../../../../components/left-sidebar/left-sidebar.service'; +import {WorkflowsService} from '../../services/workflows.service'; +import {SessionModel, WorkflowDefModel, WorkflowModel} from '../../models/workflows.model'; +import {SidebarNode} from '../../../../models/sidebar-node.model'; +import {retry} from 'rxjs'; +import {WebuiSettingsService} from '../../../../services/webui-settings.service'; + + +export const WORKFLOW_DESCRIPTION_LENGTH = 1024; + +@Component({ + selector: 'app-workflows-dashboard', + templateUrl: './workflows-dashboard.component.html', + styleUrl: './workflows-dashboard.component.scss' +}) +export class WorkflowsDashboardComponent implements OnInit, OnDestroy { + private readonly _route = inject(ActivatedRoute); + private readonly _router = inject(Router); + private readonly _toast = inject(ToasterService); + private readonly _sidebar = inject(LeftSidebarService); + private readonly _workflows = inject(WorkflowsService); + readonly _settings = inject(WebuiSettingsService); + + public route = signal(null); + + workflowDefs: Record; + workflowDefsCount = 0; + sortedGroups: { groupName: string, defs: { id: string, def: WorkflowDefModel }[] }[]; + visibleGroups: Record> = {}; + sessions: SessionModel[] = []; + userSessions: SessionModel[] = []; + apiSessions: SessionModel[] = []; + selectedVersion: Record = {}; + newWorkflowName = ''; + newWorkflowGroup = ''; + uploadWorkflowName = ''; + uploadWorkflowGroup = ''; + uploadedWorkflow: WorkflowModel = null; + + showDeleteModal = signal(false); + workflowToDelete: { id: string, def: WorkflowDefModel } = null; + + showCopyModal = signal(false); + workflowToCopy: { id: string, version: string } = null; + copyWorkflowName = ''; + copyWorkflowGroup = ''; + + showDescriptionModal = signal(false); + workflowToModify: { id: string, def: WorkflowDefModel } = null; + updatedWorkflowDescription = ''; + expandedDescriptions = new Set(); + readonly MAX_DESCRIPTION_LENGTH = WORKFLOW_DESCRIPTION_LENGTH; + + protected readonly Object = Object; + + constructor() { + effect(() => { + if (this.route()) { + this.getWorkflowDefs(); + this.getSessions(); + } + }); + } + + ngOnInit(): void { + this.getRoute(); + this.initSidebar(); + } + + ngOnDestroy(): void { + this._sidebar.hide(); + } + + private getRoute() { + this.route.set(this._route.snapshot.paramMap.get('route')); + this._route.params.subscribe(params => { + this.route.set(params['route']); + }); + } + + private getWorkflowDefs() { + this._workflows.getWorkflowDefs().pipe( + retry({ + count: 30, + delay: 10000 + }) + ).subscribe({ + next: res => { + this.workflowDefs = res; + this.workflowDefsCount = Object.keys(res).length; + + const groups = new Map(); + //const groups: {group: string, defs: {id: string, def: WorkflowDefModel}[]}[] = []; + + Object.entries(res).forEach(([id, def]) => { + const groupName = def.group || ''; + const group = groups.get(groupName) || []; + group.push({id, def}); + groups.set(groupName, group); + }); + + const groupsArray: { groupName: string, defs: { id: string, def: WorkflowDefModel }[] }[] = []; + const visibleGroups: Record> = {}; + groups.forEach((defs, groupName) => { + defs.sort((a, b) => a.def.name.localeCompare(b.def.name)); + groupsArray.push({groupName, defs}); + visibleGroups[groupName] = signal(this.visibleGroups[groupName] ? this.visibleGroups[groupName]() : true); + }); + this.sortedGroups = groupsArray.sort((a, b) => a.groupName.localeCompare(b.groupName)); + this.visibleGroups = visibleGroups; + + const selectedVersion = {}; + Object.entries(res).forEach(([key, value]) => { + selectedVersion[key] = Math.max(...Object.keys(value.versions).map(versionId => parseInt(versionId, 10))); + }); + this.selectedVersion = selectedVersion; + } + }); + } + + private getSessions() { + this._workflows.getSessions().subscribe({ + next: res => { + this.sessions = Object.values(res).sort((a, b) => + new Date(b.lastInteraction).getTime() - new Date(a.lastInteraction).getTime() + ); + this.userSessions = this.sessions.filter(s => s.type === 'USER_SESSION'); + this.apiSessions = this.sessions.filter(s => s.type === 'API_SESSION'); + } + }); + } + + private initSidebar() { + const sidebarNodes: SidebarNode[] = [ + new SidebarNode(0, 'Dashboard', null, '/views/workflows/dashboard'), + new SidebarNode(1, 'Sessions', null, '/views/workflows/sessions'), + new SidebarNode(2, 'API', null, '/views/workflows/api'), + new SidebarNode(3, 'Jobs', null, '/views/workflows/jobs') + ]; + this._sidebar.setNodes(sidebarNodes); + this._sidebar.open(); + } + + openVersion(key: string) { + this._workflows.openWorkflow(key, this.selectedVersion[key]).subscribe({ + next: sessionId => this.openSession(sessionId), + error: err => this._toast.error(err.error) + }); + } + + createAndOpenWorkflow() { + this._workflows.createSession(this.newWorkflowName, this.newWorkflowGroup).subscribe({ + next: sessionId => this.openSession(sessionId), + error: err => this._toast.error(err.error) + }); + } + + openSession(sessionId: string) { + this._router.navigate([`/views/workflows/sessions/${sessionId}`]); + } + + terminateSession(sessionId: string) { + this._workflows.terminateSession(sessionId).subscribe({ + next: () => { + this._toast.success('Successfully terminated session', 'Terminate session'); + this.getSessions(); + }, + error: e => this._toast.error(e, 'Unable to terminate session'), + }); + + } + + toggleCollapse(groupName: string) { + this.visibleGroups[groupName].update(b => !b); + } + + confirmDelete(workflowId: string) { + this.workflowToDelete = {id: workflowId, def: this.workflowDefs[workflowId]}; + this.showDeleteModal.set(true); + } + + toggleDeleteModal() { + this.showDeleteModal.update(b => !b); + } + + deleteVersion(workflowId: string, version: string) { + this._workflows.deleteVersion(workflowId, parseInt(version, 10)).subscribe({ + next: () => { + this._toast.success('Successfully deleted workflow version ' + version, 'Delete Version'); + this.getWorkflowDefs(); + }, + error: e => this._toast.error(e.error, 'Unable to delete version') + }); + } + + deleteWorkflow(workflowId: string) { + this.showDeleteModal.set(false); + this._workflows.deleteWorkflow(workflowId).subscribe({ + next: () => { + this._toast.success('Successfully deleted workflow', 'Delete workflow'); + this.getWorkflowDefs(); + }, + error: e => this._toast.error(e.error, 'Unable to delete workflow') + }); + } + + toggleCopyModal() { + this.showCopyModal.update(b => !b); + } + + copyWorkflow() { + this.showCopyModal.set(false); + this._workflows.copyWorkflow(this.workflowToCopy.id, parseInt(this.workflowToCopy.version, 10), + this.copyWorkflowName, this.copyWorkflowGroup).subscribe({ + next: () => { + this._toast.success('Successfully copied workflow', 'Copy Workflow'); + this.getWorkflowDefs(); + }, + error: e => this._toast.error(e.error, 'Unable to copy workflow') + }); + } + + openCopyModal(id: string, version: string) { + if (this.showCopyModal()) { + return; + } + this.workflowToCopy = {id, version}; + this.copyWorkflowName = this.workflowDefs[id].name + '_copy'; + this.copyWorkflowGroup = this.workflowDefs[id].group; + this.showCopyModal.set(true); + } + + openDescriptionModal(id: string) { + if (this.showDescriptionModal()) { + return; + } + this.workflowToModify = {id, def: this.workflowDefs[id]}; + this.updatedWorkflowDescription = this.workflowDefs[id].description; + this.showDescriptionModal.set(true); + } + + changeName(workflowId: string, name: string) { + this._workflows.renameWorkflow(workflowId, name).subscribe({ + next: () => { + this._toast.success('Successfully renamed workflow ', 'Rename Workflow'); + this.getWorkflowDefs(); + }, + error: e => this._toast.error(e.error, 'Unable to rename workflow') + }); + } + + changeGroup(workflowId: string, groupName: string) { + this._workflows.renameWorkflow(workflowId, null, groupName).subscribe({ + next: () => { + this._toast.success('Successfully changed workflow group', 'Change Workflow Group'); + this.getWorkflowDefs(); + }, + error: e => this._toast.error(e.error, 'Unable to change workflow group') + }); + } + + changeDescription() { + this.showDescriptionModal.set(false); + this._workflows.renameWorkflow(this.workflowToModify.id, null, null, this.updatedWorkflowDescription).subscribe({ + next: () => { + this._toast.success('Successfully changed workflow description', 'Change Workflow Description'); + this.getWorkflowDefs(); + }, + error: e => this._toast.error(e.error, 'Unable to change workflow description') + }); + } + + onFileChange(files: any[]) { + if (!files || files.length === 0) { + this.uploadedWorkflow = null; + return; + } + const file = files[0]; + if (!(this.uploadWorkflowName?.length > 0)) { + this.uploadWorkflowName = file.name.replace(/(_v\d+)?(\.json)?$/, ''); + } + + const reader = new FileReader(); + reader.onload = () => { + try { + this.uploadedWorkflow = JSON.parse(reader.result as string) as WorkflowModel; + } catch (error) { + this._toast.error('Invalid JSON file: ' + error); + } + }; + reader.onerror = () => this._toast.error('Error reading file'); + reader.readAsText(file); + } + + importWorkflow() { + this._workflows.importWorkflow(this.uploadWorkflowName, this.uploadWorkflowGroup, this.uploadedWorkflow).subscribe({ + next: () => { + this.uploadWorkflowName = ''; + this.uploadWorkflowGroup = ''; + this._toast.success('Successfully uploaded workflow "' + this.uploadWorkflowName + '"', 'Upload Successful'); + this.getWorkflowDefs(); + }, + error: err => this._toast.error(err.error) + }); + } + + exportVersion(workflowId: string, version: string) { + this._workflows.getWorkflow(workflowId, parseInt(version, 10)).subscribe({ + next: workflow => { + const blob = new Blob([JSON.stringify(workflow, null, 2)], {type: 'application/json'}); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = this.workflowDefs[workflowId].name + '_v' + version; + a.click(); + URL.revokeObjectURL(a.href); + }, + error: err => this._toast.error(err.error) + }); + } + + toggleExpandDescription(id: string) { + if (this.expandedDescriptions.has(id)) { + this.expandedDescriptions.delete(id); + } else { + this.expandedDescriptions.add(id); + } + } +} diff --git a/src/app/plugins/workflows/models/activity-registry.model.ts b/src/app/plugins/workflows/models/activity-registry.model.ts new file mode 100644 index 00000000..f02559c0 --- /dev/null +++ b/src/app/plugins/workflows/models/activity-registry.model.ts @@ -0,0 +1,283 @@ +import {DataModel} from '../../../models/ui-request.model'; +import {SettingsModel} from './workflows.model'; +import JsonPointer from 'json-pointer'; +import * as _ from 'lodash'; + +export const DEFAULT_GROUP = ''; +export const ADVANCED_GROUP = 'advanced'; +export const DEFAULT_SUBGROUP = ''; + +export class ActivityRegistry { + private readonly registry: Map; + public readonly categories: string[] = []; + + constructor(data: Record) { + this.registry = new Map(Object.entries(data).map( + ([key, model]) => [key, new ActivityDef(model)] + )); + const categories = new Set(); + this.registry.forEach((def,) => { + def.categories.forEach(c => categories.add(c)); + }); + this.categories.push(...categories); + this.categories.sort((a, b) => a.localeCompare(b)); + } + + public getDef(activityType: string) { + return this.registry.get(activityType); + } + + public getTypes() { + return [...this.registry.keys()].sort(); + } +} + +export class ActivityDef { + type: string; + displayName: string; + shortDescription: string; + longDescription: string; + categories: ActivityCategory[]; + inPorts: InPortDef[]; + outPorts: OutPortDef[]; + iconPath: string; // unused + fusable: boolean; + pipeable: boolean; + variableWriter: boolean; + groups: GroupDef[]; + nonEmptyGroupCount: number; + private readonly settingDefMap = new Map(); + + constructor(model: ActivityDefModel) { + this.type = model.type; + this.displayName = model.displayName; + this.shortDescription = model.shortDescription; + this.longDescription = model.longDescription; + this.categories = model.categories; + this.inPorts = model.inPorts; + this.outPorts = model.outPorts; + this.iconPath = model.iconPath; + this.fusable = model.fusable; + this.pipeable = model.pipeable; + this.variableWriter = model.variableWriter; + + const sortedGroups = [...model.groups].sort((a, b) => a.position - b.position); + this.groups = sortedGroups.map(group => new GroupDef(group, model.settings)); + this.nonEmptyGroupCount = this.groups.filter(group => !group.isEmpty).length; + + this.groups.forEach(g => g.subgroups.forEach(sg => sg.settings.forEach( + setting => this.settingDefMap.set(setting.key, setting) + ))); + } + + public getFirstGroup(): GroupDef | undefined { + return this.groups.find(group => !group.isEmpty); + } + + getSettingDef(key: string): T { + return this.settingDefMap.get(key) as T; + } + +} + +export class GroupDef { + key: string; + displayName: string; + subgroups: SubgroupDef[]; + isEmpty: boolean; + + constructor(model: GroupDefModel, settings: Record) { + this.key = model.key; + this.displayName = model.displayName; + + const sortedSubGroups = [...model.subgroups].sort((a, b) => a.position - b.position); + const filteredSettings = Object.entries(settings).filter( + ([, setting]) => setting.group === this.key).map( + ([, setting]) => setting); + this.subgroups = [ + new SubgroupDef(null, filteredSettings), // manually add default subgroup + ...sortedSubGroups.map(sub => new SubgroupDef(sub, filteredSettings)) + ]; + this.isEmpty = this.subgroups.every(sub => sub.isEmpty); + } + + isDefault() { + return this.key === DEFAULT_GROUP; + } + + isAdvanced() { + return this.key === ADVANCED_GROUP; + } +} + +export class SubgroupDef { + key: string; + displayName: string; + settings: SettingDef[]; + isEmpty: boolean; + + constructor(model: SubgroupDefModel, settings: SettingDefModel[]) { + this.key = model?.key || DEFAULT_SUBGROUP; + this.displayName = model?.displayName || null; + + const filteredSettings = settings.filter(setting => setting.subgroup === this.key); + filteredSettings.sort((a, b) => a.position - b.position); + this.settings = filteredSettings.map(setting => new SettingDef(setting)); + this.isEmpty = this.settings.length === 0; + } + + isDefault() { + return this.key === DEFAULT_SUBGROUP; + } +} + +export class SettingDef { + type: SettingType; + key: string; + displayName: string; + shortDescription: string; + longDescription: string; + subPointer: string; + subValues: any[]; + model: SettingDefModel; // used to access values specific to a given SettingDef implementation + + constructor(model: SettingDefModel) { + this.type = model.type; + this.key = model.key; + this.displayName = model.displayName; + this.shortDescription = model.shortDescription; + this.longDescription = model.longDescription; + this.subPointer = model.subPointer; + this.subValues = model.subValues; + this.model = model; + } + + isVisible(settings: SettingsModel) { + if (this.subPointer.length === 0) { + return true; + } + try { + const value = JsonPointer.get(settings, this.subPointer); + return this.subValues.find(subValue => _.isEqual(subValue, value)) !== undefined; + } catch (e) { + return false; + } + } + + getGroup() { + return this.model.group; + } + + getSubgroup() { + return this.model.subgroup; + } +} + +export interface ActivityDefModel { + type: string; + displayName: string; + shortDescription: string; + longDescription: string; + categories: ActivityCategory[]; + inPorts: InPortDef[]; + outPorts: OutPortDef[]; + iconPath: string; + groups: GroupDefModel[]; + settings: Record; + fusable: boolean; + pipeable: boolean; + variableWriter: boolean; +} + +export interface InPortDef { + type: PortType; + description: string; + isOptional: boolean; + isMulti: boolean; +} + +export interface OutPortDef { + type: PortType; + description: string; +} + +export enum ActivityCategory { + EXTRACT = 'EXTRACT', + TRANSFORM = 'TRANSFORM', + LOAD = 'LOAD', + RELATIONAL = 'RELATIONAL', + DOCUMENT = 'DOCUMENT', + GRAPH = 'GRAPH', + VARIABLES = 'VARIABLES', + CLEANING = 'CLEANING', + CROSS_MODEL = 'CROSS_MODEL', + ESSENTIALS = 'ESSENTIALS', + NESTED = 'NESTED', + DEVELOPMENT = 'DEVELOPMENT', + EXTERNAL = 'EXTERNAL', +} + +export enum PortType { + ANY = 'ANY', + REL = 'REL', + DOC = 'DOC', + LPG = 'LPG' +} + +export function portTypeToDataModel(type: PortType): DataModel { + switch (type) { + case PortType.ANY: + return null; + case PortType.REL: + return DataModel.RELATIONAL; + case PortType.DOC: + return DataModel.DOCUMENT; + case PortType.LPG: + return DataModel.GRAPH; + } +} + +export interface GroupDefModel { + key: string; + displayName: string; + position: number; + subgroups: SubgroupDefModel[]; +} + +export interface SubgroupDefModel { + key: string; + displayName: string; + position: number; +} + +export enum SettingType { + // update when a new settingType is added + STRING = 'STRING', + INT = 'INT', + ENTITY = 'ENTITY', + BOOLEAN = 'BOOLEAN', + DOUBLE = 'DOUBLE', + QUERY = 'QUERY', + FIELD_SELECT = 'FIELD_SELECT', + ENUM = 'ENUM', + COLLATION = 'COLLATION', + FIELD_RENAME = 'FIELD_RENAME', + CAST = 'CAST', + FILTER = 'FILTER', + GRAPH_MAP = 'GRAPH_MAP', + FILE = 'FILE', + AGGREGATE = 'AGGREGATE', +} + +export interface SettingDefModel { + type: SettingType; + key: string; + displayName: string; + shortDescription: string; + longDescription: string; + group: string; + subgroup: string; + position: number; + subPointer: string; + subValues: any[]; +} diff --git a/src/app/plugins/workflows/models/workflows.model.ts b/src/app/plugins/workflows/models/workflows.model.ts new file mode 100644 index 00000000..d0469849 --- /dev/null +++ b/src/app/plugins/workflows/models/workflows.model.ts @@ -0,0 +1,241 @@ +import {DataModel} from '../../../models/ui-request.model'; +import {FieldDefinition} from '../../../components/data-view/models/result-set.model'; +import {PortType} from './activity-registry.model'; + +export enum EdgeState { + IDLE = 'IDLE', + ACTIVE = 'ACTIVE', + INACTIVE = 'INACTIVE' +} + +export enum CommonType { + NONE = 'NONE', + EXTRACT = 'EXTRACT', + LOAD = 'LOAD' +} + +export enum ExpectedOutcome { + MUST_SUCCEED = 'MUST_SUCCEED', + MUST_FAIL = 'MUST_FAIL', + ANY = 'ANY' +} + +export enum ControlStateMerger { + AND_OR = 'AND_OR', + AND_AND = 'AND_AND' +} + +export enum ActivityState { + IDLE = 'IDLE', + QUEUED = 'QUEUED', + EXECUTING = 'EXECUTING', + SKIPPED = 'SKIPPED', + FAILED = 'FAILED', + FINISHED = 'FINISHED', + SAVED = 'SAVED' +} + +export enum WorkflowState { + IDLE = 'IDLE', + EXECUTING = 'EXECUTING', + INTERRUPTED = 'INTERRUPTED' +} + +export enum SessionModelType { + USER_SESSION = 'USER_SESSION', + API_SESSION = 'API_SESSION', + NESTED_SESSION = 'NESTED_SESSION', + JOB_SESSION = 'JOB_SESSION' +} + +export enum ExecutionState { + SUBMITTED = 'SUBMITTED', + EXECUTING = 'EXECUTING', + AWAIT_PROCESSING = 'AWAIT_PROCESSING', + PROCESSING_RESULT = 'PROCESSING_RESULT', + DONE = 'DONE' +} + +export enum ExecutorType { + DEFAULT = 'DEFAULT', + FUSION = 'FUSION', + PIPE = 'PIPE', + VARIABLE_WRITER = 'VARIABLE_WRITER' +} + +export enum TriggerType { + SCHEDULED = 'SCHEDULED' +} + +export enum JobResult { + SUCCESS = 'SUCCESS', + FAILED = 'FAILED', + SKIPPED = 'SKIPPED', +} + +export type SettingsModel = Record; +export type Variables = Record; +export const errorKey = '$errorMsg'; +export const wfVarsKey = '$workflow'; +export const envVarsKey = '$env'; +export const PK_COL = '_key'; // primary key column of relational checkpoints + +export interface ErrorVariable { + message: string; + cause?: string; + origin: string; +} + +export interface EdgeModel { + fromId: string; + toId: string; + fromPort: number; + toPort: number; + isControl: boolean; + state?: EdgeState; // only non-null when receiving, not when referencing an edge from the frontend +} + +export interface ActivityConfigModel { + enforceCheckpoint: boolean; + timeoutSeconds: number; // 0 for no timeout + preferredStores: string[]; + commonType: CommonType; + controlStateMerger: ControlStateMerger; + expectedOutcome: ExpectedOutcome; + logErrors: boolean; +} + +export interface RenderModel { + posX: number; + posY: number; + name: string; + notes: string; +} + +export interface TypePreviewModel { + portType: PortType; // this can be more specific than the port type of the def + columns?: FieldDefinition[] | null; + fields?: string[] | null; // for documents + labels?: string[] | null; // for graphs + properties?: string[] | null; // for graphs + notConnected: boolean; // only relevant for input previews +} + + +export interface ActivityModel { + type: string; + id: string; + settings: SettingsModel; + config: ActivityConfigModel; + rendering: RenderModel; + state?: ActivityState; + rolledBack?: boolean; + inTypePreview?: TypePreviewModel[]; + outTypePreview?: TypePreviewModel[]; + invalidReason?: string; + invalidSettings?: Record; + variables?: Variables; + dynamicName?: string; + executionInfo?: ExecutionInfoModel; +} + +export interface ExecutionInfoModel { + submissionTime: string; // ISO 8601 + totalDuration: number; + durations: Record; + activities: string[]; + root: string; + executorType: ExecutorType; + state: ExecutionState; + isSuccess: boolean; // only valid when state == DONE + tuplesWritten: number; // -1 if not written at all (failed or not yet finished or no data output) + log?: string[]; // activityId|level|time|message only available when part of ActivityModel +} + +export interface ExecutionMonitorModel { + startTime: string; // ISO 8601 + totalDuration: number; + targetActivity: string; + infos: ExecutionInfoModel[]; // no logs + totalCount: number; + successCount: number; + failCount: number; + skipCount: number; + countByExecutorType: Record; + tuplesWritten: number; + isSuccess?: boolean; // overall success of workflow execution +} + +export interface SessionModel { + type: SessionModelType; + sessionId: string; + connectionCount: number; + lastInteraction: string; // ISO-8601 + activityCount: number; + + // Only for USER_SESSION & JOB_SESSION + workflowId?: string; + version?: number; + workflowDef?: WorkflowDefModel; + state?: WorkflowState; + + // JOB_SESSION + executionHistory?: JobExecutionModel[]; + jobId?: string; +} + +export interface WorkflowDefModel { + name: string; + versions: Record; + group: string; + description: string; +} + +export interface VersionInfo { + description: string; + creationTime: Date; +} + +export interface WorkflowModel { + format_version: string; + activities: ActivityModel[]; + edges: EdgeModel[]; + config: WorkflowConfigModel; + variables: SettingsModel; + state?: WorkflowState; +} + +export interface WorkflowConfigModel { + preferredStores: Record; + fusionEnabled: boolean; + pipelineEnabled: boolean; + timeoutSeconds: number; // 0 for no timeout + dropUnusedCheckpoints: boolean; + maxWorkers: number; + pipelineQueueCapacity: number; + logCapacity: number; +} + +export interface JobModel { + jobId: string; + type: TriggerType; + workflowId: string; + version: number; + enableOnStartup: boolean; + name: string; + maxRetries: number; // 0 for no retries + performance: boolean; + variables: Variables; + sessionId?: string; // if present: job is active + + //SCHEDULED + schedule?: string; +} + +export interface JobExecutionModel { + message: string; + variables: Record; + result: JobResult; + startTime: string; // ISO 8601 format + statistics: ExecutionMonitorModel; +} diff --git a/src/app/plugins/workflows/models/ws-response.model.ts b/src/app/plugins/workflows/models/ws-response.model.ts new file mode 100644 index 00000000..12c5a787 --- /dev/null +++ b/src/app/plugins/workflows/models/ws-response.model.ts @@ -0,0 +1,76 @@ +import {ActivityModel, ActivityState, EdgeModel, RenderModel, TypePreviewModel, WorkflowState} from './workflows.model'; +import {Result} from '../../../components/data-view/models/result-set.model'; + +export enum ResponseType { + ACTIVITY_UPDATE = 'ACTIVITY_UPDATE', + RENDERING_UPDATE = 'RENDERING_UPDATE', + STATE_UPDATE = 'STATE_UPDATE', + PROGRESS_UPDATE = 'PROGRESS_UPDATE', + CHECKPOINT_DATA = 'CHECKPOINT_DATA', + ERROR = 'ERROR' +} + +export enum RequestType { // no specific interfaces for requests are required, since they are sent by the frontend + CREATE_ACTIVITY = 'CREATE_ACTIVITY', + DELETE_ACTIVITY = 'DELETE_ACTIVITY', + UPDATE_ACTIVITY = 'UPDATE_ACTIVITY', + CLONE_ACTIVITY = 'CLONE_ACTIVITY', + CREATE_EDGE = 'CREATE_EDGE', + DELETE_EDGE = 'DELETE_EDGE', + MOVE_MULTI_EDGE = 'MOVE_MULTI_EDGE', + EXECUTE = 'EXECUTE', + INTERRUPT = 'INTERRUPT', + RESET = 'RESET', + UPDATE_CONFIG = 'UPDATE_CONFIG', + UPDATE_VARIABLES = 'UPDATE_VARIABLES', + GET_CHECKPOINT = 'GET_CHECKPOINT' +} + +export interface WsResponse { + type: ResponseType; + msgId: string; + parentId?: string; +} + +export interface ActivityUpdateResponse extends WsResponse { + type: ResponseType.ACTIVITY_UPDATE; + activity: ActivityModel; +} + +export interface RenderingUpdateResponse extends WsResponse { + type: ResponseType.RENDERING_UPDATE; + activityId: string; + rendering: RenderModel; +} + +export interface StateUpdateResponse extends WsResponse { + type: ResponseType.STATE_UPDATE; + workflowState: WorkflowState; + activityStates: Record; + rolledBack: string[]; // rolled back activity ids + inTypePreviews: Record; + outTypePreviews: Record; + activityInvalidReasons: Record; + activityInvalidSettings: Record>; + dynamicActivityNames: Record; + edgeStates: EdgeModel[]; +} + +export interface ProgressUpdateResponse extends WsResponse { + type: ResponseType.PROGRESS_UPDATE; + progress: Record; +} + +export interface ErrorResponse extends WsResponse { + type: ResponseType.ERROR; + reason: string; + cause?: string; + parentType: RequestType; +} + +export interface CheckpointDataResponse extends WsResponse { + type: ResponseType.CHECKPOINT_DATA; + result: Result; + limit: number; + totalCount: number; +} diff --git a/src/app/plugins/workflows/services/checkpoint-viewer.service.ts b/src/app/plugins/workflows/services/checkpoint-viewer.service.ts new file mode 100644 index 00000000..0ba6d320 --- /dev/null +++ b/src/app/plugins/workflows/services/checkpoint-viewer.service.ts @@ -0,0 +1,106 @@ +import {computed, effect, Injectable, Signal, signal, WritableSignal} from '@angular/core'; +import {WorkflowsWebSocketService} from './workflows-websocket.service'; +import {CheckpointDataResponse, ResponseType, WsResponse} from '../models/ws-response.model'; +import {FieldDefinition, Result} from '../../../components/data-view/models/result-set.model'; +import {Activity} from '../components/workflow-viewer/workflow'; +import {ActivityState} from '../models/workflows.model'; +import {DataModel} from '../../../models/ui-request.model'; +import {PortType} from '../models/activity-registry.model'; +import {ToasterService} from '../../../components/toast-exposer/toaster.service'; +import {EntityConfig} from '../../../components/data-view/data-table/entity-config'; + +@Injectable() +export class CheckpointViewerService { + readonly showModal = signal(false); + + readonly selectedActivity = signal(undefined); + readonly selectedOutput = signal(0); + readonly outPreview = computed(() => this.selectedActivity().outTypePreview()[this.selectedOutput()]); + readonly outPreviewAsResult: Signal> = computed(() => { + if (this.outPreview().portType !== PortType.REL) { + return null; + } + return { + dataModel: DataModel.RELATIONAL, + header: this.outPreview().columns, + data: [] + } as Result; + }); + readonly isLoading = signal(false); + readonly isWaitingForExecution = signal(false); + result: WritableSignal> = signal(null); + readonly limit = signal(0); + readonly totalCount = signal(0); + readonly isLimited = computed(() => this.totalCount() > this.limit()); + readonly config: EntityConfig = { + create: false, update: false, delete: false, sort: false, search: false, + exploring: true, hideCreateView: true, cardRelWidth: true + }; + readonly canExecute = signal(false); + + constructor(private _websocket: WorkflowsWebSocketService, private _toast: ToasterService) { + this._websocket.onMessage().subscribe(msg => this.handleWsMsg(msg)); + + effect(() => { + if (this.showModal() && this.isWaitingForExecution() && this.selectedActivity().state() === ActivityState.SAVED) { + this.isWaitingForExecution.set(false); + this._websocket.getCheckpoint(this.selectedActivity().id, this.selectedOutput()); + } + }, {allowSignalWrites: true}); + } + + toggleModal() { + this.setModal(!this.showModal()); + } + + setModal(visible: boolean) { + this.showModal.set(visible); + if (!visible) { + this.selectedActivity.set(null); + this.selectedOutput.set(null); + this.result.set(null); + this.isLoading.set(false); + this.isWaitingForExecution.set(false); + } + } + + private getCheckpoint(activity: Activity, outputIndex: number) { + this.selectedActivity.set(activity); + this.selectedOutput.set(outputIndex); + this.isLoading.set(true); + this._websocket.getCheckpoint(activity.id, outputIndex); + } + + openCheckpoint(activity: Activity, outputIndex: number, canExecute: boolean) { + this.canExecute.set(canExecute); + if (activity.state() === ActivityState.FINISHED) { + this.selectedActivity.set(activity); + this.selectedOutput.set(outputIndex); + + } else if (activity.state() === ActivityState.SAVED) { + this.getCheckpoint(activity, outputIndex); + } else { + return; + } + this.setModal(true); + } + + private handleWsMsg(msg: { response: WsResponse; isDirect: boolean }) { + const {response} = msg; + if (response.type === ResponseType.CHECKPOINT_DATA) { + const r = (response as CheckpointDataResponse); + this.result.set(r.result); + this.limit.set(r.limit); + this.totalCount.set(r.totalCount); + this.isLoading.set(false); + } + } + + materialize() { + if (this.selectedActivity()) { + this.isWaitingForExecution.set(true); + this.isLoading.set(true); + this._websocket.execute(this.selectedActivity().id); + } + } +} diff --git a/src/app/plugins/workflows/services/job-creator.service.ts b/src/app/plugins/workflows/services/job-creator.service.ts new file mode 100644 index 00000000..ac8eda33 --- /dev/null +++ b/src/app/plugins/workflows/services/job-creator.service.ts @@ -0,0 +1,144 @@ +import {computed, inject, Injectable, signal} from '@angular/core'; +import {WorkflowsService} from './workflows.service'; +import {JobModel, TriggerType, Variables, WorkflowDefModel} from '../models/workflows.model'; +import * as uuid from 'uuid'; +import {ToasterService} from '../../../components/toast-exposer/toaster.service'; +import {Subject} from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class JobCreatorService { + private readonly _workflows = inject(WorkflowsService); + private readonly _toast = inject(ToasterService); + + readonly showModal = signal(false); + readonly isModifying = signal(false); + private readonly jobSaveSubject = new Subject(); + + jobId: string; + type: TriggerType; + workflowName: string; + version: number; + enableOnStartup: boolean; + name: string; + maxRetries: number; + performance: boolean; + variables: Variables; + + //scheduled + schedule: string; + + + workflowDefs = signal>({}); + workflowNames = computed(() => { + const names = Object.values(this.workflowDefs()).map(def => def.name); + names.sort((a, b) => a.localeCompare(b)); + return names; + }); + workflowNamesToId = computed(() => { + const map = new Map(); + Object.entries(this.workflowDefs()).forEach(([key, def]) => map.set(def.name, key)); + return map; + }); + + constructor() { + this.updateDefs(); + } + + openCreate(type: TriggerType) { + this.updateDefs(); + this.jobId = uuid.v4(); + this.type = type; + this.workflowName = ''; + this.version = 0; + this.enableOnStartup = false; + this.name = 'New Job'; + this.maxRetries = 0; + this.performance = false; + this.variables = {}; + this.schedule = '0 * * * *'; + + this.isModifying.set(false); + this.showModal.set(true); + } + + openModify(job: JobModel) { + this.updateDefs(); + const def = this.workflowDefs()[job.workflowId]; + this.jobId = job.jobId; + this.type = job.type; + this.workflowName = def?.name || ''; + this.version = job.version; + this.enableOnStartup = job.enableOnStartup; + this.maxRetries = job.maxRetries; + this.performance = job.performance; + this.name = job.name; + this.variables = job.variables; + + if (this.type === TriggerType.SCHEDULED) { + this.schedule = job.schedule; + } + this.isModifying.set(true); + this.showModal.set(true); + } + + close() { + this.showModal.set(false); + } + + changeWorkflowName(name: string) { + this.workflowName = name; + const def = this.workflowDefs()[this.workflowNamesToId().get(this.workflowName)]; + if (def) { + this.version = Math.max(...Object.keys(def.versions).map(versionId => parseInt(versionId, 10))); + } else { + this.version = 0; + } + } + + isValid(): boolean { + if (!this.workflowNamesToId().has(this.workflowName)) { + this._toast.warn('Workflow with name "' + this.workflowName + '" does not exists'); + return false; + } + const wId = this.workflowNamesToId().get(this.workflowName); + const def = this.workflowDefs()[wId]; + if (!Object.keys(def.versions).includes(String(this.version))) { + this._toast.warn('Workflow version "' + this.version + '" does not exist'); + return false; + } + return true; + } + + build(): JobModel { + const job = { + jobId: this.jobId, + type: TriggerType.SCHEDULED, + workflowId: this.workflowNamesToId().get(this.workflowName), + version: this.version, + enableOnStartup: this.enableOnStartup, + maxRetries: this.maxRetries, + performance: this.performance, + variables: this.variables, + name: this.name + }; + if (this.type === TriggerType.SCHEDULED) { + job['schedule'] = this.schedule; + return job; + } + throw new Error('Unknown job type'); + } + + buildAndSave() { + this.jobSaveSubject.next(this.build()); + } + + onSaveJob() { + return this.jobSaveSubject.asObservable(); + } + + private updateDefs() { + this._workflows.getWorkflowDefs().subscribe(defs => this.workflowDefs.set(defs)); + } +} diff --git a/src/app/plugins/workflows/services/workflows-websocket.service.ts b/src/app/plugins/workflows/services/workflows-websocket.service.ts new file mode 100644 index 00000000..cc95e661 --- /dev/null +++ b/src/app/plugins/workflows/services/workflows-websocket.service.ts @@ -0,0 +1,236 @@ +import {Injectable, signal} from '@angular/core'; +import {webSocket, WebSocketSubject} from 'rxjs/webSocket'; +import {RequestType, WsResponse} from '../models/ws-response.model'; +import {Observable, Subject} from 'rxjs'; +import {WebuiSettingsService} from '../../../services/webui-settings.service'; +import {ActivityConfigModel, EdgeModel, RenderModel, Variables, WorkflowConfigModel} from '../models/workflows.model'; +import * as uuid from 'uuid'; + +interface Request { + msgId: string; + + [key: string]: any; +} + +@Injectable() +export class WorkflowsWebSocketService { + private socket: WebSocketSubject; + public readonly connected = signal(false); + private msgSubject = new Subject<{ response: WsResponse, isDirect: boolean }>(); // whether it is a direct response to a request originating here + private keepalive: number; + private sessionId: string; + private readonly sentRequests = new Set(); // contains the ws request ID until a response for that ID was received + + constructor(private readonly _settings: WebuiSettingsService) { + } + + + initWebSocket(sessionId: string) { + if (this.sessionId === sessionId) { + return; + } + if (this.socket) { + this.close(); + } + this.sessionId = sessionId; + + this.socket = webSocket({ + url: this._settings.getConnection('workflows.socket') + `/${this.sessionId}`, + openObserver: { + next: () => { + this.connected.set(true); + } + } + }); + this.socket.subscribe({ + next: msg => { + const isDirect = this.sentRequests.delete(msg.parentId) || !msg.parentId; + this.msgSubject.next({response: msg, isDirect}); + }, + error: err => { + console.warn(err); + this.connected.set(false); + }, + complete: () => { + this.connected.set(false); + this.msgSubject.complete(); + } + }); + + this.keepalive = setInterval(() => { + if (this.connected) { + // @ts-ignore + this.sendMessage({type: 'KEEPALIVE', msgId: uuid.v4()}); + } + }, +this._settings.getSetting('reconnection.timeout')); + } + + private sendMessage(obj: Request) { + if (!this.connected()) { + throw new Error('not connected'); + } + this.sentRequests.add(obj.msgId); + this.socket.next(obj as any); + } + + createActivity(activityType: string, rendering: RenderModel): string { + const id = uuid.v4(); + const msg = { + type: RequestType.CREATE_ACTIVITY, + msgId: id, + activityType: activityType, + rendering: rendering + }; + this.sendMessage(msg); + return id; + } + + cloneActivity(targetId: string, posX: number, posY: number): string { + const id = uuid.v4(); + const msg = { + type: RequestType.CLONE_ACTIVITY, + msgId: id, + targetId, + posX, + posY + }; + this.sendMessage(msg); + return id; + } + + updateActivity(targetId: string, settings?: Record, config?: ActivityConfigModel, rendering?: RenderModel): string { + const id = uuid.v4(); + const msg = { + type: RequestType.UPDATE_ACTIVITY, + msgId: id, + targetId, + settings, + config, + rendering, + }; + this.sendMessage(msg); + return id; + } + + deleteActivity(targetId: string): string { + const id = uuid.v4(); + const msg = { + type: RequestType.DELETE_ACTIVITY, + msgId: id, + targetId, + }; + this.sendMessage(msg); + return id; + } + + createEdge(edge: EdgeModel): string { + const id = uuid.v4(); + const msg = { + type: RequestType.CREATE_EDGE, + msgId: id, + edge, + }; + this.sendMessage(msg); + return id; + } + + deleteEdge(edge: EdgeModel): string { + const id = uuid.v4(); + const msg = { + type: RequestType.DELETE_EDGE, + msgId: id, + edge, + }; + this.sendMessage(msg); + return id; + } + + moveMultiEdge(edge: EdgeModel, targetIndex: number): string { + const id = uuid.v4(); + const msg = { + type: RequestType.MOVE_MULTI_EDGE, + msgId: id, + edge, + targetIndex + }; + this.sendMessage(msg); + return id; + } + + execute(targetId?: string): string { + const id = uuid.v4(); + const msg = { + type: RequestType.EXECUTE, + msgId: id, + targetId, + }; + this.sendMessage(msg); + return id; + } + + interrupt(): string { + const id = uuid.v4(); + const msg = { + type: RequestType.INTERRUPT, + msgId: id, + }; + this.sendMessage(msg); + return id; + } + + reset(rootId?: string): string { + const id = uuid.v4(); + const msg = { + type: RequestType.RESET, + msgId: id, + rootId, + }; + this.sendMessage(msg); + return id; + } + + updateConfig(workflowConfig: WorkflowConfigModel): string { + const id = uuid.v4(); + const msg = { + type: RequestType.UPDATE_CONFIG, + msgId: id, + workflowConfig, + }; + this.sendMessage(msg); + return id; + } + + updateVariables(variables: Variables): string { + const id = uuid.v4(); + const msg = { + type: RequestType.UPDATE_VARIABLES, + msgId: id, + variables, + }; + this.sendMessage(msg); + return id; + } + + getCheckpoint(activityId: string, outputIndex: number): string { + const id = uuid.v4(); + const msg = { + type: RequestType.GET_CHECKPOINT, + msgId: id, + activityId, + outputIndex + }; + this.sendMessage(msg); + return id; + } + + onMessage(): Observable<{ response: WsResponse, isDirect: boolean }> { + return this.msgSubject.asObservable(); + } + + close() { + if (this.keepalive) { + clearInterval(this.keepalive); + } + this.socket.complete(); + } +} diff --git a/src/app/plugins/workflows/services/workflows.service.ts b/src/app/plugins/workflows/services/workflows.service.ts new file mode 100644 index 00000000..1acbecb2 --- /dev/null +++ b/src/app/plugins/workflows/services/workflows.service.ts @@ -0,0 +1,158 @@ +import {Injectable, signal} from '@angular/core'; +import {HttpClient, HttpHeaders} from '@angular/common/http'; +import {WebuiSettingsService} from '../../../services/webui-settings.service'; +import {ActivityModel, ExecutionMonitorModel, JobModel, SessionModel, Variables, WorkflowConfigModel, WorkflowDefModel, WorkflowModel} from '../models/workflows.model'; +import {ActivityDefModel, ActivityRegistry} from '../models/activity-registry.model'; + +class JsonNode { +} + +@Injectable({ + providedIn: 'root' +}) +export class WorkflowsService { + + private activityRegistry: ActivityRegistry; + registryLoaded = signal(false); + + constructor(private _http: HttpClient, private _settings: WebuiSettingsService) { + this._http.get>(`${this.httpUrl}/registry`, this.httpOptions).subscribe({ + next: defs => { + this.activityRegistry = new ActivityRegistry(defs); + this.registryLoaded.set(true); + } + }); + } + + private httpUrl = this._settings.getConnection('workflows.rest'); + private httpOptions = {headers: new HttpHeaders({'Content-Type': 'application/json'})}; + + getSessions() { + return this._http.get>(`${this.httpUrl}/sessions`, this.httpOptions); + } + + getSession(sessionId: string) { + return this._http.get(`${this.httpUrl}/sessions/${sessionId}`, this.httpOptions); + } + + getActiveWorkflow(sessionId: string) { + return this._http.get(`${this.httpUrl}/sessions/${sessionId}/workflow`, this.httpOptions); + } + + getWorkflowConfig(sessionId: string) { + return this._http.get(`${this.httpUrl}/sessions/${sessionId}/workflow/config`, this.httpOptions); + } + + getWorkflowVariables(sessionId: string) { + return this._http.get(`${this.httpUrl}/sessions/${sessionId}/workflow/variables`, this.httpOptions); + } + + getExecutionMonitor(sessionId: string) { + return this._http.get(`${this.httpUrl}/sessions/${sessionId}/workflow/monitor`, this.httpOptions); + } + + getActivity(sessionId: string, activityId: string) { + return this._http.get(`${this.httpUrl}/sessions/${sessionId}/workflow/${activityId}`, this.httpOptions); + } + + getNestedSession(sessionId: string, activityId: string) { + return this._http.get(`${this.httpUrl}/sessions/${sessionId}/workflow/${activityId}/nested`, this.httpOptions); + } + + getIntermediaryResult(sessionId: string, activityId: string, outIndex: number) { + return this._http.get(`${this.httpUrl}/sessions/${sessionId}/workflow/${activityId}/${outIndex}`, this.httpOptions); + } + + getWorkflowDefs() { + return this._http.get>(`${this.httpUrl}/workflows`, this.httpOptions); + } + + getWorkflow(workflowId: string, version: number) { + return this._http.get(`${this.httpUrl}/workflows/${workflowId}/${version}`, this.httpOptions); + } + + getJobs() { + return this._http.get>(`${this.httpUrl}/jobs/`, this.httpOptions); + } + + createSession(workflowName: string, group: string) { + const json = { + name: workflowName, + group: group + }; + return this._http.post(`${this.httpUrl}/sessions`, json, this.httpOptions); + } + + importWorkflow(name: string, group: string = null, workflow: WorkflowModel) { + const json = { + name, + group, + workflow + }; + return this._http.post(`${this.httpUrl}/workflows`, json, this.httpOptions); + } + + openWorkflow(workflowId: string, version: number) { + return this._http.post(`${this.httpUrl}/workflows/${workflowId}/${version}`, {}, this.httpOptions); + } + + copyWorkflow(workflowId: string, version: number, newName: string, newGroup: string = null) { + const json = { + name: newName, + group: newGroup + }; + return this._http.post(`${this.httpUrl}/workflows/${workflowId}/${version}/copy`, json, this.httpOptions); + } + + setJob(job: JobModel) { + return this._http.post(`${this.httpUrl}/jobs`, job, this.httpOptions); // returns jobId + } + + enableJob(jobId: string) { + return this._http.post(`${this.httpUrl}/jobs/${jobId}/enable`, {}, this.httpOptions); // returns sessionId + } + + disableJob(jobId: string) { + return this._http.post(`${this.httpUrl}/jobs/${jobId}/disable`, {}, this.httpOptions); + } + + triggerJob(jobId: string) { + return this._http.post(`${this.httpUrl}/jobs/${jobId}/trigger`, {}, this.httpOptions); + } + + renameWorkflow(workflowId: string, newName: string = null, newGroup: string = null, newDescription: string = null) { + const json = { + name: newName, + group: newGroup, + description: newDescription + }; + return this._http.patch(`${this.httpUrl}/workflows/${workflowId}`, json, this.httpOptions); + } + + deleteWorkflow(workflowId: string) { + return this._http.delete(`${this.httpUrl}/workflows/${workflowId}`, this.httpOptions); + } + + deleteVersion(workflowId: string, version: number) { + return this._http.delete(`${this.httpUrl}/workflows/${workflowId}/${version}`, this.httpOptions); + } + + deleteJob(jobId: string) { + return this._http.delete(`${this.httpUrl}/jobs/${jobId}`, this.httpOptions); + } + + saveSession(sessionId: string, saveMessage: string) { + const json = { + message: saveMessage + }; + return this._http.post(`${this.httpUrl}/sessions/${sessionId}/save`, json, this.httpOptions); // returns the new version + } + + terminateSession(sessionId: string) { + return this._http.delete(`${this.httpUrl}/sessions/${sessionId}`, this.httpOptions); + } + + getRegistry() { + return this.activityRegistry; + } +} diff --git a/src/app/plugins/workflows/workflows.module.ts b/src/app/plugins/workflows/workflows.module.ts new file mode 100644 index 00000000..3bc7bdaf --- /dev/null +++ b/src/app/plugins/workflows/workflows.module.ts @@ -0,0 +1,248 @@ +import {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {WorkflowViewerComponent} from './components/workflow-viewer/workflow-viewer.component'; +import {ReteModule} from 'rete-angular-plugin/17'; +import {WorkflowsDashboardComponent} from './components/workflows-dashboard/workflows-dashboard.component'; +import {WorkflowSessionComponent} from './components/workflow-session/workflow-session.component'; +import { + AccordionButtonDirective, + AccordionComponent, + AccordionItemComponent, + AlertComponent, + BadgeComponent, + BgColorDirective, + BorderDirective, + ButtonCloseDirective, + ButtonDirective, + ButtonGroupComponent, + ButtonToolbarComponent, + CalloutComponent, + CardBodyComponent, + CardComponent, + CardHeaderComponent, + CardSubtitleDirective, + CardTextDirective, + CardTitleDirective, + ColComponent, + ColDirective, + CollapseDirective, + DropdownComponent, + DropdownItemDirective, + DropdownMenuDirective, + DropdownToggleDirective, + FormCheckComponent, + FormCheckInputDirective, + FormCheckLabelDirective, + FormControlDirective, + FormDirective, + FormFeedbackComponent, + FormFloatingDirective, + FormLabelDirective, + FormSelectDirective, + FormTextDirective, + GutterDirective, + InputGroupComponent, + InputGroupTextDirective, + ListGroupDirective, + ListGroupItemDirective, + ModalBodyComponent, + ModalComponent, + ModalFooterComponent, + ModalHeaderComponent, + ModalTitleDirective, + NavComponent, + NavLinkDirective, + OffcanvasBodyComponent, + OffcanvasComponent, + OffcanvasHeaderComponent, + OffcanvasTitleDirective, + OffcanvasToggleDirective, + PopoverDirective, + RowComponent, + RowDirective, + SidebarBrandComponent, + SidebarComponent, + SidebarFooterComponent, + SidebarHeaderComponent, + SidebarNavComponent, + SidebarToggleDirective, + SpinnerComponent, + TabContentComponent, + TabContentRefDirective, + TableColorDirective, + TableDirective, + TabPaneComponent, + TemplateIdDirective, + TextColorDirective, + TooltipDirective +} from '@coreui/angular'; +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {ActivityComponent} from './components/workflow-viewer/editor/activity/activity.component'; +import {EdgeComponent} from './components/workflow-viewer/editor/edge/edge.component'; +import {RightMenuComponent} from './components/workflow-viewer/right-menu/right-menu.component'; +import {ComponentsModule} from '../../components/components.module'; +import {ActivityConfigEditorComponent} from './components/workflow-viewer/right-menu/activity-config-editor/activity-config-editor.component'; +import {WorkflowConfigEditorComponent} from './components/workflow-viewer/workflow-config-editor/workflow-config-editor.component'; +import {LeftMenuComponent} from './components/workflow-viewer/left-menu/left-menu.component'; +import {ActivityHelpComponent} from './components/workflow-viewer/activity-help/activity-help.component'; +import {MarkdownComponent} from 'ngx-markdown'; +import {ActivitySettingsComponent} from './components/workflow-viewer/right-menu/activity-settings/activity-settings.component'; +import {IntSettingComponent} from './components/workflow-viewer/right-menu/activity-settings/int-setting/int-setting.component'; +import {StringSettingComponent} from './components/workflow-viewer/right-menu/activity-settings/string-setting/string-setting.component'; +import {CdkDrag, CdkDragPlaceholder, CdkDragPreview, CdkDropList} from '@angular/cdk/drag-drop'; +import {AngularMultiSelectModule} from 'angular2-multiselect-dropdown'; +import {BooleanSettingComponent} from './components/workflow-viewer/right-menu/activity-settings/boolean-setting/boolean-setting.component'; +import {DoubleSettingComponent} from './components/workflow-viewer/right-menu/activity-settings/double-setting/double-setting.component'; +import {EntitySettingComponent} from './components/workflow-viewer/right-menu/activity-settings/entity-setting/entity-setting.component'; +import {ActivityExecStatsComponent} from './components/workflow-viewer/right-menu/activity-exec-stats/activity-exec-stats.component'; +import {AutocompleteLibModule} from 'angular-ng-autocomplete'; +import {ExecutionMonitorComponent} from './components/workflow-viewer/execution-monitor/execution-monitor.component'; +import {QuerySettingComponent} from './components/workflow-viewer/right-menu/activity-settings/query-setting/query-setting.component'; +import {FieldSelectSettingComponent} from './components/workflow-viewer/right-menu/activity-settings/field-select-setting/field-select-setting.component'; +import {AddVariableComponent} from './components/workflow-viewer/right-menu/activity-settings/add-variable/add-variable.component'; +import {EnumSettingComponent} from './components/workflow-viewer/right-menu/activity-settings/enum-setting/enum-setting.component'; +import {CollationSettingComponent} from './components/workflow-viewer/right-menu/activity-settings/collation-setting/collation-setting.component'; +import {FieldRenameSettingComponent} from './components/workflow-viewer/right-menu/activity-settings/field-rename-setting/field-rename-setting.component'; +import {CastSettingComponent} from './components/workflow-viewer/right-menu/activity-settings/cast-setting/cast-setting.component'; +import {FilterSettingComponent} from './components/workflow-viewer/right-menu/activity-settings/filter-setting/filter-setting.component'; +import {WorkflowHelpComponent} from './components/workflow-viewer/workflow-help/workflow-help.component'; +import {CheckpointViewerComponent} from './components/checkpoint-viewer/checkpoint-viewer.component'; +import {GraphMapSettingComponent} from './components/workflow-viewer/right-menu/activity-settings/graph-map-setting/graph-map-setting.component'; +import {FileSettingComponent} from './components/workflow-viewer/right-menu/activity-settings/file-setting/file-setting.component'; +import {CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport} from '@angular/cdk/scrolling'; +import {WorkflowJobComponent} from './components/workflow-job/workflow-job.component'; +import {JobListComponent} from './components/workflow-job/job-list/job-list.component'; +import {JobCreatorComponent} from './components/workflow-job/job-creator/job-creator.component'; +import {AggregateSettingComponent} from './components/workflow-viewer/right-menu/activity-settings/aggregate-setting/aggregate-setting.component'; + +@NgModule({ + imports: [ + CommonModule, + ReteModule, + AccordionComponent, + AccordionItemComponent, + AccordionButtonDirective, + TemplateIdDirective, + FormsModule, + ListGroupDirective, + ListGroupItemDirective, + InputGroupComponent, + ButtonDirective, + FormControlDirective, + InputGroupTextDirective, + ColComponent, + BadgeComponent, + FormSelectDirective, + RowComponent, + GutterDirective, + ButtonGroupComponent, + ButtonCloseDirective, + ModalBodyComponent, + ModalComponent, + ModalFooterComponent, + ModalHeaderComponent, + ModalTitleDirective, + FormFeedbackComponent, + OffcanvasComponent, + OffcanvasHeaderComponent, + OffcanvasBodyComponent, + OffcanvasToggleDirective, + OffcanvasTitleDirective, + SidebarComponent, + SidebarHeaderComponent, + SidebarBrandComponent, + SidebarNavComponent, + SidebarFooterComponent, + SidebarToggleDirective, + NavComponent, + TabContentRefDirective, + TabContentComponent, + TabPaneComponent, + NavLinkDirective, + ComponentsModule, + ActivityConfigEditorComponent, + FormDirective, + FormCheckComponent, + FormCheckInputDirective, + FormCheckLabelDirective, + FormLabelDirective, + CardComponent, + CardBodyComponent, + CardTitleDirective, + CardSubtitleDirective, + CardTextDirective, + CardHeaderComponent, + BorderDirective, + MarkdownComponent, + CollapseDirective, + CdkDrag, + CdkDropList, + CdkDragPreview, + AngularMultiSelectModule, + ColDirective, + ReactiveFormsModule, + RowDirective, + TableDirective, + TableColorDirective, + TooltipDirective, + PopoverDirective, + FormFloatingDirective, + BgColorDirective, + SpinnerComponent, + TextColorDirective, + AutocompleteLibModule, + FormTextDirective, + ButtonToolbarComponent, + CdkDragPlaceholder, + CalloutComponent, + DropdownComponent, + DropdownToggleDirective, + DropdownMenuDirective, + DropdownItemDirective, + AlertComponent, + CdkVirtualScrollViewport, + CdkVirtualForOf, + CdkFixedSizeVirtualScroll, + ], + declarations: [ + WorkflowViewerComponent, + WorkflowsDashboardComponent, + WorkflowSessionComponent, + ActivityComponent, + EdgeComponent, + RightMenuComponent, + WorkflowConfigEditorComponent, + LeftMenuComponent, + ActivityHelpComponent, + ActivitySettingsComponent, + IntSettingComponent, + StringSettingComponent, + BooleanSettingComponent, + DoubleSettingComponent, + EntitySettingComponent, + QuerySettingComponent, + FieldSelectSettingComponent, + ActivityExecStatsComponent, + ExecutionMonitorComponent, + AddVariableComponent, + EnumSettingComponent, + CollationSettingComponent, + FieldRenameSettingComponent, + CastSettingComponent, + FilterSettingComponent, + AggregateSettingComponent, + WorkflowHelpComponent, + CheckpointViewerComponent, + GraphMapSettingComponent, + FileSettingComponent, + WorkflowJobComponent, + JobListComponent, + JobCreatorComponent + ], + exports: [ + WorkflowViewerComponent, + WorkflowsDashboardComponent + ] +}) +export class WorkflowsModule { +} diff --git a/src/app/services/catalog.service.ts b/src/app/services/catalog.service.ts index d7799f1b..80339bed 100644 --- a/src/app/services/catalog.service.ts +++ b/src/app/services/catalog.service.ts @@ -1,24 +1,7 @@ import {effect, inject, Injectable, signal, untracked, WritableSignal} from '@angular/core'; import {HttpClient} from '@angular/common/http'; import {WebuiSettingsService} from './webui-settings.service'; -import { - AdapterTemplateModel, - AllocationColumnModel, - AllocationEntityModel, - AllocationPartitionModel, - AllocationPlacementModel, - AssetsModel, - CatalogState, - ColumnModel, - ConstraintModel, - EntityModel, - EntityType, - FieldModel, - IdEntity, - KeyModel, - LogicalSnapshotModel, - NamespaceModel -} from '../models/catalog.model'; +import {AdapterTemplateModel, AllocationColumnModel, AllocationEntityModel, AllocationPartitionModel, AllocationPlacementModel, AssetsModel, CatalogState, ColumnModel, ConstraintModel, EntityModel, EntityType, FieldModel, IdEntity, KeyModel, LogicalSnapshotModel, NamespaceModel} from '../models/catalog.model'; import {DataModel} from '../models/ui-request.model'; import {SidebarNode} from '../models/sidebar-node.model'; import {combineLatestWith, Observable, Subject} from 'rxjs'; @@ -179,6 +162,9 @@ export class CatalogService { } getEntityFromName(namespace: string, name: string): EntityModel { + if (name === undefined) { + name = namespace; + } const namespaces = Array.from(this.namespaces().values()).filter(n => (n.caseSensitive ? n.name === namespace : n.name.toLowerCase() === namespace.toLowerCase()) || n.dataModel === DataModel.GRAPH && name.toLowerCase() === n.name.toLowerCase() || namespace.toLowerCase() === n.name.toLowerCase()); if (namespaces.length === 0) { diff --git a/src/app/services/webui-settings.service.ts b/src/app/services/webui-settings.service.ts index 561f9fd5..1421d62a 100644 --- a/src/app/services/webui-settings.service.ts +++ b/src/app/services/webui-settings.service.ts @@ -42,6 +42,10 @@ export class WebuiSettingsService { 'ws://' + this.host + ':' + localStorage.getItem('webUI.port') + '/notebooks/webSocket'); this.connections.set('notebooks.file', 'http://' + this.host + ':' + localStorage.getItem('webUI.port') + '/notebooks/file'); + this.connections.set('workflows.rest', + 'http://' + this.host + ':' + localStorage.getItem('webUI.port') + '/workflows'); + this.connections.set('workflows.socket', + 'ws://' + this.host + ':' + localStorage.getItem('webUI.port') + '/workflows/webSocket'); } diff --git a/src/app/views/schema-editing/graph-edit-graph/graph-edit-graph.component.ts b/src/app/views/schema-editing/graph-edit-graph/graph-edit-graph.component.ts index fac239b6..b8ebfd75 100644 --- a/src/app/views/schema-editing/graph-edit-graph/graph-edit-graph.component.ts +++ b/src/app/views/schema-editing/graph-edit-graph/graph-edit-graph.component.ts @@ -6,15 +6,10 @@ import {ToasterService} from '../../../components/toast-exposer/toaster.service' import {DbmsTypesService} from '../../../services/dbms-types.service'; import {ModalDirective} from 'ngx-bootstrap/modal'; import {Subscription} from 'rxjs'; -import { - AllocationEntityModel, - AllocationPartitionModel, - AllocationPlacementModel, - NamespaceModel, - TableModel -} from '../../../models/catalog.model'; +import {AllocationEntityModel, AllocationPartitionModel, AllocationPlacementModel, NamespaceModel, TableModel} from '../../../models/catalog.model'; import {Method} from '../../../models/ui-request.model'; import {AdapterModel} from '../../adapters/adapter.model'; +import {CatalogService} from '../../../services/catalog.service'; @Component({ selector: 'app-graph-edit', @@ -27,6 +22,7 @@ export class GraphEditGraphComponent implements OnInit, OnDestroy { public readonly _crud = inject(CrudService); public readonly _types = inject(DbmsTypesService); private readonly _toast = inject(ToasterService); + private readonly _catalog = inject(CatalogService); constructor() { diff --git a/src/app/views/views-routing.module.ts b/src/app/views/views-routing.module.ts index e40afa30..090ac595 100644 --- a/src/app/views/views-routing.module.ts +++ b/src/app/views/views-routing.module.ts @@ -13,6 +13,9 @@ import {QueryInterfacesComponent} from './query-interfaces/query-interfaces.comp import {NotebooksComponent} from '../plugins/notebooks/components/notebooks.component'; import {UnsavedChangesGuard} from '../plugins/notebooks/services/unsaved-changes.guard'; import {DockerconfigComponent} from './dockerconfig/dockerconfig.component'; +import {WorkflowsDashboardComponent} from '../plugins/workflows/components/workflows-dashboard/workflows-dashboard.component'; +import {WorkflowSessionComponent} from '../plugins/workflows/components/workflow-session/workflow-session.component'; +import {WorkflowJobComponent} from '../plugins/workflows/components/workflow-job/workflow-job.component'; const routes: Routes = [ { @@ -181,6 +184,33 @@ const routes: Routes = [ } ] }, + { + path: 'workflows', + redirectTo: 'workflows/dashboard', + pathMatch: 'full' + }, + { + path: 'workflows/sessions/:sessionId', + component: WorkflowSessionComponent, + data: { + title: 'Workflow Session', + isFullWidth: true + } + }, + { + path: 'workflows/jobs/:jobId', + component: WorkflowJobComponent, + data: { + title: 'Workflow Job' + } + }, + { + path: 'workflows/:route', + component: WorkflowsDashboardComponent, + data: { + title: 'Workflows Dashboard' + } + } ]; @NgModule({ diff --git a/src/scss/angular2-multiselect-dropdown.scss b/src/scss/angular2-multiselect-dropdown.scss new file mode 100644 index 00000000..12e9e225 --- /dev/null +++ b/src/scss/angular2-multiselect-dropdown.scss @@ -0,0 +1,188 @@ +// An edited copy of node_modules/angular2-multiselect-dropdown to match the CoreUI theme +$default-color: #ffffff; +$base-color: var(--cui-primary); +$btn-background: #fff; +$btn-border: var(--cui-border-color); +$btn-text-color: #333; +$btn-arrow: #333; +$border-radius: var(--cui-border-radius); + + +$token-background: $base-color; +$token-text-color: #fff; +$token-remove-color: #fff; + +$box-shadow-color: #959595; +$list-hover-background: #f5f5f5; +$label-color: #000; +$selected-background: #e9f4ff; + + +.mat-toolbar { + background: $default-color; +} + +.c-btn { + background: $btn-background; + border: 1px solid $btn-border; + border-radius: $border-radius !important; + color: $btn-text-color; +} + +.selected-list { + .c-list { + .c-token { + background: $token-background; + + .c-label { + color: $token-text-color; + } + + .c-remove { + svg { + fill: $token-remove-color; + } + } + + } + } + + .c-angle-down, .c-angle-up { + svg { + fill: $btn-arrow; + } + } +} + +.dropdown-list { + ul { + li:hover { + background: $list-hover-background; + } + } +} + +.arrow-up, .arrow-down { + border-bottom: 15px solid #fff; +} + +.arrow-2 { + border-bottom: 15px solid #ccc; +} + +.list-area { + border: 1px solid #ccc; + border-radius: $border-radius !important; + background: #fff; + box-shadow: 0px 1px 5px $box-shadow-color; +} + +.select-all { + border-bottom: 1px solid #ccc; +} + +.list-filter { + border-bottom: 1px solid #ccc; + + .c-search { + svg { + fill: #888; + } + } + + .c-clear { + svg { + fill: #888; + } + } +} + +.pure-checkbox { + input[type="checkbox"]:focus + label:before, input[type="checkbox"]:hover + label:before { + border-color: $base-color; + background-color: #f2f2f2; + } + + input[type="checkbox"] + label { + color: $label-color; + } + + input[type="checkbox"] + label:before { + color: $base-color; + border: 1px solid $base-color; + } + + input[type="checkbox"] + label:after { + background-color: $base-color; + } + + input[type="checkbox"]:disabled + label:before { + border-color: #cccccc; + } + + input[type="checkbox"]:disabled:checked + label:before { + background-color: #cccccc; + } + + input[type="checkbox"] + label:after { + border-color: #ffffff; + } + + input[type="radio"]:checked + label:before { + background-color: white; + } + + input[type="checkbox"]:checked + label:before { + background: $base-color; + } +} + +.single-select-mode .pure-checkbox { + input[type="checkbox"]:focus + label:before, input[type="checkbox"]:hover + label:before { + border-color: $base-color; + background-color: #f2f2f2; + } + + input[type="checkbox"] + label { + color: $label-color; + } + + input[type="checkbox"] + label:before { + color: transparent !important; + border: 0px solid $base-color; + } + + input[type="checkbox"] + label:after { + background-color: transparent !important; + } + + input[type="checkbox"]:disabled + label:before { + border-color: #cccccc; + } + + input[type="checkbox"]:disabled:checked + label:before { + background-color: #cccccc; + } + + input[type="checkbox"] + label:after { + border-color: $base-color; + } + + input[type="radio"]:checked + label:before { + background-color: white; + } + + input[type="checkbox"]:checked + label:before { + background: none !important; + } +} + +.selected-item { + background: $selected-background; +} + +.btn-iceblue { + background: $base-color; + border: 1px solid $btn-border; + color: #fff; +} \ No newline at end of file diff --git a/src/scss/style.scss b/src/scss/style.scss index 6d29fa04..5ebd6731 100644 --- a/src/scss/style.scss +++ b/src/scss/style.scss @@ -80,7 +80,7 @@ } } -/* Node Editor for query plans */ +/* Node Editor for query plans and workflows */ [rete-context-menu] { width: 200px !important; border-radius: 4px; diff --git a/tsconfig.json b/tsconfig.json index d8a79ecc..5bdcfdb7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,8 @@ "types": [ "hammerjs" ], - "useDefineForClassFields": false + "useDefineForClassFields": false, + "allowSyntheticDefaultImports": true }, "exclude": ["node_modules"], "angularCompilerOptions": {