From 42d29e43221f85aebb1cda19e2ada16f8d16d245 Mon Sep 17 00:00:00 2001 From: David Butenhof Date: Wed, 13 Nov 2024 11:08:35 -0500 Subject: [PATCH] Add metrics pulldown to compare view Support selection of multiple metrics using the pulldown in the comparison page. The update occurs when the pulldown closes. To simplify the management of "available metrics" across multiple selected runs, which might have entirely different metrics, the reducer no longer tries to store separate metric selection lists for each run. This also means that the "default" metrics selection remains when adding another comparison run, or expanding another row. --- frontend/src/actions/ilabActions.js | 292 +++++++++--------- .../components/templates/ILab/ILabSummary.jsx | 23 +- .../templates/ILab/IlabCompareComponent.jsx | 16 +- .../templates/ILab/IlabExpandedRow.jsx | 11 +- .../templates/ILab/MetricsDropdown.jsx | 78 +++-- .../src/components/templates/ILab/index.jsx | 10 +- frontend/src/reducers/ilabReducer.js | 2 +- 7 files changed, 246 insertions(+), 186 deletions(-) diff --git a/frontend/src/actions/ilabActions.js b/frontend/src/actions/ilabActions.js index 53f051f..d715c72 100644 --- a/frontend/src/actions/ilabActions.js +++ b/frontend/src/actions/ilabActions.js @@ -78,8 +78,11 @@ export const setIlabDateFilter = appendQueryString({ ...appliedFilters, start_date, end_date }, navigate); }; -export const fetchMetricsInfo = (uid) => async (dispatch) => { +export const fetchMetricsInfo = (uid) => async (dispatch, getState) => { try { + if (getState().ilab.metrics?.find((i) => i.uid == uid)) { + return; + } dispatch({ type: TYPES.LOADING }); const response = await API.get(`/api/v1/ilab/runs/${uid}/metrics`); if (response.status === 200) { @@ -87,9 +90,10 @@ export const fetchMetricsInfo = (uid) => async (dispatch) => { response.data.constructor === Object && Object.keys(response.data).length > 0 ) { + const metrics = Object.keys(response.data).sort(); dispatch({ type: TYPES.SET_ILAB_METRICS, - payload: { uid, metrics: Object.keys(response.data).sort() }, + payload: { uid, metrics }, }); } } @@ -100,8 +104,11 @@ export const fetchMetricsInfo = (uid) => async (dispatch) => { dispatch({ type: TYPES.COMPLETED }); }; -export const fetchPeriods = (uid) => async (dispatch) => { +export const fetchPeriods = (uid) => async (dispatch, getState) => { try { + if (getState().ilab.periods?.find((i) => i.uid == uid)) { + return; + } dispatch({ type: TYPES.LOADING }); const response = await API.get(`/api/v1/ilab/runs/${uid}/periods`); if (response.status === 200) { @@ -121,146 +128,147 @@ export const fetchPeriods = (uid) => async (dispatch) => { dispatch({ type: TYPES.COMPLETED }); }; -export const fetchSummaryData = - (uid, metric = null) => - async (dispatch, getState) => { - try { - const periods = getState().ilab.periods.find((i) => i.uid == uid); - const metrics = getState().ilab.metrics_selected[uid]; - dispatch({ type: TYPES.SET_ILAB_SUMMARY_LOADING }); - let summaries = []; - periods?.periods?.forEach((p) => { - if (p.is_primary) { - summaries.push({ - run: uid, - metric: p.primary_metric, - periods: [p.id], - }); - } - if (metrics) { - metrics.forEach((metric) => +export const fetchSummaryData = (uid) => async (dispatch, getState) => { + try { + const periods = getState().ilab.periods.find((i) => i.uid == uid); + const metrics = getState().ilab.metrics_selected; + const avail_metrics = getState().ilab.metrics; + dispatch({ type: TYPES.SET_ILAB_SUMMARY_LOADING }); + let summaries = []; + periods?.periods?.forEach((p) => { + if (p.is_primary) { + summaries.push({ + run: uid, + metric: p.primary_metric, + periods: [p.id], + }); + } + if (metrics) { + metrics.forEach((metric) => { + if ( + avail_metrics.find((m) => m.uid == uid)?.metrics?.includes(metric) + ) { summaries.push({ run: uid, metric, aggregate: true, periods: [p.id], - }) - ); - } - }); - const response = await API.post( - `/api/v1/ilab/runs/multisummary`, - summaries - ); - if (response.status === 200) { - dispatch({ - type: TYPES.SET_ILAB_SUMMARY_DATA, - payload: { uid, data: response.data }, + }); + } }); } - } catch (error) { - console.error( - `ERROR (${error?.response?.status}): ${JSON.stringify( - error?.response?.data - )}` - ); - dispatch(showFailureToast()); - } - dispatch({ type: TYPES.SET_ILAB_SUMMARY_COMPLETE }); - }; - -export const handleSummaryData = - (uids, metric = null) => - 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; + }); + const response = await API.post( + `/api/v1/ilab/runs/multisummary`, + summaries + ); + if (response.status === 200) { + dispatch({ + type: TYPES.SET_ILAB_SUMMARY_DATA, + payload: { uid, data: response.data }, }); - console.log(`Missing periods for ${missingPeriods}`); - await Promise.all( - missingPeriods.map(async (uid) => { - console.log(`Fetching periods for ${uid}`); - await dispatch(fetchPeriods(uid)); // Dispatch each item - }) - ); - await Promise.all( - uids.map(async (uid) => { - console.log(`Fetching summary data for ${uid}`); - await dispatch(fetchSummaryData(uid, metric)); - }) - ); - } catch (error) { - console.error(`ERROR: ${JSON.stringify(error)}`); - dispatch(showFailureToast()); } - }; + } catch (error) { + console.error( + `ERROR (${error?.response?.status}): ${JSON.stringify( + error?.response?.data + )}` + ); + dispatch(showFailureToast()); + } + dispatch({ type: TYPES.SET_ILAB_SUMMARY_COMPLETE }); +}; -export const fetchGraphData = - (uid, metric = null) => - async (dispatch, getState) => { - try { - const periods = getState().ilab.periods.find((i) => i.uid == uid); - const graphData = cloneDeep(getState().ilab.graphData); - const filterData = graphData.filter((i) => i.uid !== uid); - const metrics = getState().ilab.metrics_selected[uid]; - dispatch({ - type: TYPES.SET_ILAB_GRAPH_DATA, - payload: filterData, - }); - const copyData = cloneDeep(filterData); - dispatch({ type: TYPES.GRAPH_LOADING }); - let graphs = []; - periods?.periods?.forEach((p) => { - if (p.is_primary) { - graphs.push({ run: uid, metric: p.primary_metric, periods: [p.id] }); - } - if (metrics) { - metrics.forEach((metric) => +export const handleSummaryData = (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 + }) + ); + await Promise.all( + uids.map(async (uid) => { + await dispatch(fetchSummaryData(uid)); + }) + ); + } catch (error) { + console.error(`ERROR: ${JSON.stringify(error)}`); + dispatch(showFailureToast()); + } +}; + +export const fetchGraphData = (uid) => async (dispatch, getState) => { + try { + const periods = getState().ilab.periods.find((i) => i.uid == uid); + const graphData = cloneDeep(getState().ilab.graphData); + const filterData = graphData.filter((i) => i.uid !== uid); + const metrics = getState().ilab.metrics_selected; + const avail_metrics = getState().ilab.metrics; + dispatch({ + type: TYPES.SET_ILAB_GRAPH_DATA, + payload: filterData, + }); + const copyData = cloneDeep(filterData); + dispatch({ type: TYPES.GRAPH_LOADING }); + let graphs = []; + periods?.periods?.forEach((p) => { + if (p.is_primary) { + graphs.push({ run: uid, metric: p.primary_metric, periods: [p.id] }); + } + if (metrics) { + metrics.forEach((metric) => { + if ( + avail_metrics.find((m) => m.uid == uid)?.metrics?.includes(metric) + ) { graphs.push({ run: uid, metric, aggregate: true, periods: [p.id], - }) - ); - } - }); - const response = await API.post(`/api/v1/ilab/runs/multigraph`, { - name: `graph ${uid}`, - graphs, - }); - if (response.status === 200) { - response.data.layout["showlegend"] = true; - response.data.layout["responsive"] = "true"; - response.data.layout["autosize"] = "true"; - response.data.layout["legend"] = { - orientation: "h", - xanchor: "left", - yanchor: "top", - y: -0.1, - }; - copyData.push({ - uid, - data: response.data.data, - layout: response.data.layout, - }); - dispatch({ - type: TYPES.SET_ILAB_GRAPH_DATA, - payload: copyData, + }); + } }); } - } catch (error) { - console.error( - `ERROR (${error?.response?.status}): ${JSON.stringify( - error?.response?.data - )}` - ); - dispatch(showFailureToast()); + }); + const response = await API.post(`/api/v1/ilab/runs/multigraph`, { + name: `graph ${uid}`, + graphs, + }); + if (response.status === 200) { + response.data.layout["showlegend"] = true; + response.data.layout["responsive"] = "true"; + response.data.layout["autosize"] = "true"; + response.data.layout["legend"] = { + orientation: "h", + xanchor: "left", + yanchor: "top", + y: -0.1, + }; + copyData.push({ + uid, + data: response.data.data, + layout: response.data.layout, + }); + dispatch({ + type: TYPES.SET_ILAB_GRAPH_DATA, + payload: copyData, + }); } - dispatch({ type: TYPES.GRAPH_COMPLETED }); - }; + } catch (error) { + console.error( + `ERROR (${error?.response?.status}): ${JSON.stringify( + error?.response?.data + )}` + ); + dispatch(showFailureToast()); + } + dispatch({ type: TYPES.GRAPH_COMPLETED }); +}; export const handleMultiGraph = (uids) => async (dispatch, getState) => { try { @@ -292,6 +300,8 @@ export const fetchMultiGraphData = (uids) => async (dispatch, getState) => { dispatch({ type: TYPES.LOADING }); const periods = getState().ilab.periods; const filterPeriods = periods.filter((item) => uids.includes(item.uid)); + const get_metrics = getState().ilab.metrics_selected; + const avail_metrics = getState().ilab.metrics; let graphs = []; uids.forEach(async (uid) => { @@ -304,12 +314,20 @@ export const fetchMultiGraphData = (uids) => async (dispatch, getState) => { periods: [p.id], }); } - // graphs.push({ - // run: uid, - // metric, - // aggregate: true, - // periods: [p.id], - // }); + if (get_metrics) { + get_metrics.forEach((m) => { + if ( + avail_metrics.find((m) => m.uid == uid)?.metrics?.includes(metric) + ) { + graphs.push({ + run: uid, + metric: m, + aggregate: true, + periods: [p.id], + }); + } + }); + } }); }); console.log(graphs); @@ -374,15 +392,13 @@ export const checkIlabJobs = (newPage) => (dispatch, getState) => { } }; -export const toggleSelectedMetric = (id, metric) => (dispatch, getState) => { - const metrics_selected = cloneDeep(getState().ilab.metrics_selected); - var new_selected = metrics_selected[id] ? metrics_selected[id] : []; - if (new_selected.includes(metric)) { - new_selected = new_selected.filter((m) => m !== metric); +export const toggleSelectedMetric = (metric) => (dispatch, getState) => { + let metrics_selected = getState().ilab.metrics_selected; + if (metrics_selected.includes(metric)) { + metrics_selected = metrics_selected.filter((m) => m !== metric); } else { - new_selected = [...new_selected, metric]; + metrics_selected = [...metrics_selected, metric]; } - metrics_selected[id] = new_selected; dispatch({ type: TYPES.SET_ILAB_SELECTED_METRICS, payload: metrics_selected, diff --git a/frontend/src/components/templates/ILab/ILabSummary.jsx b/frontend/src/components/templates/ILab/ILabSummary.jsx index 25526a7..45e1fd9 100644 --- a/frontend/src/components/templates/ILab/ILabSummary.jsx +++ b/frontend/src/components/templates/ILab/ILabSummary.jsx @@ -11,9 +11,9 @@ const ILabSummary = (props) => { const data = summaryData?.find((a) => a.uid === id); return data; }; - const hasSummaryData = (ids) => { + const hasSummaryData = (runs) => { const hasData = Boolean( - summaryData.filter((i) => ids.includes(i.uid)).length === ids.length + summaryData.filter((i) => runs.includes(i.uid)).length === ids.length ); return hasData; }; @@ -23,11 +23,12 @@ const ILabSummary = (props) => { {hasSummaryData(ids) ? ( - + {ids.length > 1 ? : <>} @@ -40,14 +41,12 @@ const ILabSummary = (props) => { {ids.map((id, ridx) => getSummaryData(id).data.map((stat, sidx) => ( - + {ids.length > 1 && sidx === 0 ? ( - ) : undefined} + ) : ( + <> + )}
RunMetric
{ridx + 1}{stat.title} {typeof stat.min === "number" @@ -84,6 +83,6 @@ const ILabSummary = (props) => { }; ILabSummary.propTypes = { - item: PropType.object, + ids: PropType.array, }; export default ILabSummary; diff --git a/frontend/src/components/templates/ILab/IlabCompareComponent.jsx b/frontend/src/components/templates/ILab/IlabCompareComponent.jsx index 19b8364..96bebd0 100644 --- a/frontend/src/components/templates/ILab/IlabCompareComponent.jsx +++ b/frontend/src/components/templates/ILab/IlabCompareComponent.jsx @@ -18,11 +18,17 @@ import Plot from "react-plotly.js"; import PropTypes from "prop-types"; import RenderPagination from "@/components/organisms/Pagination"; import { cloneDeep } from "lodash"; -import { handleMultiGraph, handleSummaryData } from "@/actions/ilabActions.js"; +import { + fetchPeriods, + handleMultiGraph, + handleSummaryData, + fetchMetricsInfo, +} from "@/actions/ilabActions.js"; import { uid } from "@/utils/helper"; import { useState } from "react"; import ILabSummary from "./ILabSummary"; import ILabMetadata from "./ILabMetadata"; +import MetricsSelect from "./MetricsDropdown"; const IlabCompareComponent = () => { const { page, perPage, totalItems, tableData } = useSelector( @@ -42,6 +48,8 @@ const IlabCompareComponent = () => { setSelectedItems(selectedItems.filter((id) => id !== item)); } else { setSelectedItems([...selectedItems, item]); + dispatch(fetchPeriods(item)); + dispatch(fetchMetricsInfo(item)); } }; const dummy = () => { @@ -76,9 +84,8 @@ const IlabCompareComponent = () => { Metadata} + headerContent={Metadata} appendTo={() => document.body} - // hasAutoWidth hasNoPadding position="auto" className="mini-metadata" @@ -109,6 +116,9 @@ const IlabCompareComponent = () => { /> + + + {isSummaryLoading ? (
diff --git a/frontend/src/components/templates/ILab/IlabExpandedRow.jsx b/frontend/src/components/templates/ILab/IlabExpandedRow.jsx index 06078fe..99f095a 100644 --- a/frontend/src/components/templates/ILab/IlabExpandedRow.jsx +++ b/frontend/src/components/templates/ILab/IlabExpandedRow.jsx @@ -14,6 +14,7 @@ import MetricsSelect from "./MetricsDropdown"; import PropTypes from "prop-types"; import { setMetaRowExpanded } from "@/actions/ilabActions"; import ILabMetadata from "./ILabMetadata"; +import { uid } from "@/utils/helper"; const IlabRowContent = (props) => { const { item } = props; @@ -34,7 +35,7 @@ const IlabRowContent = (props) => { }; return ( - + { onToggle(`metadata-toggle-${item.id}`); @@ -52,7 +53,7 @@ const IlabRowContent = (props) => { - + { onToggle(`graph-toggle-${item.id}`); @@ -67,12 +68,12 @@ const IlabRowContent = (props) => { isHidden={!metaRowExpanded.includes(`graph-toggle-${item.id}`)} >
Metrics:
- + - + - + diff --git a/frontend/src/components/templates/ILab/MetricsDropdown.jsx b/frontend/src/components/templates/ILab/MetricsDropdown.jsx index 4bf4194..68f738d 100644 --- a/frontend/src/components/templates/ILab/MetricsDropdown.jsx +++ b/frontend/src/components/templates/ILab/MetricsDropdown.jsx @@ -8,22 +8,19 @@ import { } from "@patternfly/react-core"; import { fetchGraphData, + fetchMultiGraphData, fetchSummaryData, + handleSummaryData, toggleSelectedMetric, } 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"; const MetricsSelect = (props) => { const { metrics, metrics_selected } = useSelector((state) => state.ilab); - const { item } = props; - var current_metrics = metrics_selected[item.id] - ? metrics_selected[item.id] - : []; + const { ids } = props; /* Metrics select */ const [isOpen, setIsOpen] = useState(false); @@ -34,51 +31,82 @@ const MetricsSelect = (props) => { ref={toggleRef} onClick={onToggleClick} isExpanded={isOpen} - badge={{`${current_metrics.length} selected`}} + badge={{`${metrics_selected.length} selected`}} > Additional metrics ); - const onToggleClick = () => { + const onToggleClick = async () => { setIsOpen(!isOpen); }; - const onSelect = (_event, value) => { - const [run, metric] = value; - dispatch(toggleSelectedMetric(run, metric)); - dispatch(fetchGraphData(run, metric)); - dispatch(fetchSummaryData(run, metric)); + const onSelect = (_event, metric) => { + dispatch(toggleSelectedMetric(metric)); + }; + + const onOpenChange = async (nextOpen) => { + if (!nextOpen) { + // If we're closing, fetch data + if (ids.length === 1) { + await Promise.all([ + await dispatch(fetchGraphData(ids[0])), + await dispatch(fetchSummaryData(ids[0])), + ]); + } else { + await Promise.all([ + await dispatch(fetchMultiGraphData(ids)), + await dispatch(handleSummaryData(ids)), + ]); + } + }; + setIsOpen(nextOpen); }; - const metricsDataCopy = cloneDeep(metrics); const getMetricsData = (id) => { - const data = metricsDataCopy?.filter((a) => a.uid === id); - return data; + const data = metrics?.filter((a) => a.uid === id); + return data?.metrics; }; - const hasMetricsData = (uuid) => { - const hasData = getMetricsData(uuid).length > 0; + const hasAllMetricsData = (runs) => { + const hasData = Boolean( + metrics?.filter((i) => runs.includes(i.uid)).length === runs.length + ); return hasData; }; + + // de-dup a "set" using object keys + var collector = {}; + if (hasAllMetricsData(ids)) { + const datas = metrics.filter((a) => ids.includes(a.uid)); + if (datas) { + datas.forEach((a) => { + if (a.metrics) { + a.metrics.forEach((k) => (collector[k] = true)); + } + }); + } + } + const all_metrics = Object.keys(collector).sort(); + /* Metrics select */ return ( <> - {hasMetricsData(item.id) ? ( + {hasAllMetricsData(ids) ? (