[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
This commit is contained in:
Phillip Burch 2020-06-29 16:53:36 -05:00 committed by GitHub
parent 3f44757973
commit 470397075f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 1023 additions and 215 deletions

View file

@ -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 = {

View file

@ -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),
});

View file

@ -55,6 +55,9 @@ export const metricsExplorerViewSavedObjectType: SavedObjectsType = {
aggregation: {
type: 'keyword',
},
source: {
type: 'keyword',
},
},
},
chartOptions: {

View file

@ -63,6 +63,8 @@ describe('ExpressionChart', () => {
logColumns: [],
metricAlias: 'metricbeat-*',
logAlias: 'filebeat-*',
inventoryDefaultView: 'host',
metricsExplorerDefaultView: 'host',
fields: {
timestamp: '@timestamp',
message: ['message'],

View file

@ -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<ViewState> {
views: Array<SavedView<ViewState>>;
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) => {
<FormattedMessage defaultMessage="cancel" id="xpack.infra.waffle.savedViews.cancel" />
</EuiButtonEmpty>
<EuiButton
disabled={props.isDisabled}
fill={true}
iconType="trash"
color="danger"
@ -64,13 +69,17 @@ const DeleteConfimation = (props: DeleteConfimationProps) => {
);
};
export function SavedViewListFlyout<ViewState>({
export function SavedViewManageViewsFlyout<ViewState>({
close,
views,
defaultViewId,
setView,
makeDefault,
deleteView,
loading,
sourceIsLoading,
}: Props<ViewState>) {
const [inProgressView, setInProgressView] = useState<string | null>(null);
const renderName = useCallback(
(name: string, item: SavedView<ViewState>) => (
<EuiButtonEmpty
@ -89,6 +98,7 @@ export function SavedViewListFlyout<ViewState>({
(item: SavedView<ViewState>) => {
return (
<DeleteConfimation
isDisabled={item.isDefault}
confirmedAction={() => {
deleteView(item.id);
}}
@ -98,6 +108,25 @@ export function SavedViewListFlyout<ViewState>({
[deleteView]
);
const renderMakeDefaultAction = useCallback(
(item: SavedView<ViewState>) => {
const isDefault = item.id === defaultViewId;
return (
<>
<EuiButtonEmpty
isLoading={inProgressView === item.id && sourceIsLoading}
iconType={isDefault ? 'starFilled' : 'starEmpty'}
onClick={() => {
setInProgressView(item.id);
makeDefault(item.id);
}}
/>
</>
);
},
[makeDefault, defaultViewId, sourceIsLoading, inProgressView]
);
const columns = [
{
field: 'name',
@ -112,7 +141,11 @@ export function SavedViewListFlyout<ViewState>({
}),
actions: [
{
available: (item: SavedView<ViewState>) => !item.isDefault,
available: () => true,
render: renderMakeDefaultAction,
},
{
available: (item: SavedView<ViewState>) => true,
render: renderDeleteAction,
},
],
@ -124,7 +157,10 @@ export function SavedViewListFlyout<ViewState>({
<EuiFlyoutHeader>
<EuiTitle size="m">
<h2>
<FormattedMessage defaultMessage="Load views" id="xpack.infra.openView.flyoutHeader" />
<FormattedMessage
defaultMessage="Manage saved views"
id="xpack.infra.openView.flyoutHeader"
/>
</h2>
</EuiTitle>
</EuiFlyoutHeader>

View file

@ -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<ViewState> {
viewType: string;
viewState: ViewState;
defaultViewState: ViewState;
onViewChange(viewState: ViewState): void;
}
export function SavedViewsToolbarControls<ViewState>(props: Props<ViewState>) {
@ -26,37 +34,80 @@ export function SavedViewsToolbarControls<ViewState>(props: Props<ViewState>) {
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<ViewState>(props: Props<ViewState>) {
}, [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<ViewState>(props: Props<ViewState>) {
return (
<>
<EuiFlexGroup>
<EuiButtonEmpty iconType="save" onClick={openSaveModal} data-test-subj="openSaveViewModal">
<FormattedMessage
defaultMessage="Save"
id="xpack.infra.waffle.savedViews.saveViewLabel"
/>
</EuiButtonEmpty>
<EuiButtonEmpty iconType="importAction" onClick={loadViews} data-test-subj="loadViews">
<FormattedMessage
defaultMessage="Load"
id="xpack.infra.waffle.savedViews.loadViewsLabel"
/>
</EuiButtonEmpty>
<EuiPopover
button={
<EuiFlexGroup gutterSize={'s'} alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonIcon
aria-label={i18n.translate('xpack.infra.savedView.changeView', {
defaultMessage: 'Change view',
})}
onClick={showSavedViewMenu}
iconType="globe"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionList onClick={showSavedViewMenu}>
<EuiDescriptionListTitle>
<FormattedMessage
defaultMessage="Current view"
id="xpack.infra.savedView.currentView"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{currentView
? currentView.name
: i18n.translate('xpack.infra.savedView.unknownView', {
defaultMessage: 'Unknown',
})}
</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
</EuiFlexGroup>
}
isOpen={isSavedViewMenuOpen}
closePopover={hideSavedViewMenu}
anchorPosition="upCenter"
>
<EuiListGroup flush={true}>
<EuiListGroupItem
iconType={'indexSettings'}
onClick={loadViews}
label={i18n.translate('xpack.infra.savedView.manageViews', {
defaultMessage: 'Manage views',
})}
/>
<EuiListGroupItem
iconType={'refresh'}
onClick={openUpdateModal}
disabled={!currentView || currentView.id === '0'}
label={i18n.translate('xpack.infra.savedView.updateView', {
defaultMessage: 'Update view',
})}
/>
<EuiListGroupItem
iconType={'importAction'}
onClick={openViewListModal}
label={i18n.translate('xpack.infra.savedView.loadView', {
defaultMessage: 'Load view',
})}
/>
<EuiListGroupItem
iconType={'save'}
onClick={openSaveModal}
label={i18n.translate('xpack.infra.savedView.saveNewView', {
defaultMessage: 'Save new view',
})}
/>
</EuiListGroup>
</EuiPopover>
</EuiFlexGroup>
{createModalOpen && (
<SavedViewCreateModal isInvalid={isInvalid} close={closeCreateModal} save={save} />
)}
{updateModalOpen && (
<SavedViewUpdateModal
currentView={currentView}
isInvalid={isInvalid}
close={closeUpdateModal}
save={update}
/>
)}
{viewListModalOpen && (
<SavedViewListModal<any>
currentView={currentView}
views={views}
close={closeViewListModal}
setView={setCurrentView}
/>
)}
{modalOpen && (
<SavedViewListFlyout<ViewState>
<SavedViewManageViewsFlyout<ViewState>
sourceIsLoading={sourceIsLoading}
loading={loading}
views={views}
defaultViewId={defaultViewId}
makeDefault={makeDefault}
deleteView={deleteView}
close={closeModal}
setView={props.onViewChange}
setView={setCurrentView}
/>
)}
</>

View file

@ -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<ViewState> {
isInvalid: boolean;
close(): void;
save(name: string, shouldIncludeTime: boolean): void;
currentView: ViewState;
}
export function SavedViewUpdateModal<ViewState extends { id: string; name: string }>({
close,
save,
isInvalid,
currentView,
}: Props<ViewState>) {
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 (
<EuiOverlayMask>
<EuiModal onClose={close}>
<EuiModalHeader>
<EuiModalHeaderTitle>
<FormattedMessage
defaultMessage="Update View"
id="xpack.infra.waffle.savedView.updateHeader"
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiFieldText
isInvalid={isInvalid}
placeholder={i18n.translate('xpack.infra.waffle.savedViews.viewNamePlaceholder', {
defaultMessage: 'Name',
})}
data-test-subj="savedViewViweName"
value={viewName}
onChange={textChange}
aria-label={i18n.translate('xpack.infra.waffle.savedViews.viewNamePlaceholder', {
defaultMessage: 'Name',
})}
/>
<EuiSpacer size="xl" />
<EuiSwitch
id={'saved-view-save-time-checkbox'}
label={
<FormattedMessage
defaultMessage="Store time with view"
id="xpack.infra.waffle.savedViews.includeTimeFilterLabel"
/>
}
checked={includeTime}
onChange={onCheckChange}
/>
<EuiSpacer size="s" />
<EuiText size={'xs'} grow={false} style={{ maxWidth: 400 }}>
<FormattedMessage
defaultMessage="This changes the time filter to the currently selected time each time the view is loaded"
id="xpack.infra.waffle.savedViews.includeTimeHelpText"
/>
</EuiText>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={close}>
<FormattedMessage
defaultMessage="Cancel"
id="xpack.infra.waffle.savedViews.cancelButton"
/>
</EuiButtonEmpty>
<EuiButton
color="primary"
disabled={!viewName}
fill={true}
onClick={saveView}
data-test-subj="updateSavedViewButton"
>
<FormattedMessage defaultMessage="Save" id="xpack.infra.waffle.savedViews.saveButton" />
</EuiButton>
</EuiModalFooter>
</EuiModal>
</EuiOverlayMask>
);
}

View file

@ -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<ViewState> {
views: Array<SavedView<ViewState>>;
close(): void;
setView(viewState: ViewState): void;
currentView?: ViewState;
}
export function SavedViewListModal<ViewState extends { id: string; name: string }>({
close,
views,
setView,
currentView,
}: Props<ViewState>) {
const [options, setOptions] = useState<EuiSelectableOption[] | null>(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<EuiSelectableOption[]>(() => {
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 (
<EuiOverlayMask>
<EuiModal onClose={close}>
<EuiModalHeader>
<EuiModalHeaderTitle>
<FormattedMessage
defaultMessage="Select a view to load"
id="xpack.infra.waffle.savedView.selectViewHeader"
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiSelectable
singleSelection={true}
searchable={true}
options={options || defaultOptions}
onChange={onChange}
searchProps={{
placeholder: i18n.translate('xpack.infra.savedView.searchPlaceholder', {
defaultMessage: 'Search for saved views',
}),
}}
listProps={{ bordered: true }}
>
{(list, search) => (
<>
{search}
<div style={{ marginTop: 20 }}>{list}</div>
</>
)}
</EuiSelectable>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty data-test-subj="cancelSavedViewModal" onClick={close}>
<FormattedMessage defaultMessage="Cancel" id="xpack.infra.openView.cancelButton" />
</EuiButtonEmpty>
<EuiButton
fill={true}
color={'primary'}
data-test-subj="loadSavedViewModal"
onClick={loadView}
>
<FormattedMessage defaultMessage="Load view" id="xpack.infra.openView.loadButton" />
</EuiButton>
</EuiModalFooter>
</EuiModal>
</EuiOverlayMask>
);
}

View file

@ -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)) {

View file

@ -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> = ViewState & {
name: string;
id: string;
isDefault?: boolean;
};
export type SavedViewSavedObject<ViewState = {}> = ViewState & {
name: string;
};
export type ViewType =
| typeof metricsExplorerViewSavedObjectName
| typeof inventoryViewSavedObjectName;
interface Props {
defaultViewState: SavedView<any>;
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<ViewState>
>(viewType);
const [currentView, setCurrentView] = useState<SavedView<any> | null>(null);
const [loadingDefaultView, setLoadingDefaultView] = useState<boolean | null>(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<string | null>(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<SavedObjectAttributes>) => {
return {
...o.attributes,
id: o.id,
isDefault: defaultViewId === o.id,
};
},
[defaultViewId]
);
const savedObjects = useMemo(() => (data ? data.savedObjects : []), [data]);
const views = useMemo(() => {
const items: Array<SavedView<ViewState>> = [
{
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;

View file

@ -12,6 +12,8 @@ export const sourceConfigurationFieldsFragment = gql`
description
logAlias
metricAlias
inventoryDefaultView
metricsExplorerDefaultView
fields {
container
host

View file

@ -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[];
};

View file

@ -49,7 +49,7 @@ export const useFindSavedObject = <SavedObjectType extends SavedObjectAttributes
const objects = await savedObjectsClient.find<SavedObjectType>({
type,
});
return objects.savedObjects.filter((o) => o.attributes.name === name).length > 0;
return objects.savedObjects.find((o) => o.attributes.name === name);
};
return {

View file

@ -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 = <SavedObjectType extends SavedObjectAttributes>(type: string) => {
const kibana = useKibana();
const [data, setData] = useState<SimpleSavedObject<SavedObjectType> | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(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<SavedObjectType>(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,
};
};

View file

@ -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<Response>(
@ -40,6 +40,9 @@ export function useHTTPRequest<Response>(
onResolve: (resp) => setResponse(decode(resp)),
onReject: (e: unknown) => {
const err = e as IHttpFetchError;
if (e && e instanceof CanceledPromiseError) {
return;
}
setError(err);
toast({
toastLifeTimeMs: 3000,

View file

@ -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> = ViewState & {
name: string;
id: string;
isDefault?: boolean;
};
export type SavedViewSavedObject<ViewState = {}> = ViewState & {
name: string;
};
export const useSavedView = <ViewState>(defaultViewState: ViewState, viewType: string) => {
const { data, loading, find, error: errorOnFind, hasView } = useFindSavedObject<
SavedViewSavedObject<ViewState>
>(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<string | null>(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<SavedView<ViewState>> = [
{
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,
};
};

View file

@ -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<SimpleSavedObject<SavedObjectAttributes> | null>(null);
const [updatedId, setUpdatedId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(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,
};
};

View file

@ -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) => {
<MetricsExplorerOptionsContainer.Provider>
<WithMetricsExplorerOptionsUrlState />
{configuration ? (
<MetricsExplorerPage
derivedIndexPattern={createDerivedIndexPattern('metrics')}
source={configuration}
{...props}
<PageContent
configuration={configuration}
createDerivedIndexPattern={createDerivedIndexPattern}
/>
) : (
<SourceLoadingPage />
@ -162,3 +167,25 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => {
</EuiErrorBoundary>
);
};
const PageContent = (props: {
configuration: SourceConfigurationFields.Fragment;
createDerivedIndexPattern: (type: 'logs' | 'metrics' | 'both') => IIndexPattern;
}) => {
const { createDerivedIndexPattern, configuration } = props;
const { options } = useContext(MetricsExplorerOptionsContainer.Context);
return (
<SavedView.Provider
shouldLoadDefault={options.source === 'default'}
viewType={'metrics-explorer-view'}
defaultViewState={DEFAULT_METRICS_EXPLORER_VIEW_STATE}
>
<MetricsExplorerPage
derivedIndexPattern={createDerivedIndexPattern('metrics')}
source={configuration}
{...props}
/>
</SavedView.Provider>
);
};

View file

@ -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 = () => {
<BottomActionContainer>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<SavedViews />
<SavedViewsToolbarControls viewState={viewState} />
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ position: 'relative', minWidth: 400 }}>
<Legend

View file

@ -5,17 +5,9 @@
*/
import React from 'react';
import { SavedViewsToolbarControls } from '../../../../components/saved_views/toolbar_control';
import { inventoryViewSavedObjectName } from '../../../../../common/saved_objects/inventory_view';
import { useWaffleViewState } from '../hooks/use_waffle_view_state';
export const SavedViews = () => {
const { viewState, defaultViewState, onViewChange } = useWaffleViewState();
return (
<SavedViewsToolbarControls
defaultViewState={defaultViewState}
viewState={viewState}
onViewChange={onViewChange}
viewType={inventoryViewSavedObjectName}
/>
);
const { viewState } = useWaffleViewState();
return <SavedViewsToolbarControls viewState={viewState} />;
};

View file

@ -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,

View file

@ -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<typeof WaffleSortOptionRT>;
export type WaffleOptionsState = rt.TypeOf<typeof WaffleOptionsStateRT>;
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;

View file

@ -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,
};
};

View file

@ -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 ? (
<SourceLoadingPage />
) : metricIndicesExist ? (
<>
<FilterBar />
<Layout />
<SavedView.Provider
shouldLoadDefault={optionsSource === 'default'}
viewType={'inventory-view'}
defaultViewState={DEFAULT_WAFFLE_VIEW_STATE}
>
<Layout />
</SavedView.Provider>
</>
) : hasFailedLoadingSource ? (
<SourceErrorPage errorMessage={loadSourceFailureMessage || ''} retry={loadSource} />

View file

@ -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 = ({
<EuiFlexItem grow={false}>
<SavedViewsToolbarControls
defaultViewState={defaultViewState}
viewState={{
options,
chartOptions,
currentTimerange: timeRange,
}}
viewType={metricsExplorerViewSavedObjectName}
onViewChange={onViewStateChange}
/>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ marginRight: 5 }}>

View file

@ -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<string | null | Record<string, string | null>>(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,
};

View file

@ -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<string, string | null>,
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<MetricsExplorerOptions | null>(null);
const [lastTimerange, setLastTimerange] = useState<MetricsExplorerTimeOptions | null>(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 };
}

View file

@ -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<Obj>(value: string | null, defaultValue: Obj): Obj {

View file

@ -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 (
<EuiErrorBoundary>
<DocumentTitle
@ -68,8 +83,6 @@ export const MetricsExplorerPage = ({ source, derivedIndexPattern }: MetricsExpl
onMetricsChange={handleMetricsChange}
onAggregationChange={handleAggregationChange}
onChartOptionsChange={setChartOptions}
defaultViewState={defaultViewState}
onViewStateChange={onViewStateChange}
/>
{error ? (
<NoData

View file

@ -29,6 +29,8 @@ export const source = {
logAlias: 'filebeat-*',
metricAlias: 'metricbeat-*',
logColumns: [],
inventoryDefaultView: 'host',
metricsExplorerDefaultView: 'host',
fields: {
host: 'host.name',
container: 'container.id',

View file

@ -36,6 +36,10 @@ export const sourcesSchema = gql`
metricAlias: String!
"The alias to read log data from"
logAlias: String!
"Default view for inventory"
inventoryDefaultView: String!
"Default view for Metrics Explorer"
metricsExplorerDefaultView: String!
"The field mapping to use for this source"
fields: InfraSourceFields!
"The columns to use for log display"
@ -128,6 +132,10 @@ export const sourcesSchema = gql`
logAlias: String
"The field mapping to use for this source"
fields: UpdateSourceFieldsInput
"Name of default inventory view"
inventoryDefaultView: String
"Default view for Metrics Explorer"
metricsExplorerDefaultView: String
"The log columns to display for this source"
logColumns: [UpdateSourceLogColumnInput!]
}

View file

@ -357,6 +357,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;
}

View file

@ -19,6 +19,8 @@ export const defaultSourceConfiguration: InfraSourceConfiguration = {
tiebreaker: '_doc',
timestamp: '@timestamp',
},
inventoryDefaultView: '0',
metricsExplorerDefaultView: '0',
logColumns: [
{
timestampColumn: {

View file

@ -30,6 +30,12 @@ export const infraSourceConfigurationSavedObjectType: SavedObjectsType = {
logAlias: {
type: 'keyword',
},
inventoryDefaultView: {
type: 'keyword',
},
metricsExplorerDefaultView: {
type: 'keyword',
},
fields: {
properties: {
container: {

View file

@ -7863,7 +7863,6 @@
"xpack.infra.registerFeatures.logsDescription": "ログをリアルタイムでストリーするか、コンソール式の UI で履歴ビューをスクロールします。",
"xpack.infra.registerFeatures.logsTitle": "ログ",
"xpack.infra.sampleDataLinkLabel": "ログ",
"xpack.infra.savedView.defaultViewName": "デフォルト",
"xpack.infra.savedView.errorOnCreate.duplicateViewName": "その名前のビューは既に存在します",
"xpack.infra.savedView.errorOnCreate.title": "ビューの保存中にエラーが発生しました。",
"xpack.infra.savedView.findError.title": "ビューの読み込み中にエラーが発生しました。",
@ -7997,9 +7996,7 @@
"xpack.infra.waffle.savedViews.cancelButton": "キャンセル",
"xpack.infra.waffle.savedViews.includeTimeFilterLabel": "ビューに時刻を保存",
"xpack.infra.waffle.savedViews.includeTimeHelpText": "ビューが読み込まれるごとに現在選択された時刻の時間フィルターが変更されます。",
"xpack.infra.waffle.savedViews.loadViewsLabel": "読み込み",
"xpack.infra.waffle.savedViews.saveButton": "保存",
"xpack.infra.waffle.savedViews.saveViewLabel": "保存",
"xpack.infra.waffle.savedViews.viewNamePlaceholder": "名前",
"xpack.infra.waffle.selectTwoGroupingsTitle": "最大 2 つのグループ分けを選択してください",
"xpack.infra.waffle.unableToSelectGroupErrorMessage": "{nodeType} のオプションでグループを選択できません",

View file

@ -7868,7 +7868,6 @@
"xpack.infra.registerFeatures.logsDescription": "实时流式传输日志或在类似控制台的工具中滚动浏览历史视图。",
"xpack.infra.registerFeatures.logsTitle": "鏃ュ織",
"xpack.infra.sampleDataLinkLabel": "日志",
"xpack.infra.savedView.defaultViewName": "默认值",
"xpack.infra.savedView.errorOnCreate.duplicateViewName": "具有该名称的视图已存在。",
"xpack.infra.savedView.errorOnCreate.title": "保存视图时出错。",
"xpack.infra.savedView.findError.title": "加载视图时出错。",
@ -8002,9 +8001,7 @@
"xpack.infra.waffle.savedViews.cancelButton": "取消",
"xpack.infra.waffle.savedViews.includeTimeFilterLabel": "将时间与视图一起存储",
"xpack.infra.waffle.savedViews.includeTimeHelpText": "每次加载此仪表板时,这都会将时间筛选更改为当前选定的时间",
"xpack.infra.waffle.savedViews.loadViewsLabel": "负载",
"xpack.infra.waffle.savedViews.saveButton": "保存",
"xpack.infra.waffle.savedViews.saveViewLabel": "保存",
"xpack.infra.waffle.savedViews.viewNamePlaceholder": "名称",
"xpack.infra.waffle.selectTwoGroupingsTitle": "选择最多两个分组",
"xpack.infra.waffle.unableToSelectGroupErrorMessage": "无法选择 {nodeType} 的分组依据选项",