This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new fa075d1154 feat(python-notebook-migration): add Jupyter panel mapping
highlight service (#5263)
fa075d1154 is described below
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}`);
+ }
+ }
+ }
+}