From 3109588477f826e57d203f31d58c13bec75efe2d Mon Sep 17 00:00:00 2001 From: Phillip Burch Date: Mon, 29 Jun 2020 16:53:36 -0500 Subject: [PATCH] [Metrics UI] UX improvements for saved views (#69910) * Works-ish * Load the default view without throwing error * Design feedback * Update Saved Views design on Metrics explorer * Fix types * UX improvements when saving and editng * Only load default view if there is no state from anywhere else. * Add loading indicator and other polish * Hide saved view menu when opening modals * Fix typecheck * Fix typo * Fix translations --- x-pack/plugins/infra/common/graphql/types.ts | 8 + .../infra/common/http_api/source_api.ts | 17 +- .../saved_objects/metrics_explorer_view.ts | 3 + .../components/expression_chart.test.tsx | 2 + ...ist_flyout.tsx => manage_views_flyout.tsx} | 44 ++- .../saved_views/toolbar_control.tsx | 198 +++++++++++-- .../components/saved_views/update_modal.tsx | 113 ++++++++ .../saved_views/view_list_modal.tsx | 113 ++++++++ ...ith_metrics_explorer_options_url_state.tsx | 2 + .../containers/saved_view/saved_view.tsx | 262 ++++++++++++++++++ .../source_fields_fragment.gql_query.ts | 2 + x-pack/plugins/infra/public/graphql/types.ts | 12 + .../public/hooks/use_find_saved_object.tsx | 2 +- .../public/hooks/use_get_saved_object.tsx | 46 +++ .../infra/public/hooks/use_http_request.tsx | 5 +- .../infra/public/hooks/use_saved_view.ts | 90 ------ .../public/hooks/use_update_saved_object.tsx | 54 ++++ .../infra/public/pages/metrics/index.tsx | 39 ++- .../inventory_view/components/layout.tsx | 23 +- .../inventory_view/components/saved_views.tsx | 12 +- .../inventory_view/hooks/use_snaphot.ts | 7 - .../hooks/use_waffle_options.ts | 55 ++-- .../hooks/use_waffle_view_state.ts | 16 +- .../pages/metrics/inventory_view/index.tsx | 15 +- .../metrics_explorer/components/toolbar.tsx | 9 - .../hooks/use_metric_explorer_state.ts | 11 +- .../hooks/use_metrics_explorer_data.ts | 19 +- .../hooks/use_metrics_explorer_options.ts | 8 + .../pages/metrics/metrics_explorer/index.tsx | 23 +- .../public/utils/fixtures/metrics_explorer.ts | 2 + .../server/graphql/sources/schema.gql.ts | 8 + x-pack/plugins/infra/server/graphql/types.ts | 4 + .../infra/server/lib/sources/defaults.ts | 2 + .../server/lib/sources/saved_object_type.ts | 6 + .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - 36 files changed, 1023 insertions(+), 215 deletions(-) rename x-pack/plugins/infra/public/components/saved_views/{view_list_flyout.tsx => manage_views_flyout.tsx} (73%) create mode 100644 x-pack/plugins/infra/public/components/saved_views/update_modal.tsx create mode 100644 x-pack/plugins/infra/public/components/saved_views/view_list_modal.tsx create mode 100644 x-pack/plugins/infra/public/containers/saved_view/saved_view.tsx create mode 100644 x-pack/plugins/infra/public/hooks/use_get_saved_object.tsx delete mode 100644 x-pack/plugins/infra/public/hooks/use_saved_view.ts create mode 100644 x-pack/plugins/infra/public/hooks/use_update_saved_object.tsx diff --git a/x-pack/plugins/infra/common/graphql/types.ts b/x-pack/plugins/infra/common/graphql/types.ts index bb089bf8bf8ad..4a18c3d5ff334 100644 --- a/x-pack/plugins/infra/common/graphql/types.ts +++ b/x-pack/plugins/infra/common/graphql/types.ts @@ -329,6 +329,10 @@ export interface UpdateSourceInput { logAlias?: string | null; /** The field mapping to use for this source */ fields?: UpdateSourceFieldsInput | null; + /** Default view for inventory */ + inventoryDefaultView?: string | null; + /** Default view for Metrics Explorer */ + metricsExplorerDefaultView?: string | null; /** The log columns to display for this source */ logColumns?: UpdateSourceLogColumnInput[] | null; } @@ -875,6 +879,10 @@ export namespace SourceConfigurationFields { fields: Fields; logColumns: LogColumns[]; + + inventoryDefaultView: string; + + metricsExplorerDefaultView: string; }; export type Fields = { diff --git a/x-pack/plugins/infra/common/http_api/source_api.ts b/x-pack/plugins/infra/common/http_api/source_api.ts index 2c7d15d317cac..be50989358c72 100644 --- a/x-pack/plugins/infra/common/http_api/source_api.ts +++ b/x-pack/plugins/infra/common/http_api/source_api.ts @@ -69,6 +69,8 @@ export const SavedSourceConfigurationRuntimeType = rt.partial({ description: rt.string, metricAlias: rt.string, logAlias: rt.string, + inventoryDefaultView: rt.string, + metricsExplorerDefaultView: rt.string, fields: SavedSourceConfigurationFieldsRuntimeType, logColumns: rt.array(SavedSourceConfigurationColumnRuntimeType), }); @@ -79,7 +81,16 @@ export interface InfraSavedSourceConfiguration export const pickSavedSourceConfiguration = ( value: InfraSourceConfiguration ): InfraSavedSourceConfiguration => { - const { name, description, metricAlias, logAlias, fields, logColumns } = value; + const { + name, + description, + metricAlias, + logAlias, + fields, + inventoryDefaultView, + metricsExplorerDefaultView, + logColumns, + } = value; const { container, host, pod, tiebreaker, timestamp } = fields; return { @@ -87,6 +98,8 @@ export const pickSavedSourceConfiguration = ( description, metricAlias, logAlias, + inventoryDefaultView, + metricsExplorerDefaultView, fields: { container, host, pod, tiebreaker, timestamp }, logColumns, }; @@ -106,6 +119,8 @@ export const StaticSourceConfigurationRuntimeType = rt.partial({ description: rt.string, metricAlias: rt.string, logAlias: rt.string, + inventoryDefaultView: rt.string, + metricsExplorerDefaultView: rt.string, fields: StaticSourceConfigurationFieldsRuntimeType, logColumns: rt.array(SavedSourceConfigurationColumnRuntimeType), }); diff --git a/x-pack/plugins/infra/common/saved_objects/metrics_explorer_view.ts b/x-pack/plugins/infra/common/saved_objects/metrics_explorer_view.ts index 88bbc945e32dc..a92809022c7e8 100644 --- a/x-pack/plugins/infra/common/saved_objects/metrics_explorer_view.ts +++ b/x-pack/plugins/infra/common/saved_objects/metrics_explorer_view.ts @@ -55,6 +55,9 @@ export const metricsExplorerViewSavedObjectType: SavedObjectsType = { aggregation: { type: 'keyword', }, + source: { + type: 'keyword', + }, }, }, chartOptions: { diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx index 1ca7f7bff83ed..39a5a7feb2774 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx @@ -63,6 +63,8 @@ describe('ExpressionChart', () => { logColumns: [], metricAlias: 'metricbeat-*', logAlias: 'filebeat-*', + inventoryDefaultView: 'host', + metricsExplorerDefaultView: 'host', fields: { timestamp: '@timestamp', message: ['message'], diff --git a/x-pack/plugins/infra/public/components/saved_views/view_list_flyout.tsx b/x-pack/plugins/infra/public/components/saved_views/manage_views_flyout.tsx similarity index 73% rename from x-pack/plugins/infra/public/components/saved_views/view_list_flyout.tsx rename to x-pack/plugins/infra/public/components/saved_views/manage_views_flyout.tsx index 69f3d68023a24..fa9b45558e491 100644 --- a/x-pack/plugins/infra/public/components/saved_views/view_list_flyout.tsx +++ b/x-pack/plugins/infra/public/components/saved_views/manage_views_flyout.tsx @@ -19,17 +19,21 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { SavedView } from '../../hooks/use_saved_view'; +import { SavedView } from '../../containers/saved_view/saved_view'; interface Props { views: Array>; loading: boolean; + defaultViewId: string; + sourceIsLoading: boolean; close(): void; + makeDefault(id: string): void; setView(viewState: ViewState): void; deleteView(id: string): void; } interface DeleteConfimationProps { + isDisabled?: boolean; confirmedAction(): void; } const DeleteConfimation = (props: DeleteConfimationProps) => { @@ -46,6 +50,7 @@ const DeleteConfimation = (props: DeleteConfimationProps) => { { ); }; -export function SavedViewListFlyout({ +export function SavedViewManageViewsFlyout({ close, views, + defaultViewId, setView, + makeDefault, deleteView, loading, + sourceIsLoading, }: Props) { + const [inProgressView, setInProgressView] = useState(null); const renderName = useCallback( (name: string, item: SavedView) => ( ({ (item: SavedView) => { return ( { deleteView(item.id); }} @@ -98,6 +108,25 @@ export function SavedViewListFlyout({ [deleteView] ); + const renderMakeDefaultAction = useCallback( + (item: SavedView) => { + const isDefault = item.id === defaultViewId; + return ( + <> + { + setInProgressView(item.id); + makeDefault(item.id); + }} + /> + + ); + }, + [makeDefault, defaultViewId, sourceIsLoading, inProgressView] + ); + const columns = [ { field: 'name', @@ -112,7 +141,11 @@ export function SavedViewListFlyout({ }), actions: [ { - available: (item: SavedView) => !item.isDefault, + available: () => true, + render: renderMakeDefaultAction, + }, + { + available: (item: SavedView) => true, render: renderDeleteAction, }, ], @@ -124,7 +157,10 @@ export function SavedViewListFlyout({

- +

diff --git a/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx b/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx index c66aea669682e..4e539541ac71b 100644 --- a/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx +++ b/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx @@ -4,20 +4,28 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonEmpty, EuiFlexGroup } from '@elastic/eui'; -import React, { useCallback, useState, useEffect } from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup } from '@elastic/eui'; +import React, { useCallback, useState, useEffect, useContext } from 'react'; import { i18n } from '@kbn/i18n'; -import { useSavedView } from '../../hooks/use_saved_view'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, +} from '@elastic/eui'; +import { EuiPopover } from '@elastic/eui'; +import { EuiListGroup, EuiListGroupItem } from '@elastic/eui'; +import { EuiFlexItem } from '@elastic/eui'; +import { EuiButtonIcon } from '@elastic/eui'; import { SavedViewCreateModal } from './create_modal'; -import { SavedViewListFlyout } from './view_list_flyout'; +import { SavedViewUpdateModal } from './update_modal'; +import { SavedViewManageViewsFlyout } from './manage_views_flyout'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { SavedView } from '../../containers/saved_view/saved_view'; +import { SavedViewListModal } from './view_list_modal'; interface Props { - viewType: string; viewState: ViewState; - defaultViewState: ViewState; - onViewChange(viewState: ViewState): void; } export function SavedViewsToolbarControls(props: Props) { @@ -26,37 +34,80 @@ export function SavedViewsToolbarControls(props: Props) { views, saveView, loading, + updateView, deletedId, deleteView, + defaultViewId, + makeDefault, + sourceIsLoading, find, errorOnFind, errorOnCreate, - createdId, - } = useSavedView(props.defaultViewState, props.viewType); + createdView, + updatedView, + currentView, + setCurrentView, + } = useContext(SavedView.Context); const [modalOpen, setModalOpen] = useState(false); + const [viewListModalOpen, setViewListModalOpen] = useState(false); const [isInvalid, setIsInvalid] = useState(false); + const [isSavedViewMenuOpen, setIsSavedViewMenuOpen] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false); + const [updateModalOpen, setUpdateModalOpen] = useState(false); + const hideSavedViewMenu = useCallback(() => { + setIsSavedViewMenuOpen(false); + }, [setIsSavedViewMenuOpen]); + const openViewListModal = useCallback(() => { + hideSavedViewMenu(); + find(); + setViewListModalOpen(true); + }, [setViewListModalOpen, find, hideSavedViewMenu]); + const closeViewListModal = useCallback(() => { + setViewListModalOpen(false); + }, [setViewListModalOpen]); const openSaveModal = useCallback(() => { + hideSavedViewMenu(); setIsInvalid(false); setCreateModalOpen(true); - }, []); + }, [hideSavedViewMenu]); + const openUpdateModal = useCallback(() => { + hideSavedViewMenu(); + setIsInvalid(false); + setUpdateModalOpen(true); + }, [hideSavedViewMenu]); const closeModal = useCallback(() => setModalOpen(false), []); const closeCreateModal = useCallback(() => setCreateModalOpen(false), []); + const closeUpdateModal = useCallback(() => setUpdateModalOpen(false), []); const loadViews = useCallback(() => { + hideSavedViewMenu(); find(); setModalOpen(true); - }, [find]); + }, [find, hideSavedViewMenu]); + const showSavedViewMenu = useCallback(() => { + setIsSavedViewMenuOpen(true); + }, [setIsSavedViewMenuOpen]); const save = useCallback( (name: string, hasTime: boolean = false) => { const currentState = { ...props.viewState, ...(!hasTime ? { time: undefined } : {}), }; - saveView({ name, ...currentState }); + saveView({ ...currentState, name }); }, [props.viewState, saveView] ); + const update = useCallback( + (name: string, hasTime: boolean = false) => { + const currentState = { + ...props.viewState, + ...(!hasTime ? { time: undefined } : {}), + }; + updateView(currentView.id, { ...currentState, name }); + }, + [props.viewState, updateView, currentView] + ); + useEffect(() => { if (errorOnCreate) { setIsInvalid(true); @@ -64,11 +115,20 @@ export function SavedViewsToolbarControls(props: Props) { }, [errorOnCreate]); useEffect(() => { - if (createdId !== undefined) { + if (updatedView !== undefined) { + setCurrentView(updatedView); // INFO: Close the modal after the view is created. + closeUpdateModal(); + } + }, [updatedView, setCurrentView, closeUpdateModal]); + + useEffect(() => { + if (createdView !== undefined) { + // INFO: Close the modal after the view is created. + setCurrentView(createdView); closeCreateModal(); } - }, [createdId, closeCreateModal]); + }, [createdView, setCurrentView, closeCreateModal]); useEffect(() => { if (deletedId !== undefined) { @@ -88,30 +148,110 @@ export function SavedViewsToolbarControls(props: Props) { return ( <> - - - - - - + + + + + + + + + + + {currentView + ? currentView.name + : i18n.translate('xpack.infra.savedView.unknownView', { + defaultMessage: 'Unknown', + })} + + + + + } + isOpen={isSavedViewMenuOpen} + closePopover={hideSavedViewMenu} + anchorPosition="upCenter" + > + + + + + + + + + + {createModalOpen && ( )} + + {updateModalOpen && ( + + )} + + {viewListModalOpen && ( + + currentView={currentView} + views={views} + close={closeViewListModal} + setView={setCurrentView} + /> + )} + {modalOpen && ( - + + sourceIsLoading={sourceIsLoading} loading={loading} views={views} + defaultViewId={defaultViewId} + makeDefault={makeDefault} deleteView={deleteView} close={closeModal} - setView={props.onViewChange} + setView={setCurrentView} /> )} diff --git a/x-pack/plugins/infra/public/components/saved_views/update_modal.tsx b/x-pack/plugins/infra/public/components/saved_views/update_modal.tsx new file mode 100644 index 0000000000000..4e481b02ad52d --- /dev/null +++ b/x-pack/plugins/infra/public/components/saved_views/update_modal.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButtonEmpty, + EuiButton, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, + EuiFieldText, + EuiSpacer, + EuiSwitch, + EuiText, +} from '@elastic/eui'; + +interface Props { + isInvalid: boolean; + close(): void; + save(name: string, shouldIncludeTime: boolean): void; + currentView: ViewState; +} + +export function SavedViewUpdateModal({ + close, + save, + isInvalid, + currentView, +}: Props) { + const [viewName, setViewName] = useState(currentView.name); + const [includeTime, setIncludeTime] = useState(false); + const onCheckChange = useCallback((e) => setIncludeTime(e.target.checked), []); + const textChange = useCallback((e) => setViewName(e.target.value), []); + + const saveView = useCallback(() => { + save(viewName, includeTime); + }, [includeTime, save, viewName]); + + return ( + + + + + + + + + + + + + } + checked={includeTime} + onChange={onCheckChange} + /> + + + + + + + + + + + + + + + + + ); +} diff --git a/x-pack/plugins/infra/public/components/saved_views/view_list_modal.tsx b/x-pack/plugins/infra/public/components/saved_views/view_list_modal.tsx new file mode 100644 index 0000000000000..4015d64e1097f --- /dev/null +++ b/x-pack/plugins/infra/public/components/saved_views/view_list_modal.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback, useState, useMemo } from 'react'; + +import { EuiButtonEmpty, EuiModalFooter, EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiOverlayMask, + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, +} from '@elastic/eui'; +import { EuiSelectable } from '@elastic/eui'; +import { EuiSelectableOption } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { SavedView } from '../../containers/saved_view/saved_view'; + +interface Props { + views: Array>; + close(): void; + setView(viewState: ViewState): void; + currentView?: ViewState; +} + +export function SavedViewListModal({ + close, + views, + setView, + currentView, +}: Props) { + const [options, setOptions] = useState(null); + + const onChange = useCallback((opts: EuiSelectableOption[]) => { + setOptions(opts); + }, []); + + const loadView = useCallback(() => { + if (!options) { + close(); + return; + } + + const selected = options.find((o) => o.checked); + if (!selected) { + close(); + return; + } + setView(views.find((v) => v.id === selected.key)!); + close(); + }, [options, views, setView, close]); + + const defaultOptions = useMemo(() => { + return views.map((v) => ({ + label: v.name, + key: v.id, + checked: currentView?.id === v.id ? 'on' : undefined, + })); + }, [views, currentView]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + + + + + + + + + {(list, search) => ( + <> + {search} +
{list}
+ + )} +
+
+ + + + + + + + +
+
+ ); +} diff --git a/x-pack/plugins/infra/public/containers/metrics_explorer/with_metrics_explorer_options_url_state.tsx b/x-pack/plugins/infra/public/containers/metrics_explorer/with_metrics_explorer_options_url_state.tsx index b0823f8717a84..22f7d3d3cd50a 100644 --- a/x-pack/plugins/infra/public/containers/metrics_explorer/with_metrics_explorer_options_url_state.tsx +++ b/x-pack/plugins/infra/public/containers/metrics_explorer/with_metrics_explorer_options_url_state.tsx @@ -97,6 +97,7 @@ function isMetricExplorerOptions(subject: any): subject is MetricsExplorerOption limit: t.number, groupBy: t.string, filterQuery: t.string, + source: t.string, }); const Options = t.intersection([OptionsRequired, OptionsOptional]); @@ -156,6 +157,7 @@ const mapToUrlState = (value: any): MetricsExplorerUrlState | undefined => { const finalState = {}; if (value) { if (value.options && isMetricExplorerOptions(value.options)) { + value.options.source = 'url'; set(finalState, 'options', value.options); } if (value.timerange && isMetricExplorerTimeOption(value.timerange)) { diff --git a/x-pack/plugins/infra/public/containers/saved_view/saved_view.tsx b/x-pack/plugins/infra/public/containers/saved_view/saved_view.tsx new file mode 100644 index 0000000000000..58fecdd54e303 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/saved_view/saved_view.tsx @@ -0,0 +1,262 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import createContainer from 'constate'; +import { useCallback, useMemo, useState, useEffect, useContext } from 'react'; +import { i18n } from '@kbn/i18n'; +import { SimpleSavedObject, SavedObjectAttributes } from 'kibana/public'; +import { useFindSavedObject } from '../../hooks/use_find_saved_object'; +import { useCreateSavedObject } from '../../hooks/use_create_saved_object'; +import { useDeleteSavedObject } from '../../hooks/use_delete_saved_object'; +import { Source } from '../source'; +import { metricsExplorerViewSavedObjectName } from '../../../common/saved_objects/metrics_explorer_view'; +import { inventoryViewSavedObjectName } from '../../../common/saved_objects/inventory_view'; +import { useSourceConfigurationFormState } from '../../components/source_configuration/source_configuration_form_state'; +import { useGetSavedObject } from '../../hooks/use_get_saved_object'; +import { useUpdateSavedObject } from '../../hooks/use_update_saved_object'; + +export type SavedView = ViewState & { + name: string; + id: string; + isDefault?: boolean; +}; + +export type SavedViewSavedObject = ViewState & { + name: string; +}; + +export type ViewType = + | typeof metricsExplorerViewSavedObjectName + | typeof inventoryViewSavedObjectName; + +interface Props { + defaultViewState: SavedView; + viewType: ViewType; + shouldLoadDefault: boolean; +} + +export const useSavedView = (props: Props) => { + const { + source, + isLoading: sourceIsLoading, + sourceExists, + createSourceConfiguration, + updateSourceConfiguration, + } = useContext(Source.Context); + const { viewType, defaultViewState } = props; + type ViewState = typeof defaultViewState; + const { data, loading, find, error: errorOnFind, hasView } = useFindSavedObject< + SavedViewSavedObject + >(viewType); + + const [currentView, setCurrentView] = useState | null>(null); + const [loadingDefaultView, setLoadingDefaultView] = useState(null); + const { create, error: errorOnCreate, data: createdViewData, createdId } = useCreateSavedObject( + viewType + ); + const { update, error: errorOnUpdate, data: updatedViewData, updatedId } = useUpdateSavedObject( + viewType + ); + const { deleteObject, deletedId } = useDeleteSavedObject(viewType); + const { getObject, data: currentViewSavedObject } = useGetSavedObject(viewType); + const [createError, setCreateError] = useState(null); + + useEffect(() => setCreateError(errorOnCreate), [errorOnCreate]); + + const deleteView = useCallback((id: string) => deleteObject(id), [deleteObject]); + const formState = useSourceConfigurationFormState(source && source.configuration); + const defaultViewFieldName = useMemo( + () => (viewType === 'inventory-view' ? 'inventoryDefaultView' : 'metricsExplorerDefaultView'), + [viewType] + ); + + const makeDefault = useCallback( + async (id: string) => { + if (sourceExists) { + await updateSourceConfiguration({ + ...formState.formStateChanges, + [defaultViewFieldName]: id, + }); + } else { + await createSourceConfiguration({ + ...formState.formState, + [defaultViewFieldName]: id, + }); + } + }, + [ + formState.formState, + formState.formStateChanges, + sourceExists, + defaultViewFieldName, + createSourceConfiguration, + updateSourceConfiguration, + ] + ); + + const saveView = useCallback( + (d: { [p: string]: any }) => { + const doSave = async () => { + const exists = await hasView(d.name); + if (exists) { + setCreateError( + i18n.translate('xpack.infra.savedView.errorOnCreate.duplicateViewName', { + defaultMessage: `A view with that name already exists.`, + }) + ); + return; + } + create(d); + }; + setCreateError(null); + doSave(); + }, + [create, hasView] + ); + + const updateView = useCallback( + (id, d: { [p: string]: any }) => { + const doSave = async () => { + const view = await hasView(d.name); + if (view && view.id !== id) { + setCreateError( + i18n.translate('xpack.infra.savedView.errorOnCreate.duplicateViewName', { + defaultMessage: `A view with that name already exists.`, + }) + ); + return; + } + update(id, d); + }; + setCreateError(null); + doSave(); + }, + [update, hasView] + ); + + const defaultViewId = useMemo(() => { + if (!source || !source.configuration) { + return ''; + } + if (defaultViewFieldName === 'inventoryDefaultView') { + return source.configuration.inventoryDefaultView; + } else if (defaultViewFieldName === 'metricsExplorerDefaultView') { + return source.configuration.metricsExplorerDefaultView; + } else { + return ''; + } + }, [source, defaultViewFieldName]); + + const mapToView = useCallback( + (o: SimpleSavedObject) => { + return { + ...o.attributes, + id: o.id, + isDefault: defaultViewId === o.id, + }; + }, + [defaultViewId] + ); + + const savedObjects = useMemo(() => (data ? data.savedObjects : []), [data]); + + const views = useMemo(() => { + const items: Array> = [ + { + name: i18n.translate('xpack.infra.savedView.defaultViewNameHosts', { + defaultMessage: 'Default view', + }), + id: '0', + isDefault: !defaultViewId || defaultViewId === '0', // If there is no default view then hosts is the default + ...defaultViewState, + }, + ]; + + savedObjects.forEach((o) => o.type === viewType && items.push(mapToView(o))); + + return items; + }, [defaultViewState, savedObjects, viewType, defaultViewId, mapToView]); + + const createdView = useMemo(() => { + return createdViewData ? mapToView(createdViewData) : null; + }, [createdViewData, mapToView]); + + const updatedView = useMemo(() => { + return updatedViewData ? mapToView(updatedViewData) : null; + }, [updatedViewData, mapToView]); + + const loadDefaultView = useCallback(() => { + setLoadingDefaultView(true); + getObject(defaultViewId); + }, [setLoadingDefaultView, getObject, defaultViewId]); + + useEffect(() => { + if (currentViewSavedObject) { + setCurrentView(mapToView(currentViewSavedObject)); + setLoadingDefaultView(false); + } + }, [currentViewSavedObject, defaultViewId, mapToView]); + + const setDefault = useCallback(() => { + setCurrentView({ + name: i18n.translate('xpack.infra.savedView.defaultViewNameHosts', { + defaultMessage: 'Default view', + }), + id: '0', + isDefault: !defaultViewId || defaultViewId === '0', // If there is no default view then hosts is the default + ...defaultViewState, + }); + }, [setCurrentView, defaultViewId, defaultViewState]); + + useEffect(() => { + const shouldLoadDefault = props.shouldLoadDefault; + + if (loadingDefaultView || currentView || !shouldLoadDefault) { + return; + } + + if (defaultViewId !== '0') { + loadDefaultView(); + } else { + setDefault(); + setLoadingDefaultView(false); + } + }, [ + loadDefaultView, + props.shouldLoadDefault, + setDefault, + loadingDefaultView, + currentView, + defaultViewId, + ]); + + return { + views, + saveView, + defaultViewId, + loading, + updateView, + updatedView, + updatedId, + deletedId, + createdId, + createdView, + errorOnUpdate, + errorOnFind, + errorOnCreate: createError, + shouldLoadDefault: props.shouldLoadDefault, + makeDefault, + sourceIsLoading, + deleteView, + loadingDefaultView, + setCurrentView, + currentView, + loadDefaultView, + find, + }; +}; + +export const SavedView = createContainer(useSavedView); +export const [SavedViewProvider, useSavedViewContext] = SavedView; diff --git a/x-pack/plugins/infra/public/containers/source/source_fields_fragment.gql_query.ts b/x-pack/plugins/infra/public/containers/source/source_fields_fragment.gql_query.ts index 0c28220aed802..61312a0f2890e 100644 --- a/x-pack/plugins/infra/public/containers/source/source_fields_fragment.gql_query.ts +++ b/x-pack/plugins/infra/public/containers/source/source_fields_fragment.gql_query.ts @@ -12,6 +12,8 @@ export const sourceConfigurationFieldsFragment = gql` description logAlias metricAlias + inventoryDefaultView + metricsExplorerDefaultView fields { container host diff --git a/x-pack/plugins/infra/public/graphql/types.ts b/x-pack/plugins/infra/public/graphql/types.ts index 79351d8dc16cd..f0f74c34a19e6 100644 --- a/x-pack/plugins/infra/public/graphql/types.ts +++ b/x-pack/plugins/infra/public/graphql/types.ts @@ -54,6 +54,10 @@ export interface InfraSourceConfiguration { logAlias: string; /** The field mapping to use for this source */ fields: InfraSourceFields; + /** Default view for inventory */ + inventoryDefaultView: string; + /** Default view for Metrics Explorer */ + metricsExplorerDefaultView?: string | null; /** The columns to use for log display */ logColumns: InfraSourceLogColumn[]; } @@ -331,6 +335,10 @@ export interface UpdateSourceInput { logAlias?: string | null; /** The field mapping to use for this source */ fields?: UpdateSourceFieldsInput | null; + /** Name of default inventory view */ + inventoryDefaultView?: string | null; + /** Default view for Metrics Explorer */ + metricsExplorerDefaultView?: string | null; /** The log columns to display for this source */ logColumns?: UpdateSourceLogColumnInput[] | null; } @@ -876,6 +884,10 @@ export namespace SourceConfigurationFields { fields: Fields; + inventoryDefaultView: string; + + metricsExplorerDefaultView: string; + logColumns: LogColumns[]; }; diff --git a/x-pack/plugins/infra/public/hooks/use_find_saved_object.tsx b/x-pack/plugins/infra/public/hooks/use_find_saved_object.tsx index 8eb6db6103ed8..8aead6adfd0ab 100644 --- a/x-pack/plugins/infra/public/hooks/use_find_saved_object.tsx +++ b/x-pack/plugins/infra/public/hooks/use_find_saved_object.tsx @@ -49,7 +49,7 @@ export const useFindSavedObject = ({ type, }); - return objects.savedObjects.filter((o) => o.attributes.name === name).length > 0; + return objects.savedObjects.find((o) => o.attributes.name === name); }; return { diff --git a/x-pack/plugins/infra/public/hooks/use_get_saved_object.tsx b/x-pack/plugins/infra/public/hooks/use_get_saved_object.tsx new file mode 100644 index 0000000000000..0298155441f42 --- /dev/null +++ b/x-pack/plugins/infra/public/hooks/use_get_saved_object.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState, useCallback } from 'react'; +import { SavedObjectAttributes, SimpleSavedObject } from 'src/core/public'; +import { useKibana } from '../../../../../src/plugins/kibana_react/public'; + +export const useGetSavedObject = (type: string) => { + const kibana = useKibana(); + const [data, setData] = useState | null>(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const getObject = useCallback( + (id: string) => { + setLoading(true); + const fetchData = async () => { + try { + const savedObjectsClient = kibana.services.savedObjects?.client; + if (!savedObjectsClient) { + throw new Error('Saved objects client is unavailable'); + } + const d = await savedObjectsClient.get(type, id); + setError(null); + setLoading(false); + setData(d); + } catch (e) { + setLoading(false); + setError(e); + } + }; + fetchData(); + }, + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + [type, kibana.services.savedObjects] + ); + + return { + data, + loading, + error, + getObject, + }; +}; diff --git a/x-pack/plugins/infra/public/hooks/use_http_request.tsx b/x-pack/plugins/infra/public/hooks/use_http_request.tsx index 943aa059d5951..6143d3fc60a52 100644 --- a/x-pack/plugins/infra/public/hooks/use_http_request.tsx +++ b/x-pack/plugins/infra/public/hooks/use_http_request.tsx @@ -9,7 +9,7 @@ import { IHttpFetchError } from 'src/core/public'; import { i18n } from '@kbn/i18n'; import { HttpHandler } from 'src/core/public'; import { ToastInput } from 'src/core/public'; -import { useTrackedPromise } from '../utils/use_tracked_promise'; +import { useTrackedPromise, CanceledPromiseError } from '../utils/use_tracked_promise'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; export function useHTTPRequest( @@ -40,6 +40,9 @@ export function useHTTPRequest( onResolve: (resp) => setResponse(decode(resp)), onReject: (e: unknown) => { const err = e as IHttpFetchError; + if (e && e instanceof CanceledPromiseError) { + return; + } setError(err); toast({ toastLifeTimeMs: 3000, diff --git a/x-pack/plugins/infra/public/hooks/use_saved_view.ts b/x-pack/plugins/infra/public/hooks/use_saved_view.ts deleted file mode 100644 index 60869d8267b8c..0000000000000 --- a/x-pack/plugins/infra/public/hooks/use_saved_view.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { useCallback, useMemo, useState, useEffect } from 'react'; -import { i18n } from '@kbn/i18n'; -import { useFindSavedObject } from './use_find_saved_object'; -import { useCreateSavedObject } from './use_create_saved_object'; -import { useDeleteSavedObject } from './use_delete_saved_object'; - -export type SavedView = ViewState & { - name: string; - id: string; - isDefault?: boolean; -}; - -export type SavedViewSavedObject = ViewState & { - name: string; -}; - -export const useSavedView = (defaultViewState: ViewState, viewType: string) => { - const { data, loading, find, error: errorOnFind, hasView } = useFindSavedObject< - SavedViewSavedObject - >(viewType); - const { create, error: errorOnCreate, createdId } = useCreateSavedObject(viewType); - const { deleteObject, deletedId } = useDeleteSavedObject(viewType); - const deleteView = useCallback((id: string) => deleteObject(id), [deleteObject]); - const [createError, setCreateError] = useState(null); - - useEffect(() => setCreateError(errorOnCreate), [errorOnCreate]); - - const saveView = useCallback( - (d: { [p: string]: any }) => { - const doSave = async () => { - const exists = await hasView(d.name); - if (exists) { - setCreateError( - i18n.translate('xpack.infra.savedView.errorOnCreate.duplicateViewName', { - defaultMessage: `A view with that name already exists.`, - }) - ); - return; - } - create(d); - }; - setCreateError(null); - doSave(); - }, - [create, hasView] - ); - - const savedObjects = useMemo(() => (data ? data.savedObjects : []), [data]); - const views = useMemo(() => { - const items: Array> = [ - { - name: i18n.translate('xpack.infra.savedView.defaultViewName', { - defaultMessage: 'Default', - }), - id: '0', - isDefault: true, - ...defaultViewState, - }, - ]; - - savedObjects.forEach( - (o) => - o.type === viewType && - items.push({ - ...o.attributes, - id: o.id, - }) - ); - - return items; - }, [defaultViewState, savedObjects, viewType]); - - return { - views, - saveView, - loading, - deletedId, - createdId, - errorOnFind, - errorOnCreate: createError, - deleteView, - find, - }; -}; diff --git a/x-pack/plugins/infra/public/hooks/use_update_saved_object.tsx b/x-pack/plugins/infra/public/hooks/use_update_saved_object.tsx new file mode 100644 index 0000000000000..4c1e9ef7a6136 --- /dev/null +++ b/x-pack/plugins/infra/public/hooks/use_update_saved_object.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState, useCallback } from 'react'; +import { + SavedObjectAttributes, + SavedObjectsCreateOptions, + SimpleSavedObject, +} from 'src/core/public'; +import { useKibana } from '../../../../../src/plugins/kibana_react/public'; + +export const useUpdateSavedObject = (type: string) => { + const kibana = useKibana(); + const [data, setData] = useState | null>(null); + const [updatedId, setUpdatedId] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const update = useCallback( + (id: string, attributes: SavedObjectAttributes, options?: SavedObjectsCreateOptions) => { + setLoading(true); + const save = async () => { + try { + const savedObjectsClient = kibana.services.savedObjects?.client; + if (!savedObjectsClient) { + throw new Error('Saved objects client is unavailable'); + } + const d = await savedObjectsClient.update(type, id, attributes, options); + setUpdatedId(d.id); + setError(null); + setData(d); + setLoading(false); + } catch (e) { + setLoading(false); + setError(e); + } + }; + save(); + }, + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + [type, kibana.services.savedObjects] + ); + + return { + data, + loading, + error, + update, + updatedId, + }; +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/index.tsx b/x-pack/plugins/infra/public/pages/metrics/index.tsx index 121748f8e5220..3b3ed80f9e731 100644 --- a/x-pack/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx @@ -6,16 +6,20 @@ import { i18n } from '@kbn/i18n'; -import React from 'react'; +import React, { useContext } from 'react'; import { Route, RouteComponentProps, Switch } from 'react-router-dom'; import { EuiErrorBoundary, EuiFlexItem, EuiFlexGroup, EuiButtonEmpty } from '@elastic/eui'; +import { IIndexPattern } from 'src/plugins/data/common'; import { DocumentTitle } from '../../components/document_title'; import { HelpCenterContent } from '../../components/help_center_content'; import { RoutedTabs } from '../../components/navigation/routed_tabs'; import { ColumnarPage } from '../../components/page'; import { Header } from '../../components/header'; -import { MetricsExplorerOptionsContainer } from './metrics_explorer/hooks/use_metrics_explorer_options'; +import { + MetricsExplorerOptionsContainer, + DEFAULT_METRICS_EXPLORER_VIEW_STATE, +} from './metrics_explorer/hooks/use_metrics_explorer_options'; import { WithMetricsExplorerOptionsUrlState } from '../../containers/metrics_explorer/with_metrics_explorer_options_url_state'; import { WithSource } from '../../containers/with_source'; import { Source } from '../../containers/source'; @@ -31,6 +35,8 @@ import { WaffleFiltersProvider } from './inventory_view/hooks/use_waffle_filters import { InventoryAlertDropdown } from '../../alerting/inventory/components/alert_dropdown'; import { MetricsAlertDropdown } from '../../alerting/metric_threshold/components/alert_dropdown'; +import { SavedView } from '../../containers/saved_view/saved_view'; +import { SourceConfigurationFields } from '../../graphql/types'; import { AlertPrefillProvider } from '../../alerting/use_alert_prefill'; const ADD_DATA_LABEL = i18n.translate('xpack.infra.metricsHeaderAddDataButtonLabel', { @@ -138,10 +144,9 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { {configuration ? ( - ) : ( @@ -162,3 +167,25 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { ); }; + +const PageContent = (props: { + configuration: SourceConfigurationFields.Fragment; + createDerivedIndexPattern: (type: 'logs' | 'metrics' | 'both') => IIndexPattern; +}) => { + const { createDerivedIndexPattern, configuration } = props; + const { options } = useContext(MetricsExplorerOptionsContainer.Context); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx index 1452772e49ca1..3884ee5b7279a 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { useInterval } from 'react-use'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; @@ -20,14 +20,17 @@ import { InfraFormatterType } from '../../../../lib/lib'; import { euiStyled } from '../../../../../../observability/public'; import { Toolbar } from './toolbars/toolbar'; import { ViewSwitcher } from './waffle/view_switcher'; -import { SavedViews } from './saved_views'; import { IntervalLabel } from './waffle/interval_label'; import { Legend } from './waffle/legend'; import { createInventoryMetricFormatter } from '../lib/create_inventory_metric_formatter'; import { createLegend } from '../lib/create_legend'; +import { useSavedViewContext } from '../../../../containers/saved_view/saved_view'; +import { useWaffleViewState } from '../hooks/use_waffle_view_state'; +import { SavedViewsToolbarControls } from '../../../../components/saved_views/toolbar_control'; export const Layout = () => { const { sourceId, source } = useSourceContext(); + const { currentView, shouldLoadDefault } = useSavedViewContext(); const { metric, groupBy, @@ -78,6 +81,20 @@ export const Layout = () => { const bounds = autoBounds ? dataBounds : boundsOverride; /* eslint-disable-next-line react-hooks/exhaustive-deps */ const formatter = useCallback(createInventoryMetricFormatter(options.metric), [options.metric]); + const { viewState, onViewChange } = useWaffleViewState(); + + useEffect(() => { + if (currentView) { + onViewChange(currentView); + } + }, [currentView, onViewChange]); + + useEffect(() => { + // load snapshot data after default view loaded, unless we're not loading a view + if (currentView != null || !shouldLoadDefault) { + reload(); + } + }, [reload, currentView, shouldLoadDefault]); return ( <> @@ -107,7 +124,7 @@ export const Layout = () => { - + { - const { viewState, defaultViewState, onViewChange } = useWaffleViewState(); - return ( - - ); + const { viewState } = useWaffleViewState(); + return ; }; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_snaphot.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_snaphot.ts index 721a2d5792dca..63e4579dbbfcd 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_snaphot.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_snaphot.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useEffect } from 'react'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; @@ -63,12 +62,6 @@ export function useSnapshot( decodeResponse ); - useEffect(() => { - (async () => { - await makeRequest(); - })(); - }, [makeRequest]); - return { error: (error && error.message) || null, loading, diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts index a3132c8384979..8059d1ad12a3a 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts @@ -39,6 +39,7 @@ export const DEFAULT_WAFFLE_OPTIONS_STATE: WaffleOptionsState = { steps: 10, reverseColors: false, }, + source: 'default', sort: { by: 'name', direction: 'desc' }, }; @@ -161,36 +162,44 @@ export const WaffleSortOptionRT = rt.type({ direction: rt.keyof({ asc: null, desc: null }), }); -export const WaffleOptionsStateRT = rt.type({ - metric: SnapshotMetricInputRT, - groupBy: SnapshotGroupByRT, - nodeType: ItemTypeRT, - view: rt.string, - customOptions: rt.array( - rt.type({ - text: rt.string, - field: rt.string, - }) - ), - boundsOverride: rt.type({ - min: rt.number, - max: rt.number, +export const WaffleOptionsStateRT = rt.intersection([ + rt.type({ + metric: SnapshotMetricInputRT, + groupBy: SnapshotGroupByRT, + nodeType: ItemTypeRT, + view: rt.string, + customOptions: rt.array( + rt.type({ + text: rt.string, + field: rt.string, + }) + ), + boundsOverride: rt.type({ + min: rt.number, + max: rt.number, + }), + autoBounds: rt.boolean, + accountId: rt.string, + region: rt.string, + customMetrics: rt.array(SnapshotCustomMetricInputRT), + legend: WaffleLegendOptionsRT, + sort: WaffleSortOptionRT, }), - autoBounds: rt.boolean, - accountId: rt.string, - region: rt.string, - customMetrics: rt.array(SnapshotCustomMetricInputRT), - legend: WaffleLegendOptionsRT, - sort: WaffleSortOptionRT, -}); + rt.partial({ source: rt.string }), +]); export type WaffleSortOption = rt.TypeOf; export type WaffleOptionsState = rt.TypeOf; const encodeUrlState = (state: WaffleOptionsState) => { return WaffleOptionsStateRT.encode(state); }; -const decodeUrlState = (value: unknown) => - pipe(WaffleOptionsStateRT.decode(value), fold(constant(undefined), identity)); +const decodeUrlState = (value: unknown) => { + const state = pipe(WaffleOptionsStateRT.decode(value), fold(constant(undefined), identity)); + if (state) { + state.source = 'url'; + } + return state; +}; export const WaffleOptions = createContainer(useWaffleOptions); export const [WaffleOptionsProvider, useWaffleOptionsContext] = WaffleOptions; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_view_state.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_view_state.ts index 35313320a5dce..c1900ac1f3245 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_view_state.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_view_state.ts @@ -16,6 +16,13 @@ import { WaffleFiltersState, } from './use_waffle_filters'; +export const DEFAULT_WAFFLE_VIEW_STATE: WaffleViewState = { + ...DEFAULT_WAFFLE_OPTIONS_STATE, + filterQuery: DEFAULT_WAFFLE_FILTERS_STATE, + time: DEFAULT_WAFFLE_TIME_STATE.currentTime, + autoReload: DEFAULT_WAFFLE_TIME_STATE.isAutoReloading, +}; + export const useWaffleViewState = () => { const { metric, @@ -53,13 +60,6 @@ export const useWaffleViewState = () => { legend, }; - const defaultViewState: WaffleViewState = { - ...DEFAULT_WAFFLE_OPTIONS_STATE, - filterQuery: DEFAULT_WAFFLE_FILTERS_STATE, - time: DEFAULT_WAFFLE_TIME_STATE.currentTime, - autoReload: DEFAULT_WAFFLE_TIME_STATE.isAutoReloading, - }; - const onViewChange = useCallback( (newState: WaffleViewState) => { setWaffleOptionsState({ @@ -89,7 +89,7 @@ export const useWaffleViewState = () => { return { viewState, - defaultViewState, + defaultViewState: DEFAULT_WAFFLE_VIEW_STATE, onViewChange, }; }; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx index 5c94568861429..976fead10f7b6 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx @@ -22,6 +22,9 @@ import { useTrackPageview } from '../../../../../observability/public'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { Layout } from './components/layout'; import { useLinkProps } from '../../../hooks/use_link_props'; +import { SavedView } from '../../../containers/saved_view/saved_view'; +import { DEFAULT_WAFFLE_VIEW_STATE } from './hooks/use_waffle_view_state'; +import { useWaffleOptionsContext } from './hooks/use_waffle_options'; export const SnapshotPage = () => { const uiCapabilities = useKibana().services.application?.capabilities; @@ -30,10 +33,12 @@ export const SnapshotPage = () => { isLoading, loadSourceFailureMessage, loadSource, + source, metricIndicesExist, } = useContext(Source.Context); useTrackPageview({ app: 'infra_metrics', path: 'inventory' }); useTrackPageview({ app: 'infra_metrics', path: 'inventory', delay: 15000 }); + const { source: optionsSource } = useWaffleOptionsContext(); const tutorialLinkProps = useLinkProps({ app: 'home', @@ -53,12 +58,18 @@ export const SnapshotPage = () => { }) } /> - {isLoading ? ( + {isLoading && !source ? ( ) : metricIndicesExist ? ( <> - + + + ) : hasFailedLoadingSource ? ( diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/toolbar.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/toolbar.tsx index 1471efbd21e18..c0ee03eb97b59 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/toolbar.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/toolbar.tsx @@ -23,8 +23,6 @@ import { MetricsExplorerGroupBy } from './group_by'; import { MetricsExplorerAggregationPicker } from './aggregation'; import { MetricsExplorerChartOptions as MetricsExplorerChartOptionsComponent } from './chart_options'; import { SavedViewsToolbarControls } from '../../../../components/saved_views/toolbar_control'; -import { MetricExplorerViewState } from '../hooks/use_metric_explorer_state'; -import { metricsExplorerViewSavedObjectName } from '../../../../../common/saved_objects/metrics_explorer_view'; import { useKibanaUiSetting } from '../../../../utils/use_kibana_ui_setting'; import { mapKibanaQuickRangesToDatePickerRanges } from '../../../../utils/map_timepicker_quickranges_to_datepicker_ranges'; import { ToolbarPanel } from '../../../../components/toolbar_panel'; @@ -34,7 +32,6 @@ interface Props { timeRange: MetricsExplorerTimeOptions; options: MetricsExplorerOptions; chartOptions: MetricsExplorerChartOptions; - defaultViewState: MetricExplorerViewState; onRefresh: () => void; onTimeChange: (start: string, end: string) => void; onGroupByChange: (groupBy: string | null | string[]) => void; @@ -42,7 +39,6 @@ interface Props { onMetricsChange: (metrics: MetricsExplorerMetric[]) => void; onAggregationChange: (aggregation: MetricsExplorerAggregation) => void; onChartOptionsChange: (chartOptions: MetricsExplorerChartOptions) => void; - onViewStateChange: (vs: MetricExplorerViewState) => void; } export const MetricsExplorerToolbar = ({ @@ -57,8 +53,6 @@ export const MetricsExplorerToolbar = ({ onAggregationChange, chartOptions, onChartOptionsChange, - defaultViewState, - onViewStateChange, }: Props) => { const isDefaultOptions = options.aggregation === 'avg' && options.metrics.length === 0; const [timepickerQuickRanges] = useKibanaUiSetting(UI_SETTINGS.TIMEPICKER_QUICK_RANGES); @@ -123,14 +117,11 @@ export const MetricsExplorerToolbar = ({ diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts index 66cc77a576f99..4b46ed2efafc0 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts @@ -27,7 +27,8 @@ export interface MetricExplorerViewState { export const useMetricsExplorerState = ( source: SourceQuery.Query['source']['configuration'], - derivedIndexPattern: IIndexPattern + derivedIndexPattern: IIndexPattern, + shouldLoadImmediately = true ) => { const [refreshSignal, setRefreshSignal] = useState(0); const [afterKey, setAfterKey] = useState>(null); @@ -40,13 +41,15 @@ export const useMetricsExplorerState = ( setTimeRange, setOptions, } = useContext(MetricsExplorerOptionsContainer.Context); - const { loading, error, data } = useMetricsExplorerData( + const { loading, error, data, loadData } = useMetricsExplorerData( options, source, derivedIndexPattern, currentTimerange, afterKey, - refreshSignal + refreshSignal, + undefined, + shouldLoadImmediately ); const handleRefresh = useCallback(() => { @@ -144,7 +147,7 @@ export const useMetricsExplorerState = ( handleLoadMore: setAfterKey, defaultViewState, onViewStateChange, - + loadData, refreshSignal, afterKey, }; diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts index 5ed710414718a..db1e4ec8e4db8 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts @@ -6,7 +6,7 @@ import DateMath from '@elastic/datemath'; import { isEqual } from 'lodash'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import { HttpHandler } from 'src/core/public'; import { IIndexPattern } from 'src/plugins/data/public'; import { SourceQuery } from '../../../../../common/graphql/types'; @@ -30,7 +30,8 @@ export function useMetricsExplorerData( timerange: MetricsExplorerTimeOptions, afterKey: string | null | Record, signal: any, - fetch?: HttpHandler + fetch?: HttpHandler, + shouldLoadImmediately = true ) { const kibana = useKibana(); const fetchFn = fetch ? fetch : kibana.services.http?.fetch; @@ -40,7 +41,7 @@ export function useMetricsExplorerData( const [lastOptions, setLastOptions] = useState(null); const [lastTimerange, setLastTimerange] = useState(null); - useEffect(() => { + const loadData = useCallback(() => { (async () => { setLoading(true); try { @@ -112,9 +113,15 @@ export function useMetricsExplorerData( } setLoading(false); })(); - - // TODO: fix this dependency list while preserving the semantics // eslint-disable-next-line react-hooks/exhaustive-deps }, [options, source, timerange, signal, afterKey]); - return { error, loading, data }; + + useEffect(() => { + if (!shouldLoadImmediately) { + return; + } + + loadData(); + }, [loadData, shouldLoadImmediately]); + return { error, loading, data, loadData }; } diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts index 8abdffd39ed3a..fa103ce15e3e8 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts @@ -43,6 +43,7 @@ export interface MetricsExplorerOptions { aggregation: MetricsExplorerAggregation; forceInterval?: boolean; dropLastBucket?: boolean; + source?: string; } export interface MetricsExplorerTimeOptions { @@ -84,6 +85,13 @@ export const DEFAULT_METRICS: MetricsExplorerOptionsMetric[] = [ export const DEFAULT_OPTIONS: MetricsExplorerOptions = { aggregation: 'avg', metrics: DEFAULT_METRICS, + source: 'default', +}; + +export const DEFAULT_METRICS_EXPLORER_VIEW_STATE = { + options: DEFAULT_OPTIONS, + chartOptions: DEFAULT_CHART_OPTIONS, + currentTimerange: DEFAULT_TIMERANGE, }; function parseJsonOrDefault(value: string | null, defaultValue: Obj): Obj { diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx index 8b703b1177c8c..cd875ae54071c 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx @@ -6,7 +6,7 @@ import { EuiErrorBoundary } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; +import React, { useEffect } from 'react'; import { IIndexPattern } from 'src/plugins/data/public'; import { useTrackPageview } from '../../../../../observability/public'; import { SourceQuery } from '../../../../common/graphql/types'; @@ -15,6 +15,7 @@ import { NoData } from '../../../components/empty_states'; import { MetricsExplorerCharts } from './components/charts'; import { MetricsExplorerToolbar } from './components/toolbar'; import { useMetricsExplorerState } from './hooks/use_metric_explorer_state'; +import { useSavedViewContext } from '../../../containers/saved_view/saved_view'; interface MetricsExplorerPageProps { source: SourceQuery.Query['source']['configuration']; @@ -37,13 +38,27 @@ export const MetricsExplorerPage = ({ source, derivedIndexPattern }: MetricsExpl handleTimeChange, handleRefresh, handleLoadMore, - defaultViewState, onViewStateChange, - } = useMetricsExplorerState(source, derivedIndexPattern); + loadData, + } = useMetricsExplorerState(source, derivedIndexPattern, false); + const { currentView, shouldLoadDefault } = useSavedViewContext(); useTrackPageview({ app: 'infra_metrics', path: 'metrics_explorer' }); useTrackPageview({ app: 'infra_metrics', path: 'metrics_explorer', delay: 15000 }); + useEffect(() => { + if (currentView) { + onViewStateChange(currentView); + } + }, [currentView, onViewStateChange]); + + useEffect(() => { + if (currentView != null || !shouldLoadDefault) { + // load metrics explorer data after default view loaded, unless we're not loading a view + loadData(); + } + }, [loadData, currentView, shouldLoadDefault]); + return ( {error ? (