From 8c9ab603731619801033c4f79b83f6cb20602d03 Mon Sep 17 00:00:00 2001 From: aldbr Date: Thu, 22 May 2025 18:17:22 +0200 Subject: [PATCH 1/2] feat: add input/output sandbox download support --- .../components/JobMonitor/JobDataService.ts | 41 ++++++++++++- .../components/JobMonitor/JobDataTable.tsx | 57 +++++++++++++++++++ .../src/types/Sandbox.ts | 10 ++++ .../diracx-web-components/src/types/index.ts | 1 + 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 packages/diracx-web-components/src/types/Sandbox.ts diff --git a/packages/diracx-web-components/src/components/JobMonitor/JobDataService.ts b/packages/diracx-web-components/src/components/JobMonitor/JobDataService.ts index 9684f7f2..1e1022ab 100644 --- a/packages/diracx-web-components/src/components/JobMonitor/JobDataService.ts +++ b/packages/diracx-web-components/src/components/JobMonitor/JobDataService.ts @@ -6,7 +6,14 @@ import utc from "dayjs/plugin/utc"; dayjs.extend(utc); import { fetcher } from "../../hooks/utils"; -import { Filter, SearchBody, Job, JobHistory } from "../../types"; +import { + Filter, + SearchBody, + Job, + JobHistory, + JobSandboxPFNResponse, + SandboxUrlResponse, +} from "../../types"; function processSearchBody(searchBody: SearchBody) { searchBody.search = searchBody.search?.map((filter: Filter) => { @@ -203,3 +210,35 @@ export async function getJobHistory( return { data: data[0].LoggingInfo }; } + +/** + * Retrieves the sandbox information for a given job ID and sandbox type. + * @param jobId - The ID of the job. + * @param sbType - The type of the sandbox (input or output). + * @param accessToken - The authentication token. + * @returns A Promise that resolves to an object containing the headers and data of the sandboxes. + */ +export function getJobSandbox( + diracxUrl: string | null, + jobId: number, + sbType: "input" | "output", + accessToken: string, +): Promise<{ headers: Headers; data: JobSandboxPFNResponse }> { + const url = `${diracxUrl}/api/jobs/${jobId}/sandbox/${sbType}`; + return fetcher([url, accessToken]); +} + +/** + * Retrieves the sandbox URL for a given PFN. + * @param pfn - The PFN of the job. + * @param accessToken - The authentication token. + * @returns A Promise that resolves to an object containing the headers and data of the sandbox URL. + */ +export function getJobSandboxUrl( + diracxUrl: string | null, + pfn: string, + accessToken: string, +): Promise<{ headers: Headers; data: SandboxUrlResponse }> { + const url = `${diracxUrl}/api/jobs/sandbox?pfn=${pfn}`; + return fetcher([url, accessToken]); +} diff --git a/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx b/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx index 4e8248e3..da142a85 100644 --- a/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx +++ b/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx @@ -31,6 +31,8 @@ import { JobHistoryDialog } from "./JobHistoryDialog"; import { deleteJobs, getJobHistory, + getJobSandbox, + getJobSandboxUrl, killJobs, refreshJobs, rescheduleJobs, @@ -362,6 +364,53 @@ export function JobDataTable({ setIsHistoryDialogOpen(false); }; + const handleSandboxDownload = async ( + jobId: number | null, + sbType: "input" | "output", + ) => { + if (!jobId) return; + setBackdropOpen(true); + try { + const { data } = await getJobSandbox( + diracxUrl, + jobId, + sbType, + accessToken, + ); + if (!data) throw new Error(`No sandbox found`); + const pfn = data[0]; + setBackdropOpen(false); + if (pfn) { + const { data } = await getJobSandboxUrl(diracxUrl, pfn, accessToken); + if (data?.url) { + const link = document.createElement("a"); + link.href = data.url; + link.download = `${sbType}-sandbox-${jobId}.tar.gz`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + setSnackbarInfo({ + open: true, + message: `Downloading ${sbType} sandbox of ${jobId}...`, + severity: "info", + }); + } else throw new Error(`Could not fetch the sandbox from ${data.url}`); + } else throw new Error(`No ${sbType} sandbox found`); + } catch (error: unknown) { + let errorMessage = "An unknown error occurred"; + if (error instanceof Error) { + errorMessage = error.message; + } + setSnackbarInfo({ + open: true, + message: `Fetching sandbox of ${jobId} failed: ` + errorMessage, + severity: "error", + }); + } finally { + setBackdropOpen(false); + } + }; + /** * The toolbar components for the data grid */ @@ -397,6 +446,14 @@ export function JobDataTable({ label: "Get history", onClick: (id: number | null) => handleHistory(id), }, + { + label: "Download input sandbox", + onClick: (id: number | null) => handleSandboxDownload(id, "input"), + }, + { + label: "Download output sandbox", + onClick: (id: number | null) => handleSandboxDownload(id, "output"), + }, ], [handleHistory], ); diff --git a/packages/diracx-web-components/src/types/Sandbox.ts b/packages/diracx-web-components/src/types/Sandbox.ts new file mode 100644 index 00000000..1639b564 --- /dev/null +++ b/packages/diracx-web-components/src/types/Sandbox.ts @@ -0,0 +1,10 @@ +// Types for sandbox-related API responses + +// Response for /api/jobs//sandbox/ +export type JobSandboxPFNResponse = string[]; + +// Response for /api/jobs/sandbox?pfn=... +export interface SandboxUrlResponse { + url: string; + expires_in: number; +} diff --git a/packages/diracx-web-components/src/types/index.ts b/packages/diracx-web-components/src/types/index.ts index 0e02f182..8826b4a8 100644 --- a/packages/diracx-web-components/src/types/index.ts +++ b/packages/diracx-web-components/src/types/index.ts @@ -5,3 +5,4 @@ export * from "./DashboardItem"; export * from "./SearchBody"; export * from "./Job"; export * from "./JobHistory"; +export * from "./Sandbox"; From 5981d1efcd05552a81ecde4ae06bc8ecb1731c96 Mon Sep 17 00:00:00 2001 From: aldbr Date: Mon, 26 May 2025 17:31:24 +0200 Subject: [PATCH 2/2] feat: add mocks and unit tests --- .../components/JobMonitor/JobDataTable.tsx | 3 + .../src/components/shared/DataTable.tsx | 2 + .../stories/mocks/JobDataService.mock.tsx | 25 +++++++++ .../test/JobMonitor.test.tsx | 56 ++++++++++++++++++- 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx b/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx index da142a85..87781710 100644 --- a/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx +++ b/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx @@ -445,14 +445,17 @@ export function JobDataTable({ { label: "Get history", onClick: (id: number | null) => handleHistory(id), + dataTestId: "get-history-button", }, { label: "Download input sandbox", onClick: (id: number | null) => handleSandboxDownload(id, "input"), + dataTestId: "download-input-sandbox-button", }, { label: "Download output sandbox", onClick: (id: number | null) => handleSandboxDownload(id, "output"), + dataTestId: "download-output-sandbox-button", }, ], [handleHistory], diff --git a/packages/diracx-web-components/src/components/shared/DataTable.tsx b/packages/diracx-web-components/src/components/shared/DataTable.tsx index aae7e16f..11044c33 100644 --- a/packages/diracx-web-components/src/components/shared/DataTable.tsx +++ b/packages/diracx-web-components/src/components/shared/DataTable.tsx @@ -39,6 +39,7 @@ import { SearchBody } from "../../types"; export interface MenuItem { label: string; onClick: (id: number | null) => void; + dataTestId?: string; } /** @@ -579,6 +580,7 @@ export function DataTable>({ {menuItems.map((menuItem, index: number) => ( { handleCloseContextMenu(); menuItem.onClick(contextMenu.id); diff --git a/packages/diracx-web-components/stories/mocks/JobDataService.mock.tsx b/packages/diracx-web-components/stories/mocks/JobDataService.mock.tsx index eec4eb9a..e0a4058e 100644 --- a/packages/diracx-web-components/stories/mocks/JobDataService.mock.tsx +++ b/packages/diracx-web-components/stories/mocks/JobDataService.mock.tsx @@ -155,3 +155,28 @@ export function rescheduleJobs( }, }); } + +// Mock implementation of getJobSandbox +export function getJobSandbox( + diracxUrl: string | null, + jobId: number, + sbType: "input" | "output", + accessToken: string, +): Promise<{ headers: Headers; data: any }> { + return Promise.resolve({ + headers: new Headers(), + data: [], + }); +} + +// Mock implementation of getJobSandboxUrl +export function getJobSandboxUrl( + diracxUrl: string | null, + pfn: string, + accessToken: string, +): Promise<{ headers: Headers; data: any }> { + return Promise.resolve({ + headers: new Headers(), + data: {}, + }); +} diff --git a/packages/diracx-web-components/test/JobMonitor.test.tsx b/packages/diracx-web-components/test/JobMonitor.test.tsx index 8a091efd..03bbc126 100644 --- a/packages/diracx-web-components/test/JobMonitor.test.tsx +++ b/packages/diracx-web-components/test/JobMonitor.test.tsx @@ -78,11 +78,63 @@ describe("JobDataTable", () => { expect(getByText("Job accepted")).toBeInTheDocument(); }); }); + + it("displays the snackbar: no input sandbox", async () => { + const { getByText, getByTestId } = render( + + + , + ); + + await act(async () => { + fireEvent.contextMenu(getByText("Job 1")); + }); + + // Now wait for the context menu to appear and click Get history + await act(async () => { + fireEvent.click(getByTestId("download-input-sandbox-button")); + // Allow time for state updates to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // Now check for the dialog + await waitFor(() => { + expect(screen.getByText(/No input sandbox found/)).toBeInTheDocument(); + }); + }); + + it("displays the snackbar: no output sandbox", async () => { + const { getByText, getByTestId } = render( + + + , + ); + + await act(async () => { + fireEvent.contextMenu(getByText("Job 1")); + }); + + // Now wait for the context menu to appear and click Get history + await act(async () => { + fireEvent.click(getByTestId("download-output-sandbox-button")); + // Allow time for state updates to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // Now check for the dialog + await waitFor(() => { + expect(screen.getByText(/No output sandbox found/)).toBeInTheDocument(); + }); + }); }); describe("JobHistoryDialog", () => { it("renders the dialog with correct data", async () => { - const { getByText } = render( + const { getByText, getByTestId } = render( @@ -96,7 +148,7 @@ describe("JobHistoryDialog", () => { // Now wait for the context menu to appear and click Get history await act(async () => { - fireEvent.click(getByText("Get history")); + fireEvent.click(getByTestId("get-history-button")); // Allow time for state updates to complete await new Promise((resolve) => setTimeout(resolve, 0)); });