From 5894481829d21ab12261a8cf4156755560ab06b3 Mon Sep 17 00:00:00 2001 From: David Butenhof Date: Thu, 24 Oct 2024 16:55:13 -0400 Subject: [PATCH] Multi-run comparison UI This adds the basic UI to support comparison of the metrics of two InstructLab runs. This compares only the primary metrics of the two runs, in a relative timeline graph. This is backed by #125, which is backed by #124, which is backed by #123, which is backed by #122. These represent a series of steps towards a complete InstructLab UI and API, and will be reviewed and merged from #122 forward. --- frontend/src/actions/filterActions.js | 2 +- frontend/src/actions/ilabActions.js | 112 ++++++++- frontend/src/actions/types.js | 3 + .../organisms/TableFilters/index.jsx | 19 +- .../organisms/TableFilters/index.less | 4 + .../templates/ILab/IlabCompareComponent.jsx | 117 ++++++++++ .../templates/ILab/IlabExpandedRow.jsx | 148 ++++++++++++ .../templates/ILab/MetricsDropdown.jsx | 13 +- .../src/components/templates/ILab/index.jsx | 213 ++++++------------ .../src/components/templates/ILab/index.less | 30 ++- frontend/src/reducers/ilabReducer.js | 9 + 11 files changed, 512 insertions(+), 158 deletions(-) create mode 100644 frontend/src/components/templates/ILab/IlabCompareComponent.jsx create mode 100644 frontend/src/components/templates/ILab/IlabExpandedRow.jsx diff --git a/frontend/src/actions/filterActions.js b/frontend/src/actions/filterActions.js index f8fa5691..6385b0bc 100644 --- a/frontend/src/actions/filterActions.js +++ b/frontend/src/actions/filterActions.js @@ -79,7 +79,7 @@ export const setDateFilter = (date, key, navigation, currType) => { dispatch(setTelcoDateFilter(date, key, navigation)); } else if (currType === "ilab") { dispatch(setIlabDateFilter(date, key, navigation)); - dispatch(fetchILabJobs()); + dispatch(fetchILabJobs(true)); } }; diff --git a/frontend/src/actions/ilabActions.js b/frontend/src/actions/ilabActions.js index 1152846b..c3782735 100644 --- a/frontend/src/actions/ilabActions.js +++ b/frontend/src/actions/ilabActions.js @@ -122,7 +122,8 @@ export const fetchPeriods = (uid) => async (dispatch) => { }; export const fetchGraphData = - (uid, metric, primary_metric) => async (dispatch, getState) => { + (uid, metric = null) => + async (dispatch, getState) => { try { const periods = getState().ilab.periods.find((i) => i.uid == uid); const graphData = cloneDeep(getState().ilab.graphData); @@ -136,15 +137,17 @@ export const fetchGraphData = let graphs = []; periods?.periods?.forEach((p) => { graphs.push({ metric: p.primary_metric, periods: [p.id] }); - graphs.push({ - metric, - aggregate: true, - periods: [p.id], - }); + if (metric) { + graphs.push({ + metric, + aggregate: true, + periods: [p.id], + }); + } }); const response = await API.post(`/api/v1/ilab/runs/multigraph`, { run: uid, - name: primary_metric, + name: `graph ${uid}`, graphs, }); if (response.status === 200) { @@ -169,6 +172,86 @@ export const fetchGraphData = dispatch({ type: TYPES.GRAPH_COMPLETED }); }; +export const handleMultiGraph = (uids) => async (dispatch, getState) => { + try { + const periods = getState().ilab.periods; + const pUids = periods.map((i) => i.uid); + + const missingPeriods = uids.filter(function (x) { + return pUids.indexOf(x) < 0; + }); + + await Promise.all( + missingPeriods.map(async (uid) => { + await dispatch(fetchPeriods(uid)); // Dispatch each item + }) + ); + + dispatch(fetchMultiGraphData(uids)); + } catch (error) { + console.error( + `ERROR (${error?.response?.status}): ${JSON.stringify( + error?.response?.data + )}` + ); + dispatch(showFailureToast()); + } +}; +export const fetchMultiGraphData = (uids) => async (dispatch, getState) => { + try { + dispatch({ type: TYPES.LOADING }); + const periods = getState().ilab.periods; + const filterPeriods = periods.filter((item) => uids.includes(item.uid)); + + let graphs = []; + uids.forEach(async (uid) => { + const periods = filterPeriods.find((i) => i.uid == uid); + periods?.periods?.forEach((p) => { + graphs.push({ + run: uid, + metric: p.primary_metric, + periods: [p.id], + }); + // graphs.push({ + // run: uid, + // metric, + // aggregate: true, + // periods: [p.id], + // }); + }); + }); + console.log(graphs); + const response = await API.post(`/api/v1/ilab/runs/multigraph`, { + name: "comparison", + relative: true, + graphs, + }); + if (response.status === 200) { + response.data.layout["showlegend"] = true; + response.data.layout["responsive"] = "true"; + response.data.layout["autosize"] = "true"; + response.data.layout["legend"] = { x: 0, y: 1.5 }; + const graphData = []; + graphData.push({ + data: response.data.data, + layout: response.data.layout, + }); + dispatch({ + type: TYPES.SET_ILAB_MULTIGRAPH_DATA, + payload: graphData, + }); + } + } catch (error) { + console.error( + `ERROR (${error?.response?.status}): ${JSON.stringify( + error?.response?.data + )}` + ); + dispatch(showFailureToast()); + } + dispatch({ type: TYPES.COMPLETED }); +}; + export const setIlabPage = (pageNo) => ({ type: TYPES.SET_ILAB_PAGE, payload: pageNo, @@ -210,4 +293,19 @@ export const tableReCalcValues = () => (dispatch, getState) => { const startIdx = page !== 1 ? (page - 1) * perPage : 0; const endIdx = page !== 1 ? page * perPage - 1 : perPage; dispatch(sliceIlabTableRows(startIdx, endIdx)); + dispatch(getMetaRowdId()); +}; + +export const getMetaRowdId = () => (dispatch, getState) => { + const tableData = getState().ilab.tableData; + const metaId = tableData.map((item) => `metadata-toggle-${item.id}`); + dispatch(setMetaRowExpanded(metaId)); }; +export const toggleComparisonSwitch = () => ({ + type: TYPES.TOGGLE_COMPARISON_SWITCH, +}); + +export const setMetaRowExpanded = (expandedItems) => ({ + type: TYPES.SET_EXPANDED_METAROW, + payload: expandedItems, +}); diff --git a/frontend/src/actions/types.js b/frontend/src/actions/types.js index 58d7506f..f7e21fec 100644 --- a/frontend/src/actions/types.js +++ b/frontend/src/actions/types.js @@ -81,6 +81,7 @@ export const SET_TELCO_GRAPH_DATA = "SET_TELCO_GRAPH_DATA"; export const SET_ILAB_JOBS_DATA = "SET_ILAB_JOBS_DATA"; export const SET_ILAB_DATE_FILTER = "SET_ILAB_DATE_FILTER"; export const SET_ILAB_GRAPH_DATA = "SET_ILAB_GRAPH_DATA"; +export const SET_ILAB_MULTIGRAPH_DATA = "SET_ILAB_MULTIGRAPH_DATA"; export const SET_ILAB_TOTAL_ITEMS = "SET_ILAB_TOTAL_ITEMS"; export const SET_ILAB_OFFSET = "SET_ILAB_OFFSET"; export const SET_ILAB_PAGE = "SET_ILAB_PAGE"; @@ -89,3 +90,5 @@ export const SET_ILAB_METRICS = "SET_ILAB_METRICS"; export const SET_ILAB_SELECTED_METRICS = "SET_ILAB_SELECTED_METRICS"; export const SET_ILAB_PERIODS = "SET_ILAB_PERIODS"; export const SET_ILAB_INIT_JOBS = "SET_ILAB_INIT_JOBS"; +export const TOGGLE_COMPARISON_SWITCH = "TOGGLE_COMPARISON_SWITCH"; +export const SET_EXPANDED_METAROW = "SET_EXPANDED_METAROW"; diff --git a/frontend/src/components/organisms/TableFilters/index.jsx b/frontend/src/components/organisms/TableFilters/index.jsx index 0dd5885d..cec3fee0 100644 --- a/frontend/src/components/organisms/TableFilters/index.jsx +++ b/frontend/src/components/organisms/TableFilters/index.jsx @@ -5,6 +5,7 @@ import "./index.less"; import { Chip, ChipGroup, + Switch, Toolbar, ToolbarContent, ToolbarItem, @@ -39,6 +40,8 @@ const TableFilter = (props) => { setColumns, selectedFilters, updateSelectedFilter, + onSwitchChange, + isSwitchChecked, } = props; const category = @@ -66,7 +69,7 @@ const TableFilter = (props) => { setDateFilter(date, key, navigation, type); }; const endDateChangeHandler = (date, key) => { - setDateFilter(key, date, navigation, type); + setDateFilter(date, key, navigation, type); }; return ( @@ -123,6 +126,18 @@ const TableFilter = (props) => { )} + {type === "ilab" && ( + + + + + + )} {appliedFilters && Object.keys(appliedFilters).length > 0 && @@ -154,5 +169,7 @@ TableFilter.propTypes = { selectedFilters: PropTypes.array, updateSelectedFilter: PropTypes.func, navigation: PropTypes.func, + isSwitchChecked: PropTypes.bool, + onSwitchChange: PropTypes.func, }; export default TableFilter; diff --git a/frontend/src/components/organisms/TableFilters/index.less b/frontend/src/components/organisms/TableFilters/index.less index b100a012..1a479703 100644 --- a/frontend/src/components/organisms/TableFilters/index.less +++ b/frontend/src/components/organisms/TableFilters/index.less @@ -11,4 +11,8 @@ .to-text { padding: 5px 0; } + #comparison-switch { + margin-left: auto; + align-content: center; + } } \ No newline at end of file diff --git a/frontend/src/components/templates/ILab/IlabCompareComponent.jsx b/frontend/src/components/templates/ILab/IlabCompareComponent.jsx new file mode 100644 index 00000000..c062be13 --- /dev/null +++ b/frontend/src/components/templates/ILab/IlabCompareComponent.jsx @@ -0,0 +1,117 @@ +import "./index.less"; + +import { + Button, + Menu, + MenuContent, + MenuItem, + MenuItemAction, + MenuList, + Title, +} from "@patternfly/react-core"; +import { useDispatch, useSelector } from "react-redux"; + +import { InfoCircleIcon } from "@patternfly/react-icons"; +import Plot from "react-plotly.js"; +import PropTypes from "prop-types"; +import RenderPagination from "@/components/organisms/Pagination"; +import { cloneDeep } from "lodash"; +import { handleMultiGraph } from "@/actions/ilabActions.js"; +import { uid } from "@/utils/helper"; +import { useState } from "react"; + +const IlabCompareComponent = () => { + // const { data } = props; + const { page, perPage, totalItems, tableData } = useSelector( + (state) => state.ilab + ); + const dispatch = useDispatch(); + const [selectedItems, setSelectedItems] = useState([]); + const { multiGraphData } = useSelector((state) => state.ilab); + const isGraphLoading = useSelector((state) => state.loading.isGraphLoading); + const graphDataCopy = cloneDeep(multiGraphData); + + const onSelect = (_event, itemId) => { + const item = itemId; + if (selectedItems.includes(item)) { + setSelectedItems(selectedItems.filter((id) => id !== item)); + } else { + setSelectedItems([...selectedItems, item]); + } + }; + const dummy = () => { + dispatch(handleMultiGraph(selectedItems)); + }; + return ( +
+
+ + Metrics + + + + + + {tableData.map((item) => { + return ( + } + actionId="code" + onClick={() => console.log("clicked on code icon")} + aria-label="Code" + /> + } + > + {`${new Date(item.begin_date).toLocaleDateString()} ${ + item.primary_metrics[0] + }`} + + ); + })} + + + + +
+
+ {isGraphLoading ? ( +
+ ) : graphDataCopy?.length > 0 && + graphDataCopy?.[0]?.data?.length > 0 ? ( +
+ +
+ ) : ( +
No data to compare
+ )} +
+
+ ); +}; + +IlabCompareComponent.propTypes = { + data: PropTypes.array, +}; +export default IlabCompareComponent; diff --git a/frontend/src/components/templates/ILab/IlabExpandedRow.jsx b/frontend/src/components/templates/ILab/IlabExpandedRow.jsx new file mode 100644 index 00000000..bcdbcc33 --- /dev/null +++ b/frontend/src/components/templates/ILab/IlabExpandedRow.jsx @@ -0,0 +1,148 @@ +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionToggle, + Card, + CardBody, +} from "@patternfly/react-core"; +import { useDispatch, useSelector } from "react-redux"; + +import ILabGraph from "./ILabGraph"; +import MetaRow from "./MetaRow"; +import MetricsSelect from "./MetricsDropdown"; +import PropTypes from "prop-types"; +import { setMetaRowExpanded } from "@/actions/ilabActions"; +import { uid } from "@/utils/helper"; + +const IlabRowContent = (props) => { + const { item } = props; + const dispatch = useDispatch(); + const { metaRowExpanded } = useSelector((state) => state.ilab); + + const onToggle = (id) => { + const index = metaRowExpanded.indexOf(id); + const newExpanded = + index >= 0 + ? [ + ...metaRowExpanded.slice(0, index), + ...metaRowExpanded.slice(index + 1, metaRowExpanded.length), + ] + : [...metaRowExpanded, id]; + + dispatch(setMetaRowExpanded(newExpanded)); + }; + return ( + + + { + onToggle(`metadata-toggle-${item.id}`); + }} + isExpanded={metaRowExpanded.includes(`metadata-toggle-${item.id}`)} + id={`metadata-toggle-${item.id}`} + > + Metadata + + + +
+ + + + + + + + + + + + + + {item.iterations.length > 1 && ( + + { + onToggle(`iterations-toggle-${item.id}`); + }} + isExpanded={metaRowExpanded.includes( + `iterations-toggle-${item.id}` + )} + id={`iterations-toggle-${item.id}`} + > + {`Unique parameters for ${item.iterations.length} Iterations`} + + + {item.iterations.map((i) => ( + !(i[0] in item.params) + )} + /> + ))} + + + )} + + +
+
+
+ + { + onToggle(`graph-toggle-${item.id}`); + }} + isExpanded={metaRowExpanded.includes(`graph-toggle-${item.id}`)} + id={`graph-toggle-${item.id}`} + > + Metrics & Graph + + +
Metrics:
+ +
+ +
+
+
+
+ ); +}; +IlabRowContent.propTypes = { + item: PropTypes.object, +}; +export default IlabRowContent; diff --git a/frontend/src/components/templates/ILab/MetricsDropdown.jsx b/frontend/src/components/templates/ILab/MetricsDropdown.jsx index f301953d..04568f0f 100644 --- a/frontend/src/components/templates/ILab/MetricsDropdown.jsx +++ b/frontend/src/components/templates/ILab/MetricsDropdown.jsx @@ -3,10 +3,12 @@ import { Select, SelectList, SelectOption, + Skeleton } from "@patternfly/react-core"; import { fetchGraphData, setSelectedMetrics } from "@/actions/ilabActions"; import { useDispatch, useSelector } from "react-redux"; +import PropTypes from "prop-types"; import { cloneDeep } from "lodash"; import { uid } from "@/utils/helper"; import { useState } from "react"; @@ -41,7 +43,7 @@ const MetricsSelect = (props) => { //setSelected(run[1].trim()); dispatch(setSelectedMetrics(run[0].trim(), run[1].trim())); setIsOpen(false); - dispatch(fetchGraphData(run[0].trim(), run[1].trim(), run[2].trim())); + dispatch(fetchGraphData(run[0].trim(), run[1].trim())); }; const metricsDataCopy = cloneDeep(metrics); @@ -57,7 +59,7 @@ const MetricsSelect = (props) => { /* Metrics select */ return ( <> - {hasMetricsData(item.id) && ( + {hasMetricsData(item.id) ? ( - )} + ): + + } ); }; +MetricsSelect.propTypes = { + item: PropTypes.object, +}; export default MetricsSelect; diff --git a/frontend/src/components/templates/ILab/index.jsx b/frontend/src/components/templates/ILab/index.jsx index d1eb5d62..d728b44a 100644 --- a/frontend/src/components/templates/ILab/index.jsx +++ b/frontend/src/components/templates/ILab/index.jsx @@ -1,13 +1,5 @@ import "./index.less"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionToggle, - Card, - CardBody, -} from "@patternfly/react-core"; import { ExpandableRowContent, Table, @@ -22,15 +14,15 @@ import { fetchMetricsInfo, fetchPeriods, setIlabDateFilter, + toggleComparisonSwitch, } from "@/actions/ilabActions"; import { formatDateTime, uid } from "@/utils/helper"; import { useDispatch, useSelector } from "react-redux"; import { useEffect, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; -import ILabGraph from "./ILabGraph"; -import MetaRow from "./MetaRow"; -import MetricsSelect from "./MetricsDropdown"; +import IlabCompareComponent from "./IlabCompareComponent"; +import IlabRowContent from "./IlabExpandedRow"; import RenderPagination from "@/components/organisms/Pagination"; import StatusCell from "./StatusCell"; import TableFilter from "@/components/organisms/TableFilters"; @@ -40,21 +32,17 @@ const ILab = () => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const { start_date, end_date } = useSelector((state) => state.ilab); + const { + start_date, + end_date, + comparisonSwitch, + tableData, + page, + perPage, + totalItems, + } = useSelector((state) => state.ilab); const [expandedResult, setExpandedResult] = useState([]); - const [expanded, setAccExpanded] = useState(["bordered-toggle1"]); - const onToggle = (id) => { - const index = expanded.indexOf(id); - const newExpanded = - index >= 0 - ? [ - ...expanded.slice(0, index), - ...expanded.slice(index + 1, expanded.length), - ] - : [...expanded, id]; - setAccExpanded(newExpanded); - }; const isResultExpanded = (res) => expandedResult?.includes(res); const setExpanded = async (run, isExpanding = true) => { setExpandedResult((prevExpanded) => { @@ -69,10 +57,6 @@ const ILab = () => { } }; - const { totalItems, page, perPage, tableData } = useSelector( - (state) => state.ilab - ); - useEffect(() => { if (searchParams.size > 0) { // date filter is set apart @@ -105,6 +89,9 @@ const ILab = () => { status: "Status", }; + const onSwitchChange = () => { + dispatch(toggleComparisonSwitch()); + }; return ( <> { type={"ilab"} showColumnMenu={false} navigation={navigate} + isSwitchChecked={comparisonSwitch} + onSwitchChange={onSwitchChange} /> - - - - - - - - - - - {tableData.map((item, rowIndex) => ( - <> + {comparisonSwitch ? ( + + ) : ( + <> +
- {columnNames.metric}{columnNames.begin_date}{columnNames.end_date}{columnNames.status}
+ - - - - + + + + - - + + {tableData.map((item, rowIndex) => ( + <> + + - - - ))} - -
- setExpanded(item, !isResultExpanded(item.id)), - expandId: `expandId-${uid()}`, - }} - /> - - {item.primary_metrics[0]}{formatDateTime(item.begin_date)}{formatDateTime(item.end_date)} - - + {columnNames.metric}{columnNames.begin_date}{columnNames.end_date}{columnNames.status}
- - - - { - onToggle("bordered-toggle1"); - }} - isExpanded={expanded.includes("bordered-toggle1")} - id="bordered-toggle1" - > - Metadata - +
+ setExpanded(item, !isResultExpanded(item.id)), + expandId: `expandId-${uid()}`, + }} + /> - -
- - - - - - - - - - - - - - - -
-
- - - { - onToggle("bordered-toggle2"); - }} - isExpanded={expanded.includes("bordered-toggle2")} - id="bordered-toggle2" - > - Metrics & Graph - - - Metrics: -
- -
-
-
- - -
- + {item.primary_metrics[0]} + {formatDateTime(item.begin_date)} + {formatDateTime(item.end_date)} + + + + + + + + + + + + + ))} + + + + + )} ); }; diff --git a/frontend/src/components/templates/ILab/index.less b/frontend/src/components/templates/ILab/index.less index 02cd02cb..399c6c77 100644 --- a/frontend/src/components/templates/ILab/index.less +++ b/frontend/src/components/templates/ILab/index.less @@ -6,8 +6,36 @@ flex-direction: row; margin-bottom: 1vw; .metadata-card { - flex: 1; /* additionally, equal width */ + flex: 1; /* additionally, equal width */ padding: 1em; margin-right: 1.5vw; } } +.comparison-container { + display: flex; + width: 100%; + height: 80%; + .metrics-container { + width: 40%; + padding: 10px; + .compare-btn { + margin: 2vh 0; + } + .pf-v5-c-menu { + height: 75%; + box-shadow: unset; + } + } + .chart-container { + width: 80%; + .js-plotly-plot { + width: 100%; + height: 100%; + overflow-x: auto; + overflow-y: visible; + } + } + .title { + margin-bottom: 2vh; + } +} diff --git a/frontend/src/reducers/ilabReducer.js b/frontend/src/reducers/ilabReducer.js index 2f1bcd91..f8cd5b0d 100644 --- a/frontend/src/reducers/ilabReducer.js +++ b/frontend/src/reducers/ilabReducer.js @@ -5,6 +5,7 @@ const initialState = { start_date: "", end_date: "", graphData: [], + multiGraphData: [], totalItems: 0, page: 1, perPage: 10, @@ -14,6 +15,8 @@ const initialState = { periods: [], metrics_selected: {}, tableData: [], + comparisonSwitch: false, + metaRowExpanded: [], }; const ILabReducer = (state = initialState, action = {}) => { const { type, payload } = action; @@ -53,6 +56,12 @@ const ILabReducer = (state = initialState, action = {}) => { return { ...state, graphData: payload }; case TYPES.SET_ILAB_INIT_JOBS: return { ...state, tableData: payload }; + case TYPES.SET_ILAB_MULTIGRAPH_DATA: + return { ...state, multiGraphData: payload }; + case TYPES.TOGGLE_COMPARISON_SWITCH: + return { ...state, comparisonSwitch: !state.comparisonSwitch }; + case TYPES.SET_EXPANDED_METAROW: + return { ...state, metaRowExpanded: payload }; default: return state; }