mirror of
https://github.com/wassname/optuna-dashboard.git
synced 2026-09-11 12:30:25 +08:00
Merge branch 'main' into add_color_scale_setting
This commit is contained in:
+2
-1
@@ -5,7 +5,8 @@ module.exports = {
|
||||
'@typescript-eslint',
|
||||
],
|
||||
rules: {
|
||||
"@typescript-eslint/ban-ts-comment": "off"
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"eqeqeq": ["error", "smart"],
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||

|
||||
[](https://pypistats.org/packages/optuna-dashboard)
|
||||
[](https://optuna-dashboard.readthedocs.io/en/latest/?badge=latest)
|
||||
[](https://codecov.io/gh/optuna/optuna-dashboard)
|
||||
|
||||
|
||||
Real-time dashboard for [Optuna](https://github.com/optuna/optuna).
|
||||
|
||||
@@ -17,4 +17,4 @@ from ._note import save_note # noqa
|
||||
from ._preference_setting import register_preference_feedback_component # noqa
|
||||
|
||||
|
||||
__version__ = "0.13.0"
|
||||
__version__ = "0.14.0"
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import functools
|
||||
import io
|
||||
from itertools import chain
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import typing
|
||||
from typing import Any
|
||||
from typing import Optional
|
||||
@@ -152,6 +156,7 @@ def create_app(
|
||||
storage=storage, study_name=dst_study_name, directions=src_study.directions
|
||||
)
|
||||
dst_study.add_trials(src_study.get_trials(deepcopy=False))
|
||||
note.copy_notes(storage, src_study, dst_study)
|
||||
except DuplicatedStudyError:
|
||||
response.status = 400 # Bad request
|
||||
return {"reason": f"study_name={dst_study_name} is duplicaated"}
|
||||
@@ -448,6 +453,48 @@ def create_app(
|
||||
response.status = 204 # No content
|
||||
return {}
|
||||
|
||||
@app.get("/csv/<study_id:int>")
|
||||
def download_csv(study_id: int) -> BottleViewReturn:
|
||||
# Create a CSV file
|
||||
try:
|
||||
study_name = storage.get_study_name_from_id(study_id)
|
||||
study = optuna.load_study(storage=storage, study_name=study_name)
|
||||
except KeyError:
|
||||
response.status = 404 # Not found
|
||||
return {"reason": f"study_id={study_id} is not found"}
|
||||
trials = study.trials
|
||||
param_names = sorted(set(chain.from_iterable([t.params.keys() for t in trials])))
|
||||
user_attr_names = sorted(set(chain.from_iterable([t.user_attrs.keys() for t in trials])))
|
||||
param_names_header = [f"Param {x}" for x in param_names]
|
||||
user_attr_names_header = [f"UserAttribute {x}" for x in user_attr_names]
|
||||
n_objs = len(study.directions)
|
||||
if study.metric_names is not None:
|
||||
value_header = study.metric_names
|
||||
else:
|
||||
value_header = ["Value"] if n_objs == 1 else [f"Objective {x}" for x in range(n_objs)]
|
||||
column_names = (
|
||||
["Number", "State"] + value_header + param_names_header + user_attr_names_header
|
||||
)
|
||||
|
||||
buf = io.StringIO("")
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(column_names)
|
||||
for frozen_trial in trials:
|
||||
row = [frozen_trial.number, frozen_trial.state.name]
|
||||
row.extend(frozen_trial.values if frozen_trial.values is not None else [None] * n_objs)
|
||||
row.extend([frozen_trial.params.get(name, None) for name in param_names])
|
||||
row.extend([frozen_trial.user_attrs.get(name, None) for name in user_attr_names])
|
||||
writer.writerow(row)
|
||||
|
||||
# Set response headers
|
||||
output_name = "-".join(re.sub(r'[\\/:*?"<>|]+', "", study_name).split(" "))
|
||||
response.headers["Content-Type"] = "text/csv; chatset=cp932"
|
||||
response.headers["Content-Disposition"] = f"attachment; filename={output_name}.csv"
|
||||
|
||||
# Response body
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
|
||||
@app.get("/favicon.ico")
|
||||
def favicon() -> BottleViewReturn:
|
||||
use_gzip = "gzip" in request.headers["Accept-Encoding"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import numbers
|
||||
import threading
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
@@ -85,9 +86,8 @@ class _CachedExtraStudyProperty:
|
||||
self._cursor = next_cursor
|
||||
|
||||
def _update_user_attrs(self, trial: FrozenTrial) -> None:
|
||||
# TODO(c-bata): Support numpy-specific number types.
|
||||
current_user_attrs = {
|
||||
k: not isinstance(v, bool) and isinstance(v, (int, float))
|
||||
k: not isinstance(v, bool) and isinstance(v, numbers.Real)
|
||||
for k, v in trial.user_attrs.items()
|
||||
}
|
||||
for attr_name, current_is_sortable in current_user_attrs.items():
|
||||
|
||||
@@ -110,6 +110,19 @@ def note_str_key_prefix(trial_id: Optional[int]) -> str:
|
||||
return f"dashboard:{trial_id}:note_str:"
|
||||
|
||||
|
||||
def copy_notes(storage: BaseStorage, src_study: optuna.Study, dst_study: optuna.Study) -> None:
|
||||
system_attrs = storage.get_study_system_attrs(study_id=src_study._study_id)
|
||||
|
||||
# Copy individual trial notes
|
||||
for src_trial, dst_trial in zip(src_study.get_trials(), dst_study.get_trials()):
|
||||
note = get_note_from_system_attrs(system_attrs, src_trial._trial_id)["body"]
|
||||
save_note_with_version(storage, dst_study._study_id, dst_trial._trial_id, 0, note)
|
||||
|
||||
# Copy study note
|
||||
note = get_note_from_system_attrs(system_attrs, None)["body"]
|
||||
save_note_with_version(storage, dst_study._study_id, None, 0, note)
|
||||
|
||||
|
||||
def get_note_from_system_attrs(system_attrs: dict[str, Any], trial_id: Optional[int]) -> NoteType:
|
||||
if note_ver_key(trial_id) not in system_attrs:
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
import numbers
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Union
|
||||
@@ -104,6 +105,8 @@ def serialize_attrs(attrs: dict[str, Any]) -> list[Attribute]:
|
||||
value = "<binary object>"
|
||||
elif isinstance(v, str):
|
||||
value = v
|
||||
elif isinstance(v, numbers.Real):
|
||||
value = str(v)
|
||||
else:
|
||||
value = json.dumps(v)
|
||||
value = value[:MAX_ATTR_LENGTH] if len(value) > MAX_ATTR_LENGTH else value
|
||||
|
||||
@@ -105,7 +105,7 @@ def register_artifact_route(
|
||||
|
||||
@app.post("/api/artifacts/<study_id:int>/<trial_id:int>")
|
||||
@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
|
||||
@@ -139,14 +139,48 @@ def register_artifact_route(
|
||||
storage.set_trial_system_attr(trial_id, attr_key, json.dumps(artifact))
|
||||
response.status = 201
|
||||
|
||||
trial = storage.get_trial(trial_id) # Fetch trial.system_attrs again.
|
||||
return {
|
||||
"artifact_id": artifact_id,
|
||||
"artifacts": list_trial_artifacts(storage.get_study_system_attrs(study_id), trial),
|
||||
}
|
||||
|
||||
@app.post("/api/artifacts/<study_id:int>")
|
||||
@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/<study_id:int>/<trial_id:int>/<artifact_id:re:[0-9a-fA-F-]+>")
|
||||
@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 +188,7 @@ def register_artifact_route(
|
||||
|
||||
# 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 +197,21 @@ def register_artifact_route(
|
||||
response.status = 204
|
||||
return {}
|
||||
|
||||
@app.delete("/api/artifacts/<study_id:int>/<artifact_id:re:[0-9a-fA-F-]+>")
|
||||
@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 +269,7 @@ def upload_artifact(
|
||||
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 +289,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 +333,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
|
||||
|
||||
@@ -317,7 +317,7 @@ class PreferentialGPSampler(optuna.samplers.BaseSampler):
|
||||
|
||||
self._rng = np.random.RandomState(seed)
|
||||
self.independent_sampler = independent_sampler or optuna.samplers.RandomSampler(
|
||||
seed=self._rng.randint(2**32)
|
||||
seed=self._rng.randint(2**32, dtype=np.int64)
|
||||
)
|
||||
|
||||
self._search_space = optuna.search_space.IntersectionSearchSpace()
|
||||
@@ -355,7 +355,7 @@ class PreferentialGPSampler(optuna.samplers.BaseSampler):
|
||||
)
|
||||
pref_ids = torch.tensor([[ids[b], ids[w]] for b, w in preferences], dtype=torch.int32)
|
||||
with torch.random.fork_rng():
|
||||
torch.manual_seed(self._rng.randint(2**32))
|
||||
torch.manual_seed(self._rng.randint(2**32, dtype=np.int64))
|
||||
|
||||
self._gp = self._gp or _PreferentialGP(
|
||||
kernel=self.kernel
|
||||
|
||||
@@ -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,
|
||||
@@ -154,7 +174,7 @@ export const actionCreator = () => {
|
||||
currentValue > bestValue
|
||||
) {
|
||||
newStudy.best_trials = [newTrial]
|
||||
} else if (currentValue == bestValue) {
|
||||
} else if (currentValue === bestValue) {
|
||||
newStudy.best_trials = [...newStudy.best_trials, newTrial]
|
||||
}
|
||||
}
|
||||
@@ -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<FileReader>) => {
|
||||
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<FileReader>) => {
|
||||
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(() => {
|
||||
deleteTrialArtifact(studyId, trialId, artifactId)
|
||||
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(() => {
|
||||
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,
|
||||
|
||||
@@ -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<UploadArtifactAPIResponse> => {
|
||||
return axiosInstance
|
||||
.post<UploadArtifactAPIResponse>(`/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<void> => {
|
||||
return axiosInstance
|
||||
.delete<void>(`/api/artifacts/${studyId}/${artifactId}`)
|
||||
.then(() => {
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
export const tellTrialAPI = (
|
||||
trialId: number,
|
||||
state: TrialStateFinished,
|
||||
|
||||
@@ -10,12 +10,16 @@ import {
|
||||
TableSortLabel,
|
||||
Collapse,
|
||||
IconButton,
|
||||
useTheme,
|
||||
Menu,
|
||||
MenuItem,
|
||||
} from "@mui/material"
|
||||
import { styled } from "@mui/system"
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"
|
||||
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"
|
||||
import { Clear } from "@mui/icons-material"
|
||||
import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank"
|
||||
import CheckBoxIcon from "@mui/icons-material/CheckBox"
|
||||
import FilterListIcon from "@mui/icons-material/FilterList"
|
||||
import ListItemIcon from "@mui/material/ListItemIcon"
|
||||
|
||||
type Order = "asc" | "desc"
|
||||
|
||||
@@ -29,14 +33,14 @@ interface DataGridColumn<T> {
|
||||
label: string
|
||||
sortable?: boolean
|
||||
less?: (a: T, b: T, ascending: boolean) => number
|
||||
filterable?: boolean
|
||||
filterChoices?: string[]
|
||||
toCellValue?: (rowIndex: number) => string | React.ReactNode
|
||||
padding?: "normal" | "checkbox" | "none"
|
||||
}
|
||||
|
||||
interface RowFilter {
|
||||
columnIdx: number
|
||||
value: Value
|
||||
values: Value[]
|
||||
}
|
||||
|
||||
function DataGrid<T>(props: {
|
||||
@@ -81,24 +85,13 @@ function DataGrid<T>(props: {
|
||||
}
|
||||
|
||||
// Filtering
|
||||
const fieldAlreadyFiltered = (columnIdx: number): boolean =>
|
||||
filters.some((f) => f.columnIdx === columnIdx)
|
||||
|
||||
const handleClickFilterCell = (columnIdx: number, value: Value) => {
|
||||
if (fieldAlreadyFiltered(columnIdx)) {
|
||||
return
|
||||
}
|
||||
const newFilters = [...filters, { columnIdx: columnIdx, value: value }]
|
||||
setFilters(newFilters)
|
||||
}
|
||||
|
||||
const filteredRows = rows.filter((row, rowIdx) => {
|
||||
if (defaultFilter !== undefined && defaultFilter(row)) {
|
||||
return false
|
||||
}
|
||||
return filters.length === 0
|
||||
? true
|
||||
: filters.some((f) => {
|
||||
: filters.every((f) => {
|
||||
if (columns.length <= f.columnIdx) {
|
||||
console.log(
|
||||
`columnIdx=${f.columnIdx} must be smaller than columns.length=${columns.length}`
|
||||
@@ -106,11 +99,11 @@ function DataGrid<T>(props: {
|
||||
return true
|
||||
}
|
||||
const toCellValue = columns[f.columnIdx].toCellValue
|
||||
if (toCellValue !== undefined) {
|
||||
return toCellValue(rowIdx) === f.value
|
||||
}
|
||||
const field = columns[f.columnIdx].field
|
||||
return row[field] === f.value
|
||||
const cellValue =
|
||||
toCellValue !== undefined
|
||||
? toCellValue(rowIdx)
|
||||
: row[columns[f.columnIdx].field]
|
||||
return f.values.some((v) => v === cellValue)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -137,21 +130,32 @@ function DataGrid<T>(props: {
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{collapseBody ? <TableCell /> : null}
|
||||
{columns.map((column, columnIdx) => (
|
||||
<DataGridHeaderColumn<T>
|
||||
key={column.label}
|
||||
column={column}
|
||||
orderBy={orderBy === columnIdx ? order : null}
|
||||
onOrderByChange={(direction: Order) => {
|
||||
setOrder(direction)
|
||||
setOrderBy(columnIdx)
|
||||
}}
|
||||
onFilterClear={() => {
|
||||
setFilters(filters.filter((f) => f.columnIdx !== columnIdx))
|
||||
}}
|
||||
filtered={fieldAlreadyFiltered(columnIdx)}
|
||||
/>
|
||||
))}
|
||||
{columns.map((column, columnIdx) => {
|
||||
return (
|
||||
<DataGridHeaderColumn<T>
|
||||
key={columnIdx}
|
||||
column={column}
|
||||
order={orderBy === columnIdx ? order : null}
|
||||
filter={
|
||||
filters.find((f) => f.columnIdx === columnIdx) || null
|
||||
}
|
||||
onOrderByChange={(direction: Order) => {
|
||||
setOrder(direction)
|
||||
setOrderBy(columnIdx)
|
||||
}}
|
||||
onFilterChange={(values: Value[]) => {
|
||||
const newFilters = filters.filter(
|
||||
(f) => f.columnIdx !== columnIdx
|
||||
)
|
||||
newFilters.push({
|
||||
columnIdx: columnIdx,
|
||||
values: values,
|
||||
})
|
||||
setFilters(newFilters)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -163,7 +167,6 @@ function DataGrid<T>(props: {
|
||||
keyField={keyField}
|
||||
collapseBody={collapseBody}
|
||||
key={`${row[keyField]}`}
|
||||
handleClickFilterCell={handleClickFilterCell}
|
||||
/>
|
||||
))}
|
||||
{emptyRows > 0 && (
|
||||
@@ -187,70 +190,103 @@ function DataGrid<T>(props: {
|
||||
)
|
||||
}
|
||||
|
||||
const TableHeaderCellSpan = styled("span")({
|
||||
display: "inline-flex",
|
||||
})
|
||||
|
||||
const HiddenSpan = styled("span")({
|
||||
border: 0,
|
||||
clip: "rect(0 0 0 0)",
|
||||
height: 1,
|
||||
margin: -1,
|
||||
overflow: "hidden",
|
||||
padding: 0,
|
||||
position: "absolute",
|
||||
top: 20,
|
||||
width: 1,
|
||||
})
|
||||
|
||||
function DataGridHeaderColumn<T>(props: {
|
||||
column: DataGridColumn<T>
|
||||
orderBy: Order | null
|
||||
onOrderByChange: (direction: Order) => void
|
||||
filtered: boolean
|
||||
onFilterClear: () => void
|
||||
order: Order | null
|
||||
onOrderByChange: (order: Order) => void
|
||||
filter: RowFilter | null
|
||||
onFilterChange: (values: Value[]) => void
|
||||
dense?: boolean
|
||||
}) {
|
||||
const { column, orderBy, onOrderByChange, filtered, onFilterClear, dense } =
|
||||
const { column, order, onOrderByChange, filter, onFilterChange, dense } =
|
||||
props
|
||||
const [filterMenuAnchorEl, setFilterMenuAnchorEl] =
|
||||
React.useState<null | HTMLElement>(null)
|
||||
|
||||
const filterChoices = column.filterChoices
|
||||
|
||||
const HiddenSpan = styled("span")({
|
||||
border: 0,
|
||||
clip: "rect(0 0 0 0)",
|
||||
height: 1,
|
||||
margin: -1,
|
||||
overflow: "hidden",
|
||||
padding: 0,
|
||||
position: "absolute",
|
||||
top: 20,
|
||||
width: 1,
|
||||
})
|
||||
const TableHeaderCellSpan = styled("span")({
|
||||
display: "inline-flex",
|
||||
})
|
||||
return (
|
||||
<TableCell
|
||||
padding={column.padding || "normal"}
|
||||
sortDirection={orderBy || false}
|
||||
sortDirection={order !== null ? order : false}
|
||||
>
|
||||
<TableHeaderCellSpan>
|
||||
{column.sortable ? (
|
||||
<TableSortLabel
|
||||
active={orderBy !== null}
|
||||
direction={orderBy || "asc"}
|
||||
active={order !== null}
|
||||
direction={order || "asc"}
|
||||
onClick={() => {
|
||||
if (orderBy === null) {
|
||||
onOrderByChange("asc")
|
||||
} else {
|
||||
onOrderByChange(orderBy === "desc" ? "asc" : "desc")
|
||||
}
|
||||
onOrderByChange(order === "asc" ? "desc" : "asc")
|
||||
}}
|
||||
>
|
||||
{column.label}
|
||||
{orderBy !== null ? (
|
||||
{order !== null ? (
|
||||
<HiddenSpan>
|
||||
{orderBy === "desc" ? "sorted descending" : "sorted ascending"}
|
||||
{order === "desc" ? "sorted descending" : "sorted ascending"}
|
||||
</HiddenSpan>
|
||||
) : null}
|
||||
</TableSortLabel>
|
||||
) : (
|
||||
column.label
|
||||
)}
|
||||
{column.filterable ? (
|
||||
<IconButton
|
||||
size={dense ? "small" : "medium"}
|
||||
style={filtered ? {} : { visibility: "hidden" }}
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
onFilterClear()
|
||||
}}
|
||||
>
|
||||
<Clear />
|
||||
</IconButton>
|
||||
{filterChoices !== undefined ? (
|
||||
<>
|
||||
<IconButton
|
||||
size={dense ? "small" : "medium"}
|
||||
onClick={(e) => {
|
||||
setFilterMenuAnchorEl(e.currentTarget)
|
||||
}}
|
||||
>
|
||||
<FilterListIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={filterMenuAnchorEl}
|
||||
open={filterMenuAnchorEl !== null}
|
||||
onClose={() => {
|
||||
setFilterMenuAnchorEl(null)
|
||||
}}
|
||||
>
|
||||
{filterChoices.map((choice) => (
|
||||
<MenuItem
|
||||
key={choice}
|
||||
onClick={() => {
|
||||
const newTickedValues =
|
||||
filter === null
|
||||
? filterChoices.filter((v) => v !== choice) // By default, every choice is ticked, so the chosen option will be unticked.
|
||||
: filter.values.some((v) => v === choice)
|
||||
? filter.values.filter((v) => v !== choice)
|
||||
: [...filter.values, choice]
|
||||
onFilterChange(newTickedValues)
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{!filter || filter.values.some((v) => v === choice) ? (
|
||||
<CheckBoxIcon color="primary" />
|
||||
) : (
|
||||
<CheckBoxOutlineBlankIcon color="primary" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
{choice}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
) : null}
|
||||
</TableHeaderCellSpan>
|
||||
</TableCell>
|
||||
@@ -263,24 +299,10 @@ function DataGridRow<T>(props: {
|
||||
row: T
|
||||
keyField: keyof T
|
||||
collapseBody?: (rowIndex: number) => React.ReactNode
|
||||
handleClickFilterCell: (columnIdx: number, value: Value) => void
|
||||
}) {
|
||||
const {
|
||||
columns,
|
||||
rowIndex,
|
||||
row,
|
||||
keyField,
|
||||
collapseBody,
|
||||
handleClickFilterCell,
|
||||
} = props
|
||||
const { columns, rowIndex, row, keyField, collapseBody } = props
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const theme = useTheme()
|
||||
|
||||
const FilterableDiv = styled("div")({
|
||||
color: theme.palette.primary.main,
|
||||
textDecoration: "underline",
|
||||
cursor: "pointer",
|
||||
})
|
||||
return (
|
||||
<React.Fragment>
|
||||
<TableRow hover tabIndex={-1}>
|
||||
@@ -301,21 +323,7 @@ function DataGridRow<T>(props: {
|
||||
: // TODO(c-bata): Avoid this implicit type conversion.
|
||||
(row[column.field] as number | string | null | undefined)
|
||||
|
||||
return column.filterable ? (
|
||||
<TableCell
|
||||
key={`${row[keyField]}:${column.field.toString()}:${columnIndex}`}
|
||||
padding={column.padding || "normal"}
|
||||
onClick={() => {
|
||||
const value =
|
||||
column.toCellValue !== undefined
|
||||
? column.toCellValue(rowIndex)
|
||||
: row[column.field]
|
||||
handleClickFilterCell(columnIndex, value)
|
||||
}}
|
||||
>
|
||||
<FilterableDiv>{cellItem}</FilterableDiv>
|
||||
</TableCell>
|
||||
) : (
|
||||
return (
|
||||
<TableCell
|
||||
key={`${row[keyField]}:${column.field.toString()}:${columnIndex}`}
|
||||
padding={column.padding || "normal"}
|
||||
@@ -376,7 +384,7 @@ function stableSort<T>(
|
||||
const stabilizedThis = array.map((el, index) => [el, index] as [T, number])
|
||||
stabilizedThis.sort((a, b) => {
|
||||
if (less) {
|
||||
const ascending = order == "asc"
|
||||
const ascending = order === "asc"
|
||||
const result = ascending
|
||||
? -less(a[0], b[0], ascending)
|
||||
: less(a[0], b[0], ascending)
|
||||
|
||||
@@ -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 (
|
||||
<Dialog
|
||||
open={openDeleteArtifactDialog}
|
||||
onClose={() => {
|
||||
handleCloseDeleteArtifactDialog()
|
||||
}}
|
||||
aria-labelledby="delete-artifact-dialog-title"
|
||||
>
|
||||
<DialogTitle id="delete-artifact-dialog-title">
|
||||
Delete artifact
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete an artifact ("
|
||||
{target[2]?.filename}")?
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDeleteArtifactDialog} color="primary">
|
||||
No
|
||||
</Button>
|
||||
<Button onClick={handleDeleteArtifact} color="primary">
|
||||
Yes
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<DeleteDialog
|
||||
openDeleteArtifactDialog={openDeleteArtifactDialog}
|
||||
handleCloseDeleteArtifactDialog={handleCloseDeleteArtifactDialog}
|
||||
filename={target[2]?.filename}
|
||||
handleDeleteArtifact={handleDeleteArtifact}
|
||||
/>
|
||||
)
|
||||
}
|
||||
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 (
|
||||
<DeleteDialog
|
||||
openDeleteArtifactDialog={openDeleteArtifactDialog}
|
||||
handleCloseDeleteArtifactDialog={handleCloseDeleteArtifactDialog}
|
||||
filename={target[1]?.filename}
|
||||
handleDeleteArtifact={handleDeleteArtifact}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return [openDialog, renderDeleteArtifactDialog]
|
||||
}
|
||||
|
||||
const DeleteDialog: FC<{
|
||||
openDeleteArtifactDialog: boolean
|
||||
handleCloseDeleteArtifactDialog: () => void
|
||||
filename: string | undefined
|
||||
handleDeleteArtifact: () => void
|
||||
}> = ({
|
||||
openDeleteArtifactDialog,
|
||||
handleCloseDeleteArtifactDialog,
|
||||
filename,
|
||||
handleDeleteArtifact,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={openDeleteArtifactDialog}
|
||||
onClose={() => {
|
||||
handleCloseDeleteArtifactDialog()
|
||||
}}
|
||||
aria-labelledby="delete-artifact-dialog-title"
|
||||
>
|
||||
<DialogTitle id="delete-artifact-dialog-title">
|
||||
Delete artifact
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete an artifact ("
|
||||
{filename}")?
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDeleteArtifactDialog} color="primary">
|
||||
No
|
||||
</Button>
|
||||
<Button onClick={handleDeleteArtifact} color="primary">
|
||||
Yes
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,25 +16,8 @@ import { useMergedUnionSearchSpace } from "../searchSpace"
|
||||
import { getColorTemplate } from "./PlotlyDarkMode"
|
||||
import { useRecoilValue } from "recoil"
|
||||
import { plotlyColorTheme } from "../state"
|
||||
import { getAxisInfo } from "../graphUtil"
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const unique = (array: any[]) => {
|
||||
const knownElements = new Map()
|
||||
array.forEach((elem) => knownElements.set(elem, true))
|
||||
return Array.from(knownElements.keys())
|
||||
}
|
||||
|
||||
type AxisInfo = {
|
||||
name: string
|
||||
min: number
|
||||
max: number
|
||||
isLog: boolean
|
||||
isCat: boolean
|
||||
indices: (string | number)[]
|
||||
values: (string | number | null)[]
|
||||
}
|
||||
|
||||
const PADDING_RATIO = 0.05
|
||||
const plotDomId = "graph-contour"
|
||||
|
||||
export const Contour: FC<{
|
||||
@@ -296,93 +279,3 @@ const plotContour = (
|
||||
]
|
||||
plotly.react(plotDomId, plotData, layout)
|
||||
}
|
||||
|
||||
const getAxisInfoForNumericalParams = (
|
||||
trials: Trial[],
|
||||
paramName: string,
|
||||
distribution: FloatDistribution | IntDistribution
|
||||
): AxisInfo => {
|
||||
let min = 0
|
||||
let max = 0
|
||||
if (distribution.log) {
|
||||
const padding =
|
||||
(Math.log10(distribution.high) - Math.log10(distribution.low)) *
|
||||
PADDING_RATIO
|
||||
min = Math.pow(10, Math.log10(distribution.low) - padding)
|
||||
max = Math.pow(10, Math.log10(distribution.high) + padding)
|
||||
} else {
|
||||
const padding = (distribution.high - distribution.low) * PADDING_RATIO
|
||||
min = distribution.low - padding
|
||||
max = distribution.high + padding
|
||||
}
|
||||
|
||||
const values = trials.map(
|
||||
(trial) =>
|
||||
trial.params.find((p) => p.name === paramName)?.param_internal_value ||
|
||||
null
|
||||
)
|
||||
const indices = unique(values)
|
||||
.filter((v) => v !== null)
|
||||
.sort((a, b) => a - b)
|
||||
if (indices.length >= 2) {
|
||||
indices.unshift(min)
|
||||
indices.push(max)
|
||||
}
|
||||
return {
|
||||
name: paramName,
|
||||
min,
|
||||
max,
|
||||
isLog: distribution.log,
|
||||
isCat: false,
|
||||
indices,
|
||||
values,
|
||||
}
|
||||
}
|
||||
|
||||
const getAxisInfoForCategoricalParams = (
|
||||
trials: Trial[],
|
||||
paramName: string,
|
||||
distribution: CategoricalDistribution
|
||||
): AxisInfo => {
|
||||
const values = trials.map(
|
||||
(trial) =>
|
||||
trial.params.find((p) => p.name === paramName)?.param_external_value ||
|
||||
null
|
||||
)
|
||||
const isDynamic = values.some((v) => v === null)
|
||||
const span = distribution.choices.length - (isDynamic ? 2 : 1)
|
||||
const padding = span * PADDING_RATIO
|
||||
const min = -padding
|
||||
const max = span + padding
|
||||
|
||||
const indices = distribution.choices
|
||||
.map((c) => c.value)
|
||||
.sort((a, b) =>
|
||||
a.toLowerCase() < b.toLowerCase()
|
||||
? -1
|
||||
: a.toLowerCase() > b.toLowerCase()
|
||||
? 1
|
||||
: 0
|
||||
)
|
||||
return {
|
||||
name: paramName,
|
||||
min,
|
||||
max,
|
||||
isLog: false,
|
||||
isCat: true,
|
||||
indices,
|
||||
values,
|
||||
}
|
||||
}
|
||||
|
||||
const getAxisInfo = (trials: Trial[], param: SearchSpaceItem): AxisInfo => {
|
||||
if (param.distribution.type === "CategoricalDistribution") {
|
||||
return getAxisInfoForCategoricalParams(
|
||||
trials,
|
||||
param.name,
|
||||
param.distribution
|
||||
)
|
||||
} else {
|
||||
return getAxisInfoForNumericalParams(trials, param.name, param.distribution)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ const plotIntermediateValue = (
|
||||
t.state === "Pruned" &&
|
||||
t.values &&
|
||||
t.values.length > 0) ||
|
||||
t.state == "Running"
|
||||
t.state === "Running"
|
||||
)
|
||||
const plotData: Partial<plotly.PlotData>[] = filteredTrials.map((trial) => {
|
||||
const values = trial.intermediate_values.filter(
|
||||
|
||||
@@ -176,7 +176,7 @@ const plotCoordinate = (
|
||||
return truncated
|
||||
.split("")
|
||||
.map((c, i) => {
|
||||
return (i + 1) % breakLength == 0 ? c + "<br>" : c
|
||||
return (i + 1) % breakLength === 0 ? c + "<br>" : c
|
||||
})
|
||||
.join("")
|
||||
}
|
||||
|
||||
@@ -12,25 +12,18 @@ import {
|
||||
Box,
|
||||
} from "@mui/material"
|
||||
import { plotlyDarkTemplate } from "./PlotlyDarkMode"
|
||||
import { makeHovertext } from "../graphUtil"
|
||||
import { getAxisInfo, makeHovertext } from "../graphUtil"
|
||||
import { useMergedUnionSearchSpace } from "../searchSpace"
|
||||
|
||||
const PADDING_RATIO = 0.05
|
||||
const plotDomId = "graph-rank"
|
||||
|
||||
interface AxisInfo {
|
||||
name: string
|
||||
range: [number, number]
|
||||
isLog: boolean
|
||||
isCat: boolean
|
||||
}
|
||||
|
||||
interface RankPlotInfo {
|
||||
xaxis: AxisInfo
|
||||
yaxis: AxisInfo
|
||||
xtitle: string
|
||||
ytitle: string
|
||||
xtype: plotly.AxisType
|
||||
ytype: plotly.AxisType
|
||||
xvalues: (string | number)[]
|
||||
yvalues: (string | number)[]
|
||||
zvalues: number[]
|
||||
colors: number[]
|
||||
is_feasible: boolean[]
|
||||
hovertext: string[]
|
||||
@@ -154,38 +147,82 @@ const getRankPlotInfo = (
|
||||
const xAxis = getAxisInfo(filteredTrials, xParam)
|
||||
const yAxis = getAxisInfo(filteredTrials, yParam)
|
||||
|
||||
const xValues: (string | number)[] = []
|
||||
const yValues: (string | number)[] = []
|
||||
let xValues: (string | number)[] = []
|
||||
let yValues: (string | number)[] = []
|
||||
const zValues: number[] = []
|
||||
const isFeasible: boolean[] = []
|
||||
const hovertext: string[] = []
|
||||
filteredTrials.forEach((trial) => {
|
||||
const xValue =
|
||||
trial.params.find((p) => p.name === xAxis.name)?.param_external_value ||
|
||||
null
|
||||
const yValue =
|
||||
trial.params.find((p) => p.name === yAxis.name)?.param_external_value ||
|
||||
null
|
||||
if (trial.values === undefined || xValue === null || yValue === null) {
|
||||
return
|
||||
const convertTrialValueToNumber = (value: TrialValueNumber): number => {
|
||||
// TrialValueNumber takes `number`, "inf", or "-inf".
|
||||
return typeof value === "number"
|
||||
? value
|
||||
: value.includes("-")
|
||||
? -Infinity
|
||||
: Infinity
|
||||
}
|
||||
filteredTrials.forEach((trial, i) => {
|
||||
const xValue = xAxis.values[i]
|
||||
const yValue = yAxis.values[i]
|
||||
if (xValue && yValue && trial.values) {
|
||||
xValues.push(xValue)
|
||||
yValues.push(yValue)
|
||||
const zValue = convertTrialValueToNumber(trial.values[objectiveId])
|
||||
zValues.push(zValue)
|
||||
const feasibility = trial.constraints.every((c) => c <= 0)
|
||||
isFeasible.push(feasibility)
|
||||
hovertext.push(makeHovertext(trial))
|
||||
}
|
||||
const zValue = Number(trial.values[objectiveId])
|
||||
const feasibility = trial.constraints.every((c) => c <= 0)
|
||||
xValues.push(xValue)
|
||||
yValues.push(yValue)
|
||||
zValues.push(zValue)
|
||||
isFeasible.push(feasibility)
|
||||
hovertext.push(makeHovertext(trial))
|
||||
})
|
||||
|
||||
const colors = getColors(zValues)
|
||||
|
||||
if (xAxis.isCat && !yAxis.isCat) {
|
||||
const indices: number[] = Array.from(Array(xValues.length).keys()).sort(
|
||||
(a, b) =>
|
||||
xValues[a]
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.localeCompare(xValues[b].toString().toLowerCase())
|
||||
)
|
||||
xValues = indices.map((i) => xValues[i])
|
||||
yValues = indices.map((i) => yValues[i])
|
||||
} else if (!xAxis.isCat && yAxis.isCat) {
|
||||
const indices: number[] = Array.from(Array(yValues.length).keys()).sort(
|
||||
(a, b) =>
|
||||
yValues[a]
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.localeCompare(yValues[b].toString().toLowerCase())
|
||||
)
|
||||
xValues = indices.map((i) => xValues[i])
|
||||
yValues = indices.map((i) => yValues[i])
|
||||
} else if (xAxis.isCat && yAxis.isCat) {
|
||||
const indices: number[] = Array.from(Array(xValues.length).keys()).sort(
|
||||
(a, b) => {
|
||||
const xComp = xValues[a]
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.localeCompare(xValues[b].toString().toLowerCase())
|
||||
if (xComp !== 0) {
|
||||
return xComp
|
||||
}
|
||||
return yValues[a]
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.localeCompare(yValues[b].toString().toLowerCase())
|
||||
}
|
||||
)
|
||||
xValues = indices.map((i) => xValues[i])
|
||||
yValues = indices.map((i) => yValues[i])
|
||||
}
|
||||
|
||||
return {
|
||||
xaxis: xAxis,
|
||||
yaxis: yAxis,
|
||||
xtitle: xAxis.name,
|
||||
ytitle: yAxis.name,
|
||||
xtype: xAxis.isCat ? "category" : xAxis.isLog ? "log" : "linear",
|
||||
ytype: yAxis.isCat ? "category" : yAxis.isLog ? "log" : "linear",
|
||||
xvalues: xValues,
|
||||
yvalues: yValues,
|
||||
zvalues: zValues,
|
||||
colors,
|
||||
is_feasible: isFeasible,
|
||||
hovertext,
|
||||
@@ -196,72 +233,6 @@ const filterFunc = (trial: Trial): boolean => {
|
||||
return trial.state === "Complete" && trial.values !== undefined
|
||||
}
|
||||
|
||||
const getAxisInfo = (trials: Trial[], param: SearchSpaceItem): AxisInfo => {
|
||||
if (param.distribution.type === "CategoricalDistribution") {
|
||||
return getAxisInfoForCategorical(trials, param.name, param.distribution)
|
||||
} else {
|
||||
return getAxisInfoForNumerical(trials, param.name, param.distribution)
|
||||
}
|
||||
}
|
||||
|
||||
const getAxisInfoForCategorical = (
|
||||
trials: Trial[],
|
||||
param: string,
|
||||
distribution: CategoricalDistribution
|
||||
): AxisInfo => {
|
||||
const values = trials.map(
|
||||
(trial) =>
|
||||
trial.params.find((p) => p.name === param)?.param_internal_value || null
|
||||
)
|
||||
const isDynamic = values.some((v) => v === null)
|
||||
const span = distribution.choices.length - (isDynamic ? 2 : 1)
|
||||
const padding = span * PADDING_RATIO
|
||||
const min = -padding
|
||||
const max = span + padding
|
||||
|
||||
return {
|
||||
name: param,
|
||||
range: [min, max],
|
||||
isLog: false,
|
||||
isCat: true,
|
||||
}
|
||||
}
|
||||
|
||||
const getAxisInfoForNumerical = (
|
||||
trials: Trial[],
|
||||
param: string,
|
||||
distribution: FloatDistribution | IntDistribution
|
||||
): AxisInfo => {
|
||||
const values = trials.map(
|
||||
(trial) =>
|
||||
trial.params.find((p) => p.name === param)?.param_internal_value || null
|
||||
)
|
||||
const nonNullValues: number[] = []
|
||||
values.forEach((value) => {
|
||||
if (value !== null) {
|
||||
nonNullValues.push(value)
|
||||
}
|
||||
})
|
||||
let min = Math.min(...nonNullValues)
|
||||
let max = Math.max(...nonNullValues)
|
||||
if (distribution.log) {
|
||||
const padding = (Math.log10(max) - Math.log10(min)) * PADDING_RATIO
|
||||
min = Math.pow(10, Math.log10(min) - padding)
|
||||
max = Math.pow(10, Math.log10(max) + padding)
|
||||
} else {
|
||||
const padding = (max - min) * PADDING_RATIO
|
||||
min = min - padding
|
||||
max = max + padding
|
||||
}
|
||||
|
||||
return {
|
||||
name: param,
|
||||
range: [min, max],
|
||||
isLog: distribution.log,
|
||||
isCat: false,
|
||||
}
|
||||
}
|
||||
|
||||
const getColors = (values: number[]): number[] => {
|
||||
const rawRanks = getOrderWithSameOrderAveraging(values)
|
||||
let colorIdxs: number[] = []
|
||||
@@ -274,7 +245,7 @@ const getColors = (values: number[]): number[] => {
|
||||
}
|
||||
|
||||
const getOrderWithSameOrderAveraging = (values: number[]): number[] => {
|
||||
const sortedValues = values.slice().sort()
|
||||
const sortedValues = values.slice().sort((a, b) => a - b)
|
||||
const ranks: number[] = []
|
||||
values.forEach((value) => {
|
||||
const firstIndex = sortedValues.indexOf(value)
|
||||
@@ -299,16 +270,14 @@ const plotRank = (rankPlotInfo: RankPlotInfo | null, mode: string) => {
|
||||
return
|
||||
}
|
||||
|
||||
const xAxis = rankPlotInfo.xaxis
|
||||
const yAxis = rankPlotInfo.yaxis
|
||||
const layout: Partial<plotly.Layout> = {
|
||||
xaxis: {
|
||||
title: xAxis.name,
|
||||
type: xAxis.isCat ? "category" : xAxis.isLog ? "log" : "linear",
|
||||
title: rankPlotInfo.xtitle,
|
||||
type: rankPlotInfo.xtype,
|
||||
},
|
||||
yaxis: {
|
||||
title: yAxis.name,
|
||||
type: yAxis.isCat ? "category" : yAxis.isLog ? "log" : "linear",
|
||||
title: rankPlotInfo.ytitle,
|
||||
type: rankPlotInfo.ytype,
|
||||
},
|
||||
margin: {
|
||||
l: 50,
|
||||
@@ -320,49 +289,8 @@ const plotRank = (rankPlotInfo: RankPlotInfo | null, mode: string) => {
|
||||
template: mode === "dark" ? plotlyDarkTemplate : {},
|
||||
}
|
||||
|
||||
let xValues = rankPlotInfo.xvalues
|
||||
let yValues = rankPlotInfo.yvalues
|
||||
if (xAxis.isCat && !yAxis.isCat) {
|
||||
const xIndices: number[] = Array.from(Array(xValues.length).keys()).sort(
|
||||
(a, b) =>
|
||||
xValues[a]
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.localeCompare(xValues[b].toString().toLowerCase())
|
||||
)
|
||||
xValues = xIndices.map((i) => xValues[i])
|
||||
yValues = xIndices.map((i) => yValues[i])
|
||||
}
|
||||
if (!xAxis.isCat && yAxis.isCat) {
|
||||
const yIndices: number[] = Array.from(Array(yValues.length).keys()).sort(
|
||||
(a, b) =>
|
||||
yValues[a]
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.localeCompare(yValues[b].toString().toLowerCase())
|
||||
)
|
||||
xValues = yIndices.map((i) => xValues[i])
|
||||
yValues = yIndices.map((i) => yValues[i])
|
||||
}
|
||||
if (xAxis.isCat && yAxis.isCat) {
|
||||
const indices: number[] = Array.from(Array(xValues.length).keys()).sort(
|
||||
(a, b) => {
|
||||
const xComp = xValues[a]
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.localeCompare(xValues[b].toString().toLowerCase())
|
||||
if (xComp !== 0) {
|
||||
return xComp
|
||||
}
|
||||
return yValues[a]
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.localeCompare(yValues[b].toString().toLowerCase())
|
||||
}
|
||||
)
|
||||
xValues = indices.map((i) => xValues[i])
|
||||
yValues = indices.map((i) => yValues[i])
|
||||
}
|
||||
const xValues = rankPlotInfo.xvalues
|
||||
const yValues = rankPlotInfo.yvalues
|
||||
|
||||
const plotData: Partial<plotly.PlotData>[] = [
|
||||
{
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", p: theme.spacing(1, 0) }}>
|
||||
{study.artifacts.map((artifact) => {
|
||||
const urlPath = `/artifacts/${study.id}/${artifact.artifact_id}`
|
||||
return (
|
||||
<Card
|
||||
key={artifact.artifact_id}
|
||||
sx={{
|
||||
marginBottom: theme.spacing(2),
|
||||
width: width,
|
||||
margin: theme.spacing(0, 1, 1, 0),
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<ArtifactCardMedia
|
||||
artifact={artifact}
|
||||
urlPath={urlPath}
|
||||
height={height}
|
||||
/>
|
||||
<CardContent
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
padding: `${theme.spacing(1)} !important`,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
p: theme.spacing(0.5, 0),
|
||||
flexGrow: 1,
|
||||
wordWrap: "break-word",
|
||||
maxWidth: `calc(100% - ${
|
||||
isThreejsArtifact(artifact)
|
||||
? theme.spacing(12)
|
||||
: theme.spacing(8)
|
||||
})`,
|
||||
}}
|
||||
>
|
||||
{artifact.filename}
|
||||
</Typography>
|
||||
{isThreejsArtifact(artifact) ? (
|
||||
<IconButton
|
||||
aria-label="show artifact 3d model"
|
||||
size="small"
|
||||
color="inherit"
|
||||
sx={{ margin: "auto 0" }}
|
||||
onClick={() => {
|
||||
openThreejsArtifactModal(urlPath, artifact)
|
||||
}}
|
||||
>
|
||||
<FullscreenIcon />
|
||||
</IconButton>
|
||||
) : null}
|
||||
<IconButton
|
||||
aria-label="delete artifact"
|
||||
size="small"
|
||||
color="inherit"
|
||||
sx={{ margin: "auto 0" }}
|
||||
onClick={() => {
|
||||
openDeleteArtifactDialog(study.id, artifact)
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label="download artifact"
|
||||
size="small"
|
||||
color="inherit"
|
||||
download={artifact.filename}
|
||||
sx={{ margin: "auto 0" }}
|
||||
href={urlPath}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
<StudyArtifactUploader study={study} width={width} height={height} />
|
||||
</Box>
|
||||
{renderDeleteArtifactDialog()}
|
||||
{renderThreejsArtifactModal()}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const StudyArtifactUploader: FC<{
|
||||
study: StudyDetail
|
||||
width: string
|
||||
height: string
|
||||
}> = ({ study, width, height }) => {
|
||||
const theme = useTheme()
|
||||
const [dragOver, setDragOver] = useState<boolean>(false)
|
||||
const action = actionCreator()
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const handleClick: MouseEventHandler = () => {
|
||||
if (!inputRef || !inputRef.current) {
|
||||
return
|
||||
}
|
||||
inputRef.current.click()
|
||||
}
|
||||
|
||||
const handleOnChange: ChangeEventHandler<HTMLInputElement> = (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 (
|
||||
<Card
|
||||
sx={{
|
||||
marginBottom: theme.spacing(2),
|
||||
width: width,
|
||||
minHeight: height,
|
||||
margin: theme.spacing(0, 1, 1, 0),
|
||||
border: dragOver
|
||||
? `3px dashed ${theme.palette.mode === "dark" ? "white" : "black"}`
|
||||
: `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<CardActionArea
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<CardContent
|
||||
sx={{
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<UploadFileIcon
|
||||
sx={{ fontSize: 80, marginBottom: theme.spacing(2) }}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
ref={inputRef}
|
||||
onChange={handleOnChange}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<Typography>Upload a New File</Typography>
|
||||
<Typography
|
||||
sx={{ textAlign: "center", color: theme.palette.grey.A400 }}
|
||||
>
|
||||
Drag your file here or click to browse.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import Grid2 from "@mui/material/Unstable_Grid2"
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight"
|
||||
import HomeIcon from "@mui/icons-material/Home"
|
||||
import DownloadIcon from "@mui/icons-material/Download"
|
||||
|
||||
import { StudyNote } from "./Note"
|
||||
import { actionCreator } from "../action"
|
||||
@@ -150,11 +151,39 @@ export const StudyDetail: FC<{
|
||||
content = <TrialList studyDetail={studyDetail} />
|
||||
} else if (page === "trialTable") {
|
||||
content = (
|
||||
<Card sx={{ margin: theme.spacing(2) }}>
|
||||
<CardContent>
|
||||
<TrialTable studyDetail={studyDetail} initialRowsPerPage={50} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Box sx={{ display: "flex", width: "100%", flexDirection: "column" }}>
|
||||
<Card
|
||||
sx={{
|
||||
margin: theme.spacing(2),
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
display: "flex",
|
||||
justifyContent: "left",
|
||||
alignItems: "left",
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<IconButton
|
||||
aria-label="download csv"
|
||||
size="small"
|
||||
color="inherit"
|
||||
download
|
||||
sx={{ margin: "auto 0" }}
|
||||
href={`/csv/${studyDetail?.id}`}
|
||||
>
|
||||
<DownloadIcon />
|
||||
<Typography variant="button" sx={{ margin: theme.spacing(2) }}>
|
||||
Download CSV File
|
||||
</Typography>
|
||||
</IconButton>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card sx={{ margin: theme.spacing(2) }}>
|
||||
<CardContent>
|
||||
<TrialTable studyDetail={studyDetail} initialRowsPerPage={50} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
)
|
||||
} else if (page === "note" && studyDetail !== null) {
|
||||
content = (
|
||||
@@ -195,7 +224,7 @@ export const StudyDetail: FC<{
|
||||
<PreferentialGraph studyDetail={studyDetail} />
|
||||
</Box>
|
||||
)
|
||||
} else if (page == "preferenceHistory") {
|
||||
} else if (page === "preferenceHistory") {
|
||||
content = <PreferenceHistory studyDetail={studyDetail} />
|
||||
}
|
||||
|
||||
|
||||
@@ -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<boolean>(false)
|
||||
const [includePruned, setIncludePruned] = useState<boolean>(true)
|
||||
const artifactEnabled = useRecoilValue<boolean>(artifactIsAvailable)
|
||||
|
||||
const handleLogScaleChange = () => {
|
||||
setLogScale(!logScale)
|
||||
@@ -104,17 +108,6 @@ export const StudyHistory: FC<{ studyId: number }> = ({ studyId }) => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Grid2 container spacing={2} sx={{ padding: theme.spacing(0, 2) }}>
|
||||
{studyDetail !== null &&
|
||||
studyDetail.directions.length == 1 &&
|
||||
studyDetail.has_intermediate_values ? (
|
||||
<Grid2 xs={6}>
|
||||
<GraphIntermediateValues
|
||||
trials={trials}
|
||||
includePruned={includePruned}
|
||||
logScale={logScale}
|
||||
/>
|
||||
</Grid2>
|
||||
) : null}
|
||||
<Grid2 xs={6}>
|
||||
<GraphHyperparameterImportance
|
||||
studyId={studyId}
|
||||
@@ -166,7 +159,44 @@ export const StudyHistory: FC<{ studyId: number }> = ({ studyId }) => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid2>
|
||||
{studyDetail !== null &&
|
||||
studyDetail.directions.length === 1 &&
|
||||
studyDetail.has_intermediate_values ? (
|
||||
<Grid2 xs={6}>
|
||||
<GraphIntermediateValues
|
||||
trials={trials}
|
||||
includePruned={includePruned}
|
||||
logScale={logScale}
|
||||
/>
|
||||
</Grid2>
|
||||
) : null}
|
||||
</Grid2>
|
||||
|
||||
{artifactEnabled && studyDetail !== null && (
|
||||
<Grid2 container spacing={2} sx={{ padding: theme.spacing(0, 2) }}>
|
||||
<Grid2 xs={6}>
|
||||
<Card>
|
||||
<CardContent
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
margin: "1em 0",
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
}}
|
||||
>
|
||||
Study Artifacts
|
||||
</Typography>
|
||||
<StudyArtifactCards study={studyDetail} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,9 +31,12 @@ import { ArtifactCardMedia } from "./ArtifactCardMedia"
|
||||
export const TrialArtifactCards: FC<{ trial: Trial }> = ({ trial }) => {
|
||||
const theme = useTheme()
|
||||
const [openDeleteArtifactDialog, renderDeleteArtifactDialog] =
|
||||
useDeleteArtifactDialog()
|
||||
useDeleteTrialArtifactDialog()
|
||||
const [openThreejsArtifactModal, renderThreejsArtifactModal] =
|
||||
useThreejsArtifactModal()
|
||||
const isArtifactModifiable = (trial: Trial) => {
|
||||
return trial.state === "Running" || trial.state === "Waiting"
|
||||
}
|
||||
|
||||
const width = "200px"
|
||||
const height = "150px"
|
||||
@@ -75,11 +78,11 @@ export const TrialArtifactCards: FC<{ trial: Trial }> = ({ trial }) => {
|
||||
p: theme.spacing(0.5, 0),
|
||||
flexGrow: 1,
|
||||
wordWrap: "break-word",
|
||||
maxWidth: `calc(100% - ${
|
||||
isThreejsArtifact(artifact)
|
||||
? theme.spacing(12)
|
||||
: theme.spacing(8)
|
||||
})`,
|
||||
maxWidth: `calc(100% - ${theme.spacing(
|
||||
4 +
|
||||
(isThreejsArtifact(artifact) ? 4 : 0) +
|
||||
(isArtifactModifiable(trial) ? 4 : 0)
|
||||
)})`,
|
||||
}}
|
||||
>
|
||||
{artifact.filename}
|
||||
@@ -97,21 +100,23 @@ export const TrialArtifactCards: FC<{ trial: Trial }> = ({ trial }) => {
|
||||
<FullscreenIcon />
|
||||
</IconButton>
|
||||
) : null}
|
||||
<IconButton
|
||||
aria-label="delete artifact"
|
||||
size="small"
|
||||
color="inherit"
|
||||
sx={{ margin: "auto 0" }}
|
||||
onClick={() => {
|
||||
openDeleteArtifactDialog(
|
||||
trial.study_id,
|
||||
trial.trial_id,
|
||||
artifact
|
||||
)
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
{isArtifactModifiable(trial) ? (
|
||||
<IconButton
|
||||
aria-label="delete artifact"
|
||||
size="small"
|
||||
color="inherit"
|
||||
sx={{ margin: "auto 0" }}
|
||||
onClick={() => {
|
||||
openDeleteArtifactDialog(
|
||||
trial.study_id,
|
||||
trial.trial_id,
|
||||
artifact
|
||||
)
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
) : null}
|
||||
<IconButton
|
||||
aria-label="download artifact"
|
||||
size="small"
|
||||
@@ -126,7 +131,9 @@ export const TrialArtifactCards: FC<{ trial: Trial }> = ({ trial }) => {
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
<TrialArtifactUploader trial={trial} width={width} height={height} />
|
||||
{isArtifactModifiable(trial) ? (
|
||||
<TrialArtifactUploader trial={trial} width={width} height={height} />
|
||||
) : null}
|
||||
</Box>
|
||||
{renderDeleteArtifactDialog()}
|
||||
{renderThreejsArtifactModal()}
|
||||
@@ -143,9 +150,6 @@ const TrialArtifactUploader: FC<{
|
||||
const action = actionCreator()
|
||||
const [dragOver, setDragOver] = useState<boolean>(false)
|
||||
|
||||
if (trial.state !== "Running" && trial.state !== "Waiting") {
|
||||
return null
|
||||
}
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const handleClick: MouseEventHandler = () => {
|
||||
if (!inputRef || !inputRef.current) {
|
||||
@@ -158,7 +162,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 +170,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) => {
|
||||
|
||||
@@ -45,12 +45,12 @@ export const TrialFormWidgets: FC<{
|
||||
? "Set Objective Values Form"
|
||||
: "Set Objective Value Form"
|
||||
const widgetNames = formWidgets.widgets.map((widget, i) => {
|
||||
if (formWidgets.output_type == "objective") {
|
||||
if (formWidgets.output_type === "objective") {
|
||||
if (objectiveNames.at(i) !== undefined) {
|
||||
return objectiveNames[i]
|
||||
}
|
||||
return directions.length == 1 ? "Objective" : `Objective ${i}`
|
||||
} else if (formWidgets.output_type == "user_attr") {
|
||||
return directions.length === 1 ? "Objective" : `Objective ${i}`
|
||||
} else if (formWidgets.output_type === "user_attr") {
|
||||
if (widget.type !== "user_attr" && widget.user_attr_key !== undefined) {
|
||||
return widget.user_attr_key
|
||||
}
|
||||
@@ -118,13 +118,13 @@ const UpdatableFormWidgets: FC<{
|
||||
const handleSubmit = (e: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
e.preventDefault()
|
||||
const values = widgetStates.map((ws) => ws.value)
|
||||
if (formWidgets.output_type == "objective") {
|
||||
if (formWidgets.output_type === "objective") {
|
||||
const filtered = values.filter<number>((v): v is number => v !== null)
|
||||
if (filtered.length !== formWidgets.widgets.length) {
|
||||
return
|
||||
}
|
||||
action.makeTrialComplete(trial.study_id, trial.trial_id, filtered)
|
||||
} else if (formWidgets.output_type == "user_attr") {
|
||||
} else if (formWidgets.output_type === "user_attr") {
|
||||
const user_attrs = Object.fromEntries(
|
||||
formWidgets.widgets.map((widget, i) => [
|
||||
widget.user_attr_key,
|
||||
@@ -433,7 +433,7 @@ const ReadonlyFormWidgets: FC<{
|
||||
max={widget.max}
|
||||
step={widget.step}
|
||||
marks={
|
||||
widget.labels === null || widget.labels.length == 0
|
||||
widget.labels === null || widget.labels.length === 0
|
||||
? true
|
||||
: widget.labels
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ const useIsBestTrial = (
|
||||
return useMemo(() => {
|
||||
const bestTrialIDs = studyDetail?.best_trials.map((t) => t.trial_id) || []
|
||||
return (trialId: number): boolean =>
|
||||
bestTrialIDs.findIndex((a) => a === trialId) != -1
|
||||
bestTrialIDs.findIndex((a) => a === trialId) !== -1
|
||||
}, [studyDetail])
|
||||
}
|
||||
|
||||
|
||||
@@ -18,12 +18,12 @@ export const TrialTable: FC<{
|
||||
field: "state",
|
||||
label: "State",
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterChoices: ["Complete", "Pruned", "Fail", "Running", "Waiting"],
|
||||
padding: "none",
|
||||
toCellValue: (i) => trials[i].state.toString(),
|
||||
},
|
||||
]
|
||||
if (studyDetail === null || studyDetail.directions.length == 1) {
|
||||
if (studyDetail === null || studyDetail.directions.length === 1) {
|
||||
columns.push({
|
||||
field: "values",
|
||||
label: "Value",
|
||||
@@ -97,7 +97,10 @@ export const TrialTable: FC<{
|
||||
) {
|
||||
studyDetail?.intersection_search_space.forEach((s) => {
|
||||
const sortable = s.distribution.type !== "CategoricalDistribution"
|
||||
const filterable = s.distribution.type === "CategoricalDistribution"
|
||||
const filterChoices =
|
||||
s.distribution.type === "CategoricalDistribution"
|
||||
? s.distribution.choices.map((c) => c.value)
|
||||
: undefined
|
||||
columns.push({
|
||||
field: "params",
|
||||
label: `Param ${s.name}`,
|
||||
@@ -105,7 +108,7 @@ export const TrialTable: FC<{
|
||||
trials[i].params.find((p) => p.name === s.name)
|
||||
?.param_external_value || null,
|
||||
sortable: sortable,
|
||||
filterable: filterable,
|
||||
filterChoices: filterChoices,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
less: (firstEl, secondEl, _): number => {
|
||||
const firstVal = firstEl.params.find(
|
||||
@@ -146,7 +149,6 @@ export const TrialTable: FC<{
|
||||
trials[i].user_attrs.find((attr) => attr.key === attr_spec.key)
|
||||
?.value || null,
|
||||
sortable: attr_spec.sortable,
|
||||
filterable: !attr_spec.sortable,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
less: (firstEl, secondEl, _): number => {
|
||||
const firstVal = firstEl.user_attrs.find(
|
||||
|
||||
@@ -27,7 +27,7 @@ export const getDominatedTrials = (
|
||||
const dominatedTrials: boolean[] = []
|
||||
normalizedValues.forEach((values0: number[], i: number) => {
|
||||
const dominated = normalizedValues.some((values1: number[], j: number) => {
|
||||
if (i === j || values0.every((v, i) => v == values1[i])) {
|
||||
if (i === j || values0.every((v, i) => v === values1[i])) {
|
||||
return false
|
||||
}
|
||||
return values0.every((value0: number, k: number) => {
|
||||
|
||||
@@ -1,3 +1,104 @@
|
||||
const PADDING_RATIO = 0.05
|
||||
|
||||
export type AxisInfo = {
|
||||
name: string
|
||||
isLog: boolean
|
||||
isCat: boolean
|
||||
indices: (string | number)[]
|
||||
values: (string | number | null)[]
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const unique = (array: any[]) => {
|
||||
const knownElements = new Map()
|
||||
array.forEach((elem) => knownElements.set(elem, true))
|
||||
return Array.from(knownElements.keys())
|
||||
}
|
||||
|
||||
export const getAxisInfo = (
|
||||
trials: Trial[],
|
||||
param: SearchSpaceItem
|
||||
): AxisInfo => {
|
||||
if (param.distribution.type === "CategoricalDistribution") {
|
||||
return getAxisInfoForCategoricalParams(
|
||||
trials,
|
||||
param.name,
|
||||
param.distribution
|
||||
)
|
||||
} else {
|
||||
return getAxisInfoForNumericalParams(trials, param.name, param.distribution)
|
||||
}
|
||||
}
|
||||
|
||||
const getAxisInfoForCategoricalParams = (
|
||||
trials: Trial[],
|
||||
paramName: string,
|
||||
distribution: CategoricalDistribution
|
||||
): AxisInfo => {
|
||||
const values = trials.map(
|
||||
(trial) =>
|
||||
trial.params.find((p) => p.name === paramName)?.param_external_value ||
|
||||
null
|
||||
)
|
||||
|
||||
const indices = distribution.choices
|
||||
.map((c) => c.value)
|
||||
.sort((a, b) =>
|
||||
a.toLowerCase() < b.toLowerCase()
|
||||
? -1
|
||||
: a.toLowerCase() > b.toLowerCase()
|
||||
? 1
|
||||
: 0
|
||||
)
|
||||
return {
|
||||
name: paramName,
|
||||
isLog: false,
|
||||
isCat: true,
|
||||
indices,
|
||||
values,
|
||||
}
|
||||
}
|
||||
|
||||
const getAxisInfoForNumericalParams = (
|
||||
trials: Trial[],
|
||||
paramName: string,
|
||||
distribution: FloatDistribution | IntDistribution
|
||||
): AxisInfo => {
|
||||
let min = 0
|
||||
let max = 0
|
||||
if (distribution.log) {
|
||||
const padding =
|
||||
(Math.log10(distribution.high) - Math.log10(distribution.low)) *
|
||||
PADDING_RATIO
|
||||
min = Math.pow(10, Math.log10(distribution.low) - padding)
|
||||
max = Math.pow(10, Math.log10(distribution.high) + padding)
|
||||
} else {
|
||||
const padding = (distribution.high - distribution.low) * PADDING_RATIO
|
||||
min = distribution.low - padding
|
||||
max = distribution.high + padding
|
||||
}
|
||||
|
||||
const values = trials.map(
|
||||
(trial) =>
|
||||
trial.params.find((p) => p.name === paramName)?.param_internal_value ||
|
||||
null
|
||||
)
|
||||
const indices = unique(values)
|
||||
.filter((v) => v !== null)
|
||||
.sort((a, b) => a - b)
|
||||
if (indices.length >= 2) {
|
||||
indices.unshift(min)
|
||||
indices.push(max)
|
||||
}
|
||||
return {
|
||||
name: paramName,
|
||||
isLog: distribution.log,
|
||||
isCat: false,
|
||||
indices,
|
||||
values,
|
||||
}
|
||||
}
|
||||
|
||||
export const makeHovertext = (trial: Trial): string => {
|
||||
return JSON.stringify(
|
||||
{
|
||||
|
||||
@@ -71,7 +71,7 @@ export const useStudyDetailValue = (studyId: number): StudyDetail | null => {
|
||||
|
||||
export const useStudySummaryValue = (studyId: number): StudySummary | null => {
|
||||
const studySummaries = useRecoilValue<StudySummary[]>(studySummariesState)
|
||||
return studySummaries.find((s) => s.study_id == studyId) || null
|
||||
return studySummaries.find((s) => s.study_id === studyId) || null
|
||||
}
|
||||
|
||||
export const useTrialUpdatingValue = (trialId: number): boolean => {
|
||||
|
||||
Generated
+812
-765
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -52,12 +52,12 @@
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/react-syntax-highlighter": "^15.5.5",
|
||||
"@typescript-eslint/eslint-plugin": "^4.26.1",
|
||||
"@typescript-eslint/parser": "^4.26.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"compression-webpack-plugin": "^10.0.0",
|
||||
"css-loader": "^6.8.1",
|
||||
"esbuild-loader": "^2.18.0",
|
||||
"eslint": "^7.28.0",
|
||||
"eslint": "^8.53.0",
|
||||
"jest": "^29.2.1",
|
||||
"jest-canvas-mock": "^2.3.1",
|
||||
"jest-environment-jsdom": "^29.3.1",
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import base64
|
||||
import json
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import optuna
|
||||
from optuna.artifacts import FileSystemArtifactStore
|
||||
from optuna.artifacts import upload_artifact
|
||||
from optuna.storages import BaseStorage
|
||||
from optuna_dashboard._app import create_app
|
||||
from optuna_dashboard.artifact import _backend
|
||||
import pytest
|
||||
|
||||
from ..wsgi_client import send_request
|
||||
|
||||
|
||||
def test_get_artifact_path() -> None:
|
||||
study = MagicMock(_study_id=0)
|
||||
@@ -12,7 +21,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:"
|
||||
|
||||
|
||||
@@ -80,3 +89,163 @@ def test_list_trial_artifacts(init_storage_with_artifact_meta: MagicMock) -> Non
|
||||
{"artifact_id": "id1", "filename": "bar.txt"},
|
||||
{"artifact_id": "id2", "filename": "baz.txt"},
|
||||
]
|
||||
|
||||
|
||||
def test_study_artifact_store_none() -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
app = create_app(storage)
|
||||
status, _, body = send_request(
|
||||
app,
|
||||
"/artifacts/0/0",
|
||||
"GET",
|
||||
)
|
||||
assert status == 400
|
||||
|
||||
|
||||
def test_study_artifact_not_found() -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
study = optuna.create_study(storage=storage)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
artifact_store = FileSystemArtifactStore(tmpdir)
|
||||
app = create_app(storage, artifact_store)
|
||||
status, _, body = send_request(
|
||||
app,
|
||||
f"/artifacts/{study._study_id}/abc123",
|
||||
"GET",
|
||||
)
|
||||
assert status == 404
|
||||
|
||||
|
||||
def test_successful_study_artifact_retrieval() -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
study = optuna.create_study(storage=storage)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
artifact_store = FileSystemArtifactStore(tmpdir)
|
||||
with tempfile.NamedTemporaryFile() as f:
|
||||
f.write(b"dummy_content")
|
||||
f.flush()
|
||||
artifact_id = upload_artifact(study, f.name, artifact_store=artifact_store)
|
||||
app = create_app(storage, artifact_store)
|
||||
status, _, body = send_request(
|
||||
app,
|
||||
f"/artifacts/{study._study_id}/{artifact_id}",
|
||||
"GET",
|
||||
)
|
||||
assert status == 200
|
||||
assert body == b"dummy_content"
|
||||
|
||||
|
||||
def test_trial_artifact_store_none() -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
app = create_app(storage)
|
||||
status, _, body = send_request(
|
||||
app,
|
||||
"/artifacts/0/0/0",
|
||||
"GET",
|
||||
)
|
||||
assert status == 400
|
||||
|
||||
|
||||
def test_trial_artifact_not_found() -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
study = optuna.create_study(storage=storage)
|
||||
trial = study.ask()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
artifact_store = FileSystemArtifactStore(tmpdir)
|
||||
app = create_app(storage, artifact_store)
|
||||
status, _, body = send_request(
|
||||
app,
|
||||
f"/artifacts/{study._study_id}/{trial._trial_id}/abc123",
|
||||
"GET",
|
||||
)
|
||||
assert status == 404
|
||||
|
||||
|
||||
def test_successful_trial_artifact_retrieval() -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
study = optuna.create_study(storage=storage)
|
||||
trial = study.ask()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
artifact_store = FileSystemArtifactStore(tmpdir)
|
||||
with tempfile.NamedTemporaryFile() as f:
|
||||
f.write(b"dummy_content")
|
||||
f.flush()
|
||||
artifact_id = upload_artifact(trial, f.name, artifact_store=artifact_store)
|
||||
app = create_app(storage, artifact_store)
|
||||
status, _, body = send_request(
|
||||
app,
|
||||
f"/artifacts/{study._study_id}/{trial._trial_id}/{artifact_id}",
|
||||
"GET",
|
||||
)
|
||||
assert status == 200
|
||||
assert body == b"dummy_content"
|
||||
|
||||
|
||||
DUMMY_DATA_URL = (
|
||||
f"data:text/plain; charset=utf-8,{base64.b64encode(b'dummy_content').decode('utf-8')}"
|
||||
)
|
||||
|
||||
|
||||
def test_upload_artifact_invalid_no_trial() -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
artifact_store = FileSystemArtifactStore(tmpdir)
|
||||
|
||||
app = create_app(storage, artifact_store)
|
||||
study = optuna.create_study(storage=storage)
|
||||
|
||||
status, _, body = send_request(
|
||||
app,
|
||||
f"/api/artifacts/{study._study_id}/0",
|
||||
"POST",
|
||||
body=json.dumps({"file": DUMMY_DATA_URL}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert status == 500 # TODO(contramundum53): This should return 400
|
||||
|
||||
|
||||
def test_upload_artifact_invalid_complete_trial() -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
artifact_store = FileSystemArtifactStore(tmpdir)
|
||||
|
||||
app = create_app(storage, artifact_store)
|
||||
study = optuna.create_study(storage=storage)
|
||||
|
||||
study.add_trial(optuna.create_trial(value=1.0, distributions={}, params={}))
|
||||
trial = study.trials[-1]
|
||||
status, _, body = send_request(
|
||||
app,
|
||||
f"/api/artifacts/{study._study_id}/{trial._trial_id}",
|
||||
"POST",
|
||||
body=json.dumps({"file": DUMMY_DATA_URL}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert status == 400
|
||||
|
||||
|
||||
def test_upload_artifact() -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
|
||||
study = optuna.create_study(storage=storage)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
artifact_store = FileSystemArtifactStore(tmpdir)
|
||||
|
||||
app = create_app(storage, artifact_store)
|
||||
|
||||
study.add_trial(optuna.create_trial(state=optuna.trial.TrialState.RUNNING))
|
||||
trial = study.trials[-1]
|
||||
status, _, body = send_request(
|
||||
app,
|
||||
f"/api/artifacts/{study._study_id}/{trial._trial_id}",
|
||||
"POST",
|
||||
body=json.dumps({"file": DUMMY_DATA_URL}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert status == 201
|
||||
res = json.loads(body)
|
||||
with open(f"{tmpdir}/{res['artifact_id']}", "r") as f:
|
||||
data = f.read()
|
||||
assert data == "dummy_content"
|
||||
|
||||
@@ -9,6 +9,8 @@ from optuna import get_all_study_summaries
|
||||
from optuna.study import StudyDirection
|
||||
from optuna_dashboard._app import create_app
|
||||
from optuna_dashboard._app import create_new_study
|
||||
from optuna_dashboard._note import note_str_key_prefix
|
||||
from optuna_dashboard._note import note_ver_key
|
||||
from optuna_dashboard._preference_setting import register_preference_feedback_component
|
||||
from optuna_dashboard._preferential_history import NewHistory
|
||||
from optuna_dashboard._preferential_history import remove_history
|
||||
@@ -220,6 +222,110 @@ class APITestCase(TestCase):
|
||||
assert study_detail["feedback_component_type"]["output_type"] == "artifact"
|
||||
assert study_detail["feedback_component_type"]["artifact_key"] == "image"
|
||||
|
||||
def test_save_trial_user_attrs(self) -> None:
|
||||
study = optuna.create_study()
|
||||
trials: list[optuna.Trial] = []
|
||||
for _ in range(2):
|
||||
trial = study.ask()
|
||||
trials.append(trial)
|
||||
|
||||
request_body = {
|
||||
"user_attrs": {
|
||||
"number": 0,
|
||||
},
|
||||
}
|
||||
|
||||
app = create_app(study._storage)
|
||||
status, _, _ = send_request(
|
||||
app,
|
||||
f"/api/trials/{trials[0]._trial_id}/user-attrs",
|
||||
"POST",
|
||||
content_type="application/json",
|
||||
body=json.dumps(request_body),
|
||||
)
|
||||
self.assertEqual(status, 204)
|
||||
|
||||
assert study.trials[0].user_attrs == request_body["user_attrs"]
|
||||
assert study.trials[1].user_attrs == {}
|
||||
|
||||
def test_save_trial_user_attrs_empty(self) -> None:
|
||||
study = optuna.create_study()
|
||||
trial = study.ask()
|
||||
|
||||
app = create_app(study._storage)
|
||||
status, _, _ = send_request(
|
||||
app,
|
||||
f"/api/trials/{trial._trial_id}/user-attrs",
|
||||
"POST",
|
||||
content_type="application/json",
|
||||
body=json.dumps({}),
|
||||
)
|
||||
self.assertEqual(status, 400)
|
||||
assert study.trials[0].user_attrs == {}
|
||||
|
||||
def _save_trial_note(self, request_body: dict[str, int | str]) -> tuple[int, optuna.Study]:
|
||||
study = optuna.create_study()
|
||||
trial = study.ask()
|
||||
app = create_app(study._storage)
|
||||
status, _, _ = send_request(
|
||||
app,
|
||||
f"/api/studies/{study._study_id}/{trial._trial_id}/note",
|
||||
"PUT",
|
||||
content_type="application/json",
|
||||
body=json.dumps(request_body),
|
||||
)
|
||||
return status, study
|
||||
|
||||
def test_save_trial_note_overwrite(self) -> None:
|
||||
study = optuna.create_study()
|
||||
trial = study.ask()
|
||||
app = create_app(study._storage)
|
||||
|
||||
def _get_request_body(note_version: int) -> dict[str, str | int]:
|
||||
return {"body": f"Test note ver. {note_version}.", "version": note_version}
|
||||
|
||||
for ver in range(1, 3):
|
||||
status, _, _ = send_request(
|
||||
app,
|
||||
f"/api/studies/{study._study_id}/{trial._trial_id}/note",
|
||||
"PUT",
|
||||
content_type="application/json",
|
||||
body=json.dumps(_get_request_body(note_version=ver)),
|
||||
)
|
||||
assert status == 204
|
||||
# Check if the version 1 is deleted.
|
||||
expected_request_body = _get_request_body(note_version=2)
|
||||
expected_system_attrs = {
|
||||
note_ver_key(trial_id=0): expected_request_body["version"],
|
||||
f"{note_str_key_prefix(trial_id=0)}{0}": expected_request_body["body"],
|
||||
}
|
||||
for k, v in expected_system_attrs.items():
|
||||
assert k in study.system_attrs
|
||||
assert study.system_attrs[k] == v
|
||||
|
||||
def test_save_trial_note(self) -> None:
|
||||
request_body: dict[str, int | str] = {"body": "Test note.", "version": 1}
|
||||
status, study = self._save_trial_note(request_body)
|
||||
assert status == 204
|
||||
expected_system_attrs = {
|
||||
note_ver_key(0): request_body["version"],
|
||||
f"{note_str_key_prefix(0)}{0}": request_body["body"],
|
||||
}
|
||||
for k, v in expected_system_attrs.items():
|
||||
assert k in study.system_attrs
|
||||
assert study.system_attrs[k] == v
|
||||
|
||||
def test_save_trial_note_with_wrong_version(self) -> None:
|
||||
request_body: dict[str, int | str] = {"body": "Test note.", "version": 0}
|
||||
status, study = self._save_trial_note(request_body)
|
||||
assert status == 409
|
||||
assert note_ver_key(0) not in study.system_attrs
|
||||
|
||||
def test_save_trial_note_empty(self) -> None:
|
||||
status, study = self._save_trial_note(request_body={})
|
||||
assert status == 400
|
||||
assert note_ver_key(0) not in study.system_attrs
|
||||
|
||||
@pytest.mark.skipif(sys.version_info < (3, 8), reason="BoTorch dropped Python3.7 support")
|
||||
def test_skip_trial(self) -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
@@ -399,6 +505,125 @@ class APITestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(status, 404)
|
||||
|
||||
def test_tell_trial_complete(self) -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
study = optuna.create_study(storage=storage)
|
||||
trial_id = study.ask()._trial_id
|
||||
|
||||
app = create_app(storage)
|
||||
status, _, _ = send_request(
|
||||
app,
|
||||
f"/api/trials/{trial_id}/tell",
|
||||
"POST",
|
||||
body=json.dumps(
|
||||
{
|
||||
"state": "Complete",
|
||||
"values": [0, 1, 2],
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(status, 204)
|
||||
trial = storage.get_trial(trial_id)
|
||||
assert trial.state == optuna.trial.TrialState.COMPLETE
|
||||
assert trial.values == [0, 1, 2]
|
||||
|
||||
def test_tell_trial_fail(self) -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
study = optuna.create_study(storage=storage)
|
||||
trial_id = study.ask()._trial_id
|
||||
|
||||
app = create_app(storage)
|
||||
status, _, _ = send_request(
|
||||
app,
|
||||
f"/api/trials/{trial_id}/tell",
|
||||
"POST",
|
||||
body=json.dumps(
|
||||
{
|
||||
"state": "Fail",
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(status, 204)
|
||||
trial = storage.get_trial(trial_id)
|
||||
assert trial.state == optuna.trial.TrialState.FAIL
|
||||
|
||||
def test_tell_trial_with_no_state(self) -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
study = optuna.create_study(storage=storage)
|
||||
trial_id = study.ask()._trial_id
|
||||
|
||||
app = create_app(storage)
|
||||
status, _, _ = send_request(
|
||||
app,
|
||||
f"/api/trials/{trial_id}/tell",
|
||||
"POST",
|
||||
body=json.dumps({}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
def test_tell_trial_with_invalid_state(self) -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
study = optuna.create_study(storage=storage)
|
||||
for state in ["Pruned", "Running", "Waiting", "Invalid"]:
|
||||
trial_id = study.ask()._trial_id
|
||||
app = create_app(storage)
|
||||
with self.subTest(state=state):
|
||||
status, _, _ = send_request(
|
||||
app,
|
||||
f"/api/trials/{trial_id}/tell",
|
||||
"POST",
|
||||
body=json.dumps(
|
||||
{
|
||||
"state": state,
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
def test_tell_trial_with_no_values(self) -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
study = optuna.create_study(storage=storage)
|
||||
trial_id = study.ask()._trial_id
|
||||
|
||||
app = create_app(storage)
|
||||
status, _, _ = send_request(
|
||||
app,
|
||||
f"/api/trials/{trial_id}/tell",
|
||||
"POST",
|
||||
body=json.dumps(
|
||||
{
|
||||
"state": "Complete",
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
def test_tell_trial_with_invalid_values(self) -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
study = optuna.create_study(storage=storage)
|
||||
for values in [1.0, ["foo"]]:
|
||||
trial_id = study.ask()._trial_id
|
||||
app = create_app(storage)
|
||||
with self.subTest(values=values):
|
||||
status, _, _ = send_request(
|
||||
app,
|
||||
f"/api/trials/{trial_id}/tell",
|
||||
"POST",
|
||||
body=json.dumps(
|
||||
{
|
||||
"state": "Complete",
|
||||
"values": values,
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
|
||||
class BottleRequestHookTestCase(TestCase):
|
||||
def test_ignore_trailing_slashes(self) -> None:
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Any
|
||||
from unittest import TestCase
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
import optuna
|
||||
from optuna import create_trial
|
||||
from optuna.distributions import BaseDistribution
|
||||
@@ -254,11 +255,29 @@ class _CachedExtraStudyPropertyUserAttrs(TestCase):
|
||||
|
||||
def test_infer_sortable(self) -> None:
|
||||
user_attrs_list: list[dict[str, Any]] = [
|
||||
{"a": 1, "b": 1, "c": 1, "d": "a", "e": 1, "f": True},
|
||||
{
|
||||
"a": 1,
|
||||
"b": 1,
|
||||
"c": 1,
|
||||
"d": "a",
|
||||
"e": 1,
|
||||
"f": True,
|
||||
"g": np.float128(1.1),
|
||||
"h": np.int64(2),
|
||||
},
|
||||
{"a": 2, "b": "a", "c": "a", "d": "a"},
|
||||
{"a": 3, "b": None, "c": 3, "d": "a", "e": 3},
|
||||
]
|
||||
expected = {"a": True, "b": False, "c": False, "d": False, "e": True, "f": False}
|
||||
expected = {
|
||||
"a": True,
|
||||
"b": False,
|
||||
"c": False,
|
||||
"d": False,
|
||||
"e": True,
|
||||
"f": False,
|
||||
"g": True,
|
||||
"h": True,
|
||||
}
|
||||
|
||||
trials = []
|
||||
for user_attrs in user_attrs_list:
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import optuna
|
||||
from optuna.trial import TrialState
|
||||
from optuna_dashboard._app import create_app
|
||||
import pytest
|
||||
|
||||
from .wsgi_client import send_request
|
||||
|
||||
|
||||
def _validate_output(
|
||||
storage: optuna.storages.BaseStorage,
|
||||
correct_status: int,
|
||||
study_id: int,
|
||||
expect_no_result: bool = False,
|
||||
extra_col_names: list[str] | None = None,
|
||||
) -> None:
|
||||
app = create_app(storage)
|
||||
status, _, body = send_request(
|
||||
app,
|
||||
f"/csv/{study_id}",
|
||||
"GET",
|
||||
content_type="application/json",
|
||||
)
|
||||
assert status == correct_status
|
||||
decoded_csv = str(body.decode("utf-8"))
|
||||
if expect_no_result:
|
||||
assert "is not found" in decoded_csv
|
||||
else:
|
||||
col_names = ["Number", "State"] + ([] if extra_col_names is None else extra_col_names)
|
||||
assert all(col_name in decoded_csv for col_name in col_names)
|
||||
|
||||
|
||||
def test_download_csv_no_trial() -> None:
|
||||
def objective(trial: optuna.Trial) -> float:
|
||||
x = trial.suggest_float("x", -100, 100)
|
||||
y = trial.suggest_categorical("y", [-1, 0, 1])
|
||||
return x**2 + y
|
||||
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
study = optuna.create_study(storage=storage)
|
||||
study.optimize(objective, n_trials=0)
|
||||
_validate_output(storage, 200, 0)
|
||||
|
||||
|
||||
def test_download_csv_all_waiting() -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
study = optuna.create_study(storage=storage)
|
||||
study.add_trial(optuna.trial.create_trial(state=TrialState.WAITING))
|
||||
_validate_output(storage, 200, 0)
|
||||
|
||||
|
||||
def test_download_csv_all_running() -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
study = optuna.create_study(storage=storage)
|
||||
study.add_trial(optuna.trial.create_trial(state=TrialState.RUNNING))
|
||||
_validate_output(storage, 200, 0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("study_id", [0, 1])
|
||||
def test_download_csv_fail(study_id: int) -> None:
|
||||
def objective(trial: optuna.Trial) -> float:
|
||||
x = trial.suggest_float("x", -100, 100)
|
||||
y = trial.suggest_categorical("y", [-1, 0, 1])
|
||||
return x**2 + y
|
||||
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
study = optuna.create_study(storage=storage)
|
||||
optuna.logging.set_verbosity(optuna.logging.ERROR)
|
||||
study.optimize(objective, n_trials=10)
|
||||
expect_no_result = study_id != 0
|
||||
cols = ["Param x", "Param y", "Value"]
|
||||
_validate_output(storage, 404 if expect_no_result else 200, study_id, expect_no_result, cols)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_multi_obj", [True, False])
|
||||
def test_download_csv_multi_obj(is_multi_obj: bool) -> None:
|
||||
def objective(trial: optuna.Trial) -> Any:
|
||||
x = trial.suggest_float("x", -100, 100)
|
||||
y = trial.suggest_categorical("y", [-1, 0, 1])
|
||||
if is_multi_obj:
|
||||
return x**2, y
|
||||
return x**2 + y
|
||||
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
directions = ["minimize", "minimize"] if is_multi_obj else ["minimize"]
|
||||
study = optuna.create_study(storage=storage, directions=directions)
|
||||
optuna.logging.set_verbosity(optuna.logging.ERROR)
|
||||
study.optimize(objective, n_trials=10)
|
||||
cols = ["Param x", "Param y"]
|
||||
cols += ["Objective 0", "Objective 1"] if is_multi_obj else ["Value"]
|
||||
_validate_output(storage, 200, 0, extra_col_names=cols)
|
||||
|
||||
|
||||
def test_download_csv_user_attr() -> None:
|
||||
def objective(trial: optuna.Trial) -> float:
|
||||
x = trial.suggest_float("x", -100, 100)
|
||||
y = trial.suggest_categorical("y", [-1, 0, 1])
|
||||
trial.set_user_attr("abs_y", abs(y))
|
||||
return x**2 + y
|
||||
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
study = optuna.create_study(storage=storage)
|
||||
optuna.logging.set_verbosity(optuna.logging.ERROR)
|
||||
study.optimize(objective, n_trials=10)
|
||||
cols = ["Param x", "Param y", "Value", "UserAttribute abs_y"]
|
||||
_validate_output(storage, 200, 0, extra_col_names=cols)
|
||||
@@ -53,3 +53,25 @@ class NoteTestCase(TestCase):
|
||||
note_dict = note.get_note_from_system_attrs(system_attrs, trial._trial_id)
|
||||
self.assertEqual(note_dict["body"], body)
|
||||
self.assertEqual(note_dict["version"], expected_ver)
|
||||
|
||||
def test_copy_notes(self) -> None:
|
||||
old_study = optuna.create_study()
|
||||
old_trials = [
|
||||
old_study.ask({"x1": optuna.distributions.FloatDistribution(0, 10)}) for _ in range(2)
|
||||
]
|
||||
storage = old_study._storage
|
||||
|
||||
notes = ["trial 0", "trial 1"]
|
||||
for trial, body in zip(old_trials, notes):
|
||||
save_note(trial, body)
|
||||
save_note(old_study, "Study")
|
||||
|
||||
new_study = optuna.create_study(storage=storage, directions=old_study.directions)
|
||||
new_study.add_trials(old_study.get_trials(deepcopy=False))
|
||||
|
||||
note.copy_notes(storage, old_study, new_study)
|
||||
system_attrs = new_study._storage.get_study_system_attrs(new_study._study_id)
|
||||
for new_trial, body in zip(new_study.get_trials(), notes):
|
||||
actual = note.get_note_from_system_attrs(system_attrs, new_trial._trial_id)
|
||||
self.assertEqual(actual["body"], body)
|
||||
self.assertEqual(get_note(new_study), "Study")
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import optuna
|
||||
from optuna_dashboard._serializer import serialize_attrs
|
||||
from optuna_dashboard._serializer import serialize_study_detail
|
||||
@@ -25,6 +26,32 @@ def test_serialize_dict() -> None:
|
||||
assert len(serialized) <= 1
|
||||
|
||||
|
||||
def test_serialize_numpy_integer() -> None:
|
||||
serialized = serialize_attrs(
|
||||
{
|
||||
"int8": np.int8(1),
|
||||
"int16": np.int16(1),
|
||||
"int32": np.int32(1),
|
||||
"int64": np.int64(1),
|
||||
}
|
||||
)
|
||||
assert len(serialized) == 4
|
||||
assert all([v["value"] == "1" for v in serialized])
|
||||
|
||||
|
||||
def test_serialize_numpy_floating() -> None:
|
||||
serialized = serialize_attrs(
|
||||
{
|
||||
"float16": np.float16(1.0),
|
||||
"float32": np.float32(1.0),
|
||||
"float64": np.float64(1.0),
|
||||
"float128": np.float128(1.0),
|
||||
}
|
||||
)
|
||||
assert len(serialized) == 4
|
||||
assert all([v["value"] == "1.0" for v in serialized])
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info < (3, 8), reason="BoTorch dropped Python3.7 support")
|
||||
def test_get_study_detail_is_preferential() -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
|
||||
@@ -358,7 +358,7 @@ function stableSort<T>(
|
||||
const stabilizedThis = array.map((el, index) => [el, index] as [T, number])
|
||||
stabilizedThis.sort((a, b) => {
|
||||
if (less) {
|
||||
const ascending = order == "asc"
|
||||
const ascending = order === "asc"
|
||||
const result = ascending
|
||||
? -less(a[0], b[0], ascending)
|
||||
: less(a[0], b[0], ascending)
|
||||
|
||||
@@ -78,7 +78,7 @@ const plotIntermediateValue = (
|
||||
t.state === "Pruned" &&
|
||||
t.values &&
|
||||
t.values.length > 0) ||
|
||||
t.state == "Running"
|
||||
t.state === "Running"
|
||||
)
|
||||
const plotData: Partial<plotly.PlotData>[] = filteredTrials.map((trial) => {
|
||||
const values = trial.intermediate_values.filter(
|
||||
|
||||
@@ -20,7 +20,7 @@ export const TrialTable: FC<{
|
||||
},
|
||||
]
|
||||
|
||||
if (study === null || study.directions.length == 1) {
|
||||
if (study === null || study.directions.length === 1) {
|
||||
columns.push({
|
||||
field: "values",
|
||||
label: "Value",
|
||||
|
||||
@@ -64,7 +64,7 @@ const getSchemaVersion = (db: SQLite3DB): string => {
|
||||
|
||||
const isSupportedSchema = (schemaVersion: string): boolean => {
|
||||
const lowestVersion = "v2.6.0.a" // supported: "v3.2.0.a", "v3.0.0.{a,b,c,d}", "v2.6.0.a"
|
||||
if (schemaVersion == lowestVersion) return true
|
||||
if (schemaVersion === lowestVersion) return true
|
||||
return isGreaterSchemaVersion(schemaVersion, lowestVersion)
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ const isGreaterSchemaVersion = (
|
||||
|
||||
const left = Number(leftVersion)
|
||||
const right = Number(rightVersion)
|
||||
if (left == right) return leftSuffix > rightSuffix
|
||||
if (left === right) return leftSuffix > rightSuffix
|
||||
return left > right
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ const getStudies = (db: SQLite3DB, schemaVersion: string): Study[] => {
|
||||
trials.forEach((trial) => {
|
||||
const userAttrs = getTrialUserAttributes(db, trial.trial_id)
|
||||
userAttrs.forEach((attr) => {
|
||||
if (union_user_attrs.findIndex((s) => s.key === attr.key) == -1) {
|
||||
if (union_user_attrs.findIndex((s) => s.key === attr.key) === -1) {
|
||||
union_user_attrs.push({ key: attr.key, sortable: false })
|
||||
}
|
||||
})
|
||||
@@ -116,7 +116,7 @@ const getStudies = (db: SQLite3DB, schemaVersion: string): Study[] => {
|
||||
params.forEach((param) => {
|
||||
param_names.add(param.name)
|
||||
if (
|
||||
union_search_space.findIndex((s) => s.name === param.name) == -1
|
||||
union_search_space.findIndex((s) => s.name === param.name) === -1
|
||||
) {
|
||||
union_search_space.push({ name: param.name })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react"
|
||||
global.URL.createObjectURL = jest.fn()
|
||||
|
||||
import { cleanup, render, fireEvent } from "@testing-library/react"
|
||||
import { cleanup, render } from "@testing-library/react"
|
||||
import {
|
||||
DataGrid,
|
||||
DataGridColumn,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
// TODO(c-bata): Add tests to check filterChoices option
|
||||
it("Filter rows of DataGrid", () => {
|
||||
interface DummyAttribute {
|
||||
id: number
|
||||
@@ -23,7 +24,7 @@ it("Filter rows of DataGrid", () => {
|
||||
{ id: 5, key: "foo", value: 3 },
|
||||
]
|
||||
const columns: DataGridColumn<DummyAttribute>[] = [
|
||||
{ field: "key", label: "Key", filterable: true },
|
||||
{ field: "key", label: "Key" },
|
||||
{
|
||||
field: "value",
|
||||
label: "Value",
|
||||
@@ -39,46 +40,4 @@ it("Filter rows of DataGrid", () => {
|
||||
/>
|
||||
)
|
||||
expect(queryAllByText("bar").length).toBe(2)
|
||||
|
||||
// Filter rows by "foo"
|
||||
fireEvent.click(queryAllByText("foo")[0])
|
||||
expect(queryAllByText("foo").length).toBe(3)
|
||||
expect(queryAllByText("bar").length).toBe(0)
|
||||
})
|
||||
|
||||
it("Filter rows after sorted", () => {
|
||||
interface DummyAttribute {
|
||||
id: number
|
||||
key: string
|
||||
value: number
|
||||
}
|
||||
const dummyAttributes = [
|
||||
{ id: 1, key: "foo", value: 4000 },
|
||||
{ id: 2, key: "bar", value: 1000 },
|
||||
{ id: 3, key: "bar", value: 2000 },
|
||||
{ id: 4, key: "foo", value: 3000 },
|
||||
{ id: 5, key: "foo", value: 5000 },
|
||||
]
|
||||
const columns: DataGridColumn<DummyAttribute>[] = [
|
||||
{ field: "key", label: "Key", filterable: true },
|
||||
{
|
||||
field: "value",
|
||||
label: "Value",
|
||||
sortable: true,
|
||||
},
|
||||
]
|
||||
|
||||
const { getByText, queryAllByText } = render(
|
||||
<DataGrid<DummyAttribute>
|
||||
columns={columns}
|
||||
rows={dummyAttributes}
|
||||
keyField={"id"}
|
||||
/>
|
||||
)
|
||||
// Sort and filter rows
|
||||
fireEvent.click(getByText("Value"))
|
||||
fireEvent.click(queryAllByText("bar")[0])
|
||||
|
||||
expect(queryAllByText("1000").length).toBe(1)
|
||||
expect(queryAllByText("2000").length).toBe(1)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user