diff --git a/insider/insider_worker/http_client/client.py b/insider/insider_worker/http_client/client.py index b3e10167f..469ebf504 100644 --- a/insider/insider_worker/http_client/client.py +++ b/insider/insider_worker/http_client/client.py @@ -9,6 +9,9 @@ def retry_if_connection_error(exception): if isinstance(exception, requests.HTTPError): if exception.response.status_code in (503,): return True + # retry too many requests + elif exception.response.status_code in (429,): + return True return False diff --git a/metroculus/metroculus_worker/processor.py b/metroculus/metroculus_worker/processor.py index baa4e01b1..3c3fa238f 100644 --- a/metroculus/metroculus_worker/processor.py +++ b/metroculus/metroculus_worker/processor.py @@ -537,12 +537,14 @@ def get_gcp_metrics(cloud_account_id, cloud_resource_ids, resource_ids_map, "cpu": "compute.googleapis.com/instance/cpu/utilization", "network_in_io": "compute.googleapis.com/instance/network/received_bytes_count", "network_out_io": "compute.googleapis.com/instance/network/sent_bytes_count", + "ram_percent": "agent.googleapis.com/memory/percent_used", "ram_size": "compute.googleapis.com/instance/memory/balloon/ram_size", "ram": "compute.googleapis.com/instance/memory/balloon/ram_used", "disk_read_io": "compute.googleapis.com/instance/disk/read_ops_count", "disk_write_io": "compute.googleapis.com/instance/disk/write_ops_count", } ram_sizes = {} + ram_percents = set() for metric_name, cloud_metric_name in metric_cloud_names_map.items(): response = adapter.get_metric( cloud_metric_name, @@ -579,17 +581,28 @@ def get_gcp_metrics(cloud_account_id, cloud_resource_ids, resource_ids_map, 'disk_write_io']: # change values per min to values per second value = value / 60 + # RAM value in % is returned on instances with Ops agent + elif metric_name == "ram_percent": + if record.metric.labels.get('state') != 'used': + continue + key = (resource_id, date) + ram_percents.add(key) # to determine RAM value in % instead of absolute values, - # we need to know values of 2 metrics - ram_used and ram_size - for the same time - # so we store ram_size in a map and pop values from it later when processing ram_used. - # we rely on the fact that the metrics API returns metrics in the same order - # as they were requested. + # on instances without Ops agent, we need to know values + # of 2 metrics - ram_used and ram_size - for the same time + # so we store ram_size in a map and pop values from it + # later when processing ram_used. + # we rely on the fact that the metrics API returns metrics + # in the same order as they were requested. elif metric_name == "ram_size": key = (resource_id, date) ram_sizes[key] = value continue elif metric_name == "ram": key = (resource_id, date) + if key in ram_percents: + # not calculate ram value, as agent's value exists + continue ram_size = ram_sizes.pop(key, None) if ram_size is None: LOG.warn( @@ -609,7 +622,7 @@ def get_gcp_metrics(cloud_account_id, cloud_resource_ids, resource_ids_map, 'cloud_account_id': cloud_account_id, 'resource_id': resource_id, 'date': date, - 'metric': metric_name, + 'metric': 'ram' if 'ram' in metric_name else metric_name, 'value': value }) return result diff --git a/ngui/ui/src/components/ApolloApiErrorAlert/ApolloApiErrorAlert.tsx b/ngui/ui/src/components/ApolloApiErrorAlert/ApolloApiErrorAlert.tsx new file mode 100644 index 000000000..5d95e4a84 --- /dev/null +++ b/ngui/ui/src/components/ApolloApiErrorAlert/ApolloApiErrorAlert.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@apollo/client"; +import ApiErrorMessage from "components/ApiErrorMessage"; +import SnackbarAlert from "components/SnackbarAlert"; +import { GET_ERROR } from "graphql/api/common"; + +// TODO: implement ERROR_HANDLER_TYPE_ALERT analogy for Apollo queries. https://www.apollographql.com/docs/react/v2/data/error-handling/ +const ApolloApiErrorAlert = () => { + const { data = {} } = useQuery(GET_ERROR); + + const { error: { error_code: errorCode, reason: errorReason, url, params } = {} } = data; + + const [open, setOpen] = useState(false); + + useEffect(() => { + setOpen(!!errorCode); + }, [errorCode]); + + const handleClose = (event, reason) => { + if (reason === "clickaway") { + return; + } + setOpen(false); + }; + + const errorMessage = errorCode && ; + + return ( + errorMessage !== null && ( + + ) + ); +}; + +export default ApolloApiErrorAlert; diff --git a/ngui/ui/src/components/ApolloApiErrorAlert/index.ts b/ngui/ui/src/components/ApolloApiErrorAlert/index.ts new file mode 100644 index 000000000..dc802c7e5 --- /dev/null +++ b/ngui/ui/src/components/ApolloApiErrorAlert/index.ts @@ -0,0 +1,3 @@ +import ApolloApiErrorAlert from "./ApolloApiErrorAlert"; + +export default ApolloApiErrorAlert; diff --git a/ngui/ui/src/components/ApolloProvider/ApolloProvider.tsx b/ngui/ui/src/components/ApolloProvider/ApolloProvider.tsx index d9e54e51e..3a7fe5ce5 100644 --- a/ngui/ui/src/components/ApolloProvider/ApolloProvider.tsx +++ b/ngui/ui/src/components/ApolloProvider/ApolloProvider.tsx @@ -1,14 +1,26 @@ -import { ApolloClient, ApolloProvider, InMemoryCache, split, HttpLink } from "@apollo/client"; +import { ApolloClient, ApolloProvider, InMemoryCache, split, HttpLink, from, type DefaultContext } from "@apollo/client"; +import { onError, type ErrorResponse } from "@apollo/client/link/error"; import { GraphQLWsLink } from "@apollo/client/link/subscriptions"; import { getMainDefinition } from "@apollo/client/utilities"; +import { type GraphQLError } from "graphql"; import { createClient } from "graphql-ws"; import { GET_TOKEN } from "api/auth/actionTypes"; +import { GET_ERROR } from "graphql/api/common"; import { useApiData } from "hooks/useApiData"; import { getEnvironmentVariable } from "utils/env"; const httpBase = getEnvironmentVariable("VITE_APOLLO_HTTP_BASE"); const wsBase = getEnvironmentVariable("VITE_APOLLO_WS_BASE"); +const writeErrorToCache = (cache: DefaultContext, graphQLError: GraphQLError) => { + const { extensions: { response: { url, body: { error } = {} } = {} } = {} } = graphQLError; + + cache.writeQuery({ + query: GET_ERROR, + data: { error: { __typename: "Error", ...error, url } } + }); +}; + const ApolloClientProvider = ({ children }) => { const { apiData: { token } @@ -27,11 +39,23 @@ const ApolloClientProvider = ({ children }) => { }) ); - /* - @param A function that's called for each operation to execute - @param The Link to use for an operation if the function returns a "truthy" value - @param The Link to use for an operation if the function returns a "falsy" value - */ + const errorLink = onError(({ graphQLErrors, networkError, operation }: ErrorResponse) => { + if (graphQLErrors) { + graphQLErrors.forEach(({ message, path }) => console.log(`[GraphQL error]: Message: ${message}, Path: ${path}`)); + + const { cache } = operation.getContext(); + writeErrorToCache(cache, graphQLErrors[0]); + } + + /* Just log network errors for now. + We rely on custom error codes that are returned in graphQLErrors. + It might be usefult to cache networkError errors to display alerts as well. + */ + if (networkError) { + console.error(`[Network error]: ${networkError}`); + } + }); + const splitLink = split( ({ query }) => { const definition = getMainDefinition(query); @@ -42,7 +66,7 @@ const ApolloClientProvider = ({ children }) => { ); const client = new ApolloClient({ - link: splitLink, + link: from([errorLink, splitLink]), cache: new InMemoryCache() }); diff --git a/ngui/ui/src/components/EditModelVersionAliasForm/FormElements/EditModelVersionAliasFormAliasesField.tsx b/ngui/ui/src/components/EditModelVersionAliasForm/FormElements/EditModelVersionAliasFormAliasesField.tsx index 30ba6151e..997b4ff9b 100644 --- a/ngui/ui/src/components/EditModelVersionAliasForm/FormElements/EditModelVersionAliasFormAliasesField.tsx +++ b/ngui/ui/src/components/EditModelVersionAliasForm/FormElements/EditModelVersionAliasFormAliasesField.tsx @@ -10,6 +10,7 @@ import SlicedText from "components/SlicedText"; import { ModelVersion } from "services/MlModelsService"; import { DEFAULT_MAX_INPUT_LENGTH } from "utils/constants"; import { SPACING_1 } from "utils/layouts"; +import { notOnlyWhiteSpaces } from "utils/validation"; export const FIELD_NAME = "aliases"; @@ -54,7 +55,13 @@ const ConflictingAliasesWarning = ({ modelVersion, aliasesFieldName, aliasToVers )); }; -export const isAliasValid = (alias: string) => alias.length <= DEFAULT_MAX_INPUT_LENGTH; +export const isAliasValid = (alias: string) => { + const isLengthValid = alias.length <= DEFAULT_MAX_INPUT_LENGTH; + + const containsNotOnlyWhiteSpaces = notOnlyWhiteSpaces(alias) === true; + + return isLengthValid && containsNotOnlyWhiteSpaces; +}; const EditModelVersionAliasFormAliasesField = ({ name = FIELD_NAME, diff --git a/ngui/ui/src/components/EditModelVersionTagsForm/FormElements/EditModelVersionTagsTagsFieldArray.tsx b/ngui/ui/src/components/EditModelVersionTagsForm/FormElements/EditModelVersionTagsTagsFieldArray.tsx index 97b055235..8417958fe 100644 --- a/ngui/ui/src/components/EditModelVersionTagsForm/FormElements/EditModelVersionTagsTagsFieldArray.tsx +++ b/ngui/ui/src/components/EditModelVersionTagsForm/FormElements/EditModelVersionTagsTagsFieldArray.tsx @@ -8,6 +8,7 @@ import IconButton from "components/IconButton"; import Input from "components/Input"; import { DEFAULT_MAX_INPUT_LENGTH } from "utils/constants"; import { SPACING_1 } from "utils/layouts"; +import { notOnlyWhiteSpaces } from "utils/validation"; export const ARRAY_FIELD_NAME = "tags"; export const KEY_FIELD_NAME = "key"; @@ -52,7 +53,8 @@ const KeyInput = ({ index }: { index: number }) => { const isPropertyUnique = tagsWithSameKey.length === 1; return isPropertyUnique || intl.formatMessage({ id: "thisFieldShouldBeUnique" }); - } + }, + notOnlyWhiteSpaces } })} dataTestId={`tag_name_${index}`} @@ -88,6 +90,9 @@ const ValueInput = ({ index }: { index: number }) => { max: DEFAULT_MAX_INPUT_LENGTH } ) + }, + validate: { + notOnlyWhiteSpaces } }} render={({ field }) => ( diff --git a/ngui/ui/src/components/MlModel/MlModel.tsx b/ngui/ui/src/components/MlModel/MlModel.tsx index 76db9794e..d30e67c44 100644 --- a/ngui/ui/src/components/MlModel/MlModel.tsx +++ b/ngui/ui/src/components/MlModel/MlModel.tsx @@ -140,6 +140,7 @@ const Version = ({ versions = [], isLoading = false }: VersionProps) => { ), id: "aliases", + enableSorting: false, accessorFn: ({ aliases }) => aliases.join(", "), cell: ({ row: { original } }) => { const { aliases } = original; @@ -201,6 +202,7 @@ const Version = ({ versions = [], isLoading = false }: VersionProps) => { style: { minWidth: "200px" }, + enableSorting: false, accessorFn: (originalRow) => Object.entries(originalRow.tags ?? {}) .map(([key, val]) => `${key}: ${val}`) diff --git a/ngui/ui/src/components/MlModelForm/FormElements/MlModelFormDescriptionField.tsx b/ngui/ui/src/components/MlModelForm/FormElements/MlModelFormDescriptionField.tsx index 86cb76737..d87fa1577 100644 --- a/ngui/ui/src/components/MlModelForm/FormElements/MlModelFormDescriptionField.tsx +++ b/ngui/ui/src/components/MlModelForm/FormElements/MlModelFormDescriptionField.tsx @@ -3,6 +3,7 @@ import { FormattedMessage, useIntl } from "react-intl"; import Input from "components/Input"; import InputLoader from "components/InputLoader"; import { DEFAULT_MAX_TEXTAREA_LENGTH } from "utils/constants"; +import { notOnlyWhiteSpaces } from "utils/validation"; export const FIELD_NAME = "description"; @@ -33,6 +34,9 @@ const MlModelFormDescriptionField = ({ name = FIELD_NAME, isLoading = false }) = { id: "maxLength" }, { inputName: intl.formatMessage({ id: "description" }), max: DEFAULT_MAX_TEXTAREA_LENGTH } ) + }, + validate: { + notOnlyWhiteSpaces } })} /> diff --git a/ngui/ui/src/components/MlModelForm/FormElements/MlModelFormKeyField.tsx b/ngui/ui/src/components/MlModelForm/FormElements/MlModelFormKeyField.tsx index 7d0d05abb..c5d88df30 100644 --- a/ngui/ui/src/components/MlModelForm/FormElements/MlModelFormKeyField.tsx +++ b/ngui/ui/src/components/MlModelForm/FormElements/MlModelFormKeyField.tsx @@ -3,6 +3,7 @@ import { FormattedMessage, useIntl } from "react-intl"; import Input from "components/Input"; import InputLoader from "components/InputLoader"; import { DEFAULT_MAX_INPUT_LENGTH } from "utils/constants"; +import { notOnlyWhiteSpaces } from "utils/validation"; export const FIELD_NAME = "key"; @@ -34,6 +35,9 @@ const MlModelFormKeyField = ({ name = FIELD_NAME, isLoading = false }) => { { id: "maxLength" }, { inputName: intl.formatMessage({ id: "key" }), max: DEFAULT_MAX_INPUT_LENGTH } ) + }, + validate: { + notOnlyWhiteSpaces } })} /> diff --git a/ngui/ui/src/components/MlModelForm/FormElements/MlModelFormNameField.tsx b/ngui/ui/src/components/MlModelForm/FormElements/MlModelFormNameField.tsx index 993c0879c..87010c2ac 100644 --- a/ngui/ui/src/components/MlModelForm/FormElements/MlModelFormNameField.tsx +++ b/ngui/ui/src/components/MlModelForm/FormElements/MlModelFormNameField.tsx @@ -3,6 +3,7 @@ import { FormattedMessage, useIntl } from "react-intl"; import Input from "components/Input"; import InputLoader from "components/InputLoader"; import { DEFAULT_MAX_INPUT_LENGTH } from "utils/constants"; +import { notOnlyWhiteSpaces } from "utils/validation"; export const FIELD_NAME = "name"; @@ -35,6 +36,9 @@ const MlModelFormNameField = ({ name = FIELD_NAME, isLoading = false }) => { { id: "maxLength" }, { inputName: intl.formatMessage({ id: "name" }), max: DEFAULT_MAX_INPUT_LENGTH } ) + }, + validate: { + notOnlyWhiteSpaces } })} /> diff --git a/ngui/ui/src/components/MlModelForm/FormElements/MlModelFormTagsFieldArray.tsx b/ngui/ui/src/components/MlModelForm/FormElements/MlModelFormTagsFieldArray.tsx index e7a044724..65baf0355 100644 --- a/ngui/ui/src/components/MlModelForm/FormElements/MlModelFormTagsFieldArray.tsx +++ b/ngui/ui/src/components/MlModelForm/FormElements/MlModelFormTagsFieldArray.tsx @@ -9,6 +9,7 @@ import Input from "components/Input"; import InputLoader from "components/InputLoader"; import { DEFAULT_MAX_INPUT_LENGTH } from "utils/constants"; import { SPACING_1 } from "utils/layouts"; +import { notOnlyWhiteSpaces } from "utils/validation"; export const ARRAY_FIELD_NAME = "tags"; export const KEY_FIELD_NAME = "key"; @@ -52,7 +53,8 @@ const KeyInput = ({ index }: { index: number }) => { const isPropertyUnique = tagsWithSameKey.length === 1; return isPropertyUnique || intl.formatMessage({ id: "thisFieldShouldBeUnique" }); - } + }, + notOnlyWhiteSpaces } })} dataTestId={`tag_name_${index}`} @@ -88,6 +90,9 @@ const ValueInput = ({ index }: { index: number }) => { max: DEFAULT_MAX_INPUT_LENGTH } ) + }, + validate: { + notOnlyWhiteSpaces } }} render={({ field }) => ( diff --git a/ngui/ui/src/components/MlModels/MlModels.tsx b/ngui/ui/src/components/MlModels/MlModels.tsx index 1b41e5a56..efa4d5798 100644 --- a/ngui/ui/src/components/MlModels/MlModels.tsx +++ b/ngui/ui/src/components/MlModels/MlModels.tsx @@ -105,8 +105,13 @@ const ModelsTable = ({ models }: ModelsTableProps) => { ), - accessorKey: "description", - cell: ({ cell }) => {cell.getValue()} + id: "description", + accessorFn: (originalRow) => originalRow.description ?? "", + cell: ({ cell }) => { + const description = cell.getValue(); + + return description ? {description} : CELL_EMPTY_VALUE; + } }, tags({ id: "tags", diff --git a/ngui/ui/src/graphql/api/common.ts b/ngui/ui/src/graphql/api/common.ts new file mode 100644 index 000000000..05185fed9 --- /dev/null +++ b/ngui/ui/src/graphql/api/common.ts @@ -0,0 +1,9 @@ +import { gql } from "@apollo/client"; + +const GET_ERROR = gql` + query GetError { + error @client + } +`; + +export { GET_ERROR }; diff --git a/ngui/ui/src/index.tsx b/ngui/ui/src/index.tsx index 9b30b2e48..211bfd491 100644 --- a/ngui/ui/src/index.tsx +++ b/ngui/ui/src/index.tsx @@ -11,6 +11,7 @@ import { PersistGate } from "redux-persist/integration/react"; import ActivityListener from "components/ActivityListener"; import ApiErrorAlert from "components/ApiErrorAlert"; import ApiSuccessAlert from "components/ApiSuccessAlert"; +import ApolloApiErrorAlert from "components/ApolloApiErrorAlert"; import ApolloProvider from "components/ApolloProvider"; import App from "components/App"; import SideModalManager from "components/SideModalManager"; @@ -42,6 +43,7 @@ root.render( +