diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 78fc75d7cbc..db907f7745f 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -20,7 +20,7 @@ import { DatePipe, registerLocaleData } from "@angular/common"; import { HTTP_INTERCEPTORS, HttpClientModule } from "@angular/common/http"; import en from "@angular/common/locales/en"; -import { APP_INITIALIZER, CUSTOM_ELEMENTS_SCHEMA, NgModule } from "@angular/core"; +import { APP_INITIALIZER, CUSTOM_ELEMENTS_SCHEMA, APP_BOOTSTRAP_LISTENER, NgModule } from "@angular/core"; import { FormsModule, ReactiveFormsModule } from "@angular/forms"; import { BrowserModule } from "@angular/platform-browser"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; @@ -191,6 +191,7 @@ import { NzCheckboxModule } from "ng-zorro-antd/checkbox"; import { RegistrationRequestModalComponent } from "./common/service/user/registration-request-modal/registration-request-modal.component"; import { UserComputingUnitComponent } from "./dashboard/component/user/user-computing-unit/user-computing-unit.component"; import { UserComputingUnitListItemComponent } from "./dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component"; +import { JupyterPanelService } from "./workspace/service/jupyter-panel/jupyter-panel.service"; registerLocaleData(en); @@ -404,6 +405,12 @@ registerLocaleData(en); deps: [GuiConfigService], multi: true, }, + { + provide: APP_BOOTSTRAP_LISTENER, + useFactory: (jupyterPanelService: JupyterPanelService) => () => jupyterPanelService.init(), + deps: [JupyterPanelService], + multi: true, + }, ], bootstrap: [AppComponent], schemas: [CUSTOM_ELEMENTS_SCHEMA], diff --git a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts index 979f131ad3c..8d07eebaf71 100644 --- a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts +++ b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts @@ -48,6 +48,7 @@ import { NzNoAnimationDirective } from "ng-zorro-antd/core/animation"; import { ContextMenuComponent } from "./context-menu/context-menu/context-menu.component"; import { NgIf } from "@angular/common"; import { AgentInteractionComponent } from "../agent/agent-interaction/agent-interaction.component"; +import { JupyterPanelService } from "../../service/jupyter-panel/jupyter-panel.service"; // jointjs interactive options for enabling and disabling interactivity // https://resources.jointjs.com/docs/jointjs/v3.2/joint.html#dia.Paper.prototype.options.interactive @@ -128,7 +129,8 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy public nzContextMenu: NzContextMenuService, private elementRef: ElementRef, private config: GuiConfigService, - private agentService: AgentService + private agentService: AgentService, + private jupyterPanelService: JupyterPanelService ) { this.wrapper = this.workflowActionService.getJointGraphWrapper(); } @@ -603,12 +605,14 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy const elementID = event[0].model.id.toString(); const highlightedOperatorIDs = this.wrapper.getCurrentHighlightedOperatorIDs(); const highlightedCommentBoxIDs = this.wrapper.getCurrentHighlightedCommentBoxIDs(); + this.jupyterPanelService.onWorkflowComponentClick(elementID); // Highlight corresponding Jupyter notebook cell if (event[1].shiftKey) { // if in multiselect toggle highlights on click if (highlightedOperatorIDs.includes(elementID)) { this.workflowActionService.unhighlightOperators(elementID); } else if (this.workflowActionService.getTexeraGraph().hasOperator(elementID)) { this.workflowActionService.highlightOperators(event[1].shiftKey, elementID); + this.jupyterPanelService.onWorkflowComponentClick(elementID); // Highlight corresponding Jupyter notebook cell } if (highlightedCommentBoxIDs.includes(elementID)) { this.wrapper.unhighlightCommentBoxes(elementID); diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts new file mode 100644 index 00000000000..e5e9a436180 --- /dev/null +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts @@ -0,0 +1,189 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { TestBed } from "@angular/core/testing"; +import { JupyterPanelService } from "./jupyter-panel.service"; +import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service"; +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { NotificationService } from "src/app/common/service/notification/notification.service"; +import { NotebookMigrationService } from "../notebook-migration/notebook-migration.service"; +import { GuiConfigService } from "src/app/common/service/gui-config.service"; +import { of } from "rxjs"; + +describe("JupyterPanelService", () => { + let service: JupyterPanelService; + let httpMock: HttpTestingController; + + let mockWorkflow: any; + let mockNotification: any; + let mockNotebook: any; + // Mutable so individual describe blocks can flip the flag mid-spec; the + // service stores a reference, so mutations are observed on the next read. + let mockGuiConfig: { env: { pythonNotebookMigrationEnabled: boolean } }; + + beforeEach(() => { + mockWorkflow = { + workflowMetaDataChanged: jasmine.createSpy().and.returnValue(of({ wid: 1 })), + getWorkflow: jasmine.createSpy().and.returnValue({ wid: 1 }), + getTexeraGraph: jasmine.createSpy().and.returnValue({ + getAllLinks: () => [ + { + linkID: "L1", + source: { operatorID: "A" }, + target: { operatorID: "B" }, + }, + ], + getAllOperators: () => [{ operatorID: "A" }, { operatorID: "B" }], + }), + highlightOperators: jasmine.createSpy(), + highlightLinks: jasmine.createSpy(), + unhighlightOperators: jasmine.createSpy(), + unhighlightLinks: jasmine.createSpy(), + }; + + mockNotification = { + warning: jasmine.createSpy(), + }; + + mockNotebook = { + hasMapping: jasmine.createSpy().and.returnValue(true), + getMapping: jasmine.createSpy().and.returnValue({ + cell_to_operator: { + cell1: ["A", "B"], + }, + operator_to_cell: {}, + }), + deleteMapping: jasmine.createSpy(), + setMapping: jasmine.createSpy(), + getJupyterURL: jasmine.createSpy().and.resolveTo("http://jupyter"), + }; + + mockGuiConfig = { env: { pythonNotebookMigrationEnabled: true } }; + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + JupyterPanelService, + { provide: WorkflowActionService, useValue: mockWorkflow }, + { provide: NotificationService, useValue: mockNotification }, + { provide: NotebookMigrationService, useValue: mockNotebook }, + { provide: GuiConfigService, useValue: mockGuiConfig }, + ], + }); + + service = TestBed.inject(JupyterPanelService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + // HTTP fetchNotebookAndMapping + it("should return 0 when exists=false", done => { + (service as any).fetchNotebookAndMapping(1, 1).subscribe((result: any) => { + expect(result).toBe(0); + done(); + }); + + const req = httpMock.expectOne(r => r.url.includes("/notebook-migration/fetch-notebook-and-mapping")); + + req.flush({ exists: false }); + }); + + // iframe ref + it("should store iframe reference", () => { + const iframe = document.createElement("iframe"); + + service.setIframeRef(iframe); + + expect((service as any).iframeRef).toBe(iframe); + }); + + // highlightFromCell + it("should highlight operators and links", () => { + (service as any).cellToHighlightMapping = { + cell1: { + components: ["op1", "op2"], + edges: ["link1"], + }, + }; + + const method = (service as any).highlightFromCell.bind(service); + + method("cell1"); + + expect(mockWorkflow.unhighlightOperators).toHaveBeenCalled(); + expect(mockWorkflow.unhighlightLinks).toHaveBeenCalled(); + expect(mockWorkflow.highlightOperators).toHaveBeenCalledWith(true, "op1", "op2"); + expect(mockWorkflow.highlightLinks).toHaveBeenCalledWith(true, "link1"); + }); + + // onWorkflowComponentClick + it("should postMessage when mapping exists", async () => { + const mockIframe = { + contentWindow: { + postMessage: jasmine.createSpy(), + }, + } as any; + + service.setIframeRef(mockIframe); + (mockNotebook as any).getMapping.and.returnValue({ + cell_to_operator: {}, + operator_to_cell: { + cell1: ["op1", "op2"], + }, + }); + + await service.onWorkflowComponentClick("cell1"); + + expect(mockIframe.contentWindow.postMessage).toHaveBeenCalledWith( + { + action: "triggerCellClick", + operators: ["op1", "op2"], + }, + "http://jupyter" + ); + }); + + // Feature flag gate (defence in depth). With the flag off, init must not + // subscribe to workflow changes, and onWorkflowComponentClick must not + // postMessage to the iframe. The window message listener is installed in + // the constructor unconditionally, but handleNotebookMessage returns early + // on the flag check. + describe("when the feature flag is disabled", () => { + beforeEach(() => { + mockGuiConfig.env.pythonNotebookMigrationEnabled = false; + }); + + it("init does not subscribe to workflowMetaDataChanged", () => { + service.init(); + expect(mockWorkflow.workflowMetaDataChanged).not.toHaveBeenCalled(); + }); + + it("onWorkflowComponentClick does not postMessage to the iframe", async () => { + const mockIframe = { + contentWindow: { postMessage: jasmine.createSpy() }, + } as any; + service.setIframeRef(mockIframe); + await service.onWorkflowComponentClick("cell1"); + expect(mockIframe.contentWindow.postMessage).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts new file mode 100644 index 00000000000..395147df07c --- /dev/null +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts @@ -0,0 +1,243 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Injectable } from "@angular/core"; +import { catchError, map, of } from "rxjs"; +import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service"; +import { OperatorLink } from "../../types/workflow-common.interface"; +import { HttpClient, HttpHeaders } from "@angular/common/http"; +import { UntilDestroy } from "@ngneat/until-destroy"; +import { NotificationService } from "src/app/common/service/notification/notification.service"; +import { distinctUntilChanged, switchMap } from "rxjs/operators"; +import { AppSettings } from "../../../common/app-setting"; +import { NotebookMigrationService } from "../notebook-migration/notebook-migration.service"; +import { GuiConfigService } from "../../../common/service/gui-config.service"; + +@UntilDestroy() +@Injectable({ + providedIn: "root", +}) +export class JupyterPanelService { + private iframeRef: HTMLIFrameElement | null = null; // Store reference to iframe element + private cellContent: string[] = []; // Store the content of the cells + private highlightedCell: number | null = null; // Track the highlighted cell + + // Precomputed dictionary for cell to highlight mapping + private cellToHighlightMapping: Record = {}; + + constructor( + private workflowActionService: WorkflowActionService, + private http: HttpClient, + private notificationService: NotificationService, + private notebookMigrationService: NotebookMigrationService, + private config: GuiConfigService + ) { + window.addEventListener("message", this.handleNotebookMessage); + } + + private get enabled(): boolean { + return this.config.env.pythonNotebookMigrationEnabled; + } + + public init(): void { + if (!this.enabled) return; + this.workflowActionService + .workflowMetaDataChanged() + .pipe( + map(meta => meta.wid), + distinctUntilChanged() + ) + .subscribe(wid => { + // Drop any stale mapping for the current workflow. This previously + // happened inside closeJupyterNotebookPanel; the panel-visibility + // surface lives with the iframe component in + // `migration-tool-jupyter-panel` now, so the cleanup is inlined. + const currentWid = this.workflowActionService.getWorkflow().wid; + if (currentWid !== undefined) { + this.notebookMigrationService.deleteMapping("mapping_wid_" + currentWid); + } + if (wid != 0) { + this.fetchNotebookAndMapping(wid).subscribe(result => { + if (result == 1) { + this.precomputeHighlightMapping(); + // Panel auto-open on workflow restore is wired in + // `migration-tool-jupyter-panel` once the visibility API exists. + } + }); + } + }); + } + + private fetchNotebookAndMapping( + workflowID: number | undefined = this.workflowActionService.getWorkflow().wid, + vId: number = 1 + ) { + // Fetch mapping and notebook from migration database if exists for wid + const dbAPIUrl = `${AppSettings.getApiEndpoint()}/notebook-migration/fetch-notebook-and-mapping`; + const headers = new HttpHeaders({ "Content-Type": "application/json" }); + const payload = { + wid: workflowID, + vid: vId, // Future work: add dynamic fetching of current workflow vId + }; + + return this.http.post(dbAPIUrl, payload, { headers }).pipe( + switchMap(async (response: any) => { + // Only load mapping and workflow if they exist + if (response.exists) { + this.notebookMigrationService.setMapping("mapping_wid_" + workflowID, response.mapping); + + if ((await this.notebookMigrationService.sendNotebookToJupyter(response.notebook)) == 1) { + return 1; + } else { + return 0; + } + } else { + return 0; + } + }), + catchError((error: unknown) => { + console.error("Network response was not ok when fetching notebook and mapping:", error); + return of(0); + }) + ); + } + + // Precompute the dictionary for O(1) highlighting + private precomputeHighlightMapping(): void { + const wid = this.workflowActionService.getWorkflow().wid; + + if (wid === undefined) { + console.warn("Workflow ID is undefined. Cannot compute highlight mapping."); + return; + } + const mappingKey = "mapping_wid_" + wid; + const mapping = this.notebookMigrationService.getMapping(mappingKey); + + if (mapping == undefined) { + console.warn(`Mapping key '${mappingKey}' not found. Cannot compute highlight mapping.`); + return; + } + const cellToOperator = mapping.cell_to_operator; + + const allLinks: OperatorLink[] = this.workflowActionService.getTexeraGraph().getAllLinks(); + if (allLinks.length === 0) { + console.warn("No links found in the graph during precompute."); + return; + } + + for (const cellUUID in cellToOperator) { + const components = cellToOperator[cellUUID] || []; + const componentSet = new Set(components); + const edges: string[] = []; + + allLinks.forEach(link => { + const sourceOperatorID = link.source.operatorID; + const targetOperatorID = link.target.operatorID; + + if ( + componentSet.has(sourceOperatorID) && + componentSet.has(targetOperatorID) && + sourceOperatorID !== targetOperatorID + ) { + edges.push(link.linkID); + } + }); + + this.cellToHighlightMapping[cellUUID] = { components, edges }; + } + } + + // Set the iframe reference (from the component's ViewChild). The panel + // component that calls this lives in `migration-tool-jupyter-panel`. + setIframeRef(iframe: HTMLIFrameElement) { + this.iframeRef = iframe; + } + + // Handle messages from the Jupyter notebook iframe + private handleNotebookMessage = async (event: MessageEvent) => { + if (!this.enabled) return; + const allowedOrigins = [window.location.origin, await this.notebookMigrationService.getJupyterURL()]; + if (!allowedOrigins.includes(event.origin)) { + return; + } + + const { action, cellIndex, cellContent, cellUUID } = event.data; + if (action === "cellClicked") { + this.highlightedCell = cellIndex; + this.cellContent[cellIndex] = cellContent || `Cell ${cellIndex + 1}`; + this.highlightFromCell(cellUUID); + } + }; + + // Highlight operators and edges based on the clicked cell + private highlightFromCell(cellUUID: string): void { + const highlightData = this.cellToHighlightMapping[cellUUID] || { components: [], edges: [] }; + + // Unhighlight all operators and links + this.workflowActionService.unhighlightOperators( + ...this.workflowActionService + .getTexeraGraph() + .getAllOperators() + .map(op => op.operatorID) + ); + this.workflowActionService.unhighlightLinks( + ...this.workflowActionService + .getTexeraGraph() + .getAllLinks() + .map(link => link.linkID) + ); + + // Highlight components and edges + if (highlightData.components.length > 0) { + this.workflowActionService.highlightOperators(true, ...highlightData.components); + } + if (highlightData.edges.length > 0) { + this.workflowActionService.highlightLinks(true, ...highlightData.edges); + } + } + + // Handle when a Texera component is clicked to trigger the corresponding notebook cell + async onWorkflowComponentClick(cellUUID: string): Promise { + if (!this.enabled) return; + const jupyterURL = await this.notebookMigrationService.getJupyterURL(); + if (jupyterURL && this.iframeRef && this.iframeRef.contentWindow) { + const wid = this.workflowActionService.getWorkflow().wid; + + if (wid == undefined) { + console.error("Error fetching wid of current workflow"); + return; + } + + const mappingKey = "mapping_wid_" + wid; + const mappingEntry = this.notebookMigrationService.getMapping(mappingKey); + + if (!mappingEntry) { + console.error("Missing mapping for workflow:", mappingKey); + return; + } + + const operatorArray = mappingEntry["operator_to_cell"][cellUUID]; + if (operatorArray) { + this.iframeRef.contentWindow.postMessage({ action: "triggerCellClick", operators: operatorArray }, jupyterURL); + } else { + console.error(`No operators found for cellUUID: ${cellUUID}`); + } + } + } +}