Skip to content

Commit 27a6b8d

Browse files
committed
feat: add input/output sandbox download support
1 parent ac25a3d commit 27a6b8d

9 files changed

Lines changed: 362 additions & 24 deletions

File tree

packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx

Lines changed: 75 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import { JobHistoryDialog } from "./JobHistoryDialog";
3131
import {
3232
deleteJobs,
3333
getJobHistory,
34+
getJobSandbox,
35+
getJobSandboxUrl,
3436
killJobs,
3537
rescheduleJobs,
3638
useJobs,
@@ -182,7 +184,6 @@ export function JobDataTable({
182184
diracxUrl,
183185
rowSelection,
184186
clearSelected,
185-
setSearchBody,
186187
mutateJobs,
187188
]);
188189

@@ -251,7 +252,6 @@ export function JobDataTable({
251252
diracxUrl,
252253
rowSelection,
253254
clearSelected,
254-
setSearchBody,
255255
mutateJobs,
256256
]);
257257

@@ -311,29 +311,19 @@ export function JobDataTable({
311311
} finally {
312312
setBackdropOpen(false);
313313
}
314-
}, [
315-
accessToken,
316-
diracxUrl,
317-
rowSelection,
318-
clearSelected,
319-
setSearchBody,
320-
mutateJobs,
321-
]);
314+
}, [accessToken, diracxUrl, rowSelection, clearSelected, mutateJobs]);
322315

