diff --git a/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx b/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx index 9e99e280..9770e0d2 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, rescheduleJobs, useJobs, @@ -182,7 +184,6 @@ export function JobDataTable({ diracxUrl, rowSelection, clearSelected, - setSearchBody, mutateJobs, ]); @@ -251,7 +252,6 @@ export function JobDataTable({ diracxUrl, rowSelection, clearSelected, - setSearchBody, mutateJobs, ]); @@ -311,29 +311,19 @@ export function JobDataTable({ } finally { setBackdropOpen(false); } - }, [ - accessToken, - diracxUrl, - rowSelection, - clearSelected, - setSearchBody, - mutateJobs, - ]); + }, [accessToken, diracxUrl, rowSelection, clearSelected, mutateJobs]); /** * Handle the history of the selected job */ const handleHistory = useCallback( - async (selectedId: number | null) => { + async (selectedId: string | null) => { if (!selectedId) return; + const jobId = Number(selectedId); setBackdropOpen(true); - setSelectedJobId(selectedId); + setSelectedJobId(jobId); try { - const { data } = await getJobHistory( - diracxUrl, - selectedId, - accessToken, - ); + const { data } = await getJobHistory(diracxUrl, jobId, accessToken); setBackdropOpen(false); // Show the history setJobHistoryData(data); @@ -360,6 +350,61 @@ export function JobDataTable({ setIsHistoryDialogOpen(false); }; + const handleSandboxDownload = useCallback( + async (selectedId: string | null, sbType: "input" | "output") => { + if (!selectedId) return; + const jobId = Number(selectedId); + setBackdropOpen(true); + try { + const { data: sandboxData } = await getJobSandbox( + diracxUrl, + jobId, + sbType, + accessToken, + ); + if (sandboxData.length === 0) + throw new Error(`No ${sbType} sandbox found`); + const pfn = sandboxData[0]; + if (pfn) { + const { data: urlData } = await getJobSandboxUrl( + diracxUrl, + pfn, + accessToken, + ); + if (urlData?.url) { + const link = document.createElement("a"); + link.href = urlData.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 retrieve a download URL for the sandbox", + ); + } 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); + } + }, + [accessToken, diracxUrl], + ); + /** * The toolbar components for the data grid */ @@ -405,10 +450,21 @@ export function JobDataTable({ () => [ { label: "Get history", - onClick: (id: string | null) => handleHistory(Number(id)), + onClick: (id: string | null) => handleHistory(id), + dataTestId: "get-history-button", + }, + { + label: "Download input sandbox", + onClick: (id: string | null) => handleSandboxDownload(id, "input"), + dataTestId: "download-input-sandbox-button", + }, + { + label: "Download output sandbox", + onClick: (id: string | null) => handleSandboxDownload(id, "output"), + dataTestId: "download-output-sandbox-button", }, ], - [handleHistory], + [handleHistory, handleSandboxDownload], ); /** diff --git a/packages/diracx-web-components/src/components/JobMonitor/jobDataService.ts b/packages/diracx-web-components/src/components/JobMonitor/jobDataService.ts index f760d95d..0105c4f6 100644 --- a/packages/diracx-web-components/src/components/JobMonitor/jobDataService.ts +++ b/packages/diracx-web-components/src/components/JobMonitor/jobDataService.ts @@ -5,8 +5,15 @@ import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import { fetcher } from "../../hooks/utils"; -import { Filter, SearchBody, Job, JobHistory } from "../../types"; import type { JobSummary } from "../../types"; +import { + Filter, + SearchBody, + Job, + JobHistory, + JobSandboxPFNResponse, + SandboxUrlResponse, +} from "../../types"; type TimeUnit = "minute" | "hour" | "day" | "month" | "year"; @@ -194,6 +201,38 @@ 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=${encodeURIComponent(pfn)}`; + return fetcher([url, accessToken]); +} + /** * Retrieves the job summary for a given grouping. * diff --git a/packages/diracx-web-components/src/components/shared/DataTable.tsx b/packages/diracx-web-components/src/components/shared/DataTable.tsx index 02a5c38e..1b4d6e06 100644 --- a/packages/diracx-web-components/src/components/shared/DataTable.tsx +++ b/packages/diracx-web-components/src/components/shared/DataTable.tsx @@ -45,6 +45,7 @@ import { SearchBody } from "../../types"; export interface MenuItem { label: string; onClick: (id: string | null) => void; + dataTestId?: string; } /** @@ -655,6 +656,7 @@ export function DataTable>({ {menuItems.map((menuItem, index: number) => ( { handleCloseContextMenu(); menuItem.onClick(contextMenu.id); 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..2e6d28c8 --- /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 | null)[]; + +// 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 880b578e..a8735fec 100644 --- a/packages/diracx-web-components/src/types/index.ts +++ b/packages/diracx-web-components/src/types/index.ts @@ -15,3 +15,4 @@ export * from "./EquationStatus"; export * from "./operators"; export * from "./SearchBarTokenNature"; export * from "./CategoryType"; +export * from "./Sandbox"; diff --git a/packages/diracx-web-components/stories/mocks/jobDataService.mock.tsx b/packages/diracx-web-components/stories/mocks/jobDataService.mock.tsx index bd3c17ec..95417064 100644 --- a/packages/diracx-web-components/stories/mocks/jobDataService.mock.tsx +++ b/packages/diracx-web-components/stories/mocks/jobDataService.mock.tsx @@ -1,5 +1,12 @@ /* eslint-disable */ -import { Job, JobHistory, SearchBody, JobSummary } from "../../src/types"; +import { + Job, + JobHistory, + SearchBody, + JobSummary, + JobSandboxPFNResponse, + SandboxUrlResponse, +} from "../../src/types"; import { useJobMockContext } from "./contexts.mock"; @@ -67,7 +74,7 @@ export function useJobs( }; } -// Mock implementation of `getJobHistory` +// Mock implementation of getJobHistory export const getJobHistory = async ( _diracxUrl: string | null, _jobId: number, @@ -79,6 +86,31 @@ export const getJobHistory = async ( return { data: mockJobHistoryResponse.jobHistory || [] }; }; +// Mock implementation of getJobSandbox +export function getJobSandbox( + diracxUrl: string | null, + jobId: number, + sbType: "input" | "output", + accessToken: string, +): Promise<{ headers: Headers; data: JobSandboxPFNResponse }> { + 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: SandboxUrlResponse }> { + return Promise.resolve({ + headers: new Headers(), + data: { url: "", expires_in: 0 }, + }); +} + // Mock implementation of refreshJobs export const refreshJobs = ( _diracxUrl: string | null, diff --git a/packages/diracx-web-components/test/JobMonitor.test.tsx b/packages/diracx-web-components/test/JobMonitor.test.tsx index d9ac835b..52181ce2 100644 --- a/packages/diracx-web-components/test/JobMonitor.test.tsx +++ b/packages/diracx-web-components/test/JobMonitor.test.tsx @@ -74,11 +74,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 Download input sandbox + 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 Download output sandbox + 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( @@ -92,7 +144,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)); }); diff --git a/packages/diracx-web/test/e2e/jobMonitor.sandbox.cy.ts b/packages/diracx-web/test/e2e/jobMonitor.sandbox.cy.ts new file mode 100644 index 00000000..25fc95e3 --- /dev/null +++ b/packages/diracx-web/test/e2e/jobMonitor.sandbox.cy.ts @@ -0,0 +1,42 @@ +/// +/// + +import { + setupJobMonitorDashboard, + addJobWithOutputSandbox, +} from "./support/jobMonitorUtils"; + +describe("Job Monitor - Sandbox Download", () => { + beforeEach(() => { + cy.login(); + cy.visit("/"); + setupJobMonitorDashboard(); + cy.contains("Job Monitor").click(); + + // Wait for the table to be ready + cy.contains("Loading OIDC Configuration").should("not.exist"); + cy.contains("Loading").should("not.exist"); + cy.get('[data-testid="loading-skeleton"]').should("not.exist"); + }); + + it("should download output sandbox", () => { + addJobWithOutputSandbox().then((jobId) => { + // Refresh to see the new job + cy.get('[data-testid="refresh-search-button"]').click(); + cy.get('[data-testid="loading-skeleton"]').should("not.exist"); + + // Find the job row by its ID and right-click to open context menu + cy.contains("table tbody td", String(jobId)).rightclick(); + + // Click download output sandbox from context menu + cy.get('[data-testid="download-output-sandbox-button"]') + .should("be.visible") + .click(); + + // Verify the success snackbar appears + cy.contains(`Downloading output sandbox of ${jobId}`).should( + "be.visible", + ); + }); + }); +}); diff --git a/packages/diracx-web/test/e2e/support/jobMonitorUtils.ts b/packages/diracx-web/test/e2e/support/jobMonitorUtils.ts index d2afe0ed..f1369f8a 100644 --- a/packages/diracx-web/test/e2e/support/jobMonitorUtils.ts +++ b/packages/diracx-web/test/e2e/support/jobMonitorUtils.ts @@ -41,6 +41,110 @@ export function addJobs(numberOfJobs: number) { }); } +/** + * Create a sandbox, upload it, submit a job, and assign the sandbox as output. + * Returns a Cypress chainable that yields the job ID. + * + * Note: Input sandbox assignment is handled by DIRAC (not DiracX), so it + * cannot be tested in the demo environment. Output sandbox assignment is + * supported via the DiracX API. + */ +export function addJobWithOutputSandbox() { + return cy.window().then(async (win) => { + const sessionData = win.sessionStorage.getItem( + "oidc.vo:diracAdmin group:admin", + ); + if (!sessionData) { + throw new Error("Access token not found in session storage"); + } + const accessToken = JSON.parse(sessionData).tokens.accessToken; + const baseUrl = Cypress.config("baseUrl"); + + // Create random sandbox data + const data = new Uint8Array(512); + crypto.getRandomValues(data); + + // Compute SHA-256 checksum + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const checksum = Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + // Step 1: Initiate sandbox upload + const initRes = await fetch(`${baseUrl}/api/jobs/sandbox`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + checksum_algorithm: "sha256", + checksum, + size: data.byteLength, + format: "tar.bz2", + }), + }); + if (!initRes.ok) { + throw new Error(`Initiate sandbox upload failed: ${initRes.status}`); + } + const uploadInfo = await initRes.json(); + const sandboxPfn: string = uploadInfo.pfn; + + // Step 2: Upload the file to the presigned URL + if (uploadInfo.url) { + const formData = new FormData(); + for (const [key, value] of Object.entries( + uploadInfo.fields as Record, + )) { + formData.append(key, value); + } + formData.append("file", new Blob([data]), "file"); + const uploadRes = await fetch(uploadInfo.url, { + method: "POST", + body: formData, + }); + if (!uploadRes.ok) { + throw new Error(`Sandbox file upload failed: ${uploadRes.status}`); + } + } + + // Step 3: Submit a job + const jobRes = await fetch(`${baseUrl}/api/jobs/jdl`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify([ + 'Arguments = "jobDescription.xml -o LogLevel=INFO', + ]), + }); + if (!jobRes.ok) { + throw new Error(`Job submission failed: ${jobRes.status}`); + } + const jobData = await jobRes.json(); + const jobId: number = jobData[0].JobID; + + // Step 4: Assign the sandbox to the job as output sandbox + const assignRes = await fetch( + `${baseUrl}/api/jobs/${jobId}/sandbox/output`, + { + method: "PATCH", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(sandboxPfn), + }, + ); + if (!assignRes.ok) { + throw new Error(`Sandbox assignment failed: ${assignRes.status}`); + } + + return jobId; + }); +} + /** * Ensure there are at least `minNumberOfJobs` in the table. * If not, add jobs and refresh. Call after the table is visible.