From c2289f1f1da6393e03d8200e97efc74288a349df Mon Sep 17 00:00:00 2001 From: Ankur Kumar Date: Sun, 25 Aug 2024 08:10:35 -0400 Subject: [PATCH] Feat(canvas): Add file viewer and job exec logic --- canvas/app/dashboard/project/all/page.tsx | 1 - canvas/app/dashboard/project/id/[id]/job.tsx | 22 +-- canvas/app/dashboard/project/id/[id]/jobs.tsx | 7 +- .../app/dashboard/project/id/[id]/project.tsx | 8 +- canvas/app/dashboard/project/id/[id]/run.tsx | 96 +++++++++++-- .../project/id/[id]/runs-actions.tsx | 43 ++---- canvas/app/dashboard/project/id/[id]/runs.tsx | 115 +++++++++++++-- .../project/id/[id]/take-credentials.tsx | 107 ++++++++++++++ .../project/id/[id]/view-input-modal.tsx | 135 ++++++++++++++++++ .../dashboard/project/id/[id]/view-input.tsx | 46 ++++++ canvas/components/custom/action-buttons.tsx | 10 +- canvas/components/custom/breadcrumb.tsx | 3 +- .../components/custom/file-viewer/index.tsx | 10 +- canvas/components/custom/sidebar.tsx | 2 +- canvas/components/ui/accordion.tsx | 26 ++-- canvas/components/ui/table.tsx | 120 ++++++++++++++++ canvas/services/api.ts | 4 +- canvas/services/job.ts | 27 ++++ canvas/services/run.ts | 3 + canvas/services/s3.ts | 78 ++++++++-- canvas/utils/download.ts | 29 ++++ canvas/utils/localstorage.ts | 85 +++++++++++ 22 files changed, 874 insertions(+), 103 deletions(-) create mode 100644 canvas/app/dashboard/project/id/[id]/take-credentials.tsx create mode 100644 canvas/app/dashboard/project/id/[id]/view-input-modal.tsx create mode 100644 canvas/app/dashboard/project/id/[id]/view-input.tsx create mode 100644 canvas/components/ui/table.tsx create mode 100644 canvas/utils/download.ts create mode 100644 canvas/utils/localstorage.ts diff --git a/canvas/app/dashboard/project/all/page.tsx b/canvas/app/dashboard/project/all/page.tsx index d75b5b9..8f6aa93 100644 --- a/canvas/app/dashboard/project/all/page.tsx +++ b/canvas/app/dashboard/project/all/page.tsx @@ -3,7 +3,6 @@ import { PageContainer } from "../../_layout/page-container"; import { AllProjects } from "./all-projects"; import { AllProjectsHeading } from "./all-projects-heading"; import { AllProjectSkeleton } from "./all-projects-skeleton"; -import { NoProjects } from "./no-projects"; export default function AllProjectPage() { return ( diff --git a/canvas/app/dashboard/project/id/[id]/job.tsx b/canvas/app/dashboard/project/id/[id]/job.tsx index 78600a2..a4e1f4f 100644 --- a/canvas/app/dashboard/project/id/[id]/job.tsx +++ b/canvas/app/dashboard/project/id/[id]/job.tsx @@ -7,24 +7,24 @@ import { import { TProjectStepJob } from "@/services/job"; import { ProjectStepJobRunsContainer } from "./runs-container"; import React from "react"; +import { useProjectStore } from "@/stores/project"; export type TProjectStepJobProps = { job: TProjectStepJob; }; export function ProjectStepJob(props: TProjectStepJobProps) { - const { job } = props; + const { step } = useProjectStore((state) => ({ step: state.currentStep })); + const { job, ...rest } = props; return ( - - - - {job.name} - - - - - - + + + {job.name} + + + + + ); } diff --git a/canvas/app/dashboard/project/id/[id]/jobs.tsx b/canvas/app/dashboard/project/id/[id]/jobs.tsx index aa78d96..92f28b8 100644 --- a/canvas/app/dashboard/project/id/[id]/jobs.tsx +++ b/canvas/app/dashboard/project/id/[id]/jobs.tsx @@ -51,9 +51,12 @@ export function ProjectStepJobs() {
- + `job-${res.id}-${step!.id}`)} + > {data.response.map((job) => ( - + ))}
diff --git a/canvas/app/dashboard/project/id/[id]/project.tsx b/canvas/app/dashboard/project/id/[id]/project.tsx index b6f1677..854d6ac 100644 --- a/canvas/app/dashboard/project/id/[id]/project.tsx +++ b/canvas/app/dashboard/project/id/[id]/project.tsx @@ -7,6 +7,7 @@ import { TProject } from "@/services/projects"; import { ProjectStoreProvider } from "@/stores/project"; import { StepsContainer } from "./steps-container"; import { ProjectActions } from "./project-actions"; +import { TakeCredentials } from "./take-credentials"; export type TProjectMainContentProps = { project: TProject; @@ -18,14 +19,17 @@ export function ProjectMainContent(props: TProjectMainContentProps) { return (
- + } + /> -
+

{project.description}

diff --git a/canvas/app/dashboard/project/id/[id]/run.tsx b/canvas/app/dashboard/project/id/[id]/run.tsx index dee8958..1d93a9b 100644 --- a/canvas/app/dashboard/project/id/[id]/run.tsx +++ b/canvas/app/dashboard/project/id/[id]/run.tsx @@ -1,11 +1,23 @@ "use client"; -import { Button } from "@/components/ui/button"; import { TProjectStepJobRun } from "@/services/run"; -import { Loader2 } from "lucide-react"; import React from "react"; import { JobBadge } from "./badge"; import { FileViewer } from "@/components/custom/file-viewer"; +import { ViewInput } from "./view-input"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { downloadFile } from "@/utils/download"; +import { getFile } from "@/services/s3"; +import { useProjectStore } from "@/stores/project"; export type TProjectStepJobRunProps = { run: TProjectStepJobRun; @@ -13,17 +25,83 @@ export type TProjectStepJobRunProps = { export function ProjectStepJobRun(props: TProjectStepJobRunProps) { const { run } = props; + const { project } = useProjectStore((state) => ({ project: state.project })); + + async function handleDownload(url: string) { + let response = await getFile(url, { + projectId: project.id, + toString: {}, + }); + + if (!response) { + alert("Failed to download file"); + return; + } + + downloadFile({ + content: response, + filename: url.split("/").pop()!, + }); + } return ( -
-
Name: {run.name}
-
- Status: +
+
+
{run.name}
+ +
+ {run.run_status === "success" && ( -
- -
+ + Job Output + + + Name + Type + URI + Actions + + + + {Object.entries(run.outputs).map(([name, data]) => ( + + {name} + {data.type} + + {data.uri || "s3://ip-merck-dev/projects/ASMA/test3.txt"} + + +
+ + +
+
+
+ ))} +
+
)}
); diff --git a/canvas/app/dashboard/project/id/[id]/runs-actions.tsx b/canvas/app/dashboard/project/id/[id]/runs-actions.tsx index f6a5496..2e0d645 100644 --- a/canvas/app/dashboard/project/id/[id]/runs-actions.tsx +++ b/canvas/app/dashboard/project/id/[id]/runs-actions.tsx @@ -1,18 +1,22 @@ import { ActionsButtons } from "@/components/custom/action-buttons"; import { toast } from "@/components/ui/use-toast"; import { executeJob, TProjectStepJob } from "@/services/job"; -import { TRunStatus } from "@/services/run"; +import { TProjectStepJobRun, TRunStatus } from "@/services/run"; import { useMutation } from "@tanstack/react-query"; import React from "react"; import { mutate } from "swr"; export type TRunsActionsProps = { job: TProjectStepJob; + runs: TProjectStepJobRun[]; + mutateKey: string; }; export function RunsActions(props: TRunsActionsProps) { - const { job, mutateKey } = props; + const { job, runs, mutateKey } = props; + + const [status, setStatus] = React.useState("init"); const { mutate: executeJobMutation, @@ -35,7 +39,7 @@ export function RunsActions(props: TRunsActionsProps) { // will tell swr to refetch data mutate(mutateKey); toast({ - title: "Job completed successfully", + title: "Job started successfully", className: "bg-black text-white", }); } else if (error) { @@ -46,21 +50,18 @@ export function RunsActions(props: TRunsActionsProps) { }); } }, [error, isSuccess, mutateKey]); - const [currentState, setCurrentState] = React.useState("init"); React.useEffect(() => { - if (isSuccess) { - setCurrentState("running"); - } else if (error) { - setCurrentState("failed"); - } else if (isPending) { - setCurrentState("pending"); + if (!runs || runs.length === 0) { + setStatus("init"); + } else { + setStatus(runs[runs.length - 1].run_status); } - }, [error, isPending, isSuccess]); + }, [runs]); return ( { @@ -81,26 +82,10 @@ export function RunsActions(props: TRunsActionsProps) { isDisabled: false, tooltipText: "Executing this job.", }} - successButton={{ - isDisabled: false, - onClick: () => { - setCurrentState("pending"); - }, - tooltipText: "Click to run this job again.", - }} failedButton={{ tooltipText: "Last execution was failed. Press this button to retry this job.", - onClick: () => { - setCurrentState("pending"); - setTimeout(() => { - setCurrentState("running"); - }, 3000); - - setTimeout(() => { - setCurrentState("success"); - }, 6000); - }, + onClick: submitJobRequest, }} /> ); diff --git a/canvas/app/dashboard/project/id/[id]/runs.tsx b/canvas/app/dashboard/project/id/[id]/runs.tsx index e8df5ee..26b4638 100644 --- a/canvas/app/dashboard/project/id/[id]/runs.tsx +++ b/canvas/app/dashboard/project/id/[id]/runs.tsx @@ -1,10 +1,22 @@ "use client"; -import { TProjectStepJob } from "@/services/job"; +import { + executeJob, + TProjectStepJob, + TProjectStepJobInput, + updateJob, +} from "@/services/job"; import { TProjectStepJobRun } from "@/services/run"; import React from "react"; import { ProjectStepJobRun } from "./run"; import { RunsActions } from "./runs-actions"; +import { ViewInputModal } from "./view-input-modal"; +import { Label } from "@radix-ui/react-label"; +import { Input } from "@/components/ui/input"; +import { useProjectStore } from "@/stores/project"; +import { useMutation } from "@tanstack/react-query"; +import { mutate } from "swr"; +import { FileViewer } from "@/components/custom/file-viewer"; export type TProjectStepJobRunProps = { runs: TProjectStepJobRun[]; @@ -14,20 +26,103 @@ export type TProjectStepJobRunProps = { export function ProjectStepJobRuns(props: TProjectStepJobRunProps) { const { runs, job, mutateKey } = props; + const { project } = useProjectStore((state) => ({ + project: state.project, + })); + + const { + mutate: executeJobMutation, + isError, + isSuccess, + } = useMutation({ + mutationFn: executeJob, + }); + + function mutateFetchJob() { + mutate(mutateKey, true); + mutate(`/job/?step_id=${job.step_id}`, true); + } + + function submitJob() { + executeJobMutation({ + jobId: job.id, + canThrowOnError: true, + }); + } + + async function updateAndSubmitJob( + inputs: Record + ) { + const response = await updateJob({ + job: { + ...job, + inputs, + }, + }); + + if (response.ok) { + submitJob(); + } + } + + const inputs = React.useMemo(() => { + const _inputs = Object.entries(job.inputs); + return _inputs.map((input) => ({ + id: input[0], + label: input[0], + type: input[1].type, + value: `${project.dataset_uri}${ + input[1].value.replace(project.dataset_uri, "") || "" + }`, + })); + }, [job.inputs, project.dataset_uri]); + + React.useEffect(() => { + if (isError || isSuccess) { + mutateFetchJob(); + } + }, [isError, isSuccess]); return (
-
-

{job.description}

-
- +
+
+
Inputs
+ +
+ +
+ + {inputs.map((input, index) => ( +
+ + + +
+ ))}
- {runs.map((run, idx) => ( -
- - {idx !== runs.length - 1 &&
} -
+ +

Runs

+ {runs.map((run) => ( + ))} {runs.length === 0 &&

No job is running.

}
diff --git a/canvas/app/dashboard/project/id/[id]/take-credentials.tsx b/canvas/app/dashboard/project/id/[id]/take-credentials.tsx new file mode 100644 index 0000000..1d944f9 --- /dev/null +++ b/canvas/app/dashboard/project/id/[id]/take-credentials.tsx @@ -0,0 +1,107 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + getAWSCredentials, + setAWSCredentials, + TAWSCredentials, +} from "@/utils/localstorage"; +import { Label } from "@radix-ui/react-label"; +import { Settings } from "lucide-react"; +import React from "react"; + +export type TTakeCredentialsProps = { + projectId: string | number; +}; + +export function TakeCredentials(props: TTakeCredentialsProps) { + const { projectId } = props; + const [credentials, setCredentials] = React.useState( + getAWSCredentials(projectId) || { + accessKeyId: "", + secretAccessKey: "", + region: "", + } + ); + + function handleOnAddClick() { + const { accessKeyId, secretAccessKey, region } = credentials; + + if (!accessKeyId || !secretAccessKey || !region) { + console.error("Please fill all the fields"); + return; + } + + setAWSCredentials(projectId, { accessKeyId, secretAccessKey, region }); + } + + return ( + + + + + + + Add AWS Credentials + +
+
+ + + setCredentials({ + ...credentials, + accessKeyId: e.currentTarget.value, + }) + } + placeholder="AWS access key id" + /> +
+
+ + + setCredentials({ + ...credentials, + secretAccessKey: e.currentTarget.value, + }) + } + placeholder="AWS secret access key" + /> +
+
+ + + setCredentials({ + ...credentials, + region: e.currentTarget.value, + }) + } + placeholder="AWS region" + /> +
+
+ + + + + +
+
+ ); +} diff --git a/canvas/app/dashboard/project/id/[id]/view-input-modal.tsx b/canvas/app/dashboard/project/id/[id]/view-input-modal.tsx new file mode 100644 index 0000000..e953a6a --- /dev/null +++ b/canvas/app/dashboard/project/id/[id]/view-input-modal.tsx @@ -0,0 +1,135 @@ +import { Button } from "@/components/ui/button"; +import { + DialogHeader, + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, + DialogFooter, + DialogClose, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { TProjectStepJobInput } from "@/services/job"; +import { uploadToS3 } from "@/services/s3"; +import { useProjectStore } from "@/stores/project"; +import { useRef, useState } from "react"; + +export type TViewInputModalProps = { + title: string; + buttonTitle: string; + inputs: { id: string; label: string; value: string; type: string }[]; + isEditable: boolean; + emptyInputPlaceholder?: string; + primaryAction?: { + label: string; + onClick: (value: Record) => void; + }; +}; + +export function ViewInputModal(props: TViewInputModalProps) { + const { + title, + inputs, + buttonTitle, + primaryAction, + isEditable = false, + emptyInputPlaceholder, + } = props; + const newValuesRefs = inputs.map(() => useRef(null)); + const inputRef = useRef(null); + const { project } = useProjectStore((state) => ({ project: state.project })); + const [isUploading, setIsUploading] = useState(false); + + async function uploadFile(url: string) { + const fileInput = inputRef.current; + if (!fileInput) return; + + const file = fileInput.files?.[0]; + if (!file) return; + + setIsUploading(true); + await uploadToS3(url, { + ContentType: file.type, + Body: file, + projectId: project.id, + }); + setIsUploading(false); + } + + function handlePrimaryAction() { + const values = inputs.reduce( + (acc, input, index) => ({ + ...acc, + [input.id]: { + type: input.type, + value: newValuesRefs[index].current?.value || "", + }, + }), + {} as Record + ); + + primaryAction?.onClick(values); + } + + return ( + + + + + + + + {title.length > 35 ? `${title.substring(0, 35)}...` : title} + + + {inputs.map((input, index) => ( +
+ + + {isEditable && Full path required} + {isEditable && ( +
+ + +
+ )} +
+ ))} + + + + + {primaryAction && ( + + + + )} + +
+
+ ); +} diff --git a/canvas/app/dashboard/project/id/[id]/view-input.tsx b/canvas/app/dashboard/project/id/[id]/view-input.tsx new file mode 100644 index 0000000..79ea981 --- /dev/null +++ b/canvas/app/dashboard/project/id/[id]/view-input.tsx @@ -0,0 +1,46 @@ +import { TProjectStepJobInput } from "@/services/job"; +import React from "react"; +import { ViewInputModal } from "./view-input-modal"; + +export type TViewInputProps = { + inputsRecord: Record; + isEditable?: boolean; + title: string; + viewButtonTitle: string; + emptyInputPlaceholder?: string; + primaryAction?: { + label: string; + onClick: (values: Record) => void; + }; +}; + +export function ViewInput(props: TViewInputProps) { + const { + inputsRecord, + isEditable = false, + title, + primaryAction, + emptyInputPlaceholder, + viewButtonTitle, + } = props; + const inputs = React.useMemo(() => { + const _inputs = Object.entries(inputsRecord); + return _inputs.map((input) => ({ + id: input[0], + label: input[0], + value: input[1].value, + type: input[1].type, + })); + }, Object.keys(inputsRecord)); + + return ( + + ); +} diff --git a/canvas/components/custom/action-buttons.tsx b/canvas/components/custom/action-buttons.tsx index 6eea0ad..52edc3c 100644 --- a/canvas/components/custom/action-buttons.tsx +++ b/canvas/components/custom/action-buttons.tsx @@ -1,7 +1,5 @@ import { - CheckIcon, CircleDotDashed, - Icon, Loader2, PlayIcon, RotateCw, @@ -26,7 +24,6 @@ export type TActionsButtonsProps = { pendingButton: TActionsButtonProps; runningButton: TActionsButtonProps; failedButton: TActionsButtonProps; - successButton: TActionsButtonProps; cancelButton: TActionsButtonProps; currentState: TRunStatus; }; @@ -39,12 +36,12 @@ export function ActionsButtons(props: TActionsButtonsProps) { runningButton, failedButton, cancelButton, - successButton, } = props; function getPrimaryAction() { switch (currentState) { case "init": + case "success": return { icon: , ...playButton, @@ -66,11 +63,6 @@ export function ActionsButtons(props: TActionsButtonsProps) { icon: , ...failedButton, }; - case "success": - return { - icon: , - ...successButton, - }; default: return null; } diff --git a/canvas/components/custom/breadcrumb.tsx b/canvas/components/custom/breadcrumb.tsx index 90602d9..636459d 100644 --- a/canvas/components/custom/breadcrumb.tsx +++ b/canvas/components/custom/breadcrumb.tsx @@ -5,7 +5,6 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; -import Link from "next/link"; import React from "react"; export type TBreadcrumbLink = { @@ -36,7 +35,7 @@ export function Breadcrumb(props: TBreadcrumbProps) { - {title} + {title} diff --git a/canvas/components/custom/file-viewer/index.tsx b/canvas/components/custom/file-viewer/index.tsx index a039af6..d330469 100644 --- a/canvas/components/custom/file-viewer/index.tsx +++ b/canvas/components/custom/file-viewer/index.tsx @@ -1,4 +1,5 @@ import { Button } from "@/components/ui/button"; +import "react-datasheet-grid/dist/style.css"; import { DataSheetGrid, textColumn, keyColumn } from "react-datasheet-grid"; import { Dialog, @@ -12,6 +13,7 @@ import { getFile } from "@/services/s3"; import { Eye, Loader2 } from "lucide-react"; import React from "react"; import Image from "next/image"; +import { useProjectStore } from "@/stores/project"; interface DSVRendererProps { data: DSVRowArray; @@ -42,6 +44,7 @@ interface FileViewerProps { } export function FileViewer(props: FileViewerProps) { + const { project } = useProjectStore((state) => ({ project: state.project })); const { fileName } = props; const [viewContent, setViewContent] = React.useState(""); const [isLoading, setIsLoading] = React.useState(true); @@ -53,7 +56,10 @@ export function FileViewer(props: FileViewerProps) { return; } const encoding = fileName.endsWith(".png") ? "base64" : "utf-8"; - const data = await getFile(fileName, { toString: { encoding } }); + const data = await getFile(fileName, { + toString: { encoding }, + projectId: project.id, + }); if (data && typeof data === "string") { if (fileName.endsWith(".png")) { @@ -84,7 +90,7 @@ export function FileViewer(props: FileViewerProps) { View - + {fileName.split("/").pop()} diff --git a/canvas/components/custom/sidebar.tsx b/canvas/components/custom/sidebar.tsx index e283c1f..d16e7f6 100644 --- a/canvas/components/custom/sidebar.tsx +++ b/canvas/components/custom/sidebar.tsx @@ -17,7 +17,7 @@ export function Sidebar(props: TSidebarProps) { const { items, active } = props; return ( -
+