diff --git a/optuna_dashboard/artifact/_backend.py b/optuna_dashboard/artifact/_backend.py index 10a5b1ee..ee1b85a8 100644 --- a/optuna_dashboard/artifact/_backend.py +++ b/optuna_dashboard/artifact/_backend.py @@ -105,7 +105,7 @@ def register_artifact_route( @app.post("/api/artifacts//") @json_api_view - def upload_artifact_api(study_id: int, trial_id: int) -> dict[str, Any]: + def upload_trial_artifact_api(study_id: int, trial_id: int) -> dict[str, Any]: trial = storage.get_trial(trial_id) if trial is None: response.status = 400 @@ -144,6 +144,39 @@ def register_artifact_route( "artifacts": list_trial_artifacts(storage.get_study_system_attrs(study_id), trial), } + @app.post("/api/artifacts/") + @json_api_view + def upload_study_artifact_api(study_id: int) -> dict[str, Any]: + if artifact_store is None: + response.status = 400 # Bad Request + return {"reason": "Cannot access to the artifacts."} + file = request.json.get("file") + if file is None: + response.status = 400 + return {"reason": "Please specify the 'file' key."} + + _, data = parse_data_uri(file) + filename = request.json.get("filename", "") + artifact_id = str(uuid.uuid4()) + artifact_store.write(artifact_id, io.BytesIO(data)) + + mimetype, encoding = mimetypes.guess_type(filename) + artifact = { + "artifact_id": artifact_id, + "filename": filename, + "mimetype": mimetype or DEFAULT_MIME_TYPE, + "encoding": encoding, + } + attr_key = ARTIFACTS_ATTR_PREFIX + artifact_id + storage.set_study_system_attr(study_id, attr_key, json.dumps(artifact)) + + response.status = 201 + + return { + "artifact_id": artifact_id, + "artifacts": list_study_artifacts(storage.get_study_system_attrs(study_id)), + } + @app.delete("/api/artifacts///") @json_api_view def delete_artifact(study_id: int, trial_id: int, artifact_id: str) -> dict[str, Any]: diff --git a/optuna_dashboard/ts/action.ts b/optuna_dashboard/ts/action.ts index 41afdadb..cfcf4c24 100644 --- a/optuna_dashboard/ts/action.ts +++ b/optuna_dashboard/ts/action.ts @@ -100,6 +100,12 @@ export const actionCreator = () => { setTrial(studyId, trialIndex, newTrial) } + const setStudyArtifacts = (studyId: number, artifacts: Artifact[]) => { + const newStudy: StudyDetail = Object.assign({}, studyDetails[studyId]) + newStudy.artifacts = artifacts + setStudyDetailState(studyId, newStudy) + } + const deleteTrialArtifact = ( studyId: number, trialId: number, @@ -430,7 +436,7 @@ export const actionCreator = () => { }) } - const uploadArtifact = ( + const uploadTrialArtifact = ( studyId: number, trialId: number, file: File @@ -467,6 +473,36 @@ export const actionCreator = () => { } } + const uploadStudyArtifact = ( + studyId: number, + file: File + ): void => { + const reader = new FileReader() + setUploading(true) + reader.readAsDataURL(file) + reader.onload = (upload: ProgressEvent) => { + uploadArtifactAPI( + studyId, + null, + file.name, + upload.target?.result as string + ) + .then((res) => { + setUploading(false) + setStudyArtifacts(studyId, res.artifacts) + }) + .catch((err) => { + setUploading(false) + const reason = err.response?.data.reason + enqueueSnackbar(`Failed to upload ${reason}`, { variant: "error" }) + }) + } + reader.onerror = (error) => { + enqueueSnackbar(`Failed to read the file ${error}`, { variant: "error" }) + console.log(error) + } + } + const deleteArtifact = ( studyId: number, trialId: number, @@ -693,7 +729,8 @@ export const actionCreator = () => { saveReloadInterval, saveStudyNote, saveTrialNote, - uploadArtifact, + uploadTrialArtifact, + uploadStudyArtifact, deleteArtifact, makeTrialComplete, makeTrialFail, diff --git a/optuna_dashboard/ts/apiClient.ts b/optuna_dashboard/ts/apiClient.ts index e5510d67..c1dedd14 100644 --- a/optuna_dashboard/ts/apiClient.ts +++ b/optuna_dashboard/ts/apiClient.ts @@ -282,12 +282,15 @@ type UploadArtifactAPIResponse = { export const uploadArtifactAPI = ( studyId: number, - trialId: number, + trialId: number | null, fileName: string, dataUrl: string ): Promise => { + const APIurl = `/api/artifacts/${studyId}${ + trialId != null ? `/${trialId}` : "" + }` return axiosInstance - .post(`/api/artifacts/${studyId}/${trialId}`, { + .post(APIurl, { file: dataUrl, filename: fileName, }) diff --git a/optuna_dashboard/ts/components/Note.tsx b/optuna_dashboard/ts/components/Note.tsx index 09270213..586f34e0 100644 --- a/optuna_dashboard/ts/components/Note.tsx +++ b/optuna_dashboard/ts/components/Note.tsx @@ -425,7 +425,7 @@ const ArtifactUploader: FC<{ if (files === null) { return } - action.uploadArtifact(studyId, trialId, files[0]) + action.uploadTrialArtifact(studyId, trialId, files[0]) } const handleDrop: DragEventHandler = (e) => { @@ -433,7 +433,7 @@ const ArtifactUploader: FC<{ e.preventDefault() const file = e.dataTransfer.files[0] setDragOver(false) - action.uploadArtifact(studyId, trialId, file) + action.uploadTrialArtifact(studyId, trialId, file) } const handleDragOver: DragEventHandler = (e) => { diff --git a/optuna_dashboard/ts/components/StudyArtifactCards.tsx b/optuna_dashboard/ts/components/StudyArtifactCards.tsx new file mode 100644 index 00000000..32f36830 --- /dev/null +++ b/optuna_dashboard/ts/components/StudyArtifactCards.tsx @@ -0,0 +1,232 @@ +import React, { + FC, + useState, + DragEventHandler, + useRef, + MouseEventHandler, + ChangeEventHandler, +} from "react" +import { + Typography, + Box, + Card, + useTheme, + CardContent, + CardActionArea, + IconButton, +} from "@mui/material" +import { ArtifactCardMedia } from "./ArtifactCardMedia" +import FullscreenIcon from "@mui/icons-material/Fullscreen" +import UploadFileIcon from "@mui/icons-material/UploadFile" +import DownloadIcon from "@mui/icons-material/Download" +import { actionCreator } from "../action" + +import { + isThreejsArtifact, + useThreejsArtifactModal, +} from "./ThreejsArtifactViewer" + +export const StudyArtifactCards: FC<{ study: StudyDetail }> = ({ study }) => { + const theme = useTheme() + const height = "150px" + const width = "200px" + + const [openThreejsArtifactModal, renderThreejsArtifactModal] = + useThreejsArtifactModal() + + return ( + <> + + Study Artifacts Test + + + + {study.artifacts.map((artifact) => { + const urlPath = `/artifacts/${study.id}/${artifact.artifact_id}` + return ( + + + + + {artifact.filename} + + {isThreejsArtifact(artifact) ? ( + { + openThreejsArtifactModal(urlPath, artifact) + }} + > + + + ) : null} + {/* TODO(gen740): add delete functionality + { + openDeleteArtifactDialog( + trial.study_id, + trial.trial_id, + artifact + ) + }} + > + + */} + + + + + + ) + })} + + + {renderThreejsArtifactModal()} + + ) +} + +const StudyArtifactUploader: FC<{ + study: StudyDetail + width: string + height: string +}> = ({ study, width, height }) => { + const theme = useTheme() + const [dragOver, setDragOver] = useState(false) + const action = actionCreator() + + const inputRef = useRef(null) + const handleClick: MouseEventHandler = () => { + if (!inputRef || !inputRef.current) { + return + } + inputRef.current.click() + } + + const handleOnChange: ChangeEventHandler = (e) => { + const files = e.target.files + if (files === null) { + return + } + action.uploadStudyArtifact(study.id, files[0]) + } + + const handleDragOver: DragEventHandler = (e) => { + e.stopPropagation() + e.preventDefault() + e.dataTransfer.dropEffect = "copy" + setDragOver(true) + } + + const handleDragLeave: DragEventHandler = (e) => { + e.stopPropagation() + e.preventDefault() + e.dataTransfer.dropEffect = "copy" + setDragOver(false) + } + + const handleDrop: DragEventHandler = (e) => { + e.stopPropagation() + e.preventDefault() + const files = e.dataTransfer.files + setDragOver(false) + for (let i = 0; i < files.length; i++) { + action.uploadStudyArtifact(study.id, files[i]) + } + } + + return ( + + + + + + Upload a New File + + Drag your file here or click to browse. + + + + + ) +} diff --git a/optuna_dashboard/ts/components/StudyHistory.tsx b/optuna_dashboard/ts/components/StudyHistory.tsx index 907acd1a..e89a0ee4 100644 --- a/optuna_dashboard/ts/components/StudyHistory.tsx +++ b/optuna_dashboard/ts/components/StudyHistory.tsx @@ -17,12 +17,15 @@ import { DataGrid, DataGridColumn } from "./DataGrid" import { GraphHyperparameterImportance } from "./GraphHyperparameterImportances" import { UserDefinedPlot } from "./UserDefinedPlot" import { BestTrialsCard } from "./BestTrialsCard" +import { StudyArtifactCards } from "./StudyArtifactCards" +import { useRecoilValue } from "recoil" import { useStudyDetailValue, useStudyDirections, useStudySummaryValue, } from "../state" import FormControlLabel from "@mui/material/FormControlLabel" +import { artifactIsAvailable } from "../state" export const StudyHistory: FC<{ studyId: number }> = ({ studyId }) => { const theme = useTheme() @@ -31,6 +34,7 @@ export const StudyHistory: FC<{ studyId: number }> = ({ studyId }) => { const studyDetail = useStudyDetailValue(studyId) const [logScale, setLogScale] = useState(false) const [includePruned, setIncludePruned] = useState(true) + const artifactEnabled = useRecoilValue(artifactIsAvailable) const handleLogScaleChange = () => { setLogScale(!logScale) @@ -167,6 +171,16 @@ export const StudyHistory: FC<{ studyId: number }> = ({ studyId }) => { + + + + + {artifactEnabled && studyDetail !== null && ( + + )} + + + ) } diff --git a/optuna_dashboard/ts/components/TrialArtifactCards.tsx b/optuna_dashboard/ts/components/TrialArtifactCards.tsx index 3e15f7ed..edbbce65 100644 --- a/optuna_dashboard/ts/components/TrialArtifactCards.tsx +++ b/optuna_dashboard/ts/components/TrialArtifactCards.tsx @@ -158,7 +158,7 @@ const TrialArtifactUploader: FC<{ if (files === null) { return } - action.uploadArtifact(trial.study_id, trial.trial_id, files[0]) + action.uploadTrialArtifact(trial.study_id, trial.trial_id, files[0]) } const handleDrop: DragEventHandler = (e) => { e.stopPropagation() @@ -166,7 +166,7 @@ const TrialArtifactUploader: FC<{ const files = e.dataTransfer.files setDragOver(false) for (let i = 0; i < files.length; i++) { - action.uploadArtifact(trial.study_id, trial.trial_id, files[i]) + action.uploadTrialArtifact(trial.study_id, trial.trial_id, files[i]) } } const handleDragOver: DragEventHandler = (e) => {