diff --git a/CHANGELOG.md b/CHANGELOG.md index cb351f1da..2ba077a3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ NOTE: As semantic versioning states all 0.y.z releases can contain breaking chan - [#49](https://github.com/kobsio/kobs/pull/49): Add new chart type `table` for Prometheus plugin, which allows a user to render the results of multiple Prometheus queries in ab table. - [#51](https://github.com/kobsio/kobs/pull/51): Add new command-line flag to forbid access for resources. - [#52](https://github.com/kobsio/kobs/pull/52): Add option to enter a single trace id in the Jaeger plugin. +- [#56](https://github.com/kobsio/kobs/pull/56): Add actions for Elasticsearch plugin to include/exclude and toggle values in the logs view. ### Fixed diff --git a/app/src/plugins/elasticsearch/ElasticsearchLogs.tsx b/app/src/plugins/elasticsearch/ElasticsearchLogs.tsx index 2a708d013..729e05dea 100644 --- a/app/src/plugins/elasticsearch/ElasticsearchLogs.tsx +++ b/app/src/plugins/elasticsearch/ElasticsearchLogs.tsx @@ -39,6 +39,7 @@ interface IElasticsearchLogsProps extends IElasticsearchOptions { setDocument?: (document: React.ReactNode) => void; setScrollID: (scrollID: string) => void; selectField?: (field: string) => void; + showActions: boolean; } // ElasticsearchLogs is a wrapper component for the Elasticsearch results view (ElasticsearchLogsGrid), it is used to @@ -54,6 +55,7 @@ const ElasticsearchLogs: React.FunctionComponent = ({ setDocument, setScrollID, selectField, + showActions, }: IElasticsearchLogsProps) => { const [data, setData] = useState({ buckets: [], @@ -186,6 +188,7 @@ const ElasticsearchLogs: React.FunctionComponent = ({ setDocument={setDocument} setScrollID={setScrollID} selectField={selectField} + showActions={showActions} /> ); }; diff --git a/app/src/plugins/elasticsearch/ElasticsearchLogsActions.tsx b/app/src/plugins/elasticsearch/ElasticsearchLogsActions.tsx new file mode 100644 index 000000000..88d1bce31 --- /dev/null +++ b/app/src/plugins/elasticsearch/ElasticsearchLogsActions.tsx @@ -0,0 +1,84 @@ +import { Button, ButtonVariant } from '@patternfly/react-core'; +import { ColumnsIcon, MinusIcon, PlusIcon } from '@patternfly/react-icons'; +import { useHistory, useLocation } from 'react-router-dom'; +import React from 'react'; + +import { IKeyValue, getOptionsFromSearch } from 'plugins/elasticsearch/helpers'; + +interface IElasticsearchLogsActionsProps { + field: IKeyValue; +} + +const ElasticsearchLogsActions: React.FunctionComponent = ({ + field, +}: IElasticsearchLogsActionsProps) => { + const history = useHistory(); + const location = useLocation(); + + const adjustQuery = (addition: string): void => { + const opts = getOptionsFromSearch(location.search); + const fields = opts.fields ? opts.fields.map((field) => `&field=${field}`) : []; + const query = `${opts.query} ${addition}`; + + history.push({ + pathname: location.pathname, + search: `?query=${query}${fields && fields.length > 0 ? fields.join('') : ''}&timeEnd=${opts.timeEnd}&timeStart=${ + opts.timeStart + }`, + }); + }; + + const adjustFields = (field: string): void => { + const opts = getOptionsFromSearch(location.search); + let tmpFields = opts.fields ? opts.fields : []; + + if (tmpFields.includes(field)) { + tmpFields = tmpFields.filter((f) => f !== field); + } else { + tmpFields.push(field); + } + + const fields = tmpFields ? tmpFields.map((field) => `&field=${field}`) : []; + + history.push({ + pathname: location.pathname, + search: `?query=${opts.query}${fields && fields.length > 0 ? fields.join('') : ''}&timeEnd=${ + opts.timeEnd + }&timeStart=${opts.timeStart}`, + }); + }; + + return ( + + + + + + ); +}; + +export default ElasticsearchLogsActions; diff --git a/app/src/plugins/elasticsearch/ElasticsearchLogsDocument.tsx b/app/src/plugins/elasticsearch/ElasticsearchLogsDocument.tsx index 725a6cd22..21e5a899e 100644 --- a/app/src/plugins/elasticsearch/ElasticsearchLogsDocument.tsx +++ b/app/src/plugins/elasticsearch/ElasticsearchLogsDocument.tsx @@ -1,18 +1,24 @@ import { + Card, DrawerActions, DrawerCloseButton, DrawerHead, DrawerPanelBody, DrawerPanelContent, + Tab, + TabTitleText, + Tabs, } from '@patternfly/react-core'; -import React from 'react'; +import React, { useState } from 'react'; import { IDocument, formatTimeWrapper } from 'plugins/elasticsearch/helpers'; import Editor from 'components/Editor'; +import ElasticsearchLogsDocumentOverview from 'plugins/elasticsearch/ElasticsearchLogsDocumentOverview'; import Title from 'components/Title'; export interface IElasticsearchLogsDocumentProps { document: IDocument; + showActions: boolean; close: () => void; } @@ -20,8 +26,11 @@ export interface IElasticsearchLogsDocumentProps { // code view. The highlighting of this JSON document is handled by highlight.js. const ElasticsearchLogsDocument: React.FunctionComponent = ({ document, + showActions, close, }: IElasticsearchLogsDocumentProps) => { + const [activeTab, setActiveTab] = useState('overview'); + return ( @@ -36,8 +45,26 @@ const ElasticsearchLogsDocument: React.FunctionComponent - -

 

+ setActiveTab(tabIndex.toString())} + className="pf-u-mt-md" + isFilled={true} + mountOnEnter={true} + > + Overview}> +
+ +
+
+ JSON}> +
+ + + +
+
+
); diff --git a/app/src/plugins/elasticsearch/ElasticsearchLogsDocumentOverview.tsx b/app/src/plugins/elasticsearch/ElasticsearchLogsDocumentOverview.tsx new file mode 100644 index 000000000..bbb6fab3d --- /dev/null +++ b/app/src/plugins/elasticsearch/ElasticsearchLogsDocumentOverview.tsx @@ -0,0 +1,45 @@ +import { Card, CardBody, DescriptionList } from '@patternfly/react-core'; +import React from 'react'; + +import { IDocument, IKeyValue } from 'plugins/elasticsearch/helpers'; +import ElasticsearchLogsDocumentOverviewItem from 'plugins/elasticsearch/ElasticsearchLogsDocumentOverviewItem'; + +// getKeyValues creates an array with all keys and values of the document. +const getKeyValues = (obj: IDocument, prefix = ''): IKeyValue[] => { + return Object.keys(obj).reduce((res: IKeyValue[], el) => { + if (Array.isArray(obj[el])) { + return res; + } else if (typeof obj[el] === 'object' && obj[el] !== null) { + return [...res, ...getKeyValues(obj[el], prefix + el + '.')]; + } + return [...res, { key: prefix + el, value: obj[el] }]; + }, []); +}; + +export interface IElasticsearchLogsDocumentOverviewProps { + document: IDocument; + showActions: boolean; +} + +// ElasticsearchLogsDocumentOverview renders a list of all keys and their values in a description list. For that we have +// to generate a fields list via the getKeyValues function first. +const ElasticsearchLogsDocumentOverview: React.FunctionComponent = ({ + document, + showActions, +}: IElasticsearchLogsDocumentOverviewProps) => { + const fields = getKeyValues(document['_source']); + + return ( + + + + {fields.map((field) => ( + + ))} + + + + ); +}; + +export default ElasticsearchLogsDocumentOverview; diff --git a/app/src/plugins/elasticsearch/ElasticsearchLogsDocumentOverviewItem.tsx b/app/src/plugins/elasticsearch/ElasticsearchLogsDocumentOverviewItem.tsx new file mode 100644 index 000000000..7f418d193 --- /dev/null +++ b/app/src/plugins/elasticsearch/ElasticsearchLogsDocumentOverviewItem.tsx @@ -0,0 +1,32 @@ +import { DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from '@patternfly/react-core'; +import React, { useState } from 'react'; + +import ElasticsearchLogsActions from 'plugins/elasticsearch/ElasticsearchLogsActions'; +import { IKeyValue } from 'plugins/elasticsearch/helpers'; + +interface IElasticsearchLogsDocumentOverviewItemProps { + field: IKeyValue; + showActions: boolean; +} + +// Item is the component to render a single field from the generated key/values list. When the user hovers over the +// title/key of the description list, we will show some actions, which can be used to include/exclude the value from the +// search or to add it as column to the table. +const ElasticsearchLogsDocumentOverviewItem: React.FunctionComponent = ({ + field, + showActions, +}: IElasticsearchLogsDocumentOverviewItemProps) => { + const [show, setShow] = useState(false); + + return ( + + setShow(true)} onMouseLeave={(): void => setShow(false)}> + {field.key} + {showActions && show ? : null} + + {field.value} + + ); +}; + +export default ElasticsearchLogsDocumentOverviewItem; diff --git a/app/src/plugins/elasticsearch/ElasticsearchLogsDocuments.tsx b/app/src/plugins/elasticsearch/ElasticsearchLogsDocuments.tsx index efe7c16d1..cc00d8403 100644 --- a/app/src/plugins/elasticsearch/ElasticsearchLogsDocuments.tsx +++ b/app/src/plugins/elasticsearch/ElasticsearchLogsDocuments.tsx @@ -1,13 +1,15 @@ -import { TableComposable, TableVariant, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import { TableComposable, TableVariant, Tbody, Th, Thead, Tr } from '@patternfly/react-table'; import { Card } from '@patternfly/react-core'; import React from 'react'; -import { IDocument, formatTimeWrapper, getProperty } from 'plugins/elasticsearch/helpers'; +import ElasticsearchLogsDocumentsItem from 'plugins/elasticsearch/ElasticsearchLogsDocumentsItem'; +import { IDocument } from 'plugins/elasticsearch/helpers'; export interface IElasticsearchLogsDocumentsProps { selectedFields: string[]; documents: IDocument[]; select?: (doc: IDocument) => void; + showActions: boolean; } // ElasticsearchLogsDocuments renders a list of documents. If the user has selected some fields, we will render the @@ -17,6 +19,7 @@ const ElasticsearchLogsDocuments: React.FunctionComponent { return ( @@ -33,18 +36,13 @@ const ElasticsearchLogsDocuments: React.FunctionComponent {documents.map((document, index) => ( - select(document) : undefined}> - {formatTimeWrapper(document['_source']['@timestamp'])} - {selectedFields.length > 0 ? ( - selectedFields.map((selectedField, index) => ( - - {getProperty(document['_source'], selectedField)} - - )) - ) : ( - {JSON.stringify(document['_source'])} - )} - + ))} diff --git a/app/src/plugins/elasticsearch/ElasticsearchLogsDocumentsItem.tsx b/app/src/plugins/elasticsearch/ElasticsearchLogsDocumentsItem.tsx new file mode 100644 index 000000000..0c00cb8a8 --- /dev/null +++ b/app/src/plugins/elasticsearch/ElasticsearchLogsDocumentsItem.tsx @@ -0,0 +1,41 @@ +import { TableText, Td, Tr } from '@patternfly/react-table'; +import React from 'react'; + +import { IDocument, formatTimeWrapper } from 'plugins/elasticsearch/helpers'; +import ElasticsearchLogsDocumentsItemField from 'plugins/elasticsearch/ElasticsearchLogsDocumentsItemField'; + +export interface IElasticsearchLogsDocumentsItemProps { + selectedFields: string[]; + document: IDocument; + select?: (doc: IDocument) => void; + showActions: boolean; +} + +const ElasticsearchLogsDocumentsItem: React.FunctionComponent = ({ + selectedFields, + document, + select, + showActions, +}: IElasticsearchLogsDocumentsItemProps) => { + return ( + + select(document) : undefined}> + {formatTimeWrapper(document['_source']['@timestamp'])} + + {selectedFields.length > 0 ? ( + selectedFields.map((selectedField, index) => ( + + )) + ) : ( + {JSON.stringify(document['_source'])} + )} + + ); +}; + +export default ElasticsearchLogsDocumentsItem; diff --git a/app/src/plugins/elasticsearch/ElasticsearchLogsDocumentsItemField.tsx b/app/src/plugins/elasticsearch/ElasticsearchLogsDocumentsItemField.tsx new file mode 100644 index 000000000..02165dd11 --- /dev/null +++ b/app/src/plugins/elasticsearch/ElasticsearchLogsDocumentsItemField.tsx @@ -0,0 +1,30 @@ +import React, { useState } from 'react'; +import { Td } from '@patternfly/react-table'; + +import { IDocument, getProperty } from 'plugins/elasticsearch/helpers'; +import ElasticsearchLogsActions from 'plugins/elasticsearch/ElasticsearchLogsActions'; + +export interface IElasticsearchLogsDocumentsItemFieldProps { + selectedField: string; + document: IDocument; + select?: (doc: IDocument) => void; + showActions: boolean; +} + +const ElasticsearchLogsDocumentsItemField: React.FunctionComponent = ({ + document, + selectedField, + showActions, +}: IElasticsearchLogsDocumentsItemFieldProps) => { + const [show, setShow] = useState(false); + const value = getProperty(document['_source'], selectedField); + + return ( + setShow(true)} onMouseLeave={(): void => setShow(false)}> + {value} + {showActions && show ? : null} + + ); +}; + +export default ElasticsearchLogsDocumentsItemField; diff --git a/app/src/plugins/elasticsearch/ElasticsearchLogsGrid.tsx b/app/src/plugins/elasticsearch/ElasticsearchLogsGrid.tsx index bcfc569a8..a733573e2 100644 --- a/app/src/plugins/elasticsearch/ElasticsearchLogsGrid.tsx +++ b/app/src/plugins/elasticsearch/ElasticsearchLogsGrid.tsx @@ -25,6 +25,7 @@ interface IElasticsearchLogsGridProps { setDocument?: (document: React.ReactNode) => void; setScrollID: (scrollID: string) => void; selectField?: (field: string) => void; + showActions: boolean; } // ElasticsearchLogsGrid renders a grid, for the Elasticsearch results. The grid contains a list of fields and selected @@ -46,6 +47,7 @@ const ElasticsearchLogsGrid: React.FunctionComponent { // showFields is used to define if we want to show the fields list in the grid or not. If the queryName isn't present, // which can only happen in the page view, we show the logs. In that way we can save some space in the plugin view, @@ -83,9 +85,16 @@ const ElasticsearchLogsGrid: React.FunctionComponent - setDocument( setDocument(undefined)} />) + setDocument( + setDocument(undefined)} + />, + ) : undefined } + showActions={showActions} /> ) : null} diff --git a/app/src/plugins/elasticsearch/ElasticsearchPage.tsx b/app/src/plugins/elasticsearch/ElasticsearchPage.tsx index 7b91f18a9..14d25444b 100644 --- a/app/src/plugins/elasticsearch/ElasticsearchPage.tsx +++ b/app/src/plugins/elasticsearch/ElasticsearchPage.tsx @@ -99,6 +99,7 @@ const ElasticsearchPage: React.FunctionComponent = ({ name, de setDocument={setDocument} setScrollID={setScrollID} selectField={selectField} + showActions={true} /> ) : null} diff --git a/app/src/plugins/elasticsearch/ElasticsearchPageToolbar.tsx b/app/src/plugins/elasticsearch/ElasticsearchPageToolbar.tsx index 4a5c3da8d..0f1045d23 100644 --- a/app/src/plugins/elasticsearch/ElasticsearchPageToolbar.tsx +++ b/app/src/plugins/elasticsearch/ElasticsearchPageToolbar.tsx @@ -9,7 +9,7 @@ import { ToolbarToggleGroup, } from '@patternfly/react-core'; import { FilterIcon, SearchIcon } from '@patternfly/react-icons'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import Options, { IAdditionalFields } from 'components/Options'; import { IElasticsearchOptions } from 'plugins/elasticsearch/helpers'; @@ -64,6 +64,14 @@ const ElasticsearchPageToolbar: React.FunctionComponent { + setData((d) => { + return { ...d, query: query }; + }); + }, [query]); + return ( diff --git a/app/src/plugins/elasticsearch/ElasticsearchPlugin.tsx b/app/src/plugins/elasticsearch/ElasticsearchPlugin.tsx index 4e6b162fe..a685f973c 100644 --- a/app/src/plugins/elasticsearch/ElasticsearchPlugin.tsx +++ b/app/src/plugins/elasticsearch/ElasticsearchPlugin.tsx @@ -74,6 +74,7 @@ const ElasticsearchPlugin: React.FunctionComponent = ({ timeStart={options.timeStart} setDocument={showDetails} setScrollID={setScrollID} + showActions={false} /> ) : null} diff --git a/app/src/plugins/elasticsearch/helpers.ts b/app/src/plugins/elasticsearch/helpers.ts index c880090c5..92c2ce116 100644 --- a/app/src/plugins/elasticsearch/helpers.ts +++ b/app/src/plugins/elasticsearch/helpers.ts @@ -23,6 +23,12 @@ export interface IDocument { [key: string]: any; } +// IKeyValue is the interface for a single field in a document, with it's key and value. +export interface IKeyValue { + key: string; + value: string; +} + // getOptionsFromSearch is used to get the Elasticsearch options from a given search location. export const getOptionsFromSearch = (search: string): IElasticsearchOptions => { const params = new URLSearchParams(search); diff --git a/app/src/plugins/jaeger/JaegerPageToolbar.tsx b/app/src/plugins/jaeger/JaegerPageToolbar.tsx index 49f9cf91e..30fba7b3e 100644 --- a/app/src/plugins/jaeger/JaegerPageToolbar.tsx +++ b/app/src/plugins/jaeger/JaegerPageToolbar.tsx @@ -136,6 +136,8 @@ const JaegerPageToolbar: React.FunctionComponent = ({ fetchOperations(); }, [fetchOperations]); + // useEffect is triggered when the tags property is changed. This is needed because, tags can also be set by clicking + // on a tag and selecting the filter option. useEffect(() => { setOptions((o) => { return { ...o, tags: tags }; diff --git a/pkg/api/plugins/elasticsearch/elasticsearch.go b/pkg/api/plugins/elasticsearch/elasticsearch.go index 2a1012d25..a7308f1aa 100644 --- a/pkg/api/plugins/elasticsearch/elasticsearch.go +++ b/pkg/api/plugins/elasticsearch/elasticsearch.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" elasticsearchProto "github.com/kobsio/kobs/pkg/api/plugins/elasticsearch/proto" pluginsProto "github.com/kobsio/kobs/pkg/api/plugins/plugins/proto" @@ -111,7 +112,7 @@ func (e *Elasticsearch) GetLogs(ctx context.Context, getLogsRequest *elasticsear if getLogsRequest.ScrollID == "" { url = fmt.Sprintf("%s/_search?scroll=15m", instance.address) - body = []byte(fmt.Sprintf(`{"size":100,"sort":[{"@timestamp":{"order":"desc"}}],"query":{"bool":{"must":[{"range":{"@timestamp":{"gte":"%d","lte":"%d"}}},{"query_string":{"query":"%s"}}]}},"aggs":{"logcount":{"auto_date_histogram":{"field":"@timestamp","buckets":30}}}}`, getLogsRequest.TimeStart*1000, getLogsRequest.TimeEnd*1000, getLogsRequest.Query.Query)) + body = []byte(fmt.Sprintf(`{"size":100,"sort":[{"@timestamp":{"order":"desc"}}],"query":{"bool":{"must":[{"range":{"@timestamp":{"gte":"%d","lte":"%d"}}},{"query_string":{"query":"%s"}}]}},"aggs":{"logcount":{"auto_date_histogram":{"field":"@timestamp","buckets":30}}}}`, getLogsRequest.TimeStart*1000, getLogsRequest.TimeEnd*1000, strings.ReplaceAll(getLogsRequest.Query.Query, "\"", "\\\""))) } else { url = fmt.Sprintf("%s/_search/scroll", instance.address) body = []byte(`{"scroll" : "15m", "scroll_id" : "` + getLogsRequest.ScrollID + `"}`)