diff --git a/optuna_dashboard/artifact/_backend.py b/optuna_dashboard/artifact/_backend.py index 10a5b1ee6..9720124b0 100644 --- a/optuna_dashboard/artifact/_backend.py +++ b/optuna_dashboard/artifact/_backend.py @@ -105,7 +105,7 @@ def proxy_trial_artifact( @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,9 +144,42 @@ def upload_artifact_api(study_id: int, trial_id: int) -> dict[str, Any]: "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]: + def delete_trial_artifact(study_id: int, trial_id: int, artifact_id: str) -> dict[str, Any]: if artifact_store is None: response.status = 400 # Bad Request return {"reason": "Cannot access to the artifacts."} @@ -154,7 +187,7 @@ def delete_artifact(study_id: int, trial_id: int, artifact_id: str) -> dict[str, # The artifact's metadata is stored in one of the following two locations: storage.set_study_system_attr( - study_id, _dashboard_trial_artifact_prefix(trial_id) + artifact_id, json.dumps(None) + study_id, _dashboard_artifact_prefix(trial_id) + artifact_id, json.dumps(None) ) storage.set_trial_system_attr( trial_id, ARTIFACTS_ATTR_PREFIX + artifact_id, json.dumps(None) @@ -163,6 +196,21 @@ def delete_artifact(study_id: int, trial_id: int, artifact_id: str) -> dict[str, response.status = 204 return {} + @app.delete("/api/artifacts//") + @json_api_view + def delete_study_artifact(study_id: int, artifact_id: str) -> dict[str, Any]: + if artifact_store is None: + response.status = 400 # Bad Request + return {"reason": "Cannot access to the artifacts."} + artifact_store.remove(artifact_id) + + storage.set_study_system_attr( + study_id, ARTIFACTS_ATTR_PREFIX + artifact_id, json.dumps(None) + ) + + response.status = 204 + return {} + def upload_artifact( backend: ArtifactBackend, @@ -220,7 +268,7 @@ def objective(trial: optuna.Trial) -> float: return artifact_id -def _dashboard_trial_artifact_prefix(trial_id: int) -> str: +def _dashboard_artifact_prefix(trial_id: int) -> str: return DASHBOARD_ARTIFACTS_ATTR_PREFIX + f"{trial_id}:" @@ -240,7 +288,7 @@ def get_trial_artifact_meta( ) -> Optional[ArtifactMeta]: # Search study_system_attrs due to backward compatibility. study_system_attrs = storage.get_study_system_attrs(study_id) - attr_key = _dashboard_trial_artifact_prefix(trial_id=trial_id) + artifact_id + attr_key = _dashboard_artifact_prefix(trial_id=trial_id) + artifact_id artifact_meta = study_system_attrs.get(attr_key) if artifact_meta is not None: return json.loads(artifact_meta) @@ -284,7 +332,7 @@ def list_trial_artifacts( dashboard_artifact_metas = [ json.loads(value) for key, value in study_system_attrs.items() - if key.startswith(_dashboard_trial_artifact_prefix(trial._trial_id)) + if key.startswith(_dashboard_artifact_prefix(trial._trial_id)) ] # Collect ArtifactMeta from trial_system_attrs. Note that artifacts uploaded via diff --git a/optuna_dashboard/ts/action.ts b/optuna_dashboard/ts/action.ts index 41afdadba..e3be3d850 100644 --- a/optuna_dashboard/ts/action.ts +++ b/optuna_dashboard/ts/action.ts @@ -11,9 +11,11 @@ import { tellTrialAPI, saveTrialUserAttrsAPI, renameStudyAPI, - uploadArtifactAPI, + uploadTrialArtifactAPI, + uploadStudyArtifactAPI, getMetaInfoAPI, - deleteArtifactAPI, + deleteTrialArtifactAPI, + deleteStudyArtifactAPI, reportPreferenceAPI, skipPreferentialTrialAPI, removePreferentialHistoryAPI, @@ -100,7 +102,13 @@ export const actionCreator = () => { setTrial(studyId, trialIndex, newTrial) } - const deleteTrialArtifact = ( + const setStudyArtifacts = (studyId: number, artifacts: Artifact[]) => { + const newStudy: StudyDetail = Object.assign({}, studyDetails[studyId]) + newStudy.artifacts = artifacts + setStudyDetailState(studyId, newStudy) + } + + const deleteTrialArtifactState = ( studyId: number, trialId: number, artifact_id: string @@ -122,6 +130,18 @@ export const actionCreator = () => { setTrialArtifacts(studyId, index, newArtifacts) } + const deleteStudyArtifactState = (studyId: number, artifact_id: string) => { + const artifacts = studyDetails[studyId].artifacts + const artifactIndex = artifacts.findIndex( + (a) => a.artifact_id === artifact_id + ) + const newArtifacts = [ + ...artifacts.slice(0, artifactIndex), + ...artifacts.slice(artifactIndex + 1, artifacts.length), + ] + setStudyArtifacts(studyId, newArtifacts) + } + const setTrialStateValues = ( studyId: number, index: number, @@ -430,7 +450,7 @@ export const actionCreator = () => { }) } - const uploadArtifact = ( + const uploadTrialArtifact = ( studyId: number, trialId: number, file: File @@ -439,7 +459,7 @@ export const actionCreator = () => { setUploading(true) reader.readAsDataURL(file) reader.onload = (upload: ProgressEvent) => { - uploadArtifactAPI( + uploadTrialArtifactAPI( studyId, trialId, file.name, @@ -467,14 +487,56 @@ export const actionCreator = () => { } } - const deleteArtifact = ( + const uploadStudyArtifact = (studyId: number, file: File): void => { + const reader = new FileReader() + setUploading(true) + reader.readAsDataURL(file) + reader.onload = (upload: ProgressEvent) => { + uploadStudyArtifactAPI( + studyId, + 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 deleteTrialArtifact = ( studyId: number, trialId: number, artifactId: string ): void => { - deleteArtifactAPI(studyId, trialId, artifactId) + deleteTrialArtifactAPI(studyId, trialId, artifactId) + .then(() => { + deleteTrialArtifactState(studyId, trialId, artifactId) + enqueueSnackbar(`Success to delete an artifact.`, { + variant: "success", + }) + }) + .catch((err) => { + const reason = err.response?.data.reason + enqueueSnackbar(`Failed to delete ${reason}.`, { + variant: "error", + }) + }) + } + + const deleteStudyArtifact = (studyId: number, artifactId: string): void => { + deleteStudyArtifactAPI(studyId, artifactId) .then(() => { - deleteTrialArtifact(studyId, trialId, artifactId) + deleteStudyArtifactState(studyId, artifactId) enqueueSnackbar(`Success to delete an artifact.`, { variant: "success", }) @@ -693,8 +755,10 @@ export const actionCreator = () => { saveReloadInterval, saveStudyNote, saveTrialNote, - uploadArtifact, - deleteArtifact, + uploadTrialArtifact, + uploadStudyArtifact, + deleteTrialArtifact, + deleteStudyArtifact, makeTrialComplete, makeTrialFail, saveTrialUserAttrs, diff --git a/optuna_dashboard/ts/apiClient.ts b/optuna_dashboard/ts/apiClient.ts index e5510d679..f42d20de3 100644 --- a/optuna_dashboard/ts/apiClient.ts +++ b/optuna_dashboard/ts/apiClient.ts @@ -280,7 +280,7 @@ type UploadArtifactAPIResponse = { artifacts: Artifact[] } -export const uploadArtifactAPI = ( +export const uploadTrialArtifactAPI = ( studyId: number, trialId: number, fileName: string, @@ -296,7 +296,22 @@ export const uploadArtifactAPI = ( }) } -export const deleteArtifactAPI = ( +export const uploadStudyArtifactAPI = ( + studyId: number, + fileName: string, + dataUrl: string +): Promise => { + return axiosInstance + .post(`/api/artifacts/${studyId}`, { + file: dataUrl, + filename: fileName, + }) + .then((res) => { + return res.data + }) +} + +export const deleteTrialArtifactAPI = ( studyId: number, trialId: number, artifactId: string @@ -308,6 +323,17 @@ export const deleteArtifactAPI = ( }) } +export const deleteStudyArtifactAPI = ( + studyId: number, + artifactId: string +): Promise => { + return axiosInstance + .delete(`/api/artifacts/${studyId}/${artifactId}`) + .then(() => { + return + }) +} + export const tellTrialAPI = ( trialId: number, state: TrialStateFinished, diff --git a/optuna_dashboard/ts/components/DeleteArtifactDialog.tsx b/optuna_dashboard/ts/components/DeleteArtifactDialog.tsx index 2c9c229e4..7ac461624 100644 --- a/optuna_dashboard/ts/components/DeleteArtifactDialog.tsx +++ b/optuna_dashboard/ts/components/DeleteArtifactDialog.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useState } from "react" +import React, { ReactNode, useState, FC } from "react" import { Dialog, DialogTitle, @@ -9,7 +9,7 @@ import { } from "@mui/material" import { actionCreator } from "../action" -export const useDeleteArtifactDialog = (): [ +export const useDeleteTrialArtifactDialog = (): [ (studyId: number, trialId: number, artifact: Artifact) => void, () => ReactNode ] => { @@ -33,7 +33,7 @@ export const useDeleteArtifactDialog = (): [ if (artifact === null) { return } - action.deleteArtifact(studyId, trialId, artifact.artifact_id) + action.deleteTrialArtifact(studyId, trialId, artifact.artifact_id) setOpenDeleteArtifactDialog(false) setTarget([-1, -1, null]) } @@ -45,32 +45,96 @@ export const useDeleteArtifactDialog = (): [ const renderDeleteArtifactDialog = () => { return ( - { - handleCloseDeleteArtifactDialog() - }} - aria-labelledby="delete-artifact-dialog-title" - > - - Delete artifact - - - - Are you sure you want to delete an artifact (" - {target[2]?.filename}")? - - - - - - - + ) } return [openDialog, renderDeleteArtifactDialog] } + +export const useDeleteStudyArtifactDialog = (): [ + (studyId: number, artifact: Artifact) => void, + () => ReactNode +] => { + const action = actionCreator() + + const [openDeleteArtifactDialog, setOpenDeleteArtifactDialog] = + useState(false) + const [target, setTarget] = useState<[number, Artifact | null]>([-1, null]) + + const handleCloseDeleteArtifactDialog = () => { + setOpenDeleteArtifactDialog(false) + setTarget([-1, null]) + } + + const handleDeleteArtifact = () => { + const [studyId, artifact] = target + if (artifact === null) { + return + } + action.deleteStudyArtifact(studyId, artifact.artifact_id) + setOpenDeleteArtifactDialog(false) + setTarget([-1, null]) + } + + const openDialog = (studyId: number, artifact: Artifact) => { + setTarget([studyId, artifact]) + setOpenDeleteArtifactDialog(true) + } + + const renderDeleteArtifactDialog = () => { + return ( + + ) + } + return [openDialog, renderDeleteArtifactDialog] +} + +const DeleteDialog: FC<{ + openDeleteArtifactDialog: boolean + handleCloseDeleteArtifactDialog: () => void + filename: string | undefined + handleDeleteArtifact: () => void +}> = ({ + openDeleteArtifactDialog, + handleCloseDeleteArtifactDialog, + filename, + handleDeleteArtifact, +}) => { + return ( + { + handleCloseDeleteArtifactDialog() + }} + aria-labelledby="delete-artifact-dialog-title" + > + + Delete artifact + + + + Are you sure you want to delete an artifact (" + {filename}")? + + + + + + + + ) +} diff --git a/optuna_dashboard/ts/components/Note.tsx b/optuna_dashboard/ts/components/Note.tsx index 09270213f..586f34e0d 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 000000000..c63948960 --- /dev/null +++ b/optuna_dashboard/ts/components/StudyArtifactCards.tsx @@ -0,0 +1,226 @@ +import React, { + ChangeEventHandler, + DragEventHandler, + FC, + MouseEventHandler, + useRef, + useState, +} from "react" +import { + Typography, + Box, + useTheme, + IconButton, + Card, + CardContent, + CardActionArea, +} from "@mui/material" +import UploadFileIcon from "@mui/icons-material/UploadFile" +import DownloadIcon from "@mui/icons-material/Download" +import DeleteIcon from "@mui/icons-material/Delete" +import FullscreenIcon from "@mui/icons-material/Fullscreen" + +import { actionCreator } from "../action" +import { useDeleteStudyArtifactDialog } from "./DeleteArtifactDialog" +import { + useThreejsArtifactModal, + isThreejsArtifact, +} from "./ThreejsArtifactViewer" +import { ArtifactCardMedia } from "./ArtifactCardMedia" + +export const StudyArtifactCards: FC<{ study: StudyDetail }> = ({ study }) => { + const theme = useTheme() + const [openDeleteArtifactDialog, renderDeleteArtifactDialog] = + useDeleteStudyArtifactDialog() + const [openThreejsArtifactModal, renderThreejsArtifactModal] = + useThreejsArtifactModal() + + const width = "200px" + const height = "150px" + + return ( + <> + + {study.artifacts.map((artifact) => { + const urlPath = `/artifacts/${study.id}/${artifact.artifact_id}` + return ( + + + + + {artifact.filename} + + {isThreejsArtifact(artifact) ? ( + { + openThreejsArtifactModal(urlPath, artifact) + }} + > + + + ) : null} + { + openDeleteArtifactDialog(study.id, artifact) + }} + > + + + + + + + + ) + })} + + + {renderDeleteArtifactDialog()} + {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 907acd1ac..bdfb225be 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,32 @@ export const StudyHistory: FC<{ studyId: number }> = ({ studyId }) => { + + {artifactEnabled && studyDetail !== null && ( + + + + + + Study Artifacts + + + + + + + )} ) } diff --git a/optuna_dashboard/ts/components/TrialArtifactCards.tsx b/optuna_dashboard/ts/components/TrialArtifactCards.tsx index 3e15f7ede..1a078f299 100644 --- a/optuna_dashboard/ts/components/TrialArtifactCards.tsx +++ b/optuna_dashboard/ts/components/TrialArtifactCards.tsx @@ -21,7 +21,7 @@ import DeleteIcon from "@mui/icons-material/Delete" import FullscreenIcon from "@mui/icons-material/Fullscreen" import { actionCreator } from "../action" -import { useDeleteArtifactDialog } from "./DeleteArtifactDialog" +import { useDeleteTrialArtifactDialog } from "./DeleteArtifactDialog" import { useThreejsArtifactModal, isThreejsArtifact, @@ -31,7 +31,7 @@ import { ArtifactCardMedia } from "./ArtifactCardMedia" export const TrialArtifactCards: FC<{ trial: Trial }> = ({ trial }) => { const theme = useTheme() const [openDeleteArtifactDialog, renderDeleteArtifactDialog] = - useDeleteArtifactDialog() + useDeleteTrialArtifactDialog() const [openThreejsArtifactModal, renderThreejsArtifactModal] = useThreejsArtifactModal() @@ -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) => { diff --git a/python_tests/artifact/test_backend.py b/python_tests/artifact/test_backend.py index 74698a095..22544e6f1 100644 --- a/python_tests/artifact/test_backend.py +++ b/python_tests/artifact/test_backend.py @@ -12,7 +12,7 @@ def test_get_artifact_path() -> None: def test_artifact_prefix() -> None: - actual = _backend._dashboard_trial_artifact_prefix(trial_id=0) + actual = _backend._dashboard_artifact_prefix(trial_id=0) assert actual == "dashboard:artifacts:0:"