From 1e5712e4187dd87ec0918c0632572205e8931b64 Mon Sep 17 00:00:00 2001 From: Alex Creasy Date: Wed, 9 Aug 2023 15:49:16 +0100 Subject: [PATCH] Adds multiple model selection to to metrics page (#3) --- src/app/components/ContextSelector.tsx | 154 +++++++++++++++++++++++++ src/app/pages/Metrics/MetricsPage.tsx | 68 ++++++++++- src/app/utils/index.ts | 8 ++ 3 files changed, 224 insertions(+), 6 deletions(-) create mode 100644 src/app/components/ContextSelector.tsx create mode 100644 src/app/utils/index.ts diff --git a/src/app/components/ContextSelector.tsx b/src/app/components/ContextSelector.tsx new file mode 100644 index 0000000..780823b --- /dev/null +++ b/src/app/components/ContextSelector.tsx @@ -0,0 +1,154 @@ +import React from 'react'; +import { + MenuToggle, + MenuFooter, + MenuSearch, + MenuSearchInput, + Divider, + InputGroup, + InputGroupItem, + Button, + ButtonVariant, + SearchInput, + Dropdown, + DropdownList, + DropdownItem, +} from '@patternfly/react-core'; +import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon'; + +type ItemData = { + text: string; + href?: string; + isDisabled?: boolean | undefined; +}; + +type ItemArrayType = (ItemData | string)[]; + +export enum ContextSelectorVariants { + DEFAULT = 'default', + PLAIN = 'plain', + PRIMARY = 'primary', + PLAIN_TEXT = 'plainText', + SECONDARY = 'secondary', + TYPEAHEAD = 'typeahead', +} + +type ContextSelectorProps = { + items: ItemArrayType; + onSelect: (itemId: string) => void; + label?: string; + variant?: ContextSelectorVariants; + footer?: React.ReactNode; +}; +const ContextSelector: React.FC = ({ + items, + onSelect: selectCallback, + label, + variant = ContextSelectorVariants.DEFAULT, + footer, +}) => { + const [isOpen, setIsOpen] = React.useState(false); + const [selected, setSelected] = React.useState(typeof items[0] === 'string' ? items[0] : items[0].text); + const [filteredItems, setFilteredItems] = React.useState(items); + const [searchInputValue, setSearchInputValue] = React.useState(''); + const menuRef = React.useRef(null); + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onSelect = (ev: React.MouseEvent | undefined, itemId: string | number | undefined) => { + if (typeof itemId === 'number' || typeof itemId === 'undefined') { + return; + } + setSelected(itemId.toString()); + selectCallback(itemId.toString()); + setIsOpen(!isOpen); + }; + + const onSearchInputChange = (value: string) => { + setSearchInputValue(value); + }; + + const onSearchButtonClick = () => { + const filtered = + searchInputValue === '' + ? items + : items.filter((item) => { + const str = typeof item === 'string' ? item : item.text; + return str.toLowerCase().indexOf(searchInputValue.toLowerCase()) !== -1; + }); + + setFilteredItems(filtered || []); + setIsOpen(true); // Keep menu open after search executed + }; + + const onEnterPressed = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + onSearchButtonClick(); + } + }; + + return ( + setIsOpen(isOpen)} + onOpenChangeKeys={['Escape']} + toggle={(toggleRef) => ( + + {label && label + ': '} {selected} + + )} + ref={menuRef} + id="context-selector" + onSelect={onSelect} + isScrollable + > + + + + + onSearchInputChange(value)} + onKeyPress={onEnterPressed} + aria-labelledby="pf-v5-context-selector-search-button-id-1" + /> + + + + + + + + + + {filteredItems.map((item, index) => { + const [itemText, isDisabled, href] = + typeof item === 'string' ? [item, null, null] : [item.text, item.isDisabled || null, item.href || null]; + return ( + + {itemText} + + ); + })} + + {footer && {footer}} + + ); +}; + +export default ContextSelector; diff --git a/src/app/pages/Metrics/MetricsPage.tsx b/src/app/pages/Metrics/MetricsPage.tsx index 4911fe5..2e23fd0 100644 --- a/src/app/pages/Metrics/MetricsPage.tsx +++ b/src/app/pages/Metrics/MetricsPage.tsx @@ -1,20 +1,76 @@ import React from 'react'; -import { Card, CardBody, CardTitle, Gallery, PageSection, Title } from '@patternfly/react-core'; +import { Bullseye, Card, CardBody, CardTitle, Gallery, PageSection, Spinner, Title } from '@patternfly/react-core'; import PageLayout from '@app/pages/PageLayout'; import { useGetAllRequests, useGetModelMetadata } from '@app/integrations/trustyai-service/api/hooks'; import RequestsTable from '@app/pages/Metrics/RequestsTable'; +import ContextSelector, { ContextSelectorVariants } from '@app/components/ContextSelector'; +import { BaseMetricResponse, ModelMetaData } from '@app/integrations/trustyai-service/api/types'; -const MetricsPage: React.FC = () => { +const MetricsPage = () => { + const [modelId, setModelId] = React.useState(); const { data, loaded, error } = useGetModelMetadata(); + const { data: requestData, loaded: requestDataLoaded } = useGetAllRequests(); + const [model, setModel] = React.useState(); + const [models, setModels] = React.useState([]); + const [requests, setRequests] = React.useState([]); - const { data: requestData } = useGetAllRequests(); + const getModelById = React.useCallback( + (id: string) => { + const modelVal = data.find((m) => m.data.modelId === id); + if (!modelVal) { + // This shouldn't happen, something is wrong if we're here. + throw new Error(`No such model with id: ${id}`); + } + return modelVal; + }, + [data], + ); + + React.useEffect(() => { + if (loaded && requestDataLoaded) { + if (!modelId) { + setModelId(data[0]?.data?.modelId); + } + + if (modelId) { + setModel(getModelById(modelId)); + setRequests(requestData.requests.filter((r) => r.request.modelId === model?.data.modelId)); + setModels(data.map((r) => r.data.modelId)); + } + } + }, [data, getModelById, loaded, model?.data.modelId, modelId, requestData.requests, requestDataLoaded]); - const model = data[0]; + if (!loaded || !requestDataLoaded) { + return ( + + + + ); + } - const requests = requestData.requests; + if (!modelId || models.length === 0) { + return ( + + Error - no model selected + + ); + } return ( - + { + setModelId(modelId); + }} + /> + } + loaded={loaded && requestDataLoaded} + error={error} + > diff --git a/src/app/utils/index.ts b/src/app/utils/index.ts new file mode 100644 index 0000000..6dcaee3 --- /dev/null +++ b/src/app/utils/index.ts @@ -0,0 +1,8 @@ +export const byId = + (arg: U) => + (arg2: T) => { + if (typeof arg === 'object') { + return arg2.id === arg.id; + } + return arg2.id === arg; + };