From f9c16398046a72695c4d59231d67e1b8eb16beb2 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Wed, 27 May 2026 22:04:12 -0700 Subject: [PATCH 1/2] added frontend notebook-migration service that communicates between the UI and lower layers --- .../notebook-migration.service.spec.ts | 187 +++++++++++++++++ .../notebook-migration.service.ts | 195 ++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts create mode 100644 frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts diff --git a/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts new file mode 100644 index 00000000000..64fe32f4fd9 --- /dev/null +++ b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts @@ -0,0 +1,187 @@ +/** + * 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 { NotebookMigrationService } from "./notebook-migration.service"; +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { NotificationService } from "src/app/common/service/notification/notification.service"; + +describe("NotebookMigrationService", () => { + let service: NotebookMigrationService; + let httpMock: HttpTestingController; + let mockNotificationService: any; + + beforeEach(() => { + mockNotificationService = { + success: jasmine.createSpy("success"), + error: jasmine.createSpy("error"), + }; + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [NotebookMigrationService, { provide: NotificationService, useValue: mockNotificationService }], + }); + + service = TestBed.inject(NotebookMigrationService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + // getAvailableModels + it("should fetch and map available models", done => { + const mockResponse = { + data: [ + { id: "gpt-4", object: "", created: 0, owned_by: "" }, + { id: "gpt-3.5", object: "", created: 0, owned_by: "" }, + ], + object: "", + }; + + service.getAvailableModels().subscribe(models => { + expect(models.length).toBe(2); + expect(models[0].name).toBe("gpt-4"); + done(); + }); + + const req = httpMock.expectOne(req => req.url.includes("/models")); + expect(req.request.method).toBe("GET"); + req.flush(mockResponse); + }); + + it("should return empty array on getAvailableModels error", done => { + service.getAvailableModels().subscribe(models => { + expect(models).toEqual([]); + done(); + }); + + const req = httpMock.expectOne(req => req.url.includes("/models")); + req.error(new ErrorEvent("Network error")); + }); + + // sendNotebookToJupyter + it("should send notebook successfully and return 1", async () => { + const mockNotebook: any = { cells: [] }; + + const promise = service.sendNotebookToJupyter(mockNotebook); + + const req = httpMock.expectOne(req => req.url.includes("/notebook-migration/set-notebook")); + + expect(req.request.method).toBe("POST"); + + req.flush({ success: true }); + + const result = await promise; + + expect(result).toBe(1); + expect(mockNotificationService.success).toHaveBeenCalled(); + }); + + it("should handle error when sending notebook and return 0", async () => { + const mockNotebook: any = { cells: [] }; + + const promise = service.sendNotebookToJupyter(mockNotebook); + + const req = httpMock.expectOne(req => req.url.includes("/notebook-migration/set-notebook")); + + req.error(new ErrorEvent("Server error")); + + const result = await promise; + + expect(result).toBe(0); + expect(mockNotificationService.error).toHaveBeenCalled(); + }); + + // fetch methods + it("should return Jupyter URL when fetch succeeds", async () => { + spyOn(window, "fetch").and.resolveTo({ + ok: true, + json: async () => ({ success: true, url: "http://jupyter" }), + } as any); + + const result = await service.getJupyterURL(); + + expect(result).toBe("http://jupyter"); + }); + + it("should return null when fetch fails", async () => { + spyOn(window, "fetch").and.resolveTo({ + ok: false, + status: 500, + } as any); + + const result = await service.getJupyterURL(); + + expect(result).toBeNull(); + }); + + it("should return iframe URL when fetch succeeds", async () => { + spyOn(window, "fetch").and.resolveTo({ + ok: true, + json: async () => ({ success: true, url: "http://iframe" }), + } as any); + + const result = await service.getJupyterIframeURL(); + + expect(result).toBe("http://iframe"); + }); + + it("should return null when iframe fetch fails", async () => { + spyOn(window, "fetch").and.resolveTo({ + ok: false, + status: 500, + } as any); + + const result = await service.getJupyterIframeURL(); + + expect(result).toBeNull(); + }); + + // mapping logic + it("should set and get mapping", () => { + const mockMapping: any = { + cell_to_operator: { a: 1 }, + operator_to_cell: { b: 2 }, + }; + + service.setMapping("test", mockMapping); + + expect(service.hasMapping("test")).toBeTrue(); + expect(service.getMapping("test")).toEqual(mockMapping); + }); + + it("should delete mapping", () => { + service.setMapping("test", { cell_to_operator: {}, operator_to_cell: {} }); + + service.deleteMapping("test"); + + expect(service.hasMapping("test")).toBeFalse(); + }); + + // storeNotebookAndMapping + it("should call storeNotebookAndMapping API", () => { + service.storeNotebookAndMapping(1, 1, {}, {}).subscribe(); + + const req = httpMock.expectOne(req => req.url.includes("/notebook-migration/store-notebook-and-mapping")); + + expect(req.request.method).toBe("POST"); + }); +}); diff --git a/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts new file mode 100644 index 00000000000..4e7e2a3ff17 --- /dev/null +++ b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts @@ -0,0 +1,195 @@ +/** + * 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 { UntilDestroy } from "@ngneat/until-destroy"; +import { Injectable } from "@angular/core"; +import { AppSettings } from "../../../common/app-setting"; +import { Notebook, NotebookMigrationLLM } from "./migration-llm"; +import { HttpClient, HttpHeaders } from "@angular/common/http"; +import { NotificationService } from "src/app/common/service/notification/notification.service"; +import { catchError, firstValueFrom, map, Observable, of } from "rxjs"; + +interface LiteLLMModel { + id: string; + object: string; + created: number; + owned_by: string; +} + +interface LiteLLMModelsResponse { + data: LiteLLMModel[]; + object: string; +} + +interface MappingContent { + cell_to_operator: { [key: string]: any }; + operator_to_cell: { [key: string]: any }; +} + +@UntilDestroy() +@Injectable({ + providedIn: "root", +}) +export class NotebookMigrationService { + private mapping: { [key: string]: MappingContent } = { + default: { + cell_to_operator: {}, + operator_to_cell: {}, + }, + }; + + constructor( + private http: HttpClient, + private notificationService: NotificationService + ) {} + + public getAvailableModels(): Observable<{ name: string }[]> { + return this.http.get(`${AppSettings.getApiEndpoint()}/models`).pipe( + map(response => + response.data.map(model => ({ + name: model.id, + })) + ), + catchError((err: unknown) => { + console.error("Failed to fetch models", err); + return of([]); + }) + ); + } + + public async sendToAIGenerateWorkflow(notebookContent: Notebook, modelType: string, apiKey: string) { + const migrationLLM = new NotebookMigrationLLM(); + migrationLLM.initialize(modelType, apiKey); + + const isValid = await migrationLLM.verifyConnection(); + if (!isValid) { + throw new Error("Invalid API key or backend connection"); + } + + try { + const result = await firstValueFrom(await migrationLLM.convertNotebookToWorkflow(notebookContent)); + const parsedResult = JSON.parse(result); + const workflowContent = parsedResult.workflowJSON; + const mappingContent = parsedResult.workflowNotebookMapping; + return { workflowContent, mappingContent }; + } catch (error) { + console.error("Error converting notebook:", error); + } finally { + migrationLLM.close(); + } + } + + public async sendNotebookToJupyter(notebookData: Notebook) { + const jupyterAPIUrl = `${AppSettings.getApiEndpoint()}/notebook-migration/set-notebook`; + + const requestBody = { + notebookName: "notebook.ipynb", + notebookData: notebookData, + }; + + const headers = new HttpHeaders({ + "Content-Type": "application/json", + }); + + try { + const response: any = await firstValueFrom(this.http.post(jupyterAPIUrl, requestBody, { headers })); + this.notificationService.success("Notebook successfully sent to Jupyter"); + return 1; + } catch (error) { + console.error("Error sending notebook to pod: ", error); + // @ts-ignore + this.notificationService.error("Error sending notebook to Jupyter: " + error.message); + return 0; + } + } + + public async getJupyterURL(): Promise { + try { + const response = await fetch("/api/notebook-migration/get-jupyter-url"); + if (!response.ok) { + console.error("Failed to get Jupyter URL:", response.status); + return null; + } + + const data = (await response.json()) as { success: boolean; url?: string }; + + if (!data.success || !data.url) { + console.error("Jupyter server unavailable"); + return null; + } + + return data.url; + } catch (err) { + console.error("Error fetching Jupyter URL:", err); + return null; + } + } + + public async getJupyterIframeURL(): Promise { + try { + const response = await fetch("/api/notebook-migration/get-jupyter-iframe-url"); + if (!response.ok) { + console.error("Failed to get Jupyter iframe URL:", response.status); + return null; + } + + const data = (await response.json()) as { success: boolean; url?: string }; + + if (!data.success || !data.url) { + console.error("Jupyter server unavailable"); + return null; + } + + return data.url; + } catch (err) { + console.error("Error fetching Jupyter iframe URL:", err); + return null; + } + } + + public storeNotebookAndMapping(wid: number | undefined, vid: number = 1, mappingContent: any, notebookContent: any) { + const dbAPIUrl = `${AppSettings.getApiEndpoint()}/notebook-migration/store-notebook-and-mapping`; + const headers = new HttpHeaders({ "Content-Type": "application/json" }); + + const payload = { + wid, + vid, + mapping: mappingContent, + notebook: notebookContent, + }; + + return this.http.post(dbAPIUrl, payload, { headers }); + } + + public hasMapping(id: string): boolean { + return id in this.mapping; + } + + public getMapping(id: string): MappingContent | undefined { + return this.mapping[id]; + } + + public setMapping(id: string, value: MappingContent): void { + this.mapping[id] = value; + } + + public deleteMapping(id: string): void { + delete this.mapping[id]; + } +} From a5f84b830c083e4c0edfb869944f224b2bd368f0 Mon Sep 17 00:00:00 2001 From: Ryan Zhang Date: Wed, 27 May 2026 22:23:49 -0700 Subject: [PATCH 2/2] added flag to disable the service and appropriate test cases --- .../notebook-migration.service.spec.ts | 67 ++++++++++++++++++- .../notebook-migration.service.ts | 14 +++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts index 64fe32f4fd9..0f66d0bae3a 100644 --- a/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts +++ b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts @@ -21,21 +21,32 @@ import { TestBed } from "@angular/core/testing"; import { NotebookMigrationService } from "./notebook-migration.service"; import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; import { NotificationService } from "src/app/common/service/notification/notification.service"; +import { GuiConfigService } from "src/app/common/service/gui-config.service"; describe("NotebookMigrationService", () => { let service: NotebookMigrationService; let httpMock: HttpTestingController; let mockNotificationService: any; + // Mutable so individual describe blocks can flip the flag mid-spec by + // reassigning `mockGuiConfigService.env.pythonNotebookMigrationEnabled`. + // The service stores a reference to this object, so mutations are observed + // on the next read of `this.enabled`. + let mockGuiConfigService: { env: { pythonNotebookMigrationEnabled: boolean } }; beforeEach(() => { mockNotificationService = { success: jasmine.createSpy("success"), error: jasmine.createSpy("error"), }; + mockGuiConfigService = { env: { pythonNotebookMigrationEnabled: true } }; TestBed.configureTestingModule({ imports: [HttpClientTestingModule], - providers: [NotebookMigrationService, { provide: NotificationService, useValue: mockNotificationService }], + providers: [ + NotebookMigrationService, + { provide: NotificationService, useValue: mockNotificationService }, + { provide: GuiConfigService, useValue: mockGuiConfigService }, + ], }); service = TestBed.inject(NotebookMigrationService); @@ -184,4 +195,58 @@ describe("NotebookMigrationService", () => { expect(req.request.method).toBe("POST"); }); + + // Feature flag gate (defence in depth). With the flag off, every public + // method must short-circuit — no HTTP traffic, no fetch, no LLM lifecycle, + // no notifications. + describe("when the feature flag is disabled", () => { + beforeEach(() => { + mockGuiConfigService.env.pythonNotebookMigrationEnabled = false; + }); + + it("getAvailableModels emits an empty array and makes no HTTP call", done => { + service.getAvailableModels().subscribe(models => { + expect(models).toEqual([]); + done(); + }); + httpMock.expectNone(req => req.url.includes("/models")); + }); + + it("sendToAIGenerateWorkflow rejects with a disabled-feature error", async () => { + await expectAsync( + service.sendToAIGenerateWorkflow({ cells: [] } as any, "gpt-4", "key") + ).toBeRejectedWithError(/disabled/i); + }); + + it("sendNotebookToJupyter returns 0 with no HTTP call or notification", async () => { + const result = await service.sendNotebookToJupyter({ cells: [] } as any); + expect(result).toBe(0); + expect(mockNotificationService.success).not.toHaveBeenCalled(); + expect(mockNotificationService.error).not.toHaveBeenCalled(); + httpMock.expectNone(req => req.url.includes("/notebook-migration/set-notebook")); + }); + + it("getJupyterURL returns null without calling fetch", async () => { + const fetchSpy = spyOn(window, "fetch"); + const result = await service.getJupyterURL(); + expect(result).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("getJupyterIframeURL returns null without calling fetch", async () => { + const fetchSpy = spyOn(window, "fetch"); + const result = await service.getJupyterIframeURL(); + expect(result).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("storeNotebookAndMapping emits without making an HTTP call", done => { + service.storeNotebookAndMapping(1, 1, {}, {}).subscribe({ + next: () => { + httpMock.expectNone(req => req.url.includes("/notebook-migration/store-notebook-and-mapping")); + done(); + }, + }); + }); + }); }); diff --git a/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts index 4e7e2a3ff17..f0b05eae7cb 100644 --- a/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts +++ b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts @@ -23,6 +23,7 @@ import { AppSettings } from "../../../common/app-setting"; import { Notebook, NotebookMigrationLLM } from "./migration-llm"; import { HttpClient, HttpHeaders } from "@angular/common/http"; import { NotificationService } from "src/app/common/service/notification/notification.service"; +import { GuiConfigService } from "../../../common/service/gui-config.service"; import { catchError, firstValueFrom, map, Observable, of } from "rxjs"; interface LiteLLMModel { @@ -56,10 +57,16 @@ export class NotebookMigrationService { constructor( private http: HttpClient, - private notificationService: NotificationService + private notificationService: NotificationService, + private config: GuiConfigService ) {} + private get enabled(): boolean { + return this.config.env.pythonNotebookMigrationEnabled; + } + public getAvailableModels(): Observable<{ name: string }[]> { + if (!this.enabled) return of([]); return this.http.get(`${AppSettings.getApiEndpoint()}/models`).pipe( map(response => response.data.map(model => ({ @@ -74,6 +81,7 @@ export class NotebookMigrationService { } public async sendToAIGenerateWorkflow(notebookContent: Notebook, modelType: string, apiKey: string) { + if (!this.enabled) throw new Error("Notebook migration feature is disabled"); const migrationLLM = new NotebookMigrationLLM(); migrationLLM.initialize(modelType, apiKey); @@ -96,6 +104,7 @@ export class NotebookMigrationService { } public async sendNotebookToJupyter(notebookData: Notebook) { + if (!this.enabled) return 0; const jupyterAPIUrl = `${AppSettings.getApiEndpoint()}/notebook-migration/set-notebook`; const requestBody = { @@ -120,6 +129,7 @@ export class NotebookMigrationService { } public async getJupyterURL(): Promise { + if (!this.enabled) return null; try { const response = await fetch("/api/notebook-migration/get-jupyter-url"); if (!response.ok) { @@ -142,6 +152,7 @@ export class NotebookMigrationService { } public async getJupyterIframeURL(): Promise { + if (!this.enabled) return null; try { const response = await fetch("/api/notebook-migration/get-jupyter-iframe-url"); if (!response.ok) { @@ -164,6 +175,7 @@ export class NotebookMigrationService { } public storeNotebookAndMapping(wid: number | undefined, vid: number = 1, mappingContent: any, notebookContent: any) { + if (!this.enabled) return of(null); const dbAPIUrl = `${AppSettings.getApiEndpoint()}/notebook-migration/store-notebook-and-mapping`; const headers = new HttpHeaders({ "Content-Type": "application/json" });