Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
608ebed
feat(recipes): allow non-guest recipe delete
JordiB-Inserma Jul 31, 2026
ca32b7d
feat(recipe-widget): add delete action and permission gating
JordiB-Inserma Jul 31, 2026
6bf29a8
feat(recipe-widget): add permission and visibility config
JordiB-Inserma Jul 31, 2026
b5f3099
feat(recipe-widget): add i18n keys for delete and property labels
JordiB-Inserma Jul 31, 2026
55f0717
fix(recipe-widget): interpolate delete-confirm recipe name
JordiB-Inserma Jul 31, 2026
82adbdb
fix(recipe-widget): use existing property-name key and drop unused im…
JordiB-Inserma Jul 31, 2026
a7c3a99
feat(server): recipe storage and runtime
JordiB-Inserma Aug 3, 2026
8a4cc25
feat(client): recipe model and service
JordiB-Inserma Aug 3, 2026
2720de4
feat(client): recipe management pages
JordiB-Inserma Aug 3, 2026
897290a
feat(client): recipe widget editor integration
JordiB-Inserma Aug 3, 2026
111b746
fix(recipes): wire recipe cancel-execution socket handler
JordiB-Inserma Aug 3, 2026
f1932b4
fix(recipes): catch unhandled rejections in download/upload routes
JordiB-Inserma Aug 3, 2026
293bb71
fix(recipes): report device write failures as errors
JordiB-Inserma Aug 3, 2026
437c392
fix(recipes): accept string/word values in recipe validation
JordiB-Inserma Aug 3, 2026
e802b38
fix(recipes): coerce number tag type as float
JordiB-Inserma Aug 3, 2026
fa37028
fix(recipes): correct copy-paste error message in upload route
JordiB-Inserma Aug 3, 2026
23ee7a3
docs: add HowTo-Recipes documentation
JordiB-Inserma Aug 3, 2026
fa4810e
fix(recipes): detect device failures without changing shared API
JordiB-Inserma Aug 3, 2026
9712ff7
fix(recipes): consume cancel-confirmed on client, single emission
JordiB-Inserma Aug 3, 2026
da2cf57
fix(recipes): guard cancel/re-run race with execution token
JordiB-Inserma Aug 3, 2026
6763329
fix(recipes): close progress dialog on start failure, fix i18n interp…
JordiB-Inserma Aug 3, 2026
735edb5
fix(recipes): use global monotonic generation counter, strip CSV form…
JordiB-Inserma Aug 3, 2026
0b209cf
fix(recipes): only strip CSV formula prefix when guard was applied
JordiB-Inserma Aug 3, 2026
e29943b
fix(recipes): add editable default value column to recipe editor
JordiB-Inserma Aug 4, 2026
465cbbe
fix(recipes): suppress stale progress events when a run is superseded
JordiB-Inserma Aug 4, 2026
6ab9856
fix(recipes): skip upload persistence when a run is cancelled
JordiB-Inserma Aug 4, 2026
fd688ad
fix(recipes): return definitive error on concurrent recipe start inst…
JordiB-Inserma Aug 4, 2026
3bcb9f4
fix(recipes): strip UTF-8 BOM on import so JSON detection and parsing…
JordiB-Inserma Aug 4, 2026
7a02b36
fix(recipes): filter widget progress handlers by recipeId
JordiB-Inserma Aug 4, 2026
5380f16
fix(recipes): remove debug console.log statements from recipe widget …
JordiB-Inserma Aug 4, 2026
4a54457
fix(recipes): default new-recipe dialog description to empty string
JordiB-Inserma Aug 4, 2026
090c6b1
fix(recipes): expose Download and Upload actions in recipe list menu
JordiB-Inserma Aug 4, 2026
7f2082d
fix(recipes): surface error message in recipe progress dialog
JordiB-Inserma Aug 4, 2026
3b4cfa4
fix(recipes): replace hardcoded literals with i18n keys
JordiB-Inserma Aug 4, 2026
a1efacb
fix(recipes): detect cancel landing during final entry device op
JordiB-Inserma Aug 4, 2026
57cae35
fix(recipes): coerce empty string to neutral default at the server bo…
JordiB-Inserma Aug 4, 2026
b285173
fix(recipes): filter widget run events by captured instance and local…
JordiB-Inserma Aug 4, 2026
415609c
WIP: Store recipe templates as project data and recipe instances in r…
unocelli Aug 9, 2026
b2cab63
fix: instance permission
unocelli Aug 11, 2026
9480a52
feat: align list and editor UI patterns
unocelli Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions client/src/app/_models/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Hmi } from './hmi';
import { Chart } from './chart';
import { Graph } from './graph';
import { Alarm } from './alarm';
import { Recipe } from './recipe';
import { Notification } from './notification';
import { Languages, LanguageText } from './language';
import { Utils } from '../_helpers/utils';
Expand All @@ -28,6 +29,8 @@ export class ProjectData {
graphs: Graph[] = [];
/** Alarms, Tags, logic, level, colors, etc. */
alarms: Alarm[] = [];
/** Recipe templates, Tags, default values, etc. */
recipes: Recipe[] = [];
/** Notifications */
notifications: Notification[] = [];
/** Scripts */
Expand Down Expand Up @@ -62,6 +65,8 @@ export enum ProjectDataCmdType {
DelText = 'del-text',
SetAlarm = 'set-alarm',
DelAlarm = 'del-alarm',
SetRecipe = 'set-recipe',
DelRecipe = 'del-recipe',
SetNotification = 'set-notification',
DelNotification = 'del-notification',
SetScript = 'set-script',
Expand Down
48 changes: 48 additions & 0 deletions client/src/app/_models/recipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Utils } from '../_helpers/utils';

export interface Recipe {
id: string; // "r_" + 12 hex chars
typeId?: string; // if set, this recipe is an INSTANCE of the recipe type with this id
name: string; // required, max 128
description: string; // optional, max 512
entries: RecipeEntry[]; // 1..1000
permission?: number;
permissionRoles?: {
show?: string[];
enabled?: string[];
};
createdAt?: string;
updatedAt?: string;
}

export interface RecipeEntry {
id: string; // "e_" + 8 hex chars
tagId: string;
tagName: string;
tagType: string; // bool, int, real, etc.
value: any;
}

export interface RecipeProgressEvent {
recipeId: string;
entryId?: string;
tagId?: string;
tagName?: string;
index: number;
total: number;
status: 'pending' | 'writing' | 'reading' | 'success' | 'error' | 'skipped';
value?: any;
error?: string;
}

export interface RecipeCompleteEvent {
recipeId: string;
successCount: number;
errorCount: number;
errors: { entryId: string; tagId: string; error: string }[];
}

export interface RecipeCancelledEvent {
recipeId: string;
completedCount: number;
}
49 changes: 48 additions & 1 deletion client/src/app/_services/hmi.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ export class HmiService {
@Output() onSchedulerEventActive: EventEmitter<any> = new EventEmitter();
@Output() onSchedulerRemainingTime: EventEmitter<any> = new EventEmitter();
@Output() onGaugeEvent: EventEmitter<any> = new EventEmitter();
@Output() onRecipeDownloadProgress: EventEmitter<any> = new EventEmitter();
@Output() onRecipeDownloadComplete: EventEmitter<any> = new EventEmitter();
@Output() onRecipeDownloadError: EventEmitter<any> = new EventEmitter();
@Output() onRecipeUploadProgress: EventEmitter<any> = new EventEmitter();
@Output() onRecipeUploadComplete: EventEmitter<any> = new EventEmitter();
@Output() onRecipeUploadError: EventEmitter<any> = new EventEmitter();
@Output() onRecipeCanceled: EventEmitter<any> = new EventEmitter();

onServerConnection$ = new BehaviorSubject<boolean>(false);

Expand Down Expand Up @@ -317,6 +324,28 @@ export class HmiService {
this.socket.on(IoEventTypes.ALIVE, (message) => {
this.onServerConnection$.next(true);
});
// recipes
this.socket.on(IoEventTypes.RECIPE_DOWNLOAD_PROGRESS, (message) => {
this.onRecipeDownloadProgress.emit(message);
});
this.socket.on(IoEventTypes.RECIPE_DOWNLOAD_COMPLETE, (message) => {
this.onRecipeDownloadComplete.emit(message);
});
this.socket.on(IoEventTypes.RECIPE_DOWNLOAD_ERROR, (message) => {
this.onRecipeDownloadError.emit(message);
});
this.socket.on(IoEventTypes.RECIPE_UPLOAD_PROGRESS, (message) => {
this.onRecipeUploadProgress.emit(message);
});
this.socket.on(IoEventTypes.RECIPE_UPLOAD_COMPLETE, (message) => {
this.onRecipeUploadComplete.emit(message);
});
this.socket.on(IoEventTypes.RECIPE_UPLOAD_ERROR, (message) => {
this.onRecipeUploadError.emit(message);
});
this.socket.on(IoEventTypes.RECIPE_CANCELED, (message) => {
this.onRecipeCanceled.emit(message);
});
this.askDeviceValues();
this.askAlarmsStatus();
}
Expand Down Expand Up @@ -474,6 +503,16 @@ export class HmiService {
this.socket.emit(IoEventTypes.DEVICE_ENABLE, msg);
}
}

/**
* Cancel recipe execution
* @param recipeId
*/
public cancelRecipeExecution(recipeId: string) {
if (this.socket) {
this.socket.emit(IoEventTypes.RECIPE_CANCEL, { recipeId });
}
}
//#endregion

//#region Signals Gauges Mapping
Expand Down Expand Up @@ -755,7 +794,15 @@ export enum IoEventTypes {
ALIVE = 'heartbeat',
SCHEDULER_UPDATED = 'scheduler:updated',
SCHEDULER_ACTIVE = 'scheduler:event-active',
SCHEDULER_REMAINING = 'scheduler:remaining-time'
SCHEDULER_REMAINING = 'scheduler:remaining-time',
RECIPE_DOWNLOAD_PROGRESS = 'recipe:download-progress',
RECIPE_DOWNLOAD_COMPLETE = 'recipe:download-complete',
RECIPE_DOWNLOAD_ERROR = 'recipe:download-error',
RECIPE_UPLOAD_PROGRESS = 'recipe:upload-progress',
RECIPE_UPLOAD_COMPLETE = 'recipe:upload-complete',
RECIPE_UPLOAD_ERROR = 'recipe:upload-error',
RECIPE_CANCEL = 'recipe:cancel-execution',
RECIPE_CANCELED = 'recipe:cancel-confirmed'
}

export const ScriptCommandEnum = {
Expand Down
97 changes: 97 additions & 0 deletions client/src/app/_services/recipe.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';

import { EndPointApi } from '../_helpers/endpointapi';
import { environment } from '../../environments/environment';
import { Recipe, RecipeEntry } from '../_models/recipe';

@Injectable({
providedIn: 'root'
})
export class RecipeService {

private endPointConfig: string = EndPointApi.getURL();

constructor(private http: HttpClient) {
}

/**
* Cache-busting param: the server sends ETags, so the browser revalidates
* with If-None-Match and Express answers 304 Not Modified. Angular's
* HttpClient treats 304 as an error (ok = 200 <= status < 300), so the
* widget would show a failure on every load after the first one.
* A unique query param forces a fresh 200 every time.
*/
private _ts() {
return { _ts: Date.now().toString() };
}

getRecipeTypes(): Observable<{ recipes: { id: string; data: Recipe }[] }> {
return this.http.get<{ recipes: { id: string; data: Recipe }[] }>(this.endPointConfig + '/api/recipes/types', {
params: this._ts()
});
}

getRecipeInstances(typeId: string): Observable<{ recipes: { id: string; data: Recipe }[] }> {
return this.http.get<{ recipes: { id: string; data: Recipe }[] }>(this.endPointConfig + '/api/recipes/instances', {
params: { typeId, ...this._ts() }
});
}

getRecipeType(id: string): Observable<Recipe> {
return this.http.get<Recipe>(this.endPointConfig + '/api/recipes/types/' + id, {
params: this._ts()
});
}

getRecipeInstance(id: string): Observable<Recipe> {
return this.http.get<Recipe>(this.endPointConfig + '/api/recipes/instances/' + id, {
params: this._ts()
});
}

saveRecipeType(recipe: { id?: string; name: string; description?: string; entries: RecipeEntry[] }): Observable<{ id: string }> {
let header = new HttpHeaders({ 'Content-Type': 'application/json' });
return this.http.post<{ id: string }>(this.endPointConfig + '/api/recipes/types', recipe, { headers: header });
}

saveRecipeInstance(recipe: { id?: string; typeId: string; name: string; description?: string; entries: RecipeEntry[] }): Observable<{ id: string }> {
let header = new HttpHeaders({ 'Content-Type': 'application/json' });
return this.http.post<{ id: string }>(this.endPointConfig + '/api/recipes/instances', recipe, { headers: header });
}

deleteRecipeType(id: string): Observable<{ result: string; deleted: number }> {
return this.http.delete<{ result: string; deleted: number }>(this.endPointConfig + '/api/recipes/types?id=' + id);
}

deleteRecipeInstance(id: string): Observable<{ result: string; deleted: number }> {
return this.http.delete<{ result: string; deleted: number }>(this.endPointConfig + '/api/recipes/instances?id=' + id);
}

downloadRecipe(id: string): Observable<{ result: string; recipeId: string; totalEntries: number }> {
let header = new HttpHeaders({ 'Content-Type': 'application/json' });
return this.http.post<{ result: string; recipeId: string; totalEntries: number }>(
this.endPointConfig + '/api/recipes/download', { id }, { headers: header });
}

uploadRecipe(id: string): Observable<{ result: string; recipeId: string; totalEntries: number }> {
let header = new HttpHeaders({ 'Content-Type': 'application/json' });
return this.http.post<{ result: string; recipeId: string; totalEntries: number }>(
this.endPointConfig + '/api/recipes/upload', { id }, { headers: header });
}

exportRecipe(id: string, format: 'json' | 'csv'): Observable<Blob> {
let header = new HttpHeaders({ 'Content-Type': 'application/json' });
return this.http.post(this.endPointConfig + '/api/recipes/export', { id, format }, {
headers: header,
responseType: 'blob'
});
}

importRecipe(data: { file: string; format?: string; name?: string; description?: string }): Observable<{ id: string; name: string; entriesCount: number }> {
let header = new HttpHeaders({ 'Content-Type': 'application/json' });
return this.http.post<{ id: string; name: string; entriesCount: number }>(
this.endPointConfig + '/api/recipes/types/import', data, { headers: header });
}
}
15 changes: 14 additions & 1 deletion client/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,12 @@ import { SectionMessageDialogComponent } from './editor/section-message-dialog/s
import { ArMarkerListComponent } from './ar/ar-marker-list/ar-marker-list.component';
import { ArMarkerPropertyComponent } from './ar/ar-marker-property/ar-marker-property.component';
import { ArViewComponent } from './ar/ar-view/ar-view.component';
import { RecipeListComponent } from './recipes/recipe-list/recipe-list.component';
import { RecipeEditorComponent } from './recipes/recipe-editor/recipe-editor.component';
import { RecipeProgressComponent } from './recipes/recipe-progress/recipe-progress.component';
import { HtmlRecipeViewComponent, HtmlRecipeComponent } from './gauges/controls/html-recipe/html-recipe.component';
import { HtmlRecipeNewDialogComponent } from './gauges/controls/html-recipe/html-recipe-new-dialog/html-recipe-new-dialog.component';
import { RecipePropertyComponent } from './gauges/controls/html-recipe/recipe-property/recipe-property.component';

export function createTranslateLoader(http: HttpClient) {
return new TranslateHttpLoader(http, './assets/i18n/', '.json');
Expand Down Expand Up @@ -430,7 +436,14 @@ export const myCustomTooltipDefaults: MatTooltipDefaultOptions = {
SectionMessageDialogComponent,
ArMarkerListComponent,
ArMarkerPropertyComponent,
ArViewComponent
ArViewComponent,
RecipeListComponent,
RecipeEditorComponent,
RecipeProgressComponent,
HtmlRecipeViewComponent,
HtmlRecipeComponent,
HtmlRecipeNewDialogComponent,
RecipePropertyComponent
],
bootstrap: [AppComponent], imports: [BrowserModule,
FormsModule,
Expand Down
2 changes: 2 additions & 0 deletions client/src/app/app.routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { ApiKeysListComponent } from './apikeys/api-keys-list/api-keys-list.comp
import { PluginsListComponent } from './plugins/plugins-list/plugins-list.component';
import { ArMarkerListComponent } from './ar/ar-marker-list/ar-marker-list.component';
import { ArViewComponent } from './ar/ar-view/ar-view.component';
import { RecipeListComponent } from './recipes/recipe-list/recipe-list.component';

const appRoutes: Routes = [
{ path: '', component: HomeComponent},//, canActivate: [AuthGuard] },
Expand All @@ -38,6 +39,7 @@ const appRoutes: Routes = [
{ path: 'messages', component: AlarmListComponent, canActivate: [AuthGuard] },
{ path: 'notifications', component: NotificationListComponent, canActivate: [AuthGuard] },
{ path: 'scripts', component: ScriptListComponent, canActivate: [AuthGuard] },
{ path: 'recipes', component: RecipeListComponent, canActivate: [AuthGuard] },
{ path: 'reports', component: ReportListComponent, canActivate: [AuthGuard] },
{ path: 'language', component: LanguageTextListComponent, canActivate: [AuthGuard] },
{ path: 'logs', component: LogsViewComponent, canActivate: [AuthGuard] },
Expand Down
7 changes: 7 additions & 0 deletions client/src/app/editor/editor.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@
<div *ngSwitchCase="gaugeDialogType.Scheduler">
<app-scheduler-property [data]="gaugeDialog.data" [reload]="reloadGaugeDialog" (onPropChanged)="onGaugeDialogChanged($event)"></app-scheduler-property>
</div>
<div *ngSwitchCase="gaugeDialogType.Recipe">
<app-recipe-property [data]="gaugeDialog.data" [reload]="reloadGaugeDialog" (onPropChanged)="onGaugeDialogChanged($event)"></app-recipe-property>
</div>
</div>
</mat-drawer>
<div id="svg_editor_container">
Expand Down Expand Up @@ -207,6 +210,10 @@
(click)="setMode('own_ctrl-scheduler')">
<span class="material-icon-tool material-icons" style="font-size: 23px;">schedule</span>
</div>
<div class="svg-tool-button" matTooltip="{{'editor.controls-recipe' | translate}}" [ngClass]="{'svg-tool-active': isModeActive('own_ctrl-recipe')}"
(click)="setMode('own_ctrl-recipe')">
<span class="material-icon-tool material-icons" style="font-size: 23px;">receipt</span>
</div>
</div>
</mat-expansion-panel>
<!-- SHAPES -->
Expand Down
14 changes: 14 additions & 0 deletions client/src/app/editor/editor.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1431,6 +1431,20 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
this.reloadGaugeDialog = !this.reloadGaugeDialog;
return;
} else if (dlgType === GaugeDialogType.Recipe) {
this.gaugeDialog.type = dlgType;
this.gaugeDialog.data = {
settings: tempsettings,
withEvents: false,
withActions: false,
withBitmask: false,
languageTextEnabled: false
};
if (!this.sidePanel.opened) {
this.sidePanel.toggle();
}
this.reloadGaugeDialog = !this.reloadGaugeDialog;
return;
} else {
//!TODO to be refactored (GaugePropertyComponent)
elementWithLanguageText = this.isSelectedElementToEnableLanguageTextSettings();
Expand Down
8 changes: 8 additions & 0 deletions client/src/app/editor/setup/setup.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,14 @@ <h1 mat-dialog-title style="display:inline-block;cursor:move;" mat-dialog-dragga
</div>
</button>
</div>
<div class="btn-card">
<button mat-button (click)="goTo('/recipes')" class="card-btn">
<div class="card-btn-content">
<mat-icon>receipt</mat-icon>
<span>{{'dlg.setup-recipes' | translate}}</span>
</div>
</button>
</div>
<div class="btn-card">
<button mat-button (click)="goTo('/language')" class="card-btn" [disabled]="isToDisable('language')">
<div class="card-btn-content">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
.newrecipe-dialog {
min-width: 380px;
padding: 16px 20px;
}

.dialog-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}

.dialog-header mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}

.dialog-header h3 {
margin: 0;
font-size: 16px;
font-weight: 500;
}

.dialog-content {
display: flex;
flex-direction: column;
gap: 12px;
}

.dialog-field {
width: 100%;
}

.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}

.full-width {
width: 100%;
}
Loading