323316
/**
324317
* Handle the history of the selected job
325318
*/
326319
const handleHistory = useCallback(
327-
async (selectedId: number | null) => {
320+
async (selectedId: string | null) => {
328321
if (!selectedId) return;
322+
const jobId = Number(selectedId);
329323
setBackdropOpen(true);
330-
setSelectedJobId(selectedId);
324+
setSelectedJobId(jobId);
331325
try {
332-
const { data } = await getJobHistory(
333-
diracxUrl,
334-
selectedId,
335-
accessToken,
336-
);
326+
const { data } = await getJobHistory(diracxUrl, jobId, accessToken);
337327
setBackdropOpen(false);
338328
// Show the history
339329
setJobHistoryData(data);
@@ -360,6 +350,61 @@ export function JobDataTable({
360350
setIsHistoryDialogOpen(false);
361351
};
362352

353+
const handleSandboxDownload = useCallback(
354+
async (selectedId: string | null, sbType: "input" | "output") => {
355+
if (!selectedId) return;
356+
const jobId = Number(selectedId);
357+
setBackdropOpen(true);
358+
try {
359+
const { data: sandboxData } = await getJobSandbox(
360+
diracxUrl,
361+
jobId,
362+
sbType,
363+
accessToken,
364+
);
365+
if (sandboxData.length === 0)
366+
throw new Error(`No ${sbType} sandbox found`);
367+
const pfn = sandboxData[0];
368+
if (pfn) {
369+
const { data: urlData } = await getJobSandboxUrl(
370+
diracxUrl,
371+
pfn,
372+
accessToken,
373+
);
374+
if (urlData?.url) {
375+
const link = document.createElement("a");
376+
link.href = urlData.url;
377+
link.download = `${sbType}-sandbox-${jobId}.tar.gz`;
378+
document.body.appendChild(link);
379+
link.click();
380+
document.body.removeChild(link);
381+
setSnackbarInfo({
382+
open: true,
383+
message: `Downloading ${sbType} sandbox of ${jobId}...`,
384+
severity: "info",
385+
});
386+
} else
387+
throw new Error(
388+
"Could not retrieve a download URL for the sandbox",
389+
);
390+
} else throw new Error(`No ${sbType} sandbox found`);
391+
} catch (error: unknown) {
392+
let errorMessage = "An unknown error occurred";
393+
if (error instanceof Error) {
394+
errorMessage = error.message;
395+
}
396+
setSnackbarInfo({
397+
open: true,
398+
message: `Fetching sandbox of ${jobId} failed: ` + errorMessage,
399+
severity: "error",
400+
});
401+
} finally {
402+
setBackdropOpen(false);
403+
}
404+
},
405+
[accessToken, diracxUrl],
406+
);
407+
363408
/**
364409
* The toolbar components for the data grid
365410
*/
@@ -405,10 +450,21 @@ export function JobDataTable({
405450
() => [
406451
{
407452
label: "Get history",
408-
onClick: (id: string | null) => handleHistory(Number(id)),
453+
onClick: (id: string | null) => handleHistory(id),
454+
dataTestId: "get-history-button",
455+
},
456+
{
457+
label: "Download input sandbox",
458+
onClick: (id: string | null) => handleSandboxDownload(id, "input"),
459+
dataTestId: "download-input-sandbox-button",
460+
},
461+
{
462+
label: "Download output sandbox",
463+
onClick: (id: string | null) => handleSandboxDownload(id, "output"),
464+
dataTestId: "download-output-sandbox-button",
409465
},
410466
],
411-
[handleHistory],
467+
[handleHistory, handleSandboxDownload],
412468
);
413469

414470
/**

packages/diracx-web-components/src/components/JobMonitor/jobDataService.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,15 @@ import dayjs from "dayjs";
55
import utc from "dayjs/plugin/utc";
66

77
import { fetcher } from "../../hooks/utils";
8-
import { Filter, SearchBody, Job, JobHistory } from "../../types";
98
import type { JobSummary } from "../../types";
9+
import {
10+
Filter,
11+
SearchBody,
12+
Job,
13+
JobHistory,
14+
JobSandboxPFNResponse,
15+
SandboxUrlResponse,
16+
} from "../../types";
1017

1118
type TimeUnit = "minute" | "hour" | "day" | "month" | "year";
1219

@@ -194,6 +201,38 @@ export async function getJobHistory(
194201
return { data: data[0].LoggingInfo };
195202
}
196203

204+
/**
205+
* Retrieves the sandbox information for a given job ID and sandbox type.
206+
* @param jobId - The ID of the job.
207+
* @param sbType - The type of the sandbox (input or output).
208+
* @param accessToken - The authentication token.
209+
* @returns A Promise that resolves to an object containing the headers and data of the sandboxes.
210+
*/
211+
export function getJobSandbox(
212+
diracxUrl: string | null,
213+
jobId: number,
214+
sbType: "input" | "output",
215+
accessToken: string,
216+
): Promise<{ headers: Headers; data: JobSandboxPFNResponse }> {
217+
const url = `${diracxUrl}/api/jobs/${jobId}/sandbox/${sbType}`;
218+
return fetcher([url, accessToken]);
219+
}
220+
221+
/**
222+
* Retrieves the sandbox URL for a given PFN.
223+
* @param pfn - The PFN of the job.
224+
* @param accessToken - The authentication token.
225+
* @returns A Promise that resolves to an object containing the headers and data of the sandbox URL.
226+
*/
227+
export function getJobSandboxUrl(
228+
diracxUrl: string | null,
229+
pfn: string,
230+
accessToken: string,
231+
): Promise<{ headers: Headers; data: SandboxUrlResponse }> {
232+
const url = `${diracxUrl}/api/jobs/sandbox?pfn=${encodeURIComponent(pfn)}`;
233+
return fetcher([url, accessToken]);
234+
}
235+
197236
/**
198237
* Retrieves the job summary for a given grouping.
199238
*

packages/diracx-web-components/src/components/shared/DataTable.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { SearchBody } from "../../types";
4545
export interface MenuItem {
4646
label: string;
4747
onClick: (id: string | null) => void;
48+
dataTestId?: string;
4849
}
4950

5051
/**
@@ -655,6 +656,7 @@ export function DataTable<T extends Record<string, unknown>>({
655656
{menuItems.map((menuItem, index: number) => (
656657
<MenuItem
657658
key={index}
659+
data-testid={menuItem.dataTestId}
658660
onClick={() => {
659661
handleCloseContextMenu();
660662
menuItem.onClick(contextMenu.id);
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Types for sandbox-related API responses
2+
3+
// Response for /api/jobs/<jobId>/sandbox/<sbType>
4+
export type JobSandboxPFNResponse = (string | null)[];
5+
6+
// Response for /api/jobs/sandbox?pfn=...
7+
export interface SandboxUrlResponse {
8+
url: string;
9+
expires_in: number;
10+
}

packages/diracx-web-components/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ export * from "./EquationStatus";
1515
export * from "./operators";
1616
export * from "./SearchBarTokenNature";
1717
export * from "./CategoryType";
18+
export * from "./Sandbox";

packages/diracx-web-components/stories/mocks/jobDataService.mock.tsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
/* eslint-disable */
2-
import { Job, JobHistory, SearchBody, JobSummary } from "../../src/types";
2+
import {
3+
Job,
4+
JobHistory,
5+
SearchBody,
6+
JobSummary,
7+
JobSandboxPFNResponse,
8+
SandboxUrlResponse,
9+
} from "../../src/types";
310

411
import { useJobMockContext } from "./contexts.mock";
512

@@ -67,7 +74,7 @@ export function useJobs(
6774
};
6875
}
6976

70-
// Mock implementation of `getJobHistory`
77+
// Mock implementation of getJobHistory
7178
export const getJobHistory = async (
7279
_diracxUrl: string | null,
7380
_jobId: number,
@@ -79,6 +86,31 @@ export const getJobHistory = async (
7986
return { data: mockJobHistoryResponse.jobHistory || [] };
8087
};
8188

89+
// Mock implementation of getJobSandbox
90+
export function getJobSandbox(
91+
diracxUrl: string | null,
92+
jobId: number,
93+
sbType: "input" | "output",
94+
accessToken: string,
95+
): Promise<{ headers: Headers; data: JobSandboxPFNResponse }> {
96+
return Promise.resolve({
97+
headers: new Headers(),
98+
data: [],
99+
});
100+
}
101+
102+
// Mock implementation of getJobSandboxUrl
103+
export function getJobSandboxUrl(
104+
diracxUrl: string | null,
105+
pfn: string,
106+
accessToken: string,
107+
): Promise<{ headers: Headers; data: SandboxUrlResponse }> {
108+
return Promise.resolve({
109+
headers: new Headers(),
110+
data: { url: "", expires_in: 0 },
111+
});
112+
}
113+
82114
// Mock implementation of refreshJobs
83115
export const refreshJobs = (
84116
_diracxUrl: string | null,

packages/diracx-web-components/test/JobMonitor.test.tsx

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,63 @@ describe("JobDataTable", () => {
7474
expect(getByText("Job accepted")).toBeInTheDocument();
7575
});
7676
});
77+
78+
it("displays the snackbar: no input sandbox", async () => {
79+
const { getByText, getByTestId } = render(
80+
<VirtuosoMockContext.Provider
81+
value={{ viewportHeight: 300, itemHeight: 100 }}
82+
>
83+
<Default />
84+
</VirtuosoMockContext.Provider>,
85+
);
86+
87+
await act(async () => {
88+
fireEvent.contextMenu(getByText("Job 1"));
89+
});
90+
91+
// Now wait for the context menu to appear and click Download input sandbox
92+
await act(async () => {
93+
fireEvent.click(getByTestId("download-input-sandbox-button"));
94+
// Allow time for state updates to complete
95+
await new Promise((resolve) => setTimeout(resolve, 0));
96+
});
97+
98+
// Now check for the dialog
99+
await waitFor(() => {
100+
expect(screen.getByText(/No input sandbox found/)).toBeInTheDocument();
101+
});
102+
});
103+
104+
it("displays the snackbar: no output sandbox", async () => {
105+
const { getByText, getByTestId } = render(
106+
<VirtuosoMockContext.Provider
107+
value={{ viewportHeight: 300, itemHeight: 100 }}
108+
>
109+
<Default />
110+
</VirtuosoMockContext.Provider>,
111+
);
112+
113+
await act(async () => {
114+
fireEvent.contextMenu(getByText("Job 1"));
115+
});
116+
117+
// Now wait for the context menu to appear and click Download output sandbox
118+
await act(async () => {
119+
fireEvent.click(getByTestId("download-output-sandbox-button"));
120+
// Allow time for state updates to complete
121+
await new Promise((resolve) => setTimeout(resolve, 0));
122+
});
123+
124+
// Now check for the dialog
125+
await waitFor(() => {
126+
expect(screen.getByText(/No output sandbox found/)).toBeInTheDocument();
127+
});
128+
});
77129
});
78130

79131
describe("JobHistoryDialog", () => {
80132
it("renders the dialog with correct data", async () => {
81-
const { getByText } = render(
133+
const { getByText, getByTestId } = render(
82134
<VirtuosoMockContext.Provider
83135
value={{ viewportHeight: 300, itemHeight: 100 }}
84136
>
@@ -92,7 +144,7 @@ describe("JobHistoryDialog", () => {
92144

93145
// Now wait for the context menu to appear and click Get history
94146
await act(async () => {
95-
fireEvent.click(getByText("Get history"));
147+
fireEvent.click(getByTestId("get-history-button"));
96148
// Allow time for state updates to complete
97149
await new Promise((resolve) => setTimeout(resolve, 0));
98150
});

0 commit comments

Comments
 (0)