diff --git a/optuna_dashboard/_app.py b/optuna_dashboard/_app.py index d3170fad..52041867 100644 --- a/optuna_dashboard/_app.py +++ b/optuna_dashboard/_app.py @@ -25,6 +25,7 @@ from bottle import response from bottle import run from bottle import SimpleTemplate from bottle import static_file +import optuna from optuna.exceptions import DuplicatedStudyError from optuna.storages import BaseStorage from optuna.storages import RDBStorage @@ -282,6 +283,38 @@ def create_app(storage: BaseStorage, debug: bool = False) -> Bottle: response.status = 201 # Created return {"study_summary": serialize_study_summary(summary)} + @app.post("/api/studies//rename") + @json_api_view + def rename_study(study_id: int) -> BottleViewReturn: + dst_study_name = request.json.get("study_name", None) + if dst_study_name is None: + response.status = 400 # Bad request + return {"reason": "You need to set study_name and direction"} + + src_study_name = storage.get_study_name_from_id(study_id) + try: + src_study = optuna.load_study(storage=storage, study_name=src_study_name) + except KeyError: + response.status = 404 # Not found + return {"reason": f"study_id={study_id} is not found"} + + try: + dst_study = optuna.create_study(storage=storage, study_name=dst_study_name) + dst_study.add_trials(src_study.get_trials(deepcopy=False)) + except DuplicatedStudyError: + response.status = 400 # Bad request + return {"reason": f"study_name={dst_study_name} is duplicaated"} + except Exception as e: + logger.exception("Unexpected error:") + response.status = 500 + storage.delete_study(dst_study._study_id) + return {"reason": str(e)} + storage.delete_study(src_study._study_id) + + response.status = 201 + new_study_summary = get_study_summary(storage, dst_study._study_id) + return serialize_study_summary(new_study_summary) + @app.delete("/api/studies/") @json_api_view def delete_study(study_id: int) -> BottleViewReturn: diff --git a/optuna_dashboard/ts/action.ts b/optuna_dashboard/ts/action.ts index d9420bf7..edec6741 100644 --- a/optuna_dashboard/ts/action.ts +++ b/optuna_dashboard/ts/action.ts @@ -8,6 +8,7 @@ import { deleteStudyAPI, saveStudyNoteAPI, saveTrialNoteAPI, + renameStudyAPI, } from "./apiClient" import { graphVisibilityState, @@ -139,7 +140,7 @@ export const actionCreator = () => { const deleteStudy = (studyId: number) => { deleteStudyAPI(studyId) - .then((study) => { + .then(() => { setStudySummaries(studySummaries.filter((s) => s.study_id !== studyId)) enqueueSnackbar(`Success to delete a study (id=${studyId})`, { variant: "success", @@ -153,6 +154,26 @@ export const actionCreator = () => { }) } + const renameStudy = (studyId: number, studyName: string) => { + renameStudyAPI(studyId, studyName) + .then((study) => { + const newStudySummaries = [ + ...studySummaries.filter((s) => s.study_id !== studyId), + study, + ] + setStudySummaries(newStudySummaries) + enqueueSnackbar(`Success to delete a study (id=${studyId})`, { + variant: "success", + }) + }) + .catch((err) => { + enqueueSnackbar(`Failed to rename study (id=${studyId})`, { + variant: "error", + }) + console.log(err) + }) + } + const getGraphVisibility = () => { const localStoragePreferences = localStorage.getItem( localStorageGraphVisibility @@ -250,6 +271,7 @@ export const actionCreator = () => { updateParamImportance, createNewStudy, deleteStudy, + renameStudy, getGraphVisibility, saveGraphVisibility, saveStudyNote, diff --git a/optuna_dashboard/ts/apiClient.ts b/optuna_dashboard/ts/apiClient.ts index d617a91a..f98cbd18 100644 --- a/optuna_dashboard/ts/apiClient.ts +++ b/optuna_dashboard/ts/apiClient.ts @@ -91,19 +91,6 @@ interface StudySummariesResponse { study_id: number study_name: string directions: StudyDirection[] - best_trial?: { - trial_id: number - study_id: number - number: number - state: TrialState - value?: number - intermediate_values: TrialIntermediateValue[] - datetime_start: string - datetime_complete?: string - params: TrialParam[] - user_attrs: Attribute[] - system_attrs: Attribute[] - } user_attrs: Attribute[] system_attrs: Attribute[] datetime_start?: string @@ -134,19 +121,6 @@ interface CreateNewStudyResponse { study_id: number study_name: string directions: StudyDirection[] - best_trial?: { - trial_id: number - study_id: number - number: number - state: TrialState - value?: number - intermediate_values: TrialIntermediateValue[] - datetime_start: string - datetime_complete?: string - params: TrialParam[] - user_attrs: Attribute[] - system_attrs: Attribute[] - } user_attrs: Attribute[] system_attrs: Attribute[] datetime_start?: string @@ -178,12 +152,43 @@ export const createNewStudyAPI = ( }) } -export const deleteStudyAPI = (studyId: number) => { +export const deleteStudyAPI = (studyId: number): Promise => { return axiosInstance.delete(`/api/studies/${studyId}`).then((res) => { - return {} + return }) } +type RenameStudyResponse = { + study_id: number + study_name: string + directions: StudyDirection[] + user_attrs: Attribute[] + system_attrs: Attribute[] + datetime_start?: string +} + +export const renameStudyAPI = ( + studyId: number, + studyName: string +): Promise => { + return axiosInstance + .post(`/api/studies/${studyId}/rename`, { + study_name: studyName, + }) + .then((res) => { + return { + study_id: res.data.study_id, + study_name: res.data.study_name, + directions: res.data.directions, + user_attrs: res.data.user_attrs, + system_attrs: res.data.system_attrs, + datetime_start: res.data.datetime_start + ? new Date(res.data.datetime_start) + : undefined, + } + }) +} + export const saveStudyNoteAPI = ( studyId: number, note: { version: number; body: string } diff --git a/optuna_dashboard/ts/components/CreateStudyDialog.tsx b/optuna_dashboard/ts/components/CreateStudyDialog.tsx index ef1e9ec3..04665b28 100644 --- a/optuna_dashboard/ts/components/CreateStudyDialog.tsx +++ b/optuna_dashboard/ts/components/CreateStudyDialog.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react" +import React, { ReactNode, useState } from "react" import { Dialog, useTheme, @@ -19,9 +19,7 @@ import { studySummariesState } from "../state" import RemoveIcon from "@mui/icons-material/Remove" import AddIcon from "@mui/icons-material/Add" -type UsePreferenceDialogReturn = [() => void, () => JSX.Element] - -export const useCreateStudyDialog = (): UsePreferenceDialogReturn => { +export const useCreateStudyDialog = (): [() => void, () => ReactNode] => { const theme = useTheme() const action = actionCreator() diff --git a/optuna_dashboard/ts/components/DeleteStudyDialog.tsx b/optuna_dashboard/ts/components/DeleteStudyDialog.tsx index 1d8d1705..c7d7a135 100644 --- a/optuna_dashboard/ts/components/DeleteStudyDialog.tsx +++ b/optuna_dashboard/ts/components/DeleteStudyDialog.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react" +import React, { ReactNode, useState } from "react" import { Dialog, DialogTitle, @@ -9,9 +9,10 @@ import { } from "@mui/material" import { actionCreator } from "../action" -type UsePreferenceDialogReturn = [(studyId: number) => void, () => JSX.Element] - -export const useDeleteStudyDialog = (): UsePreferenceDialogReturn => { +export const useDeleteStudyDialog = (): [ + (studyId: number) => void, + () => ReactNode +] => { const action = actionCreator() const [openDeleteStudyDialog, setOpenDeleteStudyDialog] = useState(false) @@ -33,7 +34,7 @@ export const useDeleteStudyDialog = (): UsePreferenceDialogReturn => { setOpenDeleteStudyDialog(true) } - const renderCreateNewStudyDialog = () => { + const renderDeleteStudyDialog = () => { return ( { ) } - return [openDialog, renderCreateNewStudyDialog] + return [openDialog, renderDeleteStudyDialog] } diff --git a/optuna_dashboard/ts/components/RenameStudyDialog.tsx b/optuna_dashboard/ts/components/RenameStudyDialog.tsx new file mode 100644 index 00000000..4c7686e2 --- /dev/null +++ b/optuna_dashboard/ts/components/RenameStudyDialog.tsx @@ -0,0 +1,99 @@ +import React, { ReactNode, useState } from "react" +import { + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + Button, + DialogActions, + useTheme, +} from "@mui/material" +import { actionCreator } from "../action" +import { DebouncedInputTextField } from "./Debounce" + +export const useRenameStudyDialog = ( + studies: StudySummary[] +): [(studyId: number, studyName: string) => void, () => ReactNode] => { + const action = actionCreator() + const theme = useTheme() + + const [openRenameStudyDialog, setOpenRenameStudyDialog] = useState(false) + const [renameStudyID, setRenameStudyID] = useState(-1) + const [prevStudyName, setPrevStudyName] = useState("") + const [newStudyName, setNewStudyName] = useState("") + + const newStudyNameAlreadyUsed = studies.some( + (v) => v.study_name === newStudyName + ) + + const handleCloseRenameStudyDialog = () => { + setOpenRenameStudyDialog(false) + setRenameStudyID(-1) + setPrevStudyName("") + } + + const handleRenameStudy = () => { + action.renameStudy(renameStudyID, newStudyName) + setOpenRenameStudyDialog(false) + setRenameStudyID(-1) + setPrevStudyName("") + } + + const openDialog = (studyId: number, prevStudyName: string) => { + setRenameStudyID(studyId) + setPrevStudyName(prevStudyName) + setOpenRenameStudyDialog(true) + } + + const renderRenameStudyDialog = () => { + return ( + { + handleCloseRenameStudyDialog() + }} + aria-labelledby="rename-study-dialog-title" + > + + Rename "{prevStudyName}" + + + + Please note that the study_id will be changed because this function + internally creates a new study and copies all trials to it. + + + Please enter the new study name. + + { + setNewStudyName(s) + }} + delay={500} + textFieldProps={{ + autoFocus: true, + fullWidth: true, + error: newStudyNameAlreadyUsed, + helperText: newStudyNameAlreadyUsed + ? `"${newStudyName}" is already used` + : "", + label: "Study name", + type: "text", + }} + /> + + + + + + + ) + } + return [openDialog, renderRenameStudyDialog] +} diff --git a/optuna_dashboard/ts/components/StudyListBeta.tsx b/optuna_dashboard/ts/components/StudyListBeta.tsx index 3a227ee9..656060e0 100644 --- a/optuna_dashboard/ts/components/StudyListBeta.tsx +++ b/optuna_dashboard/ts/components/StudyListBeta.tsx @@ -22,6 +22,7 @@ import { Delete, Refresh, Search } from "@mui/icons-material" import SortIcon from "@mui/icons-material/Sort" import HomeIcon from "@mui/icons-material/Home" import AddBoxIcon from "@mui/icons-material/AddBox" +import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutline" import { actionCreator } from "../action" import { DebouncedInputTextField } from "./Debounce" @@ -30,6 +31,7 @@ import { styled } from "@mui/system" import { AppDrawer } from "./AppDrawer" import { useCreateStudyDialog } from "./CreateStudyDialog" import { useDeleteStudyDialog } from "./DeleteStudyDialog" +import { useRenameStudyDialog } from "./RenameStudyDialog" export const StudyListBeta: FC<{ toggleColorMode: () => void @@ -47,16 +49,18 @@ export const StudyListBeta: FC<{ return row.study_name.indexOf(k) >= 0 }) } + const studies = useRecoilValue(studySummariesState) const [openCreateStudyDialog, renderCreateStudyDialog] = useCreateStudyDialog() const [openDeleteStudyDialog, renderDeleteStudyDialog] = useDeleteStudyDialog() + const [openRenameStudyDialog, renderRenameStudyDialog] = + useRenameStudyDialog(studies) const [sortBy, setSortBy] = useState<"id-asc" | "id-desc">("id-asc") - let studies = useRecoilValue(studySummariesState) - studies = studies.filter((s) => !studyFilter(s)) + let filteredStudies = studies.filter((s) => !studyFilter(s)) if (sortBy === "id-desc") { - studies = studies.reverse() + filteredStudies = filteredStudies.reverse() } useEffect(() => { @@ -118,7 +122,7 @@ export const StudyListBeta: FC<{ - {`Thank you for testing the new UI! we would appreciate it if you could send us the feedback via `} + {`Thank you for testing the new UI! We would appreciate it if you could send us the feedback via `} - {studies.map((study) => ( + {filteredStudies.map((study) => ( + { + openRenameStudyDialog(study.study_id, study.study_name) + }} + > + + {renderCreateStudyDialog()} {renderDeleteStudyDialog()} + {renderRenameStudyDialog()} ) }