diff --git a/.vscode/launch.json b/.vscode/launch.json index 9d95f47f5..5a7ae307e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,9 +8,10 @@ "mode": "debug", "program": "${workspaceFolder}/cmd/kobs", "args": [ + "hub", "--log.level=debug", "--hub.config=../../deploy/docker/kobs/hub.yaml", - "--app.assets=''" + "--app.assets=" ] }, { @@ -20,6 +21,7 @@ "mode": "debug", "program": "${workspaceFolder}/cmd/kobs", "args": [ + "satellite", "--log.level=debug", "--satellite.config=../../deploy/docker/kobs/satellite.yaml", "--satellite.token=unsecuretoken" diff --git a/docs/plugins/klogs.md b/docs/plugins/klogs.md index 03ec020ff..ca96aadc2 100644 --- a/docs/plugins/klogs.md +++ b/docs/plugins/klogs.md @@ -21,6 +21,7 @@ To use the klogs plugin the following configuration is needed in the satellites | options.maxIdleConns | number | ClickHouse maximum number of idle connections. The default value is `5`. | No | | options.maxOpenConns | number | ClickHouse maximum number of open connections. The default value is `10`. | No | | options.materializedColumns | []string | A list of materialized columns. See [kobsio/klogs](https://github.com/kobsio/klogs#configuration) for more information. | No | +| options.integrations | []object | A list of integrations. See [Integrations](#integrations) for more information. | No | ```yaml plugins: @@ -36,6 +37,7 @@ plugins: maxIdleConns: maxOpenConns: materializedColumns: + integrations: ``` ## Insight Options @@ -58,6 +60,35 @@ The following options can be used for a panel with the klogs plugin: | queries | [[]Query](#query) | A list of queries, which can be selected by the user. This is only required for type `logs`. | Yes | | aggregation | [Aggregation](#aggregation) | Options for the aggregation. This is only required for type `aggregation`. | Yes | +## Integrations + +Integrations enhance the experience of the klogs plugin. The integrations are differentiated by the `type` property on the integration config. Integrations share the following properties: + +| Field | Type | Description | Required | +| ----- | ---- | ----------- | -------- | +| name | string | Sets the name of the integration | No | +| type | string | Sets the integration type. | Yes | + +The following integrations are available: + +### Integration - Autolinks (`type: autolinks`) + +The autolinks integration converts a value of a specific column to a link. This could be a link to a different kobs plugin. It's possible to use relative or absolute paths. +The options for this integration type are specified under the property `autolinks` and use the following configuration values: + +| Field | Type | Description | Required | +| ----- | ---- | ----------- | -------- | +| columnName | string | The column name for which the autolinks should be applied | Yes | +| path | string | The path for the autolink (can be absolute or relative) | Yes | + +The path property can contain placeholders for the document value and time frame values: + +| Placeholder | Description | +| ----- | ---- | +| `<>` | placeholder for the document value | +| `<>` | placeholder for the klogs start time (unix timestamp) | +| `<>` | placeholder for the klogs end time (unix timestamp) | + ### Query | Field | Type | Description | Required | diff --git a/plugins/plugin-klogs/cmd/klogs.go b/plugins/plugin-klogs/cmd/klogs.go index 990456a46..7c9c0a578 100644 --- a/plugins/plugin-klogs/cmd/klogs.go +++ b/plugins/plugin-klogs/cmd/klogs.go @@ -139,7 +139,7 @@ func (router *Router) getLogs(w http.ResponseWriter, r *http.Request) { data := struct { Documents []map[string]any `json:"documents"` - Fields []string `json:"fields"` + Fields []instance.Field `json:"fields"` Count int64 `json:"count"` Took int64 `json:"took"` Buckets []instance.Bucket `json:"buckets"` diff --git a/plugins/plugin-klogs/pkg/instance/aggregation.go b/plugins/plugin-klogs/pkg/instance/aggregation.go index f8f1401d3..a74924322 100644 --- a/plugins/plugin-klogs/pkg/instance/aggregation.go +++ b/plugins/plugin-klogs/pkg/instance/aggregation.go @@ -52,7 +52,7 @@ type AggregationTimes struct { // We can also directly say that the passed in field must be a number field, e.g. aggregation with the min, max, sum or // avg operation can only run against number fields. func generateFieldName(fieldName string, materializedColumns []string, customFields Fields, mustNumber bool) string { - if contains(defaultFields, fieldName) || contains(materializedColumns, fieldName) { + if containsField(defaultFields, Field{Name: fieldName}) || contains(materializedColumns, fieldName) { return fieldName } @@ -61,7 +61,7 @@ func generateFieldName(fieldName string, materializedColumns []string, customFie } for _, field := range customFields.Number { - if field == fieldName { + if field.Name == fieldName { return fmt.Sprintf("fields_number['%s']", fieldName) } } diff --git a/plugins/plugin-klogs/pkg/instance/aggregation_test.go b/plugins/plugin-klogs/pkg/instance/aggregation_test.go index b8fab9628..db7d29b23 100644 --- a/plugins/plugin-klogs/pkg/instance/aggregation_test.go +++ b/plugins/plugin-klogs/pkg/instance/aggregation_test.go @@ -19,7 +19,7 @@ func TestGenerateFieldName(t *testing.T) { {field: "content_duration", mustNumber: false, expect: "fields_number['content_duration']"}, } { t.Run(tt.field, func(t *testing.T) { - actual := generateFieldName(tt.field, nil, Fields{String: nil, Number: []string{"content_duration"}}, tt.mustNumber) + actual := generateFieldName(tt.field, nil, Fields{String: nil, Number: []Field{{Name: "content_duration"}}}, tt.mustNumber) require.Equal(t, tt.expect, actual) }) } diff --git a/plugins/plugin-klogs/pkg/instance/helpers.go b/plugins/plugin-klogs/pkg/instance/helpers.go index c16ede49c..e007dbba3 100644 --- a/plugins/plugin-klogs/pkg/instance/helpers.go +++ b/plugins/plugin-klogs/pkg/instance/helpers.go @@ -8,8 +8,22 @@ import ( "github.com/prometheus/client_golang/prometheus/promauto" ) +type Field struct { + Name string `json:"name"` + AutolinkPath string `json:"autolinkPath,omitempty"` +} + +func fieldsFromNames(names ...string) []Field { + ret := make([]Field, len(names)) + for i, n := range names { + ret[i] = Field{Name: n} + } + + return ret +} + var ( - defaultFields = []string{"timestamp", "cluster", "namespace", "app", "pod_name", "container_name", "host", "log"} + defaultFields = fieldsFromNames("timestamp", "cluster", "namespace", "app", "pod_name", "container_name", "host", "log") defaultColumns = "timestamp, cluster, namespace, app, pod_name, container_name, host, fields_string, fields_number, log" fieldsMetric = promauto.NewCounterVec(prometheus.CounterOpts{ @@ -138,7 +152,7 @@ func handleConditionParts(key, value, operator string, materializedColumns []str // ALTER TABLE logs.logs ON CLUSTER '{cluster}' ADD COLUMN Float64 DEFAULT fields_number['']; fieldsMetric.WithLabelValues(key).Inc() - if contains(defaultFields, key) || contains(materializedColumns, key) { + if containsField(defaultFields, Field{Name: key}) || contains(materializedColumns, key) { if operator == "=~" { return fmt.Sprintf("%s ILIKE %s", key, value), nil } @@ -186,7 +200,7 @@ func handleConditionParts(key, value, operator string, materializedColumns []str } func handleExistsCondition(key string, materializedColumns []string) string { - if contains(defaultFields, key) || contains(materializedColumns, key) { + if containsField(defaultFields, Field{Name: key}) || contains(materializedColumns, key) { return fmt.Sprintf("%s IS NOT NULL", key) } @@ -205,7 +219,7 @@ func parseOrder(order, orderBy string, materializedColumns []string) string { } orderBy = strings.TrimSpace(orderBy) - if contains(defaultFields, orderBy) || contains(materializedColumns, orderBy) { + if containsField(defaultFields, Field{Name: orderBy}) || contains(materializedColumns, orderBy) { return fmt.Sprintf("%s %s", orderBy, order) } @@ -226,10 +240,10 @@ func getBucketTimes(interval, bucketTime, timeStart, timeEnd int64) (int64, int6 return bucketTime, bucketTime + interval } -// appendIfMissing appends a value to a slice, when this values doesn't exist in the slice already. -func appendIfMissing(items []string, item string) []string { +// appendIf appends a value to a slice, when the predicate returns true +func appendIf[T any](items []T, item T, predecate func(iter, newItem T) bool) []T { for _, ele := range items { - if ele == item { + if predecate(ele, item) { return items } } @@ -237,14 +251,26 @@ func appendIfMissing(items []string, item string) []string { return append(items, item) } -// contains checks if the given slice of string contains the given item. It returns true when the slice contains the -// given item. -func contains(items []string, item string) bool { +func appendIfFieldIsMissing(items []Field, item Field) []Field { + return appendIf(items, item, func(a, b Field) bool { return a.Name == b.Name }) +} + +func some[T any](items []T, item T, predecate func(a, b T) bool) bool { for _, ele := range items { - if ele == item { + if predecate(ele, item) { return true } } return false } + +// contains checks if the given slice of string contains the given item. It returns true when the slice contains the +// given item. +func contains(items []string, item string) bool { + return some(items, item, func(a, b string) bool { return a == b }) +} + +func containsField(items []Field, item Field) bool { + return some(items, item, func(a, b Field) bool { return a.Name == b.Name }) +} diff --git a/plugins/plugin-klogs/pkg/instance/helpers_test.go b/plugins/plugin-klogs/pkg/instance/helpers_test.go index 917ccd579..064001910 100644 --- a/plugins/plugin-klogs/pkg/instance/helpers_test.go +++ b/plugins/plugin-klogs/pkg/instance/helpers_test.go @@ -144,15 +144,39 @@ func TestGetInterval(t *testing.T) { } } -func TestAppendIfMissing(t *testing.T) { - items := []string{"foo", "bar"} +func TestAppendIf(t *testing.T) { + t.Run("works with strings", func(t *testing.T) { + strCmpr := func(a, b string) bool { return a == b } + items := []string{"foo", "bar"} + + items = appendIf(items, "foo", strCmpr) + require.Equal(t, []string{"foo", "bar"}, items) + + items = appendIf(items, "hello", strCmpr) + items = appendIf(items, "world", strCmpr) + require.Equal(t, []string{"foo", "bar", "hello", "world"}, items) + }) + + t.Run("works with int's", func(t *testing.T) { + appendIfGreater := func(items []int, item int) []int { + return appendIf(items, item, func(a, b int) bool { return a > b }) + } + + require.Equal(t, []int{1, 2, 3}, appendIfGreater([]int{1, 2, 3}, 0)) + require.Equal(t, []int{1, 2, 3, 100}, appendIfGreater([]int{1, 2, 3}, 100)) + }) +} + +func TestSome(t *testing.T) { + items := []int64{1, 2, 3} - items = appendIfMissing(items, "foo") - require.Equal(t, []string{"foo", "bar"}, items) + require.True(t, some(items, 12, func(a, b int64) bool { + return a%10 == b%10 + })) - items = appendIfMissing(items, "hello") - items = appendIfMissing(items, "world") - require.Equal(t, []string{"foo", "bar", "hello", "world"}, items) + require.False(t, some(items, 14, func(a, b int64) bool { + return a%10 == b%10 + })) } func TestContains(t *testing.T) { diff --git a/plugins/plugin-klogs/pkg/instance/instance.go b/plugins/plugin-klogs/pkg/instance/instance.go index c8edcdb7e..9c7df86b1 100644 --- a/plugins/plugin-klogs/pkg/instance/instance.go +++ b/plugins/plugin-klogs/pkg/instance/instance.go @@ -14,25 +14,36 @@ import ( "go.uber.org/zap" ) +type IntegrationAutolinks struct { + ColumnName string `json:"columnName"` + Path string `json:"path"` +} + +type Integration struct { + Type string `json:"type"` + Autolinks IntegrationAutolinks +} + // Config is the structure of the configuration for a single klogs instance. type Config struct { - Address string `json:"address"` - Database string `json:"database"` - Username string `json:"username"` - Password string `json:"password"` - DialTimeout string `json:"dialTimeout"` - ConnMaxLifetime string `json:"connMaxLifetime"` - MaxIdleConns int `json:"maxIdleConns"` - MaxOpenConns int `json:"maxOpenConns"` - MaterializedColumns []string `json:"materializedColumns"` + Address string `json:"address"` + Database string `json:"database"` + Username string `json:"username"` + Password string `json:"password"` + DialTimeout string `json:"dialTimeout"` + ConnMaxLifetime string `json:"connMaxLifetime"` + MaxIdleConns int `json:"maxIdleConns"` + MaxOpenConns int `json:"maxOpenConns"` + MaterializedColumns []string `json:"materializedColumns"` + Integrations []Integration `json:"integration"` } type Instance interface { GetName() string getFields(ctx context.Context) (Fields, error) refreshCachedFields() []string - GetFields(filter string, fieldType string) []string - GetLogs(ctx context.Context, query, order, orderBy string, limit, timeStart, timeEnd int64) ([]map[string]any, []string, int64, int64, []Bucket, error) + GetFields(filter string, fieldType string) []Field + GetLogs(ctx context.Context, query, order, orderBy string, limit, timeStart, timeEnd int64) ([]map[string]any, []Field, int64, int64, []Bucket, error) GetRawQueryResults(ctx context.Context, query string) ([][]any, []string, error) GetAggregation(ctx context.Context, aggregation Aggregation) ([]map[string]any, []string, error) } @@ -44,6 +55,7 @@ type instance struct { client *sql.DB materializedColumns []string cachedFields Fields + integrations []Integration } func (i *instance) GetName() string { @@ -62,16 +74,16 @@ func (i *instance) getFields(ctx context.Context) (Fields, error) { defer rowsFieldKeys.Close() for rowsFieldKeys.Next() { - var field string + var f Field - if err := rowsFieldKeys.Scan(&field); err != nil { + if err := rowsFieldKeys.Scan(&f.Name); err != nil { return fields, err } if fieldType == "string" { - fields.String = append(fields.String, field) + fields.String = append(fields.String, f) } else if fieldType == "number" { - fields.Number = append(fields.Number, field) + fields.Number = append(fields.Number, f) } } @@ -113,11 +125,11 @@ func (i *instance) refreshCachedFields() []string { log.Info(ctx, "Refreshed fields", zap.Int("stringFieldsCount", len(fields.String)), zap.Int("numberFieldsCount", len(fields.Number))) for _, field := range fields.String { - i.cachedFields.String = appendIfMissing(i.cachedFields.String, field) + i.cachedFields.String = appendIfFieldIsMissing(i.cachedFields.String, field) } for _, field := range fields.Number { - i.cachedFields.Number = appendIfMissing(i.cachedFields.Number, field) + i.cachedFields.Number = appendIfFieldIsMissing(i.cachedFields.Number, field) } } } @@ -125,12 +137,12 @@ func (i *instance) refreshCachedFields() []string { } // GetFields returns all cached fields which are containing the filter term. The cached fields are refreshed every 24. -func (i *instance) GetFields(filter string, fieldType string) []string { - var fields []string +func (i *instance) GetFields(filter string, fieldType string) []Field { + var fields []Field if fieldType == "string" || fieldType == "" { for _, field := range i.cachedFields.String { - if strings.Contains(field, filter) { + if strings.Contains(field.Name, filter) { fields = append(fields, field) } } @@ -140,7 +152,7 @@ func (i *instance) GetFields(filter string, fieldType string) []string { if fieldType == "number" || fieldType == "" { for _, field := range i.cachedFields.Number { - if strings.Contains(field, filter) { + if strings.Contains(field.Name, filter) { fields = append(fields, field) } } @@ -238,6 +250,7 @@ func New(name string, options map[string]any) (Instance, error) { database: config.Database, client: client, materializedColumns: config.MaterializedColumns, + integrations: config.Integrations, } go instance.refreshCachedFields() diff --git a/plugins/plugin-klogs/pkg/instance/logs.go b/plugins/plugin-klogs/pkg/instance/logs.go index af80639cf..581cc8786 100644 --- a/plugins/plugin-klogs/pkg/instance/logs.go +++ b/plugins/plugin-klogs/pkg/instance/logs.go @@ -10,9 +10,28 @@ import ( "go.uber.org/zap" ) +func (i *instance) enrichAutolinks(fields []Field) []Field { + autolinkIntegrations := make([]IntegrationAutolinks, 0) + for _, integration := range i.integrations { + if integration.Type == "autolinks" { + autolinkIntegrations = append(autolinkIntegrations, integration.Autolinks) + } + } + + for i, f := range fields { + for _, autolink := range autolinkIntegrations { + if f.Name == autolink.ColumnName { + fields[i].AutolinkPath = autolink.Path + } + } + } + + return fields +} + // GetLogs parses the given query into the sql syntax, which is then run against the ClickHouse instance. The returned // rows are converted into a document schema which can be used by our UI. -func (i *instance) GetLogs(ctx context.Context, query, order, orderBy string, limit, timeStart, timeEnd int64) ([]map[string]any, []string, int64, int64, []Bucket, error) { +func (i *instance) GetLogs(ctx context.Context, query, order, orderBy string, limit, timeStart, timeEnd int64) ([]map[string]any, []Field, int64, int64, []Bucket, error) { var count int64 var buckets []Bucket var documents []map[string]any @@ -182,12 +201,12 @@ func (i *instance) GetLogs(ctx context.Context, query, order, orderBy string, li for k, v := range r.FieldsNumber { document[k] = v - fields = appendIfMissing(fields, k) + fields = appendIfFieldIsMissing(fields, Field{Name: k}) } for k, v := range r.FieldsString { document[k] = v - fields = appendIfMissing(fields, k) + fields = appendIfFieldIsMissing(fields, Field{Name: k}) } documents = append(documents, document) @@ -197,7 +216,10 @@ func (i *instance) GetLogs(ctx context.Context, query, order, orderBy string, li return nil, nil, 0, 0, nil, err } - sort.Strings(fields) + fields = i.enrichAutolinks(fields) + sort.SliceStable(fields, func(i, j int) bool { + return fields[i].Name < fields[j].Name + }) log.Debug(ctx, "SQL result raw logs", zap.Int("documentsCount", len(documents))) return documents, fields, count, time.Now().Sub(queryStartTime).Milliseconds(), buckets, nil diff --git a/plugins/plugin-klogs/pkg/instance/structs.go b/plugins/plugin-klogs/pkg/instance/structs.go index 81ef7b381..5ca22071c 100644 --- a/plugins/plugin-klogs/pkg/instance/structs.go +++ b/plugins/plugin-klogs/pkg/instance/structs.go @@ -6,8 +6,8 @@ import ( // Fields is the struct for cached fields, which can be of type number or string. type Fields struct { - String []string - Number []string + String []Field + Number []Field } // Row is the struct which represents a single row in the logs table of ClickHouse. diff --git a/plugins/plugin-klogs/src/components/page/Logs.tsx b/plugins/plugin-klogs/src/components/page/Logs.tsx index aa28d4b82..b0c14800d 100644 --- a/plugins/plugin-klogs/src/components/page/Logs.tsx +++ b/plugins/plugin-klogs/src/components/page/Logs.tsx @@ -11,12 +11,15 @@ import { GridItem, Spinner, } from '@patternfly/react-core'; +import { IPluginInstance, ITimes } from '@kobsio/shared'; import { QueryObserverResult, useQuery } from '@tanstack/react-query'; + import React from 'react'; + import { useNavigate } from 'react-router-dom'; -import { IPluginInstance, ITimes } from '@kobsio/shared'; -import { ILogsData } from '../../utils/interfaces'; +import { IField, ILogsData } from '../../utils/interfaces'; +import { AutolinkReference } from '../../utils/ResolveReference'; import LogsActions from './LogsActions'; import LogsChart from '../panel/LogsChart'; import LogsDocuments from '../panel/LogsDocuments'; @@ -24,14 +27,14 @@ import LogsFields from './LogsFields'; interface ILogsProps { instance: IPluginInstance; - fields?: string[]; + fields?: IField[]; order: string; orderBy: string; query: string; addFilter: (filter: string) => void; changeTime: (times: ITimes) => void; changeOrder: (order: string, orderBy: string) => void; - selectField: (field: string) => void; + selectField: (field: { name: string }) => void; changeFieldOrder: (oldIndex: number, newIndex: number) => void; times: ITimes; } @@ -163,18 +166,19 @@ const Logs: React.FunctionComponent = ({

 

- - + + + diff --git a/plugins/plugin-klogs/src/components/page/LogsActions.tsx b/plugins/plugin-klogs/src/components/page/LogsActions.tsx index 1ab290f18..6e4838e0c 100644 --- a/plugins/plugin-klogs/src/components/page/LogsActions.tsx +++ b/plugins/plugin-klogs/src/components/page/LogsActions.tsx @@ -11,7 +11,7 @@ interface ILogsActionsProps { query: string; times: ITimes; documents?: IDocument[]; - fields?: string[]; + fields?: { name: string }[]; isFetching: boolean; } @@ -48,8 +48,8 @@ export const LogsActions: React.FunctionComponent = ({ for (const document of documents) { csv = csv + formatTime(document['timestamp']); - for (const field of fields) { - csv = csv + ';' + (document.hasOwnProperty(field) ? document[field] : '-'); + for (const { name } of fields) { + csv = csv + ';' + (document.hasOwnProperty(name) ? document[name] : '-'); } csv = csv + '\r\n'; diff --git a/plugins/plugin-klogs/src/components/page/LogsFields.tsx b/plugins/plugin-klogs/src/components/page/LogsFields.tsx index 89a7f6097..1bc5d38da 100644 --- a/plugins/plugin-klogs/src/components/page/LogsFields.tsx +++ b/plugins/plugin-klogs/src/components/page/LogsFields.tsx @@ -4,9 +4,9 @@ import React from 'react'; import LogsFieldsItem from './LogsFieldsItem'; export interface ILogsFieldsProps { - fields?: string[]; - selectedFields?: string[]; - selectField: (field: string) => void; + fields?: { name: string }[]; + selectedFields?: { name: string }[]; + selectField: (field: { name: string }) => void; changeFieldOrder: (oldIndex: number, newIndex: number) => void; } @@ -44,7 +44,7 @@ const LogsFields: React.FunctionComponent = ({ {fields.map((field, index) => ( selectField(field)} isActive={false}> - {field} + {field.name} ))} diff --git a/plugins/plugin-klogs/src/components/page/LogsFieldsItem.tsx b/plugins/plugin-klogs/src/components/page/LogsFieldsItem.tsx index 47e1d95d9..4002377aa 100644 --- a/plugins/plugin-klogs/src/components/page/LogsFieldsItem.tsx +++ b/plugins/plugin-klogs/src/components/page/LogsFieldsItem.tsx @@ -8,8 +8,8 @@ import TrashIcon from '@patternfly/react-icons/dist/esm/icons/trash-icon'; export interface ILogsFieldsItemProps { index: number; length: number; - field: string; - selectField: (field: string) => void; + field: { name: string }; + selectField: (field: { name: string }) => void; changeFieldOrder: (oldIndex: number, newIndex: number) => void; } @@ -22,16 +22,16 @@ const LogsFieldsItem: React.FunctionComponent = ({ }: ILogsFieldsItemProps) => { const [showActions, setShowActions] = useState(false); - const copyField = (field: string): void => { + const copyField = (field: { name: string }): void => { if (navigator.clipboard) { - navigator.clipboard.writeText(field); + navigator.clipboard.writeText(field.name); } }; return (
setShowActions(true)} onMouseLeave={(): void => setShowActions(false)}> - {field} + {field.name} {showActions && (
{index !== length - 1 ? ( diff --git a/plugins/plugin-klogs/src/components/page/LogsPage.tsx b/plugins/plugin-klogs/src/components/page/LogsPage.tsx index 10eec3c39..140f0097b 100644 --- a/plugins/plugin-klogs/src/components/page/LogsPage.tsx +++ b/plugins/plugin-klogs/src/components/page/LogsPage.tsx @@ -2,7 +2,8 @@ import React, { useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { IPluginInstance, ITimes, PageContentSection, PageHeaderSection, PluginPageTitle } from '@kobsio/shared'; -import { IOptions } from '../../utils/interfaces'; + +import { IField, IOptions } from '../../utils/interfaces'; import Logs from './Logs'; import LogsPageActions from './LogsPageActions'; import LogsToolbar from './LogsToolbar'; @@ -21,7 +22,7 @@ const LogsPage: React.FunctionComponent = ({ instance }: ILogsPa // changeOptions is used to change the options. Besides setting a new value for the options state we also reflect the // options in the current url. const changeOptions = (opts: IOptions): void => { - const fields = opts.fields ? opts.fields.map((field) => `&field=${field}`) : []; + const fields = opts.fields ? opts.fields.map((field) => `&field=${field.name}`) : []; navigate( `${location.pathname}?query=${encodeURIComponent(opts.query)}&order=${opts.order}&orderBy=${opts.orderBy}&time=${ @@ -32,15 +33,15 @@ const LogsPage: React.FunctionComponent = ({ instance }: ILogsPa // selectField is used to add a field as parameter, when it isn't present and to remove a fields from as parameter, // when it is already present via the changeOptions function. - const selectField = (field: string): void => { + const selectField = (field: { name: string }): void => { if (options) { - let tmpFields: string[] = []; + let tmpFields: IField[] = []; if (options.fields) { tmpFields = [...options.fields]; } - if (tmpFields.includes(field)) { - tmpFields = tmpFields.filter((f) => f !== field); + if (tmpFields.some((tf) => tf.name === field.name)) { + tmpFields = tmpFields.filter((f) => f.name !== field.name); } else { tmpFields.push(field); } diff --git a/plugins/plugin-klogs/src/components/panel/Logs.tsx b/plugins/plugin-klogs/src/components/panel/Logs.tsx index ae8d5fe2f..f29729925 100644 --- a/plugins/plugin-klogs/src/components/panel/Logs.tsx +++ b/plugins/plugin-klogs/src/components/panel/Logs.tsx @@ -13,6 +13,8 @@ import InfoCircleIcon from '@patternfly/react-icons/dist/esm/icons/info-circle-i import React from 'react'; import { ILogsData, IQuery } from '../../utils/interfaces'; +import { AutolinkReference } from '../../utils/ResolveReference'; + import { IPluginInstance, ITimes } from '@kobsio/shared'; import LogsChart from './LogsChart'; import LogsDocuments from '../panel/LogsDocuments'; @@ -116,7 +118,9 @@ const Logs: React.FunctionComponent = ({ instance, query, times }: I

 

- + + +
); }; diff --git a/plugins/plugin-klogs/src/components/panel/LogsDocument.tsx b/plugins/plugin-klogs/src/components/panel/LogsDocument.tsx index 704a50af5..57b7ec99c 100644 --- a/plugins/plugin-klogs/src/components/panel/LogsDocument.tsx +++ b/plugins/plugin-klogs/src/components/panel/LogsDocument.tsx @@ -9,9 +9,9 @@ import '../../assets/logsdocuments.css'; interface ILogsDocumentProps { document: IDocument; - fields?: string[]; + fields?: { name: string }[]; addFilter?: (filter: string) => void; - selectField?: (field: string) => void; + selectField?: (field: { name: string }) => void; } const LogsDocument: React.FunctionComponent = ({ @@ -49,8 +49,8 @@ const LogsDocument: React.FunctionComponent = ({ {fields && fields.length > 0 ? ( fields.map((field, index) => ( - - {document[field]} + + {document[field.name]} )) ) : ( diff --git a/plugins/plugin-klogs/src/components/panel/LogsDocumentDetails.tsx b/plugins/plugin-klogs/src/components/panel/LogsDocumentDetails.tsx index a57759312..65e0e453a 100644 --- a/plugins/plugin-klogs/src/components/panel/LogsDocumentDetails.tsx +++ b/plugins/plugin-klogs/src/components/panel/LogsDocumentDetails.tsx @@ -9,7 +9,7 @@ import LogsDocumentDetailsRow from './LogsDocumentDetailsRow'; export interface ILogsDocumentDetailsProps { document: IDocument; addFilter?: (filter: string) => void; - selectField?: (field: string) => void; + selectField?: (field: { name: string }) => void; } const LogsDocumentDetails: React.FunctionComponent = ({ diff --git a/plugins/plugin-klogs/src/components/panel/LogsDocumentDetailsRow.tsx b/plugins/plugin-klogs/src/components/panel/LogsDocumentDetailsRow.tsx index 023dee5d3..6615745bb 100644 --- a/plugins/plugin-klogs/src/components/panel/LogsDocumentDetailsRow.tsx +++ b/plugins/plugin-klogs/src/components/panel/LogsDocumentDetailsRow.tsx @@ -1,16 +1,20 @@ import { Button, Tooltip } from '@patternfly/react-core'; -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; import { Td, Tr } from '@patternfly/react-table'; import ColumnsIcon from '@patternfly/react-icons/dist/esm/icons/columns-icon'; import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon'; import SearchMinusIcon from '@patternfly/react-icons/dist/esm/icons/search-minus-icon'; import SearchPlusIcon from '@patternfly/react-icons/dist/esm/icons/search-plus-icon'; +import { Link } from 'react-router-dom'; + +import { AutolinkReference } from '../../utils/ResolveReference'; + export interface ILogsDocumentDetailsRowProps { documentKey: string; documentValue: string; addFilter?: (filter: string) => void; - selectField?: (field: string) => void; + selectField?: (field: { name: string }) => void; } const LogsDocumentDetailsRow: React.FunctionComponent = ({ @@ -19,7 +23,9 @@ const LogsDocumentDetailsRow: React.FunctionComponent { + const { getReferenceForField } = useContext(AutolinkReference.Context); const [showActions, setShowActions] = useState(false); + const reference = getReferenceForField(documentKey, documentValue); return ( setShowActions(true)} onMouseLeave={(): void => setShowActions(false)}> @@ -69,7 +75,7 @@ const LogsDocumentDetailsRow: React.FunctionComponent selectField(documentKey)} + onClick={(): void => selectField({ name: documentKey })} > @@ -82,7 +88,9 @@ const LogsDocumentDetailsRow: React.FunctionComponent{documentKey} -
{documentValue}
+
+ {reference ? {documentValue} : documentValue} +
); diff --git a/plugins/plugin-klogs/src/components/panel/LogsDocuments.tsx b/plugins/plugin-klogs/src/components/panel/LogsDocuments.tsx index a1ac600c8..5c8f1b909 100644 --- a/plugins/plugin-klogs/src/components/panel/LogsDocuments.tsx +++ b/plugins/plugin-klogs/src/components/panel/LogsDocuments.tsx @@ -22,12 +22,12 @@ interface IPage { interface ILogsDocumentsProps { documents?: IDocument[]; - fields?: string[]; + fields?: { name: string }[]; order?: string; orderBy?: string; addFilter?: (filter: string) => void; changeOrder?: (order: string, orderBy: string) => void; - selectField?: (field: string) => void; + selectField?: (field: { name: string }) => void; } const LogsDocuments: React.FunctionComponent = ({ @@ -41,7 +41,8 @@ const LogsDocuments: React.FunctionComponent = ({ }: ILogsDocumentsProps) => { const [page, setPage] = useState({ page: 1, perPage: 100 }); - const activeSortIndex = fields && orderBy && orderBy !== 'timestamp' ? fields?.indexOf(orderBy) : -1; + const activeSortIndex = + fields && orderBy && orderBy !== 'timestamp' ? fields?.findIndex((f) => f.name === orderBy) : -1; const activeSortDirection = order === 'ascending' ? 'asc' : 'desc'; useEffect(() => { @@ -86,13 +87,13 @@ const LogsDocuments: React.FunctionComponent = ({ extraData: IExtraColumnData, ): void => { if (changeOrder) { - changeOrder(sortByDirection === 'asc' ? 'ascending' : 'descending', field); + changeOrder(sortByDirection === 'asc' ? 'ascending' : 'descending', field.name); } }, sortBy: { direction: activeSortDirection, index: activeSortIndex }, }} > - {field} + {field.name} )) ) : ( diff --git a/plugins/plugin-klogs/src/utils/ResolveReference.ts b/plugins/plugin-klogs/src/utils/ResolveReference.ts new file mode 100644 index 000000000..198195d71 --- /dev/null +++ b/plugins/plugin-klogs/src/utils/ResolveReference.ts @@ -0,0 +1,48 @@ +import React from 'react'; + +import { IField } from './interfaces'; + +interface IResolveReference { + getReferenceForField: (field: string, value: string) => string | undefined; +} +const Context = React.createContext({ + getReferenceForField: () => undefined, +}); + +type IFactory = (fields: IField[], times: { timeStart: number; timeEnd: number }) => IResolveReference; + +interface IAutolink { + fieldName: string; + path: string; +} + +const Factory: IFactory = (fields, times) => { + const autolinks = fields.reduce((acc, curr) => { + const { autolinkPath } = curr; + if (typeof autolinkPath == 'undefined') { + return acc; + } + + return [...acc, { fieldName: curr.name, path: autolinkPath }]; + }, []); + + return { + getReferenceForField: (field: string, value: string): string | undefined => { + let { path } = autolinks.find(({ fieldName }) => fieldName === field) || {}; + if (!path) { + return; + } + + path = path.replaceAll('<>', value); + path = path.replaceAll('<>', `${times.timeEnd}`); + path = path.replaceAll('<>', `${times.timeStart}`); + + return path; + }, + }; +}; + +export const AutolinkReference = { + Context, + Factory, +}; diff --git a/plugins/plugin-klogs/src/utils/helpers.ts b/plugins/plugin-klogs/src/utils/helpers.ts index f4c82973b..a4f8f7240 100644 --- a/plugins/plugin-klogs/src/utils/helpers.ts +++ b/plugins/plugin-klogs/src/utils/helpers.ts @@ -10,7 +10,7 @@ export const getInitialOptions = (search: string, isInitial: boolean): IOptions const query = params.get('query'); return { - fields: fields.length > 0 ? fields : undefined, + fields: fields.length > 0 ? fields.map((name) => ({ name })) : undefined, order: order ? order : 'descending', orderBy: orderBy ? orderBy : '', query: query ? query : '', diff --git a/plugins/plugin-klogs/src/utils/interfaces.ts b/plugins/plugin-klogs/src/utils/interfaces.ts index 4e72982ab..2981923f4 100644 --- a/plugins/plugin-klogs/src/utils/interfaces.ts +++ b/plugins/plugin-klogs/src/utils/interfaces.ts @@ -2,7 +2,7 @@ import { ITimes } from '@kobsio/shared'; // IOptions is the interface for all options, which can be set for an klogs query. export interface IOptions { - fields?: string[]; + fields?: IField[]; order: string; orderBy: string; query: string; @@ -16,10 +16,15 @@ export interface IPanelOptions { aggregation: IAggregationOptions; } +export interface IField { + name: string; + autolinkPath?: string; +} + export interface IQuery { name?: string; query?: string; - fields?: string[]; + fields?: IField[]; order?: string; orderBy?: string; } @@ -30,7 +35,7 @@ export interface ILogsData { timeStart: number; count?: number; took?: number; - fields?: string[]; + fields?: IField[]; documents?: IDocument[]; buckets?: IBucket[]; }