This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-5263-bf1f5897bed98a8d6f4adce554c4924553ce6e95 in repository https://gitbox.apache.org/repos/asf/texera.git
commit fa075d11541a3282562ed6ade743548cb8586d1b Author: Ryan Zhang <[email protected]> AuthorDate: Wed Jul 22 15:07:41 2026 -0700 feat(python-notebook-migration): add Jupyter panel mapping highlight service (#5263) ### What changes were proposed in this PR? Introduces `JupyterPanelService`, which carries the bidirectional cell to operator highlight orchestration, plus the workflow editor hook that calls into it on operator click. **`JupyterPanelService`** - `init()`: subscribes to `workflowMetaDataChanged()` and is registered as an `APP_BOOTSTRAP_LISTENER` in `app.module.ts` so it starts on app bootstrap. On each workflow change it drops any stale mapping for the current workflow (inline `deleteMapping("mapping_wid_" + currentWid)`), fetches the new workflow's stored notebook and mapping from the microservice, sends the notebook to JupyterLab, and precomputes the highlight index. - `setIframeRef(iframe)`: called by the panel component (in `migration-tool-jupyter-panel`) once its iframe is in the DOM. - `onWorkflowComponentClick(cellUUID)`: public entry called by the workflow editor's click handler. If the iframe is present and the cell UUID maps to operators, it posts a `triggerCellClick` message to the notebook so the matching cell scrolls into view and is highlighted. The editor hook fires this once per click and only for operators. - `precomputeHighlightMapping` (private): builds a `cellUUID` keyed index of `{ components, edges }` from the stored mapping and the current graph's links, for O(1) lookup per cell click. It resets on every run so entries from a previously opened workflow do not linger, and it records each cell's components even when the graph has no links. - `handleNotebookMessage` (private): the `window` message handler installed in the constructor. It validates `event.origin` against the cached Jupyter origin, ignores messages whose data is not an object, then dispatches `highlightFromCell` for `cellClicked` events. - `resolveJupyterOrigin` (private): resolves the Jupyter server origin once and caches it (the backend serves a process static base URL). Both the origin check and the `postMessage` target use it, normalized with `new URL(...).origin`, so there is no network call per message or per click. - `highlightFromCell` (private): issues the `unhighlightOperators` / `unhighlightLinks` and `highlightOperators` / `highlightLinks` calls against `WorkflowActionService`. - `fetchNotebookAndMapping` (private): `POST /api/notebook-migration/fetch-notebook-and-mapping`, sets the mapping cache and forwards the notebook to JupyterLab. Returns `1` if both succeeded, `0` otherwise (caught errors included). The service is a `providedIn: "root"` singleton with no unused dependencies, decorators, or dead state. **`frontend/LICENSE-binary`** This service is the first reachable consumer of `NotebookMigrationService`, which statically bundles the `ai` SDK, so the AI SDK npm packages now land in the frontend bundle. `LICENSE-binary` claims them: `ai`, `@ai-sdk/openai`, `@ai-sdk/gateway`, `@ai-sdk/provider`, `@ai-sdk/provider-utils`, `@opentelemetry/api`, `@vercel/oidc` (Apache-2.0), and `zod`, `eventsource-parser` (MIT). All are ASF Category A and none ship a NOTICE file. ### Any related issues, documentation, discussions? Closes #5053 Parent issue #4301 ### How was this PR tested? `jupyter-panel.service.spec.ts` (Vitest) covers `init` (subscribe, stale mapping cleanup, fetch), `fetchNotebookAndMapping`, `precomputeHighlightMapping` for both linked and linkless graphs plus its per run reset, `highlightFromCell`, `onWorkflowComponentClick` including the cached origin resolving only once across clicks, and the feature flag gate. All tests pass. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 4.8) --- frontend/LICENSE-binary | 9 + frontend/src/app/app.module.ts | 9 +- .../workflow-editor/workflow-editor.component.ts | 7 +- .../jupyter-panel/jupyter-panel.service.spec.ts | 315 +++++++++++++++++++++ .../service/jupyter-panel/jupyter-panel.service.ts | 282 ++++++++++++++++++ 5 files changed, 620 insertions(+), 2 deletions(-) diff --git a/frontend/LICENSE-binary b/frontend/LICENSE-binary index ac390c179e..ec9ea36ede 100644 --- a/frontend/LICENSE-binary +++ b/frontend/LICENSE-binary @@ -211,6 +211,13 @@ Dependencies under the Apache License, Version 2.0 -------------------------------------------------------------------------------- Angular / npm packages: + - @ai-sdk/[email protected] + - @ai-sdk/[email protected] + - @ai-sdk/[email protected] + - @ai-sdk/[email protected] + - @opentelemetry/[email protected] + - @vercel/[email protected] + - [email protected] - [email protected] - [email protected] - [email protected] @@ -291,6 +298,7 @@ Angular / npm packages: - [email protected] - [email protected] - [email protected] + - [email protected] - [email protected] - [email protected] - [email protected] @@ -337,6 +345,7 @@ Angular / npm packages: - [email protected] - [email protected] - [email protected] + - [email protected] - [email protected] -------------------------------------------------------------------------------- diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 3180ae187f..fef2fd5aa9 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, ErrorHandler, NgModule } from "@angular/core"; +import { APP_INITIALIZER, CUSTOM_ELEMENTS_SCHEMA, APP_BOOTSTRAP_LISTENER, ErrorHandler, NgModule } from "@angular/core"; import { FormsModule, ReactiveFormsModule } from "@angular/forms"; import { BrowserModule } from "@angular/platform-browser"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; @@ -197,6 +197,7 @@ import { RegistrationRequestModalComponent } from "./common/service/user/registr 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 { UserVenvComponent } from "./dashboard/component/user/user-venv/user-venv.component"; +import { JupyterPanelService } from "./workspace/service/jupyter-panel/jupyter-panel.service"; registerLocaleData(en); @@ -420,6 +421,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 042c4669d2..f84ed89e88 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(); } @@ -679,6 +681,9 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy const elementID = event[0].model.id.toString(); const highlightedOperatorIDs = this.wrapper.getCurrentHighlightedOperatorIDs(); const highlightedCommentBoxIDs = this.wrapper.getCurrentHighlightedCommentBoxIDs(); + if (this.workflowActionService.getTexeraGraph().hasOperator(elementID)) { + this.jupyterPanelService.onWorkflowComponentClick(elementID); // highlight corresponding Jupyter notebook cell + } if (event[1].shiftKey) { // if in multiselect toggle highlights on click if (highlightedOperatorIDs.includes(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 0000000000..a90714c041 --- /dev/null +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts @@ -0,0 +1,315 @@ +/** + * 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 { NotebookMigrationService } from "../notebook-migration/notebook-migration.service"; +import { GuiConfigService } from "src/app/common/service/gui-config.service"; +import { firstValueFrom, of } from "rxjs"; + +describe("JupyterPanelService", () => { + let service: JupyterPanelService; + let httpMock: HttpTestingController; + + let mockWorkflow: 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: vi.fn().mockReturnValue(of({ wid: 1 })), + getWorkflow: vi.fn().mockReturnValue({ wid: 1 }), + getTexeraGraph: vi.fn().mockReturnValue({ + getAllLinks: () => [ + { + linkID: "L1", + source: { operatorID: "A" }, + target: { operatorID: "B" }, + }, + ], + getAllOperators: () => [{ operatorID: "A" }, { operatorID: "B" }], + }), + highlightOperators: vi.fn(), + highlightLinks: vi.fn(), + unhighlightOperators: vi.fn(), + unhighlightLinks: vi.fn(), + }; + + mockNotebook = { + hasMapping: vi.fn().mockReturnValue(true), + getMapping: vi.fn().mockReturnValue({ + cell_to_operator: { + cell1: ["A", "B"], + }, + operator_to_cell: {}, + }), + deleteMapping: vi.fn(), + setMapping: vi.fn(), + getJupyterURL: vi.fn().mockResolvedValue("http://jupyter"), + }; + + mockGuiConfig = { env: { pythonNotebookMigrationEnabled: true } }; + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + JupyterPanelService, + { provide: WorkflowActionService, useValue: mockWorkflow }, + { 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", async () => { + const resultPromise = firstValueFrom((service as any).fetchNotebookAndMapping(1, 1)); + + const req = httpMock.expectOne(r => r.url.includes("/notebook-migration/fetch-notebook-and-mapping")); + req.flush({ exists: false }); + + expect(await resultPromise).toBe(0); + }); + + // init(): subscribes to workflow changes, drops the stale mapping for the + // current workflow, and fetches the incoming workflow's notebook + mapping. + it("init subscribes, drops the stale mapping, and fetches for the new workflow", () => { + service.init(); + + expect(mockWorkflow.workflowMetaDataChanged).toHaveBeenCalled(); + expect(mockNotebook.deleteMapping).toHaveBeenCalledWith("mapping_wid_1"); + + const req = httpMock.expectOne(r => r.url.includes("/notebook-migration/fetch-notebook-and-mapping")); + req.flush({ exists: false }); + }); + + // Switching workflows must clear the highlight index even when the incoming + // workflow has no stored notebook (fetch returns exists=false), otherwise the + // previous workflow's highlights stay active. + it("init clears the highlight index on every workflow change", () => { + (service as any).cellToHighlightMapping = { stale: { components: ["X"], edges: [] } }; + + service.init(); + + // Cleared synchronously in the subscription, before the fetch resolves. + expect((service as any).cellToHighlightMapping).toEqual({}); + + httpMock.expectOne(r => r.url.includes("/notebook-migration/fetch-notebook-and-mapping")).flush({ exists: false }); + }); + + // An unsaved workflow has an undefined wid; init must not POST for it. + it("init does not fetch for an unsaved workflow (undefined wid)", () => { + mockWorkflow.workflowMetaDataChanged.mockReturnValue(of({ wid: undefined })); + mockWorkflow.getWorkflow.mockReturnValue({ wid: undefined }); + + service.init(); + + httpMock.expectNone(r => r.url.includes("/notebook-migration/fetch-notebook-and-mapping")); + }); + + // 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"); + }); + + // handleNotebookMessage must only act on cellClicked messages that come from + // our own iframe (event.source) AND carry the Jupyter origin. + it("handleNotebookMessage highlights only for messages from the iframe at the Jupyter origin", async () => { + const iframeWindow = {} as Window; + service.setIframeRef({ contentWindow: iframeWindow } as any); + const highlightSpy = vi.spyOn(service as any, "highlightFromCell").mockImplementation(() => {}); + const handle = (service as any).handleNotebookMessage; + + // wrong source (some other frame/script): ignored + await handle({ source: {}, origin: "http://jupyter", data: { action: "cellClicked", cellUUID: "c1" } }); + expect(highlightSpy).not.toHaveBeenCalled(); + + // right source, wrong origin: ignored + await handle({ source: iframeWindow, origin: "http://evil", data: { action: "cellClicked", cellUUID: "c1" } }); + expect(highlightSpy).not.toHaveBeenCalled(); + + // right source and origin: highlights + await handle({ source: iframeWindow, origin: "http://jupyter", data: { action: "cellClicked", cellUUID: "c1" } }); + expect(highlightSpy).toHaveBeenCalledWith("c1"); + }); + + // A workflow with operators but no links is valid; precompute must still + // record each cell's components (with empty edges) so cell clicks highlight. + it("precomputes component mappings even when the graph has no links", () => { + mockWorkflow.getTexeraGraph.mockReturnValue({ + getAllLinks: () => [], + getAllOperators: () => [{ operatorID: "A" }, { operatorID: "B" }], + }); + mockNotebook.getMapping.mockReturnValue({ + cell_to_operator: { cell1: ["A", "B"] }, + operator_to_cell: {}, + }); + + (service as any).precomputeHighlightMapping(); + + expect((service as any).cellToHighlightMapping).toEqual({ + cell1: { components: ["A", "B"], edges: [] }, + }); + }); + + // Switching workflows re-runs precompute; the map must reflect only the + // current workflow, not accumulate entries from previously opened ones. + it("resets the highlight mapping on each precompute", () => { + mockWorkflow.getTexeraGraph.mockReturnValue({ + getAllLinks: () => [], + getAllOperators: () => [], + }); + + mockNotebook.getMapping.mockReturnValue({ + cell_to_operator: { cellA: ["A"] }, + operator_to_cell: {}, + }); + (service as any).precomputeHighlightMapping(); + + mockNotebook.getMapping.mockReturnValue({ + cell_to_operator: { cellB: ["B"] }, + operator_to_cell: {}, + }); + (service as any).precomputeHighlightMapping(); + + expect((service as any).cellToHighlightMapping).toEqual({ + cellB: { components: ["B"], edges: [] }, + }); + }); + + // onWorkflowComponentClick + it("should postMessage when mapping exists", async () => { + const mockIframe = { + contentWindow: { + postMessage: vi.fn(), + }, + } as any; + + service.setIframeRef(mockIframe); + mockNotebook.getMapping.mockReturnValue({ + 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" + ); + }); + + it("does not postMessage when the operator maps to no cells", async () => { + const mockIframe = { + contentWindow: { postMessage: vi.fn() }, + } as any; + service.setIframeRef(mockIframe); + mockNotebook.getMapping.mockReturnValue({ + cell_to_operator: {}, + operator_to_cell: { op1: [] }, + }); + + await service.onWorkflowComponentClick("op1"); + + expect(mockIframe.contentWindow.postMessage).not.toHaveBeenCalled(); + }); + + // The Jupyter origin is process-static, so it must be resolved once and cached + // rather than re-fetched on every click / incoming message. + it("resolves the Jupyter URL only once across multiple clicks", async () => { + const mockIframe = { + contentWindow: { postMessage: vi.fn() }, + } as any; + service.setIframeRef(mockIframe); + mockNotebook.getMapping.mockReturnValue({ + cell_to_operator: {}, + operator_to_cell: { cell1: ["op1"] }, + }); + + await service.onWorkflowComponentClick("cell1"); + await service.onWorkflowComponentClick("cell1"); + await service.onWorkflowComponentClick("cell1"); + + expect(mockNotebook.getJupyterURL).toHaveBeenCalledTimes(1); + }); + + // 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: vi.fn() }, + } 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 0000000000..f2083e5c5d --- /dev/null +++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts @@ -0,0 +1,282 @@ +/** + * 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 { 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"; + +@Injectable({ + providedIn: "root", +}) +export class JupyterPanelService { + private iframeRef: HTMLIFrameElement | null = null; // Store reference to iframe element + + // Precomputed dictionary for cell to highlight mapping + private cellToHighlightMapping: Record<string, { components: string[]; edges: string[] }> = {}; + + // Cached Jupyter server origin (see resolveJupyterOrigin) + private jupyterOrigin: Promise<string | null> | null = null; + + constructor( + private workflowActionService: WorkflowActionService, + private http: HttpClient, + private notebookMigrationService: NotebookMigrationService, + private config: GuiConfigService + ) { + window.addEventListener("message", this.handleNotebookMessage); + } + + private get enabled(): boolean { + return this.config.env.pythonNotebookMigrationEnabled; + } + + /** + * Resolve and cache the Jupyter server origin, used both to validate incoming + * iframe messages and as the postMessage target. The backend serves a + * process-static base URL, so the origin is fixed for the app's lifetime. + * A failed/unavailable lookup is not cached, so it can be retried once the + * Jupyter pod becomes reachable. + */ + private resolveJupyterOrigin(): Promise<string | null> { + if (this.jupyterOrigin) { + return this.jupyterOrigin; + } + const pending: Promise<string | null> = this.notebookMigrationService.getJupyterURL().then(url => { + if (url) { + try { + return new URL(url).origin; + } catch { + /* malformed URL — fall through to retry */ + } + } + this.jupyterOrigin = null; // don't cache failures + return null; + }); + this.jupyterOrigin = pending; + return pending; + } + + public init(): void { + if (!this.enabled) return; + this.workflowActionService + .workflowMetaDataChanged() + .pipe( + map(meta => meta.wid), + distinctUntilChanged() + ) + .subscribe(wid => { + // On every workflow change, drop the outgoing workflow's stale mapping + // and clear the highlight index. Clearing here (not only inside + // precomputeHighlightMapping, which runs only on a successful fetch) + // ensures switching to a workflow without a stored notebook can't leave + // the previous workflow's highlights active. This cleanup previously + // happened inside closeJupyterNotebookPanel; the panel-visibility + // surface lives with the iframe component in + // `migration-tool-jupyter-panel` now, so it is inlined. + const currentWid = this.workflowActionService.getWorkflow().wid; + if (currentWid !== undefined) { + this.notebookMigrationService.deleteMapping("mapping_wid_" + currentWid); + } + this.cellToHighlightMapping = {}; + // Skip unsaved workflows (wid undefined) and wid 0; both would POST + // without a usable wid and 500 on the backend. + if (wid) { + 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 { + // Rebuild from scratch so entries from a previously opened workflow don't linger. + this.cellToHighlightMapping = {}; + + 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(); + + 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; + + // Only accept messages posted by our own notebook iframe. This is the + // strong check: it rejects any other same-origin frame or script trying to + // drive highlighting with a synthetic cellClicked message. + if (!this.iframeRef || event.source !== this.iframeRef.contentWindow) { + return; + } + + // Defense in depth: also require the message origin to match the resolved + // Jupyter origin. + const jupyterOrigin = await this.resolveJupyterOrigin(); + if (!jupyterOrigin || event.origin !== jupyterOrigin) { + return; + } + + const { action, cellUUID } = event.data ?? {}; + if (action === "cellClicked") { + 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 operator is clicked to trigger the corresponding notebook cell(s) + async onWorkflowComponentClick(operatorId: string): Promise<void> { + if (!this.enabled) return; + const jupyterOrigin = await this.resolveJupyterOrigin(); + if (jupyterOrigin && 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 cellIds = mappingEntry["operator_to_cell"][operatorId]; + if (cellIds && cellIds.length > 0) { + // "operators" is the payload key custom.js expects; the values are the + // mapped cell UUIDs for the clicked operator. + this.iframeRef.contentWindow.postMessage({ action: "triggerCellClick", operators: cellIds }, jupyterOrigin); + } else { + console.error(`No cells mapped to operator: ${operatorId}`); + } + } + } +}